From 73f5125d3d5763922ecc785a7a0bcee473c590f0 Mon Sep 17 00:00:00 2001 From: break Date: Sat, 25 Jul 2026 17:06:58 +0800 Subject: [PATCH] =?UTF-8?q?=E4=B8=83=E6=9C=88=E8=BF=AD=E4=BB=A3=E7=9F=AD?= =?UTF-8?q?=E6=9A=82=E5=AE=8C=E7=BB=93,=E8=BF=98=E6=9C=89=E5=BE=88?= =?UTF-8?q?=E5=A4=9A=E5=90=8E=E7=AB=AF=E7=9A=84=E5=85=B3=E9=94=AE=E4=B8=9C?= =?UTF-8?q?=E8=A5=BF=E6=B2=A1=E6=9C=89=E5=BC=84,=E8=BF=99=E6=98=AF?= =?UTF-8?q?=E4=B8=80=E7=89=88=E8=B5=B6=E6=97=B6=E9=97=B4=E5=81=9A=E7=9A=84?= =?UTF-8?q?=E4=B8=9C=E8=A5=BF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .scratch/tech-global-audit/审计覆盖基线.md | 41 + README.md | 12 +- cmd/api/docs.go | 5 + cmd/gendocs/main.go | 5 + cmd/worker/main.go | 130 +- docs/7月迭代/7月迭代前后端任务工时表.md | 2 +- docs/7月迭代/7月迭代技术方案-标准评审稿.md | 21 +- docs/7月迭代/7月迭代禅道研发需求拆分表.md | 4 +- docs/7月迭代/7月迭代禅道研发需求逐条录入稿.md | 31 +- docs/7月迭代/七月迭代实现与接口对接说明.md | 152 ++ docs/7月迭代/七月迭代联调交付说明.md | 126 + docs/7月迭代/七月迭代需求范围确认表.md | 654 +++++ docs/7月迭代/企业微信审批官方接口调研.md | 202 ++ docs/7月迭代/物联网卡管系统-需求.csv | 185 +- .../接口回放示例.md | 8 +- docs/admin-openapi.yaml | 2397 ++++++++++++++++- docs/asset-package-batch-order/功能总结.md | 44 + docs/business-data-export/功能总结.md | 59 + docs/device-batch-allocation/功能总结.md | 51 + docs/environment-variables.md | 30 + docs/gateway-api-reference.md | 91 +- docs/gateway-client-usage.md | 22 +- docs/iot-card-speed-tier/功能总结.md | 55 + docs/ur33-package-expiry-reminder/功能总结.md | 29 + docs/wecom-application-connection/功能总结.md | 125 + .../存量审批切换清单.md | 62 + docs/副本IoT卡导入模板-Chc9eI72.csv | 24 + docs/副本设备导入模板-B8BDRTVm.csv | 24 + .../agentrecharge/approval_decision.go | 119 + .../agentrecharge/offline_creation.go | 188 ++ .../exchange/shipping_notification.go | 49 + internal/application/notification/read.go | 4 +- .../application/packageexpiry/reminder.go | 45 + .../application/refundapproval/creation.go | 151 ++ internal/application/shop/create.go | 3 +- internal/application/shop/update.go | 6 + internal/application/wecom/connection.go | 303 +++ internal/application/wecom/directory.go | 129 + internal/application/wecom/scene.go | 322 +++ internal/bootstrap/handlers.go | 78 +- internal/bootstrap/payment_method_config.go | 36 + internal/bootstrap/services.go | 52 +- internal/bootstrap/stores.go | 13 +- internal/bootstrap/types.go | 3 + internal/bootstrap/worker.go | 1 + internal/bootstrap/worker_services.go | 41 +- internal/bootstrap/worker_stores.go | 168 +- internal/exporter/agent_recharge_scene.go | 190 ++ .../agent_wallet_transaction_scene.go | 168 ++ internal/exporter/exchange_scene.go | 176 ++ internal/exporter/filter_helpers.go | 12 + internal/exporter/iot_card_scene.go | 38 +- internal/exporter/package_scene.go | 228 ++ internal/exporter/refund_scene.go | 206 ++ internal/exporter/registry.go | 14 +- internal/gateway/device.go | 8 - internal/gateway/flow_card.go | 13 + internal/gateway/models.go | 10 +- internal/handler/admin/account.go | 18 + internal/handler/admin/asset.go | 34 + .../admin/asset_package_batch_order.go | 92 + internal/handler/admin/device.go | 34 +- internal/handler/admin/device_import.go | 14 + internal/handler/admin/iot_card.go | 32 + internal/handler/admin/wecom.go | 197 ++ internal/handler/app/client_asset.go | 78 +- internal/handler/app/client_wallet.go | 38 +- internal/handler/callback/wecom_approval.go | 61 + .../exchange/shipping_notification.go | 49 + .../infrastructure/messaging/outbox/relay.go | 10 +- .../infrastructure/notification/registry.go | 22 + .../packageexpiry/reminder_publisher.go | 141 + .../infrastructure/systemconfig/reader.go | 38 + .../infrastructure/systemconfig/registry.go | 12 +- .../infrastructure/wallet/debit_consumer.go | 72 +- .../wecom/application_repository.go | 109 + .../wecom/approval_attachment_uploader.go | 166 ++ .../wecom/approval_context_repository.go | 395 +++ .../wecom/approval_detail_client.go | 130 + .../wecom/approval_detail_task.go | 159 ++ .../infrastructure/wecom/approval_form.go | 176 ++ .../wecom/approval_info_client.go | 195 ++ .../wecom/approval_projection.go | 200 ++ .../infrastructure/wecom/approval_provider.go | 152 ++ .../wecom/approval_recovery_task.go | 155 ++ .../wecom/approval_submission_client.go | 189 ++ .../wecom/approval_submission_consumer.go | 100 + .../infrastructure/wecom/callback_crypto.go | 85 + .../infrastructure/wecom/callback_service.go | 124 + .../infrastructure/wecom/credential_cipher.go | 61 + .../infrastructure/wecom/directory_client.go | 185 ++ .../infrastructure/wecom/member_repository.go | 89 + .../infrastructure/wecom/scene_repository.go | 83 + .../infrastructure/wecom/template_client.go | 240 ++ .../infrastructure/wecom/token_provider.go | 296 ++ internal/model/account.go | 3 + internal/model/agent_wallet.go | 3 +- internal/model/asset_package_batch_order.go | 78 + internal/model/device_import_task.go | 4 + internal/model/dto/account_dto.go | 16 + internal/model/dto/agent_recharge_dto.go | 12 +- internal/model/dto/asset_dto.go | 12 + .../dto/asset_package_batch_order_dto.go | 70 + internal/model/dto/client_asset_dto.go | 34 +- internal/model/dto/client_order_dto.go | 15 +- internal/model/dto/client_wallet_dto.go | 13 +- internal/model/dto/device_dto.go | 45 +- internal/model/dto/device_import_dto.go | 38 +- internal/model/dto/exchange_dto.go | 2 + internal/model/dto/export_task_dto.go | 6 +- internal/model/dto/iot_card_dto.go | 62 +- internal/model/dto/notification_dto.go | 19 +- internal/model/dto/order_dto.go | 8 +- internal/model/dto/package_expiry_dto.go | 47 + internal/model/dto/refund_dto.go | 78 +- internal/model/dto/shop_dto.go | 2 + internal/model/dto/storage_dto.go | 2 +- internal/model/dto/system_config_dto.go | 4 +- internal/model/dto/wecom_dto.go | 72 + internal/model/dto/wecom_member_dto.go | 41 + internal/model/dto/wecom_scene_dto.go | 55 + internal/model/order.go | 2 +- internal/model/refund.go | 15 +- internal/model/shop.go | 1 + internal/model/wecom_application.go | 29 + internal/model/wecom_approval_context.go | 37 + internal/model/wecom_approval_scene.go | 30 + internal/model/wecom_member.go | 26 + internal/query/exchange/list.go | 21 +- internal/query/notification/query.go | 7 +- internal/query/packageexpiry/list.go | 372 +++ internal/query/shop/business_owner.go | 3 +- internal/routes/account.go | 8 + internal/routes/admin.go | 7 + internal/routes/asset_package_batch_order.go | 25 + internal/routes/device.go | 27 +- internal/routes/iot_card.go | 18 + internal/routes/notification.go | 4 +- internal/routes/package_expiry.go | 21 + internal/routes/personal_notification.go | 2 +- internal/routes/routes.go | 4 + internal/routes/storage.go | 2 + internal/routes/wecom.go | 80 + internal/routes/wecom_callback.go | 24 + internal/service/account/service.go | 81 +- internal/service/agent_recharge/service.go | 215 +- .../service/asset_audit/operation_content.go | 6 +- .../asset_package_batch_order/service.go | 163 ++ internal/service/client_auth/service.go | 38 +- internal/service/client_order/service.go | 130 +- internal/service/customer_binding/service.go | 68 + internal/service/device/gateway_service.go | 58 - .../service/device/realname_policy_batch.go | 84 + internal/service/device/service.go | 3 + internal/service/device_import/audit.go | 16 + internal/service/device_import/service.go | 73 +- internal/service/exchange/service.go | 67 +- .../service/iot_card/realname_policy_batch.go | 83 + internal/service/iot_card/service.go | 9 + internal/service/iot_card/speed_tier.go | 158 ++ internal/service/order/service.go | 6 +- .../service/package/activation_service.go | 27 +- internal/service/paymentmethod/policy.go | 134 + .../service/purchase_validation/service.go | 76 +- internal/service/refund/approval_decision.go | 145 + internal/service/refund/service.go | 345 ++- internal/store/postgres/account_store.go | 23 + .../asset_package_batch_order_task_store.go | 106 + .../postgres/device_import_task_store.go | 3 + internal/store/postgres/device_store.go | 39 + internal/store/postgres/iot_card_store.go | 3 + internal/store/postgres/refund_store.go | 26 + internal/task/asset_package_batch_order.go | 242 ++ internal/task/device_batch_allocation.go | 211 ++ internal/task/device_import.go | 13 + internal/task/package_expiry_reminder.go | 31 + ...82_add_shop_client_login_disabled.down.sql | 26 + ...0182_add_shop_client_login_disabled.up.sql | 5 + ...3_add_package_expiry_scan_indexes.down.sql | 3 + ...183_add_package_expiry_scan_indexes.up.sql | 16 + .../000184_create_wecom_application.down.sql | 2 + .../000184_create_wecom_application.up.sql | 32 + .../000185_add_wecom_member_binding.down.sql | 8 + .../000185_add_wecom_member_binding.up.sql | 36 + ...00186_create_wecom_approval_scene.down.sql | 2 + .../000186_create_wecom_approval_scene.up.sql | 29 + ...187_add_wecom_approval_submission.down.sql | 5 + ...00187_add_wecom_approval_submission.up.sql | 43 + ...dd_wecom_approval_detail_snapshot.down.sql | 5 + ..._add_wecom_approval_detail_snapshot.up.sql | 20 + ...ecom_approval_recovery_timestamps.down.sql | 7 + ..._wecom_approval_recovery_timestamps.up.sql | 20 + ..._agent_recharge_approval_instance.down.sql | 6 + ...dd_agent_recharge_approval_instance.up.sql | 20 + ...0191_add_refund_approval_instance.down.sql | 6 + ...000191_add_refund_approval_instance.up.sql | 20 + ...te_asset_package_batch_order_task.down.sql | 1 + ...eate_asset_package_batch_order_task.up.sql | 49 + ...device_import_task_for_allocation.down.sql | 7 + ...d_device_import_task_for_allocation.up.sql | 15 + ...194_backfill_july_schema_comments.down.sql | 77 + ...00194_backfill_july_schema_comments.up.sql | 77 + .../.openspec.yaml | 2 + .../design.md | 163 ++ .../proposal.md | 53 + .../specs/agent-recharge/spec.md | 27 + .../specs/agent-wallet/spec.md | 13 + .../specs/asset-package-batch-order/spec.md | 20 + .../specs/asset-queries/spec.md | 13 + .../specs/asset-realname-policy/spec.md | 23 + .../specs/asset-speed-tier-management/spec.md | 16 + .../specs/business-data-export/spec.md | 16 + .../specs/card-replacement/spec.md | 16 + .../specs/device/spec.md | 16 + .../specs/exchange-admin-management/spec.md | 9 + .../exchange-client-notification/spec.md | 13 + .../specs/order-management/spec.md | 19 + .../specs/package-expiration-reminder/spec.md | 16 + .../specs/package-management/spec.md | 16 + .../specs/payment-dynamic-config/spec.md | 46 + .../specs/refund-api/spec.md | 32 + .../specs/shop-client-login-control/spec.md | 20 + .../specs/wecom-approval-integration/spec.md | 60 + .../tasks.md | 57 + .../实施证据.md | 77 + pkg/config/config.go | 15 + pkg/config/defaults/config.yaml | 11 + pkg/config/loader.go | 5 + pkg/constants/approval.go | 48 + pkg/constants/asset_audit.go | 3 +- pkg/constants/asset_package_batch_order.go | 32 + pkg/constants/card_observation.go | 51 + pkg/constants/constants.go | 25 +- pkg/constants/device_batch_allocation.go | 43 + pkg/constants/notification.go | 4 + pkg/constants/package_export.go | 67 + pkg/constants/redis.go | 10 + pkg/constants/system_config.go | 6 + pkg/constants/wallet.go | 2 + pkg/constants/wallet_export.go | 71 + pkg/constants/wecom.go | 86 + pkg/errors/codes.go | 14 + pkg/openapi/handlers.go | 2 + pkg/queue/handler.go | 28 + pkg/queue/types.go | 29 +- pkg/storage/service.go | 4 + pkg/storage/types.go | 4 + scripts/batch_exchange/exchanges.example.csv | 213 +- ...anges.example_换货结果_20260725_100636.csv | 177 ++ 249 files changed, 17137 insertions(+), 877 deletions(-) create mode 100644 docs/7月迭代/七月迭代实现与接口对接说明.md create mode 100644 docs/7月迭代/七月迭代联调交付说明.md create mode 100644 docs/7月迭代/七月迭代需求范围确认表.md create mode 100644 docs/7月迭代/企业微信审批官方接口调研.md create mode 100644 docs/asset-package-batch-order/功能总结.md create mode 100644 docs/business-data-export/功能总结.md create mode 100644 docs/device-batch-allocation/功能总结.md create mode 100644 docs/iot-card-speed-tier/功能总结.md create mode 100644 docs/ur33-package-expiry-reminder/功能总结.md create mode 100644 docs/wecom-application-connection/功能总结.md create mode 100644 docs/wecom-application-connection/存量审批切换清单.md create mode 100644 docs/副本IoT卡导入模板-Chc9eI72.csv create mode 100644 docs/副本设备导入模板-B8BDRTVm.csv create mode 100644 internal/application/agentrecharge/approval_decision.go create mode 100644 internal/application/agentrecharge/offline_creation.go create mode 100644 internal/application/exchange/shipping_notification.go create mode 100644 internal/application/packageexpiry/reminder.go create mode 100644 internal/application/refundapproval/creation.go create mode 100644 internal/application/wecom/connection.go create mode 100644 internal/application/wecom/directory.go create mode 100644 internal/application/wecom/scene.go create mode 100644 internal/bootstrap/payment_method_config.go create mode 100644 internal/exporter/agent_recharge_scene.go create mode 100644 internal/exporter/agent_wallet_transaction_scene.go create mode 100644 internal/exporter/exchange_scene.go create mode 100644 internal/exporter/package_scene.go create mode 100644 internal/exporter/refund_scene.go create mode 100644 internal/handler/admin/asset_package_batch_order.go create mode 100644 internal/handler/admin/wecom.go create mode 100644 internal/handler/callback/wecom_approval.go create mode 100644 internal/infrastructure/exchange/shipping_notification.go create mode 100644 internal/infrastructure/packageexpiry/reminder_publisher.go create mode 100644 internal/infrastructure/wecom/application_repository.go create mode 100644 internal/infrastructure/wecom/approval_attachment_uploader.go create mode 100644 internal/infrastructure/wecom/approval_context_repository.go create mode 100644 internal/infrastructure/wecom/approval_detail_client.go create mode 100644 internal/infrastructure/wecom/approval_detail_task.go create mode 100644 internal/infrastructure/wecom/approval_form.go create mode 100644 internal/infrastructure/wecom/approval_info_client.go create mode 100644 internal/infrastructure/wecom/approval_projection.go create mode 100644 internal/infrastructure/wecom/approval_provider.go create mode 100644 internal/infrastructure/wecom/approval_recovery_task.go create mode 100644 internal/infrastructure/wecom/approval_submission_client.go create mode 100644 internal/infrastructure/wecom/approval_submission_consumer.go create mode 100644 internal/infrastructure/wecom/callback_crypto.go create mode 100644 internal/infrastructure/wecom/callback_service.go create mode 100644 internal/infrastructure/wecom/credential_cipher.go create mode 100644 internal/infrastructure/wecom/directory_client.go create mode 100644 internal/infrastructure/wecom/member_repository.go create mode 100644 internal/infrastructure/wecom/scene_repository.go create mode 100644 internal/infrastructure/wecom/template_client.go create mode 100644 internal/infrastructure/wecom/token_provider.go create mode 100644 internal/model/asset_package_batch_order.go create mode 100644 internal/model/dto/asset_package_batch_order_dto.go create mode 100644 internal/model/dto/wecom_dto.go create mode 100644 internal/model/dto/wecom_member_dto.go create mode 100644 internal/model/dto/wecom_scene_dto.go create mode 100644 internal/model/wecom_application.go create mode 100644 internal/model/wecom_approval_context.go create mode 100644 internal/model/wecom_approval_scene.go create mode 100644 internal/model/wecom_member.go create mode 100644 internal/query/packageexpiry/list.go create mode 100644 internal/routes/asset_package_batch_order.go create mode 100644 internal/routes/package_expiry.go create mode 100644 internal/routes/wecom.go create mode 100644 internal/routes/wecom_callback.go create mode 100644 internal/service/asset_package_batch_order/service.go create mode 100644 internal/service/device/realname_policy_batch.go create mode 100644 internal/service/iot_card/realname_policy_batch.go create mode 100644 internal/service/iot_card/speed_tier.go create mode 100644 internal/service/paymentmethod/policy.go create mode 100644 internal/service/refund/approval_decision.go create mode 100644 internal/store/postgres/asset_package_batch_order_task_store.go create mode 100644 internal/task/asset_package_batch_order.go create mode 100644 internal/task/device_batch_allocation.go create mode 100644 internal/task/package_expiry_reminder.go create mode 100644 migrations/000182_add_shop_client_login_disabled.down.sql create mode 100644 migrations/000182_add_shop_client_login_disabled.up.sql create mode 100644 migrations/000183_add_package_expiry_scan_indexes.down.sql create mode 100644 migrations/000183_add_package_expiry_scan_indexes.up.sql create mode 100644 migrations/000184_create_wecom_application.down.sql create mode 100644 migrations/000184_create_wecom_application.up.sql create mode 100644 migrations/000185_add_wecom_member_binding.down.sql create mode 100644 migrations/000185_add_wecom_member_binding.up.sql create mode 100644 migrations/000186_create_wecom_approval_scene.down.sql create mode 100644 migrations/000186_create_wecom_approval_scene.up.sql create mode 100644 migrations/000187_add_wecom_approval_submission.down.sql create mode 100644 migrations/000187_add_wecom_approval_submission.up.sql create mode 100644 migrations/000188_add_wecom_approval_detail_snapshot.down.sql create mode 100644 migrations/000188_add_wecom_approval_detail_snapshot.up.sql create mode 100644 migrations/000189_add_wecom_approval_recovery_timestamps.down.sql create mode 100644 migrations/000189_add_wecom_approval_recovery_timestamps.up.sql create mode 100644 migrations/000190_add_agent_recharge_approval_instance.down.sql create mode 100644 migrations/000190_add_agent_recharge_approval_instance.up.sql create mode 100644 migrations/000191_add_refund_approval_instance.down.sql create mode 100644 migrations/000191_add_refund_approval_instance.up.sql create mode 100644 migrations/000192_create_asset_package_batch_order_task.down.sql create mode 100644 migrations/000192_create_asset_package_batch_order_task.up.sql create mode 100644 migrations/000193_extend_device_import_task_for_allocation.down.sql create mode 100644 migrations/000193_extend_device_import_task_for_allocation.up.sql create mode 100644 migrations/000194_backfill_july_schema_comments.down.sql create mode 100644 migrations/000194_backfill_july_schema_comments.up.sql create mode 100644 openspec/changes/deliver-july-iteration-confirmed-scope/.openspec.yaml create mode 100644 openspec/changes/deliver-july-iteration-confirmed-scope/design.md create mode 100644 openspec/changes/deliver-july-iteration-confirmed-scope/proposal.md create mode 100644 openspec/changes/deliver-july-iteration-confirmed-scope/specs/agent-recharge/spec.md create mode 100644 openspec/changes/deliver-july-iteration-confirmed-scope/specs/agent-wallet/spec.md create mode 100644 openspec/changes/deliver-july-iteration-confirmed-scope/specs/asset-package-batch-order/spec.md create mode 100644 openspec/changes/deliver-july-iteration-confirmed-scope/specs/asset-queries/spec.md create mode 100644 openspec/changes/deliver-july-iteration-confirmed-scope/specs/asset-realname-policy/spec.md create mode 100644 openspec/changes/deliver-july-iteration-confirmed-scope/specs/asset-speed-tier-management/spec.md create mode 100644 openspec/changes/deliver-july-iteration-confirmed-scope/specs/business-data-export/spec.md create mode 100644 openspec/changes/deliver-july-iteration-confirmed-scope/specs/card-replacement/spec.md create mode 100644 openspec/changes/deliver-july-iteration-confirmed-scope/specs/device/spec.md create mode 100644 openspec/changes/deliver-july-iteration-confirmed-scope/specs/exchange-admin-management/spec.md create mode 100644 openspec/changes/deliver-july-iteration-confirmed-scope/specs/exchange-client-notification/spec.md create mode 100644 openspec/changes/deliver-july-iteration-confirmed-scope/specs/order-management/spec.md create mode 100644 openspec/changes/deliver-july-iteration-confirmed-scope/specs/package-expiration-reminder/spec.md create mode 100644 openspec/changes/deliver-july-iteration-confirmed-scope/specs/package-management/spec.md create mode 100644 openspec/changes/deliver-july-iteration-confirmed-scope/specs/payment-dynamic-config/spec.md create mode 100644 openspec/changes/deliver-july-iteration-confirmed-scope/specs/refund-api/spec.md create mode 100644 openspec/changes/deliver-july-iteration-confirmed-scope/specs/shop-client-login-control/spec.md create mode 100644 openspec/changes/deliver-july-iteration-confirmed-scope/specs/wecom-approval-integration/spec.md create mode 100644 openspec/changes/deliver-july-iteration-confirmed-scope/tasks.md create mode 100644 openspec/changes/deliver-july-iteration-confirmed-scope/实施证据.md create mode 100644 pkg/constants/asset_package_batch_order.go create mode 100644 pkg/constants/device_batch_allocation.go create mode 100644 pkg/constants/package_export.go create mode 100644 pkg/constants/wallet_export.go create mode 100644 pkg/constants/wecom.go create mode 100644 scripts/batch_exchange/exchanges.example_换货结果_20260725_100636.csv diff --git a/.scratch/tech-global-audit/审计覆盖基线.md b/.scratch/tech-global-audit/审计覆盖基线.md index 049b0e7..a775bdb 100644 --- a/.scratch/tech-global-audit/审计覆盖基线.md +++ b/.scratch/tech-global-audit/审计覆盖基线.md @@ -44,6 +44,47 @@ | 联通实名成功回调 | N/A(Audit Event 已移出本 Change;不复制旧 `inner_callback`、第三方推送或 Gateway 二次确认) | `tb_iot_card` 是实名状态、首次实名时间和逆转窗口的权威事实;上游 `dateChanged` 只用于幂等和留痕 | 每次入站先写 `tb_integration_log` 正文摘要;覆盖 `invalid_payload/not_found/conflict/success/failed`,关闭时记录 `ignored` | 仅合法成功报文进入公共 `ApplyCardObservation`;实名事实首次变化时同事务写入 Outbox,重复成功不重复写事件 | | 联通解除实名回调 | N/A(只识别并留痕外部解除通知,不把单次回调作为本地实名逆转事实) | `tb_iot_card` 保持原实名状态、首次实名时间、检查时间及逆转计数,回调不写领域事实 | 每次入站先写 `tb_integration_log` 正文摘要;覆盖 `invalid_payload/not_found/conflict/ignored/failed` 终态并支持 pending 租约恢复 | N/A(不调用公共实名观测,不产生状态变化、停机或套餐事件) | +## 七月确认范围增量登记 + +| 入口 | Audit Event | Domain Ledger | Integration Log | Outbox | +|---|---|---|---|---| +| 换货迁移套餐在原订单退款后失效 | N/A(退款终态的自动后处理,不新增人工决定;人工申请与审批审计沿用退款入口) | 原订单、换货新旧资产关系和 `tb_package_usage` 是套餐权益来源及失效状态的权威事实;仅失效该订单迁移后的对应权益 | N/A(本地数据库后处理,不调用企微、支付或 Gateway) | 企微退款沿用 `approval.terminal_decision.recorded` 驱动;存量旧退款沿用原同步链路,本修复不新增 Outbox | +| 订单渠道、资产标识、退款/充值/换货提交人和卡/设备实名筛选 | N/A(均为字段来源修正或受权只读投影,不改变订单、资产、实名或审批事实) | 只读既有订单 `purchase_role`、卡 ICCID、设备 VirtualNo/IMEI、创建人账号及有效卡绑定实名事实,不另建领域账本 | N/A(查询使用本地批量账号查询和 EXISTS,不实时调用外部系统) | N/A(纯查询和创建时既有字段赋值不产生新增可靠副作用) | +| 换货创建前未终结退款拦截 | N/A(拒绝发生在换货事实写入前,拒绝原因进入 Access Log;不伪造成功业务审计) | `tb_refund_request` 的未终结状态是拦截依据;拒绝后不创建换货单、不修改资产 | N/A(本地前置校验,不调用企微或其他外部系统) | N/A(校验拒绝不产生业务事实或可靠副作用) | +| 店铺 C 端新登录限制配置与登录拦截 | 店铺更新入口记录操作者 `updater`、字段前后值进入 Access Log;本 Change 不新建店铺专用 Audit Writer,统一店铺配置 Audit Event 接缝登记为后续治理项 | `tb_shop.client_login_disabled` 是是否允许新登录的权威事实;已有 Token 不修改,拦截时不创建新 Token | N/A(配置更新与资产登录判断均为本地数据库操作) | N/A(同步配置与登录前拦截不产生必须可靠投递的提交后副作用) | +| 卡/设备实名策略单个及批量更新 | 复用资产操作审计 `card_realname_policy` / `asset_realname_policy`,记录操作者、资产集合、策略和 success/failed 结果;批量业务更新先在单事务中全量校验并写入 | `tb_iot_card.realname_policy`、`tb_device.realname_policy` 是策略权威事实,C 端仅实时计算 `effective_realname_policy` | N/A(策略更新和读取均不调用运营商或其他外部系统) | N/A(策略同步更新不产生可靠异步副作用) | +| 下架套餐当前使用者续费与普通列表过滤 | N/A(普通受权查询与既有订单创建规则;订单创建继续沿用原订单/资金审计接缝) | 当前套餐使用记录决定续费资格,新订单、订单明细、套餐当前配置和支付记录是新购买权威事实;不修改历史订单 | 第三方支付仍沿用既有支付 Integration Log,本资格判断不新增外部调用 | 新订单支付、钱包扣款、自动购包及佣金继续沿用既有任务/Outbox,本切片不新增事件类型 | +| 卡/设备 C 端支付方式配置更新 | 复用 `systemconfig.UpdateService` 的事务内 `AuditWriter`,记录操作者、请求标识及配置前后值;审计 Writer 已装配时写入失败会回滚配置更新 | `tb_system_config` 是卡、设备允许支付方式集合及更新人的 PostgreSQL 权威事实;Redis 仅为可失效缓存 | N/A(配置更新不调用外部系统) | N/A(提交后仅失效可重建缓存,不产生必须可靠投递的业务副作用) | +| C 端读取支付方式与后端订单/充值校验 | N/A(普通受权读取和业务规则校验,不产生独立敏感事实;拒绝原因进入 Access Log) | 只读 `tb_system_config`,订单创建后由 `tb_order.payment_method` 固化所选方式,充值与支付事实沿用既有订单、充值单和支付记录 | 第三方支付请求继续沿用既有支付集成日志接缝,本配置策略本身不新增外部调用 | 强充支付成功后的自动购包继续沿用既有 Asynq/业务幂等链路,本配置读取不新增 Outbox | +| 主钱包首次跌破 100 元通知店铺业务员 | N/A(由已审计资金事实派生的内部提醒,不新增人工操作或敏感读取) | `tb_agent_wallet_transaction` 与 `wallet.agent_main.debited` 是余额前后值的权威事实,`tb_notification` 保存最终通知与已读状态 | N/A(不调用外部系统) | 扣款事实消费者仅在 `balance_before >= 10000 && balance_after < 10000` 时同事务幂等写入明确后台账号通知 Outbox;无有效业务员时正常结束 | +| 创建物流换货单并提醒关联个人客户 | N/A(通知记录本身是投递事实;当前 Change 不新增全局 Audit Event Writer,后台创建操作继续进入 Access Log) | `tb_exchange_order` 是物流换货申请及状态的权威事实,`tb_notification` 是接收人通知与已读状态的权威事实 | N/A(不调用外部系统) | 换货单与每个启用关联客户的 `notification.personal_customer.direct.requested` 在同一 GORM 事务写入;事件 ID 使用换货单和客户 ID 稳定防重,消费端按事件与接收人唯一键幂等 | +| 套餐临期列表、数量与每日 15/7/3 天节点提醒 | N/A(列表和数量是普通受权读取;通知记录本身是投递事实,不新增人工状态变更) | `tb_package_usage` 的计时条款快照和到期队列是预计最终到期的权威事实,`tb_notification` 保存个人客户通知与已读状态 | N/A(不调用企业微信、短信、邮件或其他外部系统) | 每日任务按资产、到期日、节点和个人客户生成稳定事件 ID,在单个 GORM 事务中幂等写入 `notification.personal_customer.direct.requested`;列表和数量纯 Query 不产生 Outbox | +| 企业微信应用连接配置保存与明文读取 | 配置保存复用 `systemconfig.AuditWriter`,只记录应用标识、状态和 `credentials_configured=true`,不记录明文或密文;明文读取仅允许超级管理员并进入 Access Log,统一敏感读取 Audit Writer 在本 Change 的治理收口任务中继续核验 | `tb_wecom_application` 是 corp_id、agent_id、应用状态及 AES-256-GCM 密文凭据的权威事实;管理响应按用户确认向超级管理员返回解密明文 | 保存和读取本身不调用企微;连接测试或 token 缓存未命中时,每次真实回源均写 `tb_integration_log`,请求和响应摘要不含 Secret、回调凭据或 access_token | N/A(连接配置提交后仅同步失效可重建 token 缓存,不产生必须可靠投递的业务副作用) | +| 企业微信可见成员同步与账号显式绑定 | 成员同步是外部只读快照更新,不记录独立人工审计;账号绑定沿用现有账号操作日志,记录操作者、目标账号及绑定前后 `(corp_id, userid, name)`,不记录手机号或邮箱 | `tb_wecom_member` 是最近同步的应用可见成员选择快照,`tb_account.wecom_*` 是管理员确认后的账号绑定事实;不建立部门组织模型 | 每次真实调用应用可见成员接口均写 `tb_integration_log`,仅记录应用、根部门、成员数量、状态码和耗时,不保存 access_token 或成员列表正文 | N/A(同步和绑定均为同步事务,不产生必须可靠投递的提交后副作用) | +| 企业微信审批业务场景与模板控件映射 | 配置保存复用事务内 `systemconfig.AuditWriter`,记录业务类型、应用 ID、模板 ID、状态和最近校验时间;不保存凭据、审批节点或审批人规则到审计数据 | `tb_wecom_approval_scene` 是两个稳定业务类型的当前模板、控件映射、模板最小快照和启用状态权威事实 | 保存前每次真实调用模板详情接口均写 `tb_integration_log`,记录应用、模板 ID、状态码、控件数量和耗时,不保存 access_token 或完整外部响应 | N/A(配置保存为同步事务,不产生必须可靠投递的提交后副作用) | +| 企业微信默认发起人与审批提交 | 默认发起人配置复用事务内 `systemconfig.AuditWriter`,记录应用、userid 和姓名快照;真实业务提交人继续保存在业务申请及通用审批实例中,不以默认成员伪造操作者 | `tb_wecom_application.default_creator_*` 是应用默认发起人当前配置,`tb_wecom_approval_context` 冻结模板、实际 creator 来源和提交状态,`tb_approval_instance.external_ref` 保存 sp_no | 每次附件上传和 applyevent 均写 `tb_integration_log`;摘要不含 Secret、access_token、media_id、附件正文或完整企微响应;提交超时记 unknown 并登记时间窗批量单号/详情查询恢复策略 | 业务事务写入 `approval.submission.requested`;Worker 条件领取后只提交一次,明确失败和结果未知均终结自动重试,禁止盲目创建第二张审批单 | +| 企业微信审批加密回调与详情终态同步 | 回调为外部自动事实,不伪造人工 Audit Event;验签、AES 解密、receiveid 校验失败只记录安全错误,不进入业务终态 | `tb_integration_log` 按应用和签名幂等保存入站密文哈希,`tb_wecom_approval_context.latest_detail_snapshot` 保存权威详情,通用审批实例及决策投递表保存标准终态 | 入站回调先写 Integration Log pending,详情任务完成后置 completed;每次 `getapprovaldetail` 写独立出站 Integration Log,不保存 access_token,但保存 sp_no、sp_status 和耗时摘要 | 回调只入队结构化 `wecom:approval:sync` 任务;权威终态通过现有 `SyncDecisionService` 原子写 `approval.terminal_decision.recorded`,不直接修改退款或钱包 | +| 企业微信审批主动恢复、未终态轮询与审批人读取投影 | N/A(定时恢复和只读投影均为系统自动动作;不伪造人工操作者,映射不到账号不改变业务事实) | `tb_wecom_approval_context.submission_attempted_at/last_recovery_at/sp_no/latest_detail_snapshot` 与通用审批实例是恢复和展示的权威本地事实;只有唯一候选可从结果未知转为审批中 | 每次 `getapprovalinfo` 分页和 `getapprovaldetail` 均写独立出站 Integration Log,只保存应用、时间窗、模板、发起人、单号数量、sp_no、状态和耗时等安全摘要,不保存 Secret、access_token、media_id、附件正文或完整响应 | Scheduler 仅提交 `wecom:approval:recovery`;恢复和轮询仅提交结构化 `wecom:approval:sync`,不写审批提交 Outbox、不调用 `applyevent`,标准终态仍沿用既有终态 Outbox | +| 员工线下代充值申请与企微终态入账 | 申请保存以真实提交人及明文业务快照留痕;资金成功 Audit Event 延期至既有统一钱包治理任务,企微自动终态不伪造人工审批人 | `tb_agent_recharge_record`、`tb_approval_instance` 和 `tb_wecom_approval_context` 同事务保存申请事实;approved 通过 `topup + recharge_record_id` 唯一成功钱包流水幂等入账,其他终态不修改钱包,通过后撤销不自动冲正 | 申请创建本身不外呼;后续附件上传、applyevent、详情与恢复沿用企微 Integration Log,业务参数按用户确认保存明文,日志仍不记录 Secret、access_token、media_id 或附件正文 | 创建事务写 `approval.submission.requested`;标准终态写 `approval.terminal_decision.recorded`,approved 入账事务再写 `wallet.agent_main.credited`;新审批单禁止旧人工确认或驳回入口绕过 | +| 退款申请与企微终态处理 | 申请以真实提交人和订单/金额/凭证明文快照留痕;企微自动终态不伪造本地人工审批人,资金与佣金 Audit Event 继续沿用既有治理延期口径 | `tb_refund_request`、通用审批实例和企微上下文同事务保存;approved 条件更新订单与退款单,代理主钱包按 refund ID、资产钱包按退款单号复核成功回款;佣金按记录锁定并失效,套餐按订单及换货迁移关系幂等失效 | 申请创建不外呼;附件、applyevent、详情、回调和恢复沿用企微 Integration Log,业务参数明文保存在业务/审批快照中但不复制到 Integration Log,Secret、access_token、media_id 和附件正文仍禁止记录 | 创建事务写 `approval.submission.requested`;终态写 `approval.terminal_decision.recorded`;业务消费者只有在订单、钱包、佣金和资产后处理完成后才确认投递成功,失败释放租约重试 | +| 退款与线下代充值旧审批入口发布切换 | N/A(部署环境开关控制旧入口是否可用,不新增业务操作;实际旧入口操作继续沿用各自既有审计口径) | `approval_instance_id IS NULL` 是存量旧 provider 的兼容边界,非空记录只接受企微标准终态;关闭开关不修改或删除任何业务事实 | N/A(开关判断不调用外部系统,也不得写 Integration Log 冒充发布审计) | N/A(开关判断不产生可靠副作用;企微 Worker 继续消费既有标准终态 Outbox) | +| 单列 CSV 资产套餐批量订购 | 任务创建和逐行订单继续进入 Access Log、任务操作者快照与既有订单/钱包审计接缝;本 Change 不新建批量任务专用 Audit Writer | `tb_asset_package_batch_order_task` 保存输入参数和逐行结果,成功行以 `tb_order`、订单明细、套餐使用、支付记录及代理钱包成功流水为权威业务事实 | 对象存储上传和下载沿用现有存储日志,不把文件正文写入 Integration Log;本切片不新增外部支付或 Gateway 调用 | 创建接口提交结构化 `asset:package:batch_order` Asynq 任务;逐行钱包订单继续沿用既有钱包扣款 Outbox 和佣金任务,重复任务由状态条件与订单幂等规则阻断 | +| IoT 卡与套餐业务导出 | N/A:只读投影,不改变资产、套餐或权限状态;任务创建操作者与权限快照沿用现有导出任务记录 | N/A:导出只读取现有卡、套餐使用、套餐和分配事实,不写入领域账本 | N/A:不调用外部业务系统;对象存储文件生成和下载沿用现有导出基础设施日志 | 沿用现有 `export:dispatch` → `export:shard` → `export:finalize` Asynq 链路,不新增业务 Outbox | +| 钱包流水与代理充值业务导出 | N/A:只读投影,不改变钱包、充值或审批状态;任务创建操作者与店铺权限快照沿用现有导出任务记录 | N/A:只读取主钱包流水、充值记录和本地通用审批实例;金额及余额使用既有权威事实,不写入领域账本 | N/A:不实时调用支付渠道或企业微信;明文业务凭证 Key 只进入导出结果,不复制到 Integration Log,且仍禁止记录 Secret、access_token、media_id 和附件正文 | 仅沿用现有 `export:dispatch` → `export:shard` → `export:finalize` Asynq 链路,不新增业务 Outbox | +| 退款与换货业务导出 | N/A:只读投影,不改变退款、换货、资产或审批状态;任务创建操作者与店铺权限快照沿用现有导出任务记录 | N/A:只读取退款、订单、套餐使用、换货资产快照和本地审批事实;金额及处理标记沿用既有权威事实,不写入领域账本 | N/A:不实时调用企业微信、支付或 Gateway;明文业务凭证和收货资料只进入有权导出结果,不复制到 Integration Log,且仍禁止记录 Secret、access_token、media_id 和附件正文 | 仅沿用现有 `export:dispatch` → `export:shard` → `export:finalize` Asynq 链路,不新增业务 Outbox | +| IoT 卡固定档位限速 | 复用资产操作审计 `card_speed_tier`,记录后台操作者、卡 ICCID、固定档位、Integration Log ID 和 success/failed 结果;设备无入口且不通过绑定卡间接限速 | N/A:不在本地保存或修改卡当前限速状态,Gateway 是外部执行方 | 每次实际 Gateway 调用前写 pending,按 success/failed/unknown 终结;超时 unknown 保存按 ICCID 人工核对策略,摘要不含 Secret、access_token 或完整响应正文 | N/A:单次外部命令无后续可靠副作用,结果未知禁止盲目重发,不创建自动补偿 Outbox | +| 设备 CSV 批量分配代理或套餐系列 | 任务记录冻结操作者和目标;逐批复用 `device_allocate` 或 `device_series_binding` 资产操作审计,记录设备前后值、目标、成功/失败数和失败原因 | N/A:设备归属、绑定卡归属、分配记录及 `series_id` 是权威业务事实,不另建领域账本 | N/A:CSV 解析和两类分配均为本地数据库操作,不调用 Gateway、支付或企微;对象存储沿用现有存储日志 | 复用 `device:import` Asynq 任务;状态条件阻止完成任务重复执行,处理中断恢复时已达到目标关系的设备按成功处理,不新增业务 Outbox | + +### `deliver-july-iteration-confirmed-scope` 任务覆盖映射 + +- 1.1 对应“换货迁移套餐在原订单退款后失效”;1.2~1.4 对应“订单渠道、资产标识、提交人与实名筛选”;1.5 对应“换货创建前未终结退款拦截”。 +- 2.1~2.4 分别对应店铺登录限制、实名策略、下架套餐续费、支付方式配置及后端校验。 +- 3.1~3.3 分别对应物流换货提醒、主钱包低余额提醒、套餐临期提醒。 +- 4.1~4.6 分别由企业微信应用、成员绑定、场景模板、默认发起人与提交、加密回调、主动恢复六行覆盖。 +- 5.1~5.3 分别由员工线下代充值、退款企微终态、旧审批入口发布切换三行覆盖。 +- 6.1~6.6 分别由批量订购、三组业务导出、IoT 卡限速、设备批量分配六行覆盖。 +- 0.1、1.6、7.1、7.3~7.5 只产生证据、API 契约、静态检查或联调文档,不运行生产入口,因此 Audit Event、Domain Ledger、Integration Log 与 Outbox 均为 N/A;7.2 即本基线维护动作。 + ## 旧 Writer 与旧表写入口清单 ### 旧账号审计 diff --git a/README.md b/README.md index 35e7a4a..02eae70 100644 --- a/README.md +++ b/README.md @@ -232,7 +232,7 @@ default: - **统一错误处理**:全局 ErrorHandler 统一处理所有 API 错误,返回一致的 JSON 格式(包含错误码、消息、时间戳);Panic 自动恢复防止服务崩溃;错误分类处理(客户端 4xx、服务端 5xx)和日志级别控制;敏感信息自动脱敏保护 - **数据持久化**:GORM + PostgreSQL 集成,提供完整的 CRUD 操作、事务支持和数据库迁移能力 - **异步任务处理**:Asynq 任务队列集成,支持任务提交、后台执行、自动重试和幂等性保障,实现邮件发送、数据同步等异步任务 -- **统一导出任务系统**:新增全局导出任务入口(`/api/admin/export-tasks`),支持 `scene=device/iot_card`、`format=xlsx/csv`、异步分片执行、任务取消、详情直出 24 小时下载链接;详见 [功能总结](docs/unified-export-task-system/功能总结.md) 与 [验收记录](docs/unified-export-task-system/验收记录.md) +- **统一导出任务系统**:新增全局导出任务入口(`/api/admin/export-tasks`),支持设备、IoT 卡、订单、套餐、代理主钱包流水、代理充值、退款和换货等业务场景,支持 `format=xlsx/csv`、异步分片执行、任务取消、详情直出 24 小时下载链接;详见 [功能总结](docs/unified-export-task-system/功能总结.md)、[业务数据导出说明](docs/business-data-export/功能总结.md) 与 [验收记录](docs/unified-export-task-system/验收记录.md) - **资产操作审计日志**:新增 `tb_asset_operation_log`,统一覆盖卡/设备敏感写操作与统一资产入口,记录 `success/failed/denied`、前后镜像、请求上下文、批量统计并支持敏感字段脱敏;详见 [功能总结](docs/add-asset-operation-audit-log/功能总结.md)、[接口回放示例](docs/add-asset-operation-audit-log/接口回放示例.md) 与 [SQL 验收脚本](docs/add-asset-operation-audit-log/手工验收脚本.sql) - **RBAC 权限系统**:完整的基于角色的访问控制,支持账号、角色、权限的多对多关联和层级关系;基于店铺层级的自动数据权限过滤,实现多租户数据隔离;使用 PostgreSQL WITH RECURSIVE 查询下级店铺并通过 Redis 缓存优化性能;完整的权限检查功能支持路由级别的细粒度权限控制,支持平台过滤(web/h5/all)和超级管理员自动跳过(详见 [功能总结](docs/004-rbac-data-permission/功能总结.md)、[使用指南](docs/004-rbac-data-permission/使用指南.md) 和 [权限检查使用指南](docs/permission-check-usage.md)) - **商户管理**:完整的商户(Shop)和商户账号管理功能,支持商户创建时自动创建初始坐席账号、删除商户时批量禁用关联账号、账号密码重置等功能(详见 [使用指南](docs/shop-management/使用指南.md) 和 [API 文档](docs/shop-management/API文档.md)) @@ -256,6 +256,8 @@ default: - **轮询系统**:IoT 卡实名状态、流量使用、套餐余额的定时轮询检查;支持配置化轮询策略、动态并发控制、告警系统、数据清理和手动触发功能;详见 [轮询系统文档](docs/polling-system/README.md) - **套餐系统升级**:完整的套餐生命周期管理,支持主套餐排队激活、加油包绑定主套餐、囤货待实名激活、流量按优先级扣减、自然月/按天有效期计算、日/月/年流量重置、客户端流量查询和套餐流量详单;详见 [套餐系统升级文档](docs/package-system-upgrade/) - **套餐生效条件覆盖与购买快照**:支持代理分配覆盖套餐生效条件,并将生效条件、周期类型和购买时长固化到套餐使用记录;激活与排队接续只消费购买快照。详见 [功能总结](docs/ur55-package-expiry-base/功能总结.md) +- **UR#33 套餐临期列表与 C 端节点提醒**:平台和代理可按统一最终到期口径分页查看 0~15 天临期卡/设备及分类数量,返回高亮和优先标识;每日 15/7/3 天节点通过公共 Outbox 向关联个人客户幂等发送站内通知。详见 [功能总结](docs/ur33-package-expiry-reminder/功能总结.md) +- **企业微信最小审批 Adapter、退款与员工线下代充值**:超级管理员可维护自建应用连接参数、默认发起人和后台模板映射;管理接口直接返回可编辑明文,数据库使用 AES-256-GCM 密文保存。Adapter 支持成员显式绑定、安全异步提交、加密回调、权威详情同步、未终态轮询和结果未知时间窗恢复;退款和员工线下代充值均原子创建业务单与审批事实,approved 复用既有资金用例幂等执行,其他终态不产生资金副作用。未知结果只有唯一审批单号候选才关联,绝不盲目重提。详见 [功能总结](docs/wecom-application-connection/功能总结.md) - **套餐价格回退与平台赠送策略**:新增价格配置状态、普通套餐成本价回退、赠送套餐独立语义、平台后台赠送订单发放和历史 0 价复核清单;详见 [功能总结](docs/package-price-fallback-and-platform-gift-policy/功能总结.md) 与 [最终验收清单](docs/package-price-fallback-and-platform-gift-policy/最终验收清单.md) - **分佣验证指引**:对代理分佣的冻结、解冻、提现校验流程进行了结构化说明与流程图,详见 [分佣逻辑正确与否验证](docs/优化说明/分佣逻辑正确与否验证.md) - **对象存储**:S3 兼容的对象存储服务集成(联通云 OSS),支持预签名 URL 上传、文件下载、临时文件处理;用于 ICCID 批量导入、数据导出等场景;详见 [使用指南](docs/object-storage/使用指南.md) 和 [前端接入指南](docs/object-storage/前端接入指南.md) @@ -941,9 +943,17 @@ rdb.Set(ctx, key, status, time.Hour) - **[数据库验证规范](AGENTS.md#数据库验证规范)**:使用 PostgreSQL MCP 验证接口逻辑和业务数据的正确性 - **[开发规范总览](AGENTS.md)**:完整的项目开发规范(必读) - **[七月迭代 AI 实施与验收操作手册](docs/7月迭代/七月迭代-AI实施与验收操作手册.md)**:PRD 拆票、Issues 实现、测试、双轴评审与验收流程 +- **[七月迭代实现与接口对接说明](docs/7月迭代/七月迭代实现与接口对接说明.md)**:按需求说明实现关键节点、口径检查、接口变化和前端应补齐的调用 +- **[七月迭代联调交付说明](docs/7月迭代/七月迭代联调交付说明.md)**:企业微信、Gateway、Redis/Asynq、对象存储、前端契约、限制和回滚步骤 ### 功能指南 +- **[企业微信审批 Adapter](docs/wecom-application-connection/功能总结.md)**:应用连接、默认发起人、账号绑定、模板映射、安全异步提交及退款/线下代充值闭环说明 +- **[企业微信审批存量切换清单](docs/wecom-application-connection/存量审批切换清单.md)**:旧人工入口默认保留、存量核对、前端只读切换、停用门禁与回滚步骤 +- **[资产套餐批量订购](docs/asset-package-batch-order/功能总结.md)**:单列 CSV、整批统一套餐与支付方式、五态异步任务及逐行订单结果说明 +- **[业务数据导出](docs/business-data-export/功能总结.md)**:IoT 卡套餐流量列、套餐列表及后续业务 datasource 的字段、筛选和权限口径 +- **[IoT 卡固定档位限速](docs/iot-card-speed-tier/功能总结.md)**:仅按卡 ICCID 调用 Gateway 的 `-1..8` 固定档位、权限、Integration Log 与结果未知处理;设备不提供限速入口 +- **[设备 CSV 批量分配](docs/device-batch-allocation/功能总结.md)**:复用设备导入任务外壳,以单列 VirtualNo/IMEI/SN 为整批分配目标代理或套餐系列,并沿用现有权限、事务、审计和幂等规则 - **[快速开始指南](specs/001-fiber-middleware-integration/quickstart.md)**:详细设置和测试说明 - **[限流指南](docs/rate-limiting.md)**:全面的限流配置和使用 - **[错误处理使用指南](docs/003-error-handling/使用指南.md)**:错误码参考、Handler 使用、客户端处理、最佳实践 diff --git a/cmd/api/docs.go b/cmd/api/docs.go index 471da36..fac2aae 100644 --- a/cmd/api/docs.go +++ b/cmd/api/docs.go @@ -5,6 +5,7 @@ import ( "go.uber.org/zap" "github.com/break/junhong_cmp_fiber/internal/bootstrap" + "github.com/break/junhong_cmp_fiber/internal/handler/admin" "github.com/break/junhong_cmp_fiber/internal/handler/callback" "github.com/break/junhong_cmp_fiber/internal/routes" "github.com/break/junhong_cmp_fiber/pkg/openapi" @@ -24,10 +25,14 @@ func generateOpenAPIDocs(outputPath string, logger *zap.Logger) { // 3. 创建所有 Handler(使用 nil 依赖,因为只需要路由结构) // 新增 Handler 必须注册到 openapi.BuildDocHandlers,代理开放接口也从该入口进入文档生成器。 handlers := openapi.BuildDocHandlers() + handlers.AssetPackageBatchOrder = admin.NewAssetPackageBatchOrderHandler(nil, nil) + // 企业微信 Handler 在此显式装配,避免新增管理接口遗漏文档注册。 + handlers.WeCom = admin.NewWeComHandler(nil, nil) handlers.CTCCRealnameCallback = callback.NewCTCCRealnameHandler(nil, nil, nil, nil, nil, nil, nil) handlers.CMCCRealnameCallback = callback.NewCMCCRealnameHandler(nil, nil, nil, nil, nil, nil, nil) handlers.CUCCRealnameCallback = callback.NewCUCCRealnameHandler(nil, nil, nil, nil, nil, nil, nil) handlers.CUCCRealnameRemovalCallback = callback.NewCUCCRealnameRemovalHandler(nil, nil, nil, nil, nil) + handlers.WeComApprovalCallback = callback.NewWeComApprovalHandler(nil) // 4. 注册所有路由到文档生成器 routes.RegisterRoutesWithDoc(app, handlers, &bootstrap.Middlewares{}, adminDoc) diff --git a/cmd/gendocs/main.go b/cmd/gendocs/main.go index 03cb69c..69340d4 100644 --- a/cmd/gendocs/main.go +++ b/cmd/gendocs/main.go @@ -7,6 +7,7 @@ import ( "github.com/gofiber/fiber/v2" "github.com/break/junhong_cmp_fiber/internal/bootstrap" + "github.com/break/junhong_cmp_fiber/internal/handler/admin" "github.com/break/junhong_cmp_fiber/internal/handler/callback" "github.com/break/junhong_cmp_fiber/internal/routes" "github.com/break/junhong_cmp_fiber/pkg/openapi" @@ -33,10 +34,14 @@ func generateAdminDocs(outputPath string) error { // 3. 创建所有 Handler(使用 nil 依赖,因为只需要路由结构) // 新增 Handler 必须注册到 openapi.BuildDocHandlers,代理开放接口也从该入口进入文档生成器。 handlers := openapi.BuildDocHandlers() + handlers.AssetPackageBatchOrder = admin.NewAssetPackageBatchOrderHandler(nil, nil) + // 企业微信 Handler 在此显式装配,避免新增管理接口遗漏文档注册。 + handlers.WeCom = admin.NewWeComHandler(nil, nil) handlers.CTCCRealnameCallback = callback.NewCTCCRealnameHandler(nil, nil, nil, nil, nil, nil, nil) handlers.CMCCRealnameCallback = callback.NewCMCCRealnameHandler(nil, nil, nil, nil, nil, nil, nil) handlers.CUCCRealnameCallback = callback.NewCUCCRealnameHandler(nil, nil, nil, nil, nil, nil, nil) handlers.CUCCRealnameRemovalCallback = callback.NewCUCCRealnameRemovalHandler(nil, nil, nil, nil, nil) + handlers.WeComApprovalCallback = callback.NewWeComApprovalHandler(nil) // 4. 注册所有路由到文档生成器 routes.RegisterRoutesWithDoc(app, handlers, &bootstrap.Middlewares{}, adminDoc) diff --git a/cmd/worker/main.go b/cmd/worker/main.go index da22776..e27381f 100644 --- a/cmd/worker/main.go +++ b/cmd/worker/main.go @@ -14,19 +14,25 @@ import ( "github.com/redis/go-redis/v9" "go.uber.org/zap" + agentrechargeApp "github.com/break/junhong_cmp_fiber/internal/application/agentrecharge" + approvalApp "github.com/break/junhong_cmp_fiber/internal/application/approval" cardObservationApp "github.com/break/junhong_cmp_fiber/internal/application/cardobservation" notificationApp "github.com/break/junhong_cmp_fiber/internal/application/notification" + walletApp "github.com/break/junhong_cmp_fiber/internal/application/wallet" "github.com/break/junhong_cmp_fiber/internal/bootstrap" "github.com/break/junhong_cmp_fiber/internal/gateway" + approvalInfra "github.com/break/junhong_cmp_fiber/internal/infrastructure/approval" cardObservationInfra "github.com/break/junhong_cmp_fiber/internal/infrastructure/cardobservation" "github.com/break/junhong_cmp_fiber/internal/infrastructure/integrationlog" "github.com/break/junhong_cmp_fiber/internal/infrastructure/messaging/outbox" notificationInfra "github.com/break/junhong_cmp_fiber/internal/infrastructure/notification" shopInfra "github.com/break/junhong_cmp_fiber/internal/infrastructure/shop" walletInfra "github.com/break/junhong_cmp_fiber/internal/infrastructure/wallet" + wecomInfra "github.com/break/junhong_cmp_fiber/internal/infrastructure/wecom" "github.com/break/junhong_cmp_fiber/internal/model" "github.com/break/junhong_cmp_fiber/internal/polling" iot_card_svc "github.com/break/junhong_cmp_fiber/internal/service/iot_card" + refundSvc "github.com/break/junhong_cmp_fiber/internal/service/refund" "github.com/break/junhong_cmp_fiber/internal/store/postgres" "github.com/break/junhong_cmp_fiber/internal/task" pkgBootstrap "github.com/break/junhong_cmp_fiber/pkg/bootstrap" @@ -135,6 +141,7 @@ func runWorker(cfg *config.Config) { taskHandler := createTaskHandler(runtime, appLogger) taskHandler.RegisterHandlers() + registerWeComApprovalTasks(taskHandler.GetMux(), runtime, cfg, appLogger) outboxHandler := outbox.NewHandler(runtime.outboxConsumers) taskHandler.GetMux().HandleFunc(constants.TaskTypeOutboxDeliver, outboxHandler.Handle) startOutboxRelay(ctx, runtime, cfg.Worker.InstanceName, appLogger) @@ -231,12 +238,14 @@ func initWorkerRuntime(ctx context.Context, cfg *config.Config, appLogger *zap.L Password: cfg.Redis.Password, DB: cfg.Redis.DB, }) + workerQueueClient := queue.NewClient(redisClient, appLogger) workerDeps := &bootstrap.WorkerDependencies{ DB: db, Redis: redisClient, Logger: appLogger, AsynqClient: asynqClient, + QueueClient: workerQueueClient, StorageService: storageSvc, GatewayClient: gatewayClient, } @@ -247,7 +256,7 @@ func initWorkerRuntime(ctx context.Context, cfg *config.Config, appLogger *zap.L } workerServer := queue.NewServer(redisClient, &cfg.Queue, appLogger) - outboxQueueClient := queue.NewClient(redisClient, appLogger) + outboxQueueClient := workerQueueClient pollingConfigStore := postgres.NewPollingConfigStore(db) pollingConfigMgr := polling.NewPollingConfigManager(pollingConfigStore, redisClient, appLogger) @@ -301,9 +310,108 @@ func initWorkerRuntime(ctx context.Context, cfg *config.Config, appLogger *zap.L registerNotificationOutboxConsumer(runtime, appLogger) registerWalletOutboxConsumer(runtime, appLogger) registerCardObservationOutboxConsumer(runtime, appLogger) + registerWeComApprovalOutboxConsumer(runtime, cfg, appLogger) return runtime } +// registerWeComApprovalOutboxConsumer 注册企业微信审批提交和标准终态业务消费者。 +func registerWeComApprovalOutboxConsumer(runtime *workerRuntime, cfg *config.Config, appLogger *zap.Logger) { + applicationRepository := wecomInfra.NewApplicationRepository(runtime.db) + credentialCipher, err := wecomInfra.NewCredentialCipher(cfg.WeCom.CredentialEncryptionKey) + if err != nil { + appLogger.Warn("企业微信凭据加密密钥未配置或无效,审批提交将失败关闭", zap.Error(err)) + } + integrationRepository := integrationlog.NewRepository(runtime.db) + tokenProvider := wecomInfra.NewTokenProvider( + applicationRepository, credentialCipher, runtime.redisClient, integrationRepository, + cfg.WeCom.BaseURL, cfg.WeCom.Timeout, appLogger, + ) + consumer := wecomInfra.NewApprovalSubmissionConsumer( + wecomInfra.NewApprovalContextRepository(runtime.db), + wecomInfra.NewApprovalSubmissionClient(tokenProvider, integrationRepository, cfg.WeCom.BaseURL, cfg.WeCom.Timeout), + wecomInfra.NewApprovalAttachmentUploader(tokenProvider, integrationRepository, runtime.storageSvc, cfg.WeCom.BaseURL, cfg.WeCom.Timeout), + ) + if err := runtime.outboxConsumers.Register(constants.OutboxEventTypeApprovalSubmissionRequested, consumer); err != nil { + appLogger.Fatal("注册企业微信审批提交 Outbox 消费者失败", + zap.String("event_type", constants.OutboxEventTypeApprovalSubmissionRequested), zap.Error(err)) + } + owner := cfg.Worker.InstanceName + if owner == "" { + owner = fmt.Sprintf("worker-%d", os.Getpid()) + } + walletPosting := walletApp.NewPostingService( + walletInfra.NewCreditEventWriter(outbox.NewRepository()), + nil, + ) + stopResumeService, _ := runtime.workerResult.Services.StopResumeService.(*iot_card_svc.StopResumeService) + refundService := refundSvc.New( + runtime.db, + postgres.NewRefundStore(runtime.db), + runtime.workerResult.Stores.Order, + runtime.workerResult.Stores.CommissionRecord, + runtime.workerResult.Stores.AgentWallet, + runtime.workerResult.Stores.AgentWalletTransaction, + stopResumeService, + nil, + runtime.workerResult.Services.ActivationService, + runtime.workerResult.Stores.IotCard, + runtime.workerResult.Stores.Device, + runtime.workerResult.Stores.AssetWallet, + appLogger, + ) + refundService.SetAgentWalletRefundService( + walletApp.NewRefundService(walletInfra.NewRefundEventWriter(outbox.NewRepository()), nil), + ) + decisionDispatcher := approvalApp.NewDecisionDispatcher( + approvalInfra.NewDecisionDeliveryStore(runtime.db), + map[string]approvalApp.BusinessDecisionHandler{ + constants.ApprovalBusinessTypeOfflineRecharge: agentrechargeApp.NewApprovalDecisionHandler(runtime.db, walletPosting), + constants.ApprovalBusinessTypeRefund: refundService, + }, + owner, + appLogger, + nil, + ) + decisionConsumer := approvalInfra.NewTerminalDecisionConsumer(decisionDispatcher) + if err := runtime.outboxConsumers.Register(constants.OutboxEventTypeApprovalTerminalDecision, decisionConsumer); err != nil { + appLogger.Fatal("注册审批标准终态 Outbox 消费者失败", + zap.String("event_type", constants.OutboxEventTypeApprovalTerminalDecision), zap.Error(err)) + } +} + +// registerWeComApprovalTasks 注册企微权威详情同步和主动恢复任务。 +func registerWeComApprovalTasks(mux *asynq.ServeMux, runtime *workerRuntime, cfg *config.Config, appLogger *zap.Logger) { + applicationRepository := wecomInfra.NewApplicationRepository(runtime.db) + credentialCipher, err := wecomInfra.NewCredentialCipher(cfg.WeCom.CredentialEncryptionKey) + if err != nil { + appLogger.Warn("企业微信凭据加密密钥未配置或无效,审批详情同步将失败关闭", zap.Error(err)) + } + integrationRepository := integrationlog.NewRepository(runtime.db) + tokenProvider := wecomInfra.NewTokenProvider( + applicationRepository, credentialCipher, runtime.redisClient, integrationRepository, + cfg.WeCom.BaseURL, cfg.WeCom.Timeout, appLogger, + ) + decisionSync := approvalApp.NewSyncDecisionService( + runtime.db, approvalInfra.NewRepositoryProvider(), + approvalInfra.NewTerminalEventWriter(outbox.NewRepository()), + approvalInfra.NewDecisionDeliveryStore(runtime.db), nil, + ) + contexts := wecomInfra.NewApprovalContextRepository(runtime.db) + detailHandler := wecomInfra.NewApprovalDetailTaskHandler( + wecomInfra.NewApprovalDetailClient(tokenProvider, integrationRepository, cfg.WeCom.BaseURL, cfg.WeCom.Timeout), + contexts, decisionSync, integrationRepository, + ) + recoveryHandler := wecomInfra.NewApprovalRecoveryTaskHandler( + contexts, + wecomInfra.NewApprovalInfoClient(tokenProvider, integrationRepository, cfg.WeCom.BaseURL, cfg.WeCom.Timeout), + runtime.outboxQueueClient, + ) + mux.HandleFunc(constants.TaskTypeWeComApprovalSync, detailHandler.Handle) + mux.HandleFunc(constants.TaskTypeWeComApprovalRecovery, recoveryHandler.Handle) + appLogger.Info("注册企业微信审批详情同步任务处理器", zap.String("task_type", constants.TaskTypeWeComApprovalSync)) + appLogger.Info("注册企业微信审批主动恢复任务处理器", zap.String("task_type", constants.TaskTypeWeComApprovalRecovery)) +} + // registerCardObservationOutboxConsumer 注册卡观测领域事件消费者。 func registerCardObservationOutboxConsumer(runtime *workerRuntime, appLogger *zap.Logger) { stopResumeService, _ := runtime.workerResult.Services.StopResumeService.(iot_card_svc.StopResumeServiceInterface) @@ -543,7 +651,7 @@ func startAsynqScheduler(cfg *config.Config, redisAddr string, appLogger *zap.Lo } }() - appLogger.Info("Asynq Scheduler 已启动(订单超时: @every 1m, 告警检查: @every 1m, 数据清理: 0 2 * * *, 通知清理: 15 2 * * *, 每日流量落盘: 0 2 * * *)") + appLogger.Info("Asynq Scheduler 已启动(企微审批恢复: 每 2 分钟,套餐临期提醒: 上海时区每日 03:00)") return asynqScheduler } @@ -563,6 +671,15 @@ func registerAsynqScheduleTasks(asynqScheduler *asynq.Scheduler) error { )); err != nil { return fmt.Errorf("注册告警检查定时任务失败: %w", err) } + if _, err := asynqScheduler.Register("@every 2m", asynq.NewTask( + constants.TaskTypeWeComApprovalRecovery, + nil, + asynq.MaxRetry(3), + asynq.Timeout(2*time.Minute), + asynq.Queue(constants.QueueForTaskType(constants.TaskTypeWeComApprovalRecovery)), + )); err != nil { + return fmt.Errorf("注册企业微信审批主动恢复定时任务失败: %w", err) + } if _, err := asynqScheduler.Register("0 2 * * *", asynq.NewTask( constants.TaskTypeDataCleanup, nil, @@ -578,6 +695,15 @@ func registerAsynqScheduleTasks(asynqScheduler *asynq.Scheduler) error { )); err != nil { return fmt.Errorf("注册站内通知保留清理定时任务失败: %w", err) } + if _, err := asynqScheduler.Register("CRON_TZ=Asia/Shanghai 0 3 * * *", asynq.NewTask( + constants.TaskTypePackageExpiryReminder, + nil, + asynq.MaxRetry(3), + asynq.Timeout(10*time.Minute), + asynq.Queue(constants.QueueForTaskType(constants.TaskTypePackageExpiryReminder)), + )); err != nil { + return fmt.Errorf("注册套餐临期提醒定时任务失败: %w", err) + } if _, err := asynqScheduler.Register( "0 2 * * *", asynq.NewTask( diff --git a/docs/7月迭代/7月迭代前后端任务工时表.md b/docs/7月迭代/7月迭代前后端任务工时表.md index c33f33e..546437a 100644 --- a/docs/7月迭代/7月迭代前后端任务工时表.md +++ b/docs/7月迭代/7月迭代前后端任务工时表.md @@ -20,7 +20,7 @@ |需求07:实名筛选|卡和设备列表增加实名状态筛选|卡按自身状态,设备按有效绑定卡维护/查询实名状态|1~2小时|2~3小时|设备多当前卡、绑定历史异常会增加 2~3 小时| |需求08:设备批量分配|拆分“分配代理”和“分配套餐系列”两个入口,展示任务结果|复用 Excel/Asynq,新增两个独立批量命令和失败明细|2~3小时|3~4小时|前端无现成上传任务组件或生产文件格式不统一会增加 2~4 小时| |需求09:C 端支付限制|支付页按接口返回方式展示,后台增加配置控件|实现受控系统配置、缓存和订单支付二次校验|1.5~2.5小时|2~3小时|多端支付入口未复用同一接口会增加逐端排查时间| -|需求10:Gateway 限速|卡/设备详情增加设置和取消入口|统一 `speed_kbps` 接口,设备解析当前卡后调用 Gateway|1~2小时|2~3小时|Gateway 取消参数不明确或联调不稳定会增加 2~4 小时| +|需求10:Gateway 限速|仅 IoT 卡详情增加固定档位设置和恢复不限速入口|卡专用 `speed-tier` 接口按 ICCID 调用 Gateway,设备无入口|1~2小时|2~3小时|Gateway 档位或联调不稳定会增加 2~4 小时| |需求11:当前套餐到期高亮|并入需求06/22,不单独建设第二个资产汇总字段|并入预计最终到期 Query|0小时|0小时|无独立工时| |需求12:换货显示与搜索|拆分新旧资产搜索并修正字段展示|修正新建换货快照,增加新旧资产关键词过滤|1~2小时|1~2小时|历史数据不回填导致验收口径混淆会增加沟通时间| |需求13:列表字段新增|退款、充值、换货列表增加提交人和企微审批摘要|补提交人快照,批量查询企微状态和审批人摘要|1~2小时|2~3小时|旧记录缺提交人或企微实例会增加兼容展示时间| diff --git a/docs/7月迭代/7月迭代技术方案-标准评审稿.md b/docs/7月迭代/7月迭代技术方案-标准评审稿.md index 3a65551..2cba88e 100644 --- a/docs/7月迭代/7月迭代技术方案-标准评审稿.md +++ b/docs/7月迭代/7月迭代技术方案-标准评审稿.md @@ -782,13 +782,8 @@ sequenceDiagram ```mermaid flowchart TD - Trigger[后台手动设置或取消] --> Asset{资产类型} - Asset -->|卡| ICCID[读取卡 ICCID] - Asset -->|设备| Binding[查询 is_current=true 的当前卡] - Binding --> Exists{当前卡有效?} - Exists -->|否| Reject[拒绝操作并记录原因] - Exists -->|是| ICCID - ICCID --> Gateway[SetSpeedLimit cardNo, speedLevel] + Trigger[卡详情手动选择固定档位] --> ICCID[校验 IoT 卡 ICCID 与数据权限] + ICCID --> Gateway[SetCardSpeedTier cardNo, code] Gateway --> Audit[记录操作审计和 Gateway 结果] ``` @@ -796,15 +791,15 @@ flowchart TD 数据和契约: -- 应用层只接收语义化 `speed_level`,前端只能从固定档位中选择,禁止输入任意速率,也不得接触 Gateway 渠道 `code`。 -- 统一接口:`POST /api/admin/assets/{identifier}/speed-limit`。资产为设备时解析当前绑定卡,不存在当前卡则拒绝,绝不把设备 IMEI 传给 Gateway。 -- Gateway 端口只有 `SetSpeedLimit(cardNo, speedLevel)`;Infrastructure Adapter 根据 Gateway 账户和运营商把业务等级映射为上游字符串 `code`,映射不得进入 Handler、前端或领域模型。 +- 后台接口只接收固定整数档位 `code=-1..8`,前端只能从固定档位中选择,禁止输入任意速率。 +- 卡专用接口:`PUT /api/admin/iot-cards/{iccid}/speed-tier`。设备没有限速入口,也不得通过设备当前绑定卡间接限速。 +- Gateway 端口只有 `SetCardSpeedTier(cardNo, code)`,`cardNo` 必须是经过本地权限校验的卡 ICCID,不得传设备 ID 或 IMEI。 - “恢复不限速”和“限到 0kbps”是两个不同业务等级,必须分别映射为上游 `code=-1` 和 `code=0`,禁止继续用数值 `0` 表示取消限速。 -- 上游说明只有广电和电信直接支持限速接口,联通通过通信计划调整,移动不支持限速。调用前必须按卡的运营商能力校验;不支持或缺少账户档位映射时返回明确业务错误,不得猜测 `code` 或盲目调用。 +- 上游是否支持具体运营商与账户由 Gateway 返回结果确认;本系统只校验固定档位并原样发送,不自行推算运营商策略。 - 不新增 `tb_package.speed_limit_kbps`、`SpeedLimitApplyRequested` Outbox 或自动补偿 Worker。本期也不宣称能展示 Gateway 当前实际限速,除非上游另提供查询接口。 -- 卡详情和设备详情都提供固定档位选择及恢复不限速入口;设备入口明确显示“当前使用卡 ICCID”。每次操作记录资产、最终 `cardNo`、业务 `speed_level`、实际发送的渠道 `code`、Gateway 返回的 `appliedSpeed/channelRawValue`、操作人、请求结果和错误摘要。 +- 仅卡详情提供固定档位选择及恢复不限速入口。每次操作记录卡 ICCID、实际发送的 `code`、操作人、Integration Log、请求结果和错误摘要。 -CMP 固定业务等级如下。枚举名称属于本系统稳定契约;展示文案和上游默认 `code` 仅用于表达当前已知映射,实际调用仍须按 Gateway 账户和运营商查找映射。 +CMP 固定档位如下,接口直接提交表中的整数 `code`。 | `speed_level` | 展示文案 | 当前上游默认 `code` | |---------------|----------|----------------------| diff --git a/docs/7月迭代/7月迭代禅道研发需求拆分表.md b/docs/7月迭代/7月迭代禅道研发需求拆分表.md index 9afd92a..a757f08 100644 --- a/docs/7月迭代/7月迭代禅道研发需求拆分表.md +++ b/docs/7月迭代/7月迭代禅道研发需求拆分表.md @@ -103,7 +103,7 @@ FE/BE 研发需求开发完成 | #53 实名状态筛选 | **标题:**卡和设备实名状态筛选。
**页面:**两个资产列表增加全部/已实名/未实名筛选和状态列。 | **标题:**卡和设备实名状态查询。
**接口:**`GET /api/admin/iot-cards?real_name_status=0\|1`;`GET /api/admin/devices?real_name_status=0\|1`。
**返回:**`real_name_status/real_name_status_name`。 | | #49 设备批量分配 | **标题:**设备批量分配代理和套餐系列双入口。
**页面:**两个独立上传弹框,展示总数、成功数、失败数和失败原因。 | **标题:**设备两类批量分配任务。
**接口:**`POST /api/admin/devices/batch-assign-shop`、`POST /api/admin/devices/batch-assign-series`、`GET /api/admin/devices/batch-allocation/{task_id}`。
**入参:**文件和目标ID。
**返回:**任务状态及失败明细。 | | #48 支付方式限制 | **标题:**C端按资产展示支付方式。
**页面:**支付页只展示接口返回的支付方式;无可用方式时禁止提交。 | **标题:**资产支付方式配置与订单校验。
**接口:**`GET /api/c/v1/asset/info` 返回 `allowed_payment_methods`;`POST /api/c/v1/orders/create`、`POST /api/c/v1/orders/{id}/pay` 再次校验。
**后台:**`PUT /api/admin/system/config/{config_key}`。 | -| #47 Gateway限速 | **标题:**卡和设备手动限速。
**页面:**详情页提供设置、取消入口;设备显示最终使用的当前卡ICCID。 | **标题:**Gateway按cardNo统一限速。
**接口:**`POST /api/admin/assets/{identifier}/speed-limit`。
**入参:**`speed_kbps`,0表示取消。
**返回:**资产标识、最终 `card_no`、目标值和执行结果。 | +| #47 Gateway限速 | **标题:**IoT 卡手动固定档位限速。
**页面:**仅卡详情提供设置、恢复不限速入口;设备不提供限速操作。 | **标题:**Gateway 卡 ICCID 固定档位限速。
**接口:**`PUT /api/admin/iot-cards/{iccid}/speed-tier`。
**入参:**`code=-1..8`,`-1` 表示恢复不限速。
**返回:**卡ID、ICCID、档位名称和 Integration Log ID。 | | #46 预计最终到期 | **标题:**预计套餐到期时间和临期高亮。
**页面:**资产详情只显示一个最终到期字段;不可预计时显示“待激活后起算”。 | **标题:**当前及排队套餐最终到期Query。
**接口:**`GET /api/admin/assets/resolve/{identifier}`。
**返回:**`estimated_final_expires_at/days_until_final_expiry/expiry_estimate_status/is_expiring`。 | | #45 换货管理 | **标题:**换货新旧资产展示和独立搜索。
**页面:**列表分别搜索旧资产、新资产,展示统一卡ICCID或设备号。 | **标题:**换货标识快照和查询。
**接口:**`GET /api/admin/exchanges?old_asset_keyword=&new_asset_keyword=`。
**返回:**新旧资产类型、ID、标识及换货状态。新建换货统一保存规范化标识。 | | #44 列表字段新增 | **标题:**退款、充值、换货列表提交人与审批摘要。
**页面:**增加提交人、企微状态、当前审批人摘要和业务处理状态。 | **标题:**提交人快照与企微摘要查询。
**接口:**复用 `GET /api/admin/refunds`、`GET /api/admin/agent-recharges`、`GET /api/admin/exchanges`。
**返回:**`submitter_name/approval_status_name/current_approver_summary/processing_status_name`。 | @@ -200,7 +200,7 @@ FE/BE 研发需求开发完成 | INT-04 | CSV批量任务与导出联调 | #49、#36、#42 | FE 1~1.5h / BE 1~1.5h,已含 | 上传、任务详情、Worker和导出场景完成 | 模板校验、部分成功、失败明细、任务恢复、字段权限和文件下载 | | INT-05 | 支付、代理钱包、信用和余额预警联调 | #48、#38、#34、#36、#96、#97 | FE 1~1.5h / BE 1~1.5h,已含 | 支付配置、钱包领域和业务员接口完成 | 支付方式限制、扫码充值、信用扣款、角色默认额度、店铺调额、100元预警 | | INT-06 | 企业微信审批、退款和线下充值联调 | #37、#35、#34、#44 | FE 1.5~2h / BE 1.5~2h,已含 | 企微模板、绑定、回调、轮询和业务详情完成 | 扫码绑定、发起审批、意见附件、通过/驳回/撤销、退款/充值终态和列表摘要 | -| INT-07 | Gateway卡限速联调 | #47 | FE 0.5~1h / BE 0.5~1h,已含 | Gateway联调配置和卡/设备入口完成 | 单卡限速、设备解析当前卡、取消限速、无当前卡、失败审计 | +| INT-07 | Gateway卡限速联调 | #47 | FE 0.5~1h / BE 0.5~1h,已含 | Gateway联调配置和卡专用入口完成 | 单卡各固定档位、恢复不限速、越权卡、Gateway 失败/结果未知与审计 | | INT-08 | 七月迭代全链路与停机发布验收 | 全部激活需求及三项公共技术需求 | FE 3~4h / BE 4~5h,额外 | INT-01~07、公共基础12、全局审计19、公共通知08及各业务发布门禁完成 | 权限、通知、全系统审计覆盖、正式Adapter、事件消费者、历史数据、旧入口关闭、Worker恢复、生产阈值和发布检查 | ## 七、联调任务责任方式 diff --git a/docs/7月迭代/7月迭代禅道研发需求逐条录入稿.md b/docs/7月迭代/7月迭代禅道研发需求逐条录入稿.md index 421f75b..2b75b03 100644 --- a/docs/7月迭代/7月迭代禅道研发需求逐条录入稿.md +++ b/docs/7月迭代/7月迭代禅道研发需求逐条录入稿.md @@ -784,29 +784,28 @@ **标题** ```text -[FE][UR#47] 卡设备手动设置与取消限速 +[FE][UR#47] IoT 卡手动设置固定限速档位 ``` **描述** ```markdown -目标:在卡和设备详情提供统一的手动限速入口,不展示自动限速规则。 +目标:仅在 IoT 卡详情提供手动固定档位限速入口,不展示自动限速规则。 预计工时:前端1~2小时。 -页面入口:卡详情、设备详情。 +页面入口:IoT 卡详情;设备详情不提供限速入口。 页面结构: -1. “手动限速”弹框只展示后端返回的固定语义档位,不允许输入任意速率或上游code。 -2. “恢复不限速”提交speed_level=unlimited;“限到0kbps”提交speed_level=zero_kbps,两者严格区分。 -3. 设备详情必须显示本次实际作用的当前卡ICCID;无当前卡时禁用操作并展示原因。 -4. 不展示“Gateway当前实际限速”,除非接口未来明确返回查询结果。 +1. “手动限速”弹框只展示 `code=-1..8` 固定档位,不允许输入任意速率。 +2. “恢复不限速”提交 `code=-1`;“限到0kbps”提交 `code=0`,两者严格区分。 +3. 不展示“Gateway当前实际限速”,除非接口未来明确返回查询结果。 -接口约定:POST /api/admin/assets/{identifier}/speed-limit,body={speed_level:string};返回asset_type、asset_identifier、card_no、speed_level、channel_code、applied_speed、channel_raw_value、result、message。 +接口约定:`PUT /api/admin/iot-cards/{iccid}/speed-tier`,`body={code:int}`;返回 `iot_card_id/iccid/code/speed_tier_name/integration_id`。 -交互规则:提交中禁用按钮;失败保留所选档位;中国电信/广电按后端能力展示,联通/移动或映射缺失时禁用并展示原因;结果未知不能显示成功。 +交互规则:提交中禁用按钮;失败保留所选档位;结果未知不能显示成功,并提示按 ICCID 核对 Gateway 实际档位。 -完成标准:卡限速、设备解析当前卡、取消限速和无当前卡四类场景完整。 +完成标准:卡固定档位、恢复不限速、越权卡、明确失败和结果未知场景完整;设备无入口。 ``` ### 后端研发需求 @@ -814,23 +813,23 @@ **标题** ```text -[BE][UR#47] Gateway按cardNo统一限速接口 +[BE][UR#47] Gateway按卡ICCID设置固定限速档位 ``` **描述** ```markdown -目标:只开放一个手动限速接口,最终始终按卡ICCID调用Gateway。 +目标:只开放 IoT 卡手动限速接口,按卡 ICCID 调用 Gateway。 预计工时:后端2~3小时。 -接口:POST /api/admin/assets/{identifier}/speed-limit,入参为固定语义枚举speed_level;恢复不限速与限到0kbps是不同值,前端和业务层不得接触渠道code。 +接口:`PUT /api/admin/iot-cards/{iccid}/speed-tier`,入参为固定整数档位 `code=-1..8`;恢复不限速与限到0kbps分别使用 `-1` 和 `0`。 -规则:资产为卡时读取ICCID;资产为设备时解析is_current=true的唯一有效当前卡;不存在或多条当前卡都拒绝;绝不把设备号传给Gateway。仅中国电信和中国广电直连POST /flow-card/speedLimit;Adapter按Gateway账号+运营商+speed_level映射code,缺失映射不猜测。外部副作用关闭自动重试,超时等结果不明记录unknown。 +规则:按 ICCID 查询 IoT 卡并沿用现有数据权限;只向 `POST /flow-card/speedLimit` 发送 `cardNo + code`。设备没有限速入口,也不得解析设备当前绑定卡间接调用。外部副作用关闭盲目自动重试,超时等结果不明记录 `unknown`。 非目标:不建立套餐固定限速,不因激活、到期、停机或切卡自动限速,不增加自动补偿Worker。 -完成标准:返回最终card_no、语义档位和规范化调用结果;电信、广电、联通、移动、映射缺失、明确失败和结果未知均有正确行为;每次请求记录操作审计及Integration Log。 +完成标准:返回卡 ID、ICCID、固定档位和 Integration Log ID;明确失败和结果未知均有正确行为;每次请求记录操作审计及 Integration Log。 ``` ## UR#46 资产信息详情字段新增 @@ -1914,7 +1913,7 @@ 进入条件:统一限速接口、卡和设备详情入口、Gateway联调配置完成。 -联调范围:单卡固定speed_level、设备解析当前卡、恢复不限速与限到0kbps区分、设备无当前卡、电信/广电档位映射、联通/移动不支持、Gateway失败/结果未知及审计记录。 +联调范围:单卡固定 `code`、恢复不限速与限到0kbps区分、越权卡、Gateway 明确失败/结果未知及审计记录;确认设备端没有限速入口。 完成标准:Gateway收到的cardNo始终为卡ICCID,设备号不会被发送,上游结果在页面和审计中可追踪。 ``` diff --git a/docs/7月迭代/七月迭代实现与接口对接说明.md b/docs/7月迭代/七月迭代实现与接口对接说明.md new file mode 100644 index 0000000..2b3c84b --- /dev/null +++ b/docs/7月迭代/七月迭代实现与接口对接说明.md @@ -0,0 +1,152 @@ +# 七月迭代实现与接口对接说明 + +> 面向:产品、前端、测试和联调人员 +> 范围:`deliver-july-iteration-confirmed-scope` 及本期确认“后端已完成,只需前端联调”的需求 +> 接口细节:以 [`docs/admin-openapi.yaml`](../admin-openapi.yaml) 为准,本文只说明关键调用和字段变化。 + +## 一、先看这几个关键结论 + +1. **设备没有限速接口**:限速只允许对 IoT 卡 ICCID 操作,固定档位 `-1~8`,不通过设备绑定卡间接限速。 +2. **企微模板在企微后台创建**:系统只配置应用、默认发起人、账号 userid 绑定、`template_id` 和控件映射,不在本系统设计审批节点。 +3. **代理发起审批走默认发起人**:企微单据使用配置的默认成员发起,但退款、充值等本地业务单仍记录真实代理提交人。 +4. **下架套餐续费不新增专用接口**:历史订单和资产信息返回稳定套餐 ID,前端仍调用现有 C 端创建订单接口生成一张新订单。 +5. **实名流程由后端返回值决定**:前端使用 `effective_realname_policy`,不能按卡或设备自行推断;设备有任意一张有效绑定卡已实名即视为已实名。 +6. **支付按钮由后端返回值决定**:前端使用 `allowed_payment_methods`,不要自行写死卡/设备的微信、支付宝或钱包规则。 +7. **设备批量分配只复用导入任务外壳**:复用任务表、队列、进度和结果页;不会进入原 Excel 创建设备逻辑。 +8. **系列套餐批量授权后端原本就支持多选**:前端把多选套餐组装为 `packages[]` 调现有接口即可,不需要新后端接口。 + +## 二、本期需求怎么实现 + +### 2.1 本期新增或修改后端的需求 + +| 需求 | 实现方式 | 关键口径检查 | +| --- | --- | --- | +| #189 换货后退款套餐未失效 | 退款处理不再只按旧资产查套餐,而是按原订单和换货迁移关系定位新资产上的对应套餐权益 | 只失效该退款订单产生的权益,不影响其他订单套餐;无前端改动 | +| #188 换货 C 端提醒 | 创建物流换货单时写可靠通知事件,继续走现有 C 端站内通知和未读弹窗 | 不做营销投放、ERP、自动创建其他单据 | +| #182/#44 提交人和审批展示 | 退款、代理充值、换货列表/详情批量解析提交人 ID 和名称;企微详情已具备将审批节点 userid 批量映射系统账号的内部能力 | 当前退款/充值业务 DTO 只稳定返回提交人和审批状态,尚未直接返回审批节点人员列表;如页面必须展示具体审批人,仍需把已有投影接入业务查询 | +| #181 订单渠道和资产标识 | C 端新订单保存正确 `purchase_role`;卡返回 ICCID,设备优先 VirtualNo、为空时返回 IMEI | 不用 SN 冒充订单设备标识;历史空数据不伪造 | +| #41 店铺 C 端登录限制 | 店铺增加 `client_login_disabled`;C 端验证资产后、签发短期令牌前检查所属店铺 | 只阻止新登录,不吊销已有 Token;平台库存保持原行为 | +| #53 卡/设备实名筛选 | 卡按自身实名状态过滤;设备通过有效绑定卡 `EXISTS` 实时判断 | 设备任意一张有效绑定卡已实名即为已实名;未建投影或 Worker | +| #57 退款中禁止换货 | 在现有换货创建入口前检查资产未终结退款 | 拒绝文案为“该资产存在退款申请”;不依赖企微实时接口 | +| #62 三种实名顺序 | 保留 `none/before_order/after_order`,补齐卡和设备最多 500 条批量修改,C 端返回生效策略 | 批量全成全败;设备和下卡冲突时以设备策略为准 | +| #97 主钱包低余额预警 | 消费主钱包扣款事实,余额首次从不少于 100 元跌破 100 元时通知店铺业务员 | 阈值以下不重复;恢复后再次跌破可再次提醒;无业务员不猜接收人 | +| #33 套餐临期提醒 | 每日计算 15/7/3 天节点,提供后台/代理临期列表及数量,并向个人客户发送站内通知 | 高亮为 8~15、4~7、0~3 天;0~3 天优先;不发企微业务员提醒 | +| #34 员工线下代充值 | 原充值创建入口在 `offline` 场景创建通用审批实例和企微提交事件;通过后幂等入主钱包 | 在线扫码充值后置;真实提交人、金额和凭证明文业务快照保留 | +| #35 退款企微审批 | 原退款申请关联唯一通用审批实例,企微标准终态驱动现有退款处理 | 不做原路退款;重复回调/轮询不会重复退款或失效套餐 | +| #37 企业微信审批 | 完成应用连接、通讯录同步、账号绑定、默认发起人、模板映射、提交、回调、详情查询和轮询恢复 | 模板/审批人规则由企微维护;超时结果未知不盲目重提 | +| #36 批量订购套餐 | 单列 CSV,整批选择一个套餐和支付方式,走独立异步任务,逐行复用现有订单和钱包规则 | 不选择代理,不在 CSV 中逐行指定套餐;部分失败不影响其他行 | +| #40 下架套餐老客户续费 | 普通可购列表排除下架套餐;当前使用者可用历史订单或当前资产返回的套餐 ID 调现有下单接口创建新订单 | 无专用续费接口;新客户和代理代购仍拒绝;历史订单不修改 | +| #42 六类导出 | 在现有导出任务框架新增 IoT 卡、套餐、钱包流水、代理充值、退款、换货 datasource | 按现有数据权限和字段来源导出,不建设新字段权限平台 | +| #47 卡固定档位限速 | 后台选择固定档位,按 ICCID 调用 Gateway,并记录操作审计和 Integration Log | 仅 IoT 卡;设备无接口;超时返回结果未知并人工核对 | +| #48 按资产类型配置支付方式 | `system_config` 分别保存卡、设备允许的 `wallet/wechat/alipay` 集合;C 端返回业务场景交集,订单端再次校验 | 至少保留一种;强充和普通充值剔除钱包;前端不可绕过 | +| #49 设备批量分配 | 扩展现有设备导入任务的 `operation_type`,新增分配代理和设置套餐系列两个 CSV 分支 | CSV 单列 VirtualNo/IMEI/SN;复用现有权限、分配、系列绑定和幂等规则 | + +### 2.2 后端此前已经完成,主要由前端正确调用或展示 + +| 需求 | 后端现状 | 前端要做什么 | +| --- | --- | --- | +| #45 换货新旧资产展示/搜索 | 换货列表已分别返回新旧资产,并支持两个独立搜索参数 | 分别提供“旧资产”和“新资产”搜索框,不要继续共用一个字段 | +| #46 预计套餐到期 | 资产详情、C 端资产信息和相关列表已返回预计最终到期字段 | 展示 `estimated_final_expires_at`;按 `is_expiring` 或剩余天数高亮,不要只显示当前套餐到期时间 | +| #55 套餐分配生效条件 | 套餐和分配接口已支持默认值、覆盖值和最终生效值 | 创建/编辑套餐传 `expiry_base`;分配时传 `expiry_base_override`,展示 `effective_expiry_base` | +| #60 店铺联系电话搜索 | 店铺列表已支持 11 位联系电话精确查询 | 将输入值作为 `contact_phone` 查询参数传给店铺列表接口 | +| #86 资产换货标识和跳转 | 资产解析接口已返回 `exchange_trace.previous_asset/next_asset` 和 `can_view` | 仅 `can_view=true` 且存在资产 ID 时允许跳转;该需求前端已对接可保持现状 | +| #38 代理信用额度 | 角色默认额度、店铺实际额度、资金概况和负可用余额均已有接口 | 使用分单位字段;更新时携带钱包 `version`;余额为负数时正常展示 | +| #94 状态同步和运营商回调 | 后端回调、定时触发和原轮询链路已装配 | 通常无前端新调用;状态页面继续读取现有资产状态字段 | +| #96 店铺业务员 | 店铺创建/更新、候选人、列表/详情和筛选都已支持业务员 | 创建/编辑店铺选择 `business_owner_account_id`;列表可按该 ID 筛选并展示名称 | +| #98 换货新资产继承旧店铺 | 换货完成时后端自动继承旧资产店铺归属 | 前端继续调用原换货完成接口,不新增分配步骤 | +| #43 系列套餐批量授权 | 创建授权和管理套餐接口均支持 `packages[]`,单次 1~100 项 | 页面实现套餐多选,一次提交整个数组;删除项使用 `remove=true` | + +> 注意:#44 的“提交人”已经可以直接对接;“审批人”目前不是退款、充值列表的稳定返回字段。后端已经能从企微详情快照解析并映射 userid,但当前前端不能把 `processor_id` 当作完整企微审批人列表。 + +### 2.3 本期明确不由后端处理 + +- #84 H5 首页隐藏设备下 ICCID:纯前端显示调整,但本期范围确认标记为“不做”。 +- #63 授权列表滚动条和字段顺序:纯前端页面调整,需求已关闭。 +- #73 行业卡未实名复机:后端保持原有行业卡放行逻辑,不改接口、不改前端。 +- #99 原路退款、#52 聚水潭、#51 跨品类换货、#39 分销码/佣金提现:本期不做。 +- #168、#90、#75、#64:已关闭,本期不重新修改。 + +## 三、需求与接口对接表 + +### 3.1 资产、店铺、订单和换货 + +| 需求 | 接口 | 参数或返回变化 / 前端调用说明 | +| --- | --- | --- | +| #41 登录限制 | `PUT /api/admin/shops/:id`;`GET /api/admin/shops`;`GET /api/admin/shops/:id` | 更新请求增加可选 `client_login_disabled`;列表/详情返回同名布尔值。C 端仍调 `POST /api/c/v1/auth/verify-asset`,受限时直接展示后端错误,不会返回资产令牌 | +| #53 实名筛选 | `GET /api/admin/iot-cards/standalone`;`GET /api/admin/devices` | 查询参数增加 `real_name_status=0|1`;响应已有 `real_name_status`、`real_name_status_name` | +| #62 实名顺序 | `PATCH /api/admin/assets/:identifier/realname-mode`;`POST /api/admin/iot-cards/batch-update-realname-policy`;`POST /api/admin/devices/batch-update-realname-policy` | 单条传 `realname_policy`;批量传 `asset_ids[] + realname_policy`,最多 500 条 | +| #62/#48 C 端初始化 | `GET /api/c/v1/asset/info?identifier=...` | 使用 `effective_realname_policy`、`realname_required`、`real_name_status`、`allowed_payment_methods`;前端不要自行覆盖 | +| #45 换货搜索 | `GET /api/admin/exchanges` | 使用 `old_asset_keyword`、`new_asset_keyword` 两个独立参数,可同时传并按 AND 组合 | +| #57/#98 换货 | `POST /api/admin/exchanges`;`POST /api/admin/exchanges/:id/complete` | 请求结构不变;存在退款时创建接口返回业务错误;完成后店铺归属由后端继承 | +| #188 换货提醒 | `GET /api/c/v1/notifications/unread-count`;`GET /api/c/v1/notifications`;`PUT /api/c/v1/notifications/:id/read` | 无新增通知接口;前端继续使用现有未读数、列表和已读接口弹窗展示 | +| #181 订单字段 | `GET /api/admin/orders`;`GET /api/admin/orders/:id`;`GET /api/c/v1/orders`;`GET /api/c/v1/orders/:id` | 返回正确 `purchase_role`、`asset_identifier`;设备标识为 VirtualNo 优先、IMEI 兜底 | +| #40 下架套餐续费 | `GET /api/c/v1/asset/info`;`GET /api/c/v1/orders`;`GET /api/c/v1/orders/:id`;`POST /api/c/v1/orders/create` | 资产信息返回 `current_package_id`,历史订单返回 `package_ids`;前端把选中的套餐 ID、资产 `identifier` 和当前允许的 `payment_method` 传给原创建订单接口 | +| #46/#86 资产展示 | `GET /api/admin/assets/resolve/:identifier`;`GET /api/c/v1/asset/info` | 返回预计最终到期字段;后台解析额外返回 `exchange_trace`,跳转前检查 `can_view` | + +### 3.2 店铺业务员、信用和套餐授权 + +| 需求 | 接口 | 参数或返回变化 / 前端调用说明 | +| --- | --- | --- | +| #60 联系电话搜索 | `GET /api/admin/shops?contact_phone=11位号码` | 精确查询;可与店铺名称、编号等条件组合 | +| #96 店铺业务员 | `GET /api/admin/shops/business-owner-candidates`;`POST /api/admin/shops`;`PUT /api/admin/shops/:id`;`GET /api/admin/shops` | 创建/更新传 `business_owner_account_id`;列表可用同名参数筛选,响应展示账号 ID、名称和可用状态 | +| #38 信用额度 | `PUT /api/admin/roles/:id/default-credit`;`PUT /api/admin/shops/:id/credit-limit`;`GET /api/admin/shops/fund-summary` | 角色接口配置新建代理默认值;店铺接口传 `credit_enabled + credit_limit + version`;金额单位均为分 | +| #55 套餐默认生效条件 | `POST /api/admin/packages`;`PUT /api/admin/packages/:id`;`GET /api/admin/packages/:id` | 请求使用 `expiry_base=from_activation|from_purchase`;响应展示默认生效条件名称 | +| #55 分配覆盖 | `POST /api/admin/shop-package-batch-allocations`;`PATCH /api/admin/shop-package-allocations/:id/expiry-base` | 分配时使用 `expiry_base_override`;`null` 表示跟随套餐默认值 | +| #43 系列套餐多选 | `POST /api/admin/shop-series-grants`;`PUT /api/admin/shop-series-grants/:id/packages` | `packages` 是 1~100 项数组,每项包含 `package_id`、`cost_price`,删除时传 `remove=true` | + +### 3.3 审批、退款和员工线下代充值 + +| 需求 | 接口 | 参数或返回变化 / 前端调用说明 | +| --- | --- | --- | +| #182/#44 退款 | `GET /api/admin/refunds`;`GET /api/admin/refunds/:id` | 新增/补齐 `submitter_id`、`submitter_name`、`approval_provider`、`approval_status`、`approval_status_name` | +| #182/#44 充值 | `GET /api/admin/agent-recharges`;`GET /api/admin/agent-recharges/:id` | 同上;线下代充值的审批状态只读展示 | +| #44 换货 | `GET /api/admin/exchanges`;`GET /api/admin/exchanges/:id` | 新增/补齐 `submitter_id`、`submitter_name` | +| #34 员工线下代充 | `POST /api/admin/agent-recharges` | 仍用原入口;`payment_method=offline` 时传目标 `shop_id`、金额、1~5 个 `payment_voucher_key` 和备注,创建后等待企微审批 | +| #35 退款申请 | `POST /api/admin/refunds` | 仍用原入口;请求结构保持订单、实收金额、申请金额、凭证、原因等业务字段,创建后等待企微审批 | +| #34/#35 旧按钮 | `POST /api/admin/agent-recharges/:id/offline-pay`;`POST /api/admin/refunds/:id/approve|reject` | 仅兼容存量旧审批记录;企微记录前端不得显示这些操作按钮,最终按发布配置停用 | +| #37 企微应用 | `POST/GET /api/admin/wecom/applications`;`POST /api/admin/wecom/applications/:id/test` | 配置 corp_id、agent_id、Secret、回调 Token、EncodingAESKey;管理端按明文填写 | +| #37 默认发起人和成员 | `POST /api/admin/wecom/applications/:id/members/sync`;`GET /api/admin/wecom/applications/:id/members`;`PUT /api/admin/wecom/applications/:id/default-creator` | 先同步成员,再从可见成员中选择默认 `userid` | +| #37 账号绑定 | `PUT /api/admin/accounts/:id/wecom-binding` | 管理员选择应用可见成员,绑定 `(corp_id,userid)`;不做扫码绑定 | +| #37 场景模板 | `PUT /api/admin/wecom/scenes/:business_type`;`GET /api/admin/wecom/scenes` | `business_type` 为 `refund_approval` 或 `offline_recharge_approval`;传 `application_id`、`template_id`、`control_mapping[]` | +| #37 回调 | `GET/POST /api/callback/wecom/approval/:application_id` | 由企微服务器调用,前端无需调用 | + +### 3.4 通知、批量任务、导出和 Gateway + +| 需求 | 接口 | 参数或返回变化 / 前端调用说明 | +| --- | --- | --- | +| #97 后台低余额通知 | `GET /api/admin/notifications/unread-count`;`GET /api/admin/notifications`;`PUT /api/admin/notifications/:id/read` | 复用现有后台通知接口;业务员账号正常展示低余额通知 | +| #33 临期列表 | `GET /api/admin/expiring-assets` | 支持资产类型、关键词、店铺、套餐、剩余天数和日期范围;响应含 `summary`、`expiry_level`、`is_priority` | +| #33 C 端提醒 | `GET /api/c/v1/notifications/unread-count`;`GET /api/c/v1/notifications` | 复用现有站内通知,前端在 15/7/3 天节点按未读通知弹窗 | +| #36 批量订购上传 | `POST /api/admin/storage/upload-url` | `purpose=batch_purchase`,上传 UTF-8 单列 CSV 后取得 `file_key` | +| #36 批量订购任务 | `POST /api/admin/asset-package-batch-orders`;`GET /api/admin/asset-package-batch-orders`;`GET /api/admin/asset-package-batch-orders/:id` | 创建传 `file_key + package_id + payment_method`,线下支付另传 `voucher_keys[]`;详情返回逐行结果 | +| #42 业务导出 | `POST /api/admin/export-tasks`;`GET /api/admin/export-tasks`;`GET /api/admin/export-tasks/:id` | `scene` 使用 `iot_card/package/agent_wallet_transaction/agent_recharge/refund/exchange`,筛选条件放 `query` | +| #47 卡限速 | `PUT /api/admin/iot-cards/:iccid/speed-tier` | 请求只传 `code=-1..8`;设备页面不要展示限速入口 | +| #48 后台支付配置 | `GET /api/admin/system-configs`;`PUT /api/admin/system-configs/:key` | Key 为 `c2b.payment.card_allowed_methods`、`c2b.payment.device_allowed_methods`;更新请求的 `value` 是 JSON 数组字符串 | +| #48 C 端支付 | `GET /api/c/v1/asset/info`;`GET /api/c/v1/wallet/recharge-check`;`POST /api/c/v1/orders/create`;`POST /api/c/v1/wallet/recharge` | 展示和提交都使用后端返回的 `allowed_payment_methods`;创建订单时 `payment_method` 必传,微信场景按接口要求传 `app_type` | +| #49 分配 CSV 上传 | `POST /api/admin/storage/upload-url` | `purpose=device_batch_allocation`,上传单列 CSV 后取得 `file_key` | +| #49 创建设备分配任务 | `POST /api/admin/devices/import/allocations` | 传 `file_key + operation_type + target_id`;`operation_type=assign_shop|assign_series` | +| #49 查询任务 | `GET /api/admin/devices/import/tasks`;`GET /api/admin/devices/import/tasks/:id` | 复用原设备导入任务页面,新增展示 `operation_type`、`operation_name`、`target_id`、`status_name` | + +## 四、前端本期最容易漏掉的工作 + +- 换货列表拆成新、旧资产两个搜索参数。 +- 资产预计到期展示使用“预计最终到期”,并按临期字段高亮。 +- 套餐分配页面传递默认/覆盖生效条件。 +- 店铺页面接入联系电话查询、业务员选择和 C 端登录限制开关。 +- 系列套餐授权页面真正使用现有 `packages[]` 做多选提交。 +- C 端实名流程只读取 `effective_realname_policy`。 +- C 端支付按钮只读取 `allowed_payment_methods`。 +- 历史订单续费继续调用原创建订单接口,不等待新续费接口。 +- 企微退款和线下代充值只读展示审批状态,隐藏本地人工审批按钮。 +- 卡页面提供固定档位限速;设备页面不得出现限速入口。 +- 设备批量分配继续复用原设备导入任务列表/详情页面,但根据 `operation_type` 改标题和结果说明。 + +## 五、联调和验收边界 + +- 当前有两个需要在联调时明确的接口边界: + - #44:退款/充值列表已经返回提交人和审批状态,但没有直接返回企微审批节点人员列表;现有 userid 映射能力尚未接入这两个业务 DTO。 + - #49:设备批量分配创建服务允许平台和代理账号,但复用的设备导入任务列表/详情 Handler 目前仍沿用“仅平台用户可查看”的旧限制。若本期只允许平台操作则前端应隐藏代理入口;若要求代理自行查看任务,需要再统一后端权限。 +- OpenAPI 已生成,新增路径和 DTO 已检查;设备限速旧契约不存在。 +- 本次没有执行数据库迁移、完整构建、自动化测试、LSP 或真实企微/Gateway 闭环。 +- 企微、Gateway、Redis/Asynq、对象存储配置和回滚步骤见 [`七月迭代联调交付说明.md`](七月迭代联调交付说明.md)。 +- 涉及金额的字段默认单位为分;前端展示时统一转换,提交时不要传浮点元金额。 diff --git a/docs/7月迭代/七月迭代联调交付说明.md b/docs/7月迭代/七月迭代联调交付说明.md new file mode 100644 index 0000000..71dec77 --- /dev/null +++ b/docs/7月迭代/七月迭代联调交付说明.md @@ -0,0 +1,126 @@ +# 七月迭代联调交付说明 + +## 交付状态 + +本说明覆盖 `deliver-july-iteration-confirmed-scope` 的企业微信、Gateway、Redis/Asynq、对象存储和前端联调输入。生产代码、增量迁移和 OpenAPI 已生成至可联调状态;测试库已执行至 `000194` 并完成结构核对,尚未执行自动化测试、完整构建、LSP 或真实外部环境闭环。 + +接口统一使用 `{code,msg,data,timestamp}` 响应。完整请求和响应结构以 `docs/admin-openapi.yaml` 为准。 + +## 企业微信联调 + +### 环境和管理配置 + +| 配置 | 来源 | 说明 | +| --- | --- | --- | +| `JUNHONG_WECOM_BASE_URL` | 环境变量 | 默认 `https://qyapi.weixin.qq.com` | +| `JUNHONG_WECOM_TIMEOUT` | 环境变量 | 默认 `10s` | +| `JUNHONG_WECOM_CREDENTIAL_ENCRYPTION_KEY` | 环境变量 | 32 字节随机密钥的 Base64 文本;更换前必须先完成历史凭据重加密 | +| `corp_id`、`agent_id`、Secret | `POST /api/admin/wecom/applications` | 管理端按明文提交,服务端加密保存;超级管理员读取时返回明文,业务日志不记录 | +| 回调 Token、EncodingAESKey | 同上 | Token 按明文提交;EncodingAESKey 固定 43 位 | +| 默认发起人 | `PUT /api/admin/wecom/applications/:id/default-creator` | 必须先同步可见成员,再从当前可见成员中选择 | +| `template_id` 和控件映射 | `PUT /api/admin/wecom/scenes/:business_type` | 模板必须先在企微后台创建,本系统只校验和绑定 | + +部署侧还必须在企微管理后台配置:自建应用审批权限、应用可见通讯录范围、调用接口的可信 IP,以及公开可访问的回调地址: + +```text +GET/POST {API_PUBLIC_BASE_URL}/api/callback/wecom/approval/{application_id} +``` + +### 配置和联调顺序 + +1. 配置加密 Key,部署 API 和 Worker。 +2. 创建应用配置并调用 `POST /api/admin/wecom/applications/:id/test` 验证取 token。 +3. 调用 `POST /api/admin/wecom/applications/:id/members/sync`,再分页查询成员。 +4. 为内部系统账号调用 `PUT /api/admin/accounts/:id/wecom-binding` 绑定 `(corp_id,userid)`。 +5. 选择一个当前可见成员作为默认发起人。代理退款、代理相关业务以及未绑定内部员工均使用该成员发起,但本地业务单仍保存真实提交人。 +6. 分别配置 `refund_approval`、`offline_recharge_approval` 的 `template_id` 和控件映射。选择控件必须填写企微模板真实 option key。 +7. 在企微后台配置回调 URL、Token、EncodingAESKey,完成 GET 校验。 +8. 分别提交一笔退款和员工线下代充值,验证 `applyevent`、`sp_no`、回调、详情同步、标准终态和业务终态。 + +Integration Log 不记录 Secret、access_token、media_id、附件正文或完整企微响应;普通业务字段和业务快照按明文保存,便于业务处理。 + +### 结果未知和旧入口 + +- `applyevent` 超时或断连后标记为结果未知,不自动创建第二张审批单;Worker 通过时间窗单号和详情查询恢复。 +- `wecom:approval:recovery` 每 2 分钟触发,详情同步使用 `wecom:approval:sync`。 +- 在真实退款和线下代充值闭环、存量 provider 清单均验证前,保持 `JUNHONG_APPROVAL_LEGACY_REFUND_MANUAL_ENABLED=true` 和 `JUNHONG_APPROVAL_LEGACY_OFFLINE_RECHARGE_PAY_ENABLED=true`。 +- 切换后前端只读展示 `approval_provider`、`approval_status`、`approval_status_name`,不得再提供企微记录的本地人工通过、驳回或线下确认按钮。 + +## Gateway 卡限速联调 + +| 环境变量 | 说明 | +| --- | --- | +| `JUNHONG_GATEWAY_BASE_URL` | Gateway API 基础地址 | +| `JUNHONG_GATEWAY_APP_ID` | Gateway 应用 ID | +| `JUNHONG_GATEWAY_APP_SECRET` | Gateway 应用密钥 | +| `JUNHONG_GATEWAY_TIMEOUT` | 超时秒数,允许 5~300,默认配置为 60 | + +接口固定为: + +```text +PUT /api/admin/iot-cards/:iccid/speed-tier +``` + +请求只允许 `code=-1..8`,Gateway 业务参数只有卡 ICCID 对应的 `cardNo` 和档位 `code`。设备没有限速接口,也不得通过设备绑定关系间接限速。 + +联调需覆盖全部档位、越权卡、明确失败和超时。超时返回结果未知时,按 Integration Log 中的 ICCID 到 Gateway 运维侧核对当前档位,再决定是否人工重试;系统不会盲目自动重发。 + +## Redis、Asynq 和 Worker + +API 与 Worker 必须连接同一 Redis/Asynq。新增或复用的任务如下: + +| 任务类型 | 队列 | 用途 | +| --- | --- | --- | +| `wecom:approval:sync` | `wecom:approval` | 拉取企微详情并同步标准决策 | +| `wecom:approval:recovery` | `wecom:approval` | 结果未知恢复和未终态轮询 | +| `asset:package:batch_order` | 同名独立队列 | 单列 CSV 批量订购 | +| `device:import` | 同名独立队列 | 原设备导入和设备 CSV 批量分配共用任务外壳,通过 `operation_type` 分支 | +| `package:expiry:reminder` | `data:cleanup` | 每日 15/7/3 天套餐临期提醒 | +| `export:dispatch/shard/finalize` | 各自既有队列 | 六类新增 datasource 继续复用导出流水线 | + +联调时确认 Worker 启动日志已注册上述 Handler,且 `JUNHONG_WORKER_ROLE` 的部署拓扑为单实例 `all`,或严格一个 `leader` 加多个 `consumer`。 + +## 对象存储和 CSV + +先调用 `POST /api/admin/storage/upload-url` 获取预签名地址,再把文件直传对象存储,业务接口只接收返回的 `file_key`。 + +| purpose | 业务 | 文件要求 | +| --- | --- | --- | +| `batch_purchase` | 资产套餐批量订购 | UTF-8 单列 CSV,最多 1000 行、10MB | +| `device_batch_allocation` | 设备分配代理或设置套餐系列 | UTF-8 单列 CSV,支持 VirtualNo、IMEI、SN,最多 1000 行、10MB | + +对象存储需配置 `JUNHONG_STORAGE_PROVIDER`、S3 endpoint/region/bucket/access key/secret key、SSL/path style 和预签名有效期。Worker 必须具备下载相同私有对象的权限。 + +## 前端接口和字段 + +| 场景 | 前端接入要点 | +| --- | --- | +| 店铺登录限制 | `PUT /api/admin/shops/:id` 写 `client_login_disabled`;列表和详情同字段回显,只阻止新登录,不踢出已有 Token | +| 实名策略 | 卡、设备分别调用 `POST /api/admin/iot-cards/batch-update-realname-policy`、`POST /api/admin/devices/batch-update-realname-policy`;最多 500 条、全成全败 | +| C 端资产初始化 | 使用 `effective_realname_policy`、`realname_required`、`realname_status`、`allowed_payment_methods`,不要在前端重新推断策略 | +| 支付方式配置 | `GET/PUT /api/admin/system-configs` 管理 `c2b.payment.card_allowed_methods`、`c2b.payment.device_allowed_methods`;至少保留一种 | +| 临期资产 | `GET /api/admin/expiring-assets`,使用数量、高亮和优先标识 | +| 企微配置 | 使用应用、成员同步/选择、默认发起人、账号绑定和场景接口;模板节点和审批人规则仍在企微后台维护 | +| 退款/充值/换货 | 展示 `submitter_id/submitter_name`;退款和充值只读展示企微审批渠道及状态 | +| 订单/退款资产 | 卡展示 ICCID;设备优先 VirtualNo、缺失时展示 IMEI;订单同时展示 `purchase_role` | +| 批量订购 | 上传 `purpose=batch_purchase`,再调用 `POST /api/admin/asset-package-batch-orders`;任务列表和详情使用统一状态名称 | +| 设备批量分配 | 上传 `purpose=device_batch_allocation`,再调用 `POST /api/admin/devices/import/allocations`;任务查询复用设备导入任务接口并展示 `operation_type/operation_name/target_id/status_name` | +| 业务导出 | `POST /api/admin/export-tasks`;新增场景为 `iot_card`、`package`、`agent_wallet_transaction`、`agent_recharge`、`refund`、`exchange` | +| 卡固定限速 | 只在卡页面展示固定档位选择;设备页面不得出现限速入口 | + +## 已知限制 + +- 本系统不创建企微模板、不配置审批节点或审批人规则,也不提供扫码绑定。 +- 企微应用只能看到其可见范围内的成员;可见范围或可信 IP 错误会导致连接、绑定或提交失败。 +- 卡限速不保存本地当前档位,结果未知必须人工向 Gateway 核对。 +- CSV 任务逐行部分成功,不保证整批业务原子性;任务详情是失败和跳过原因的权威展示。 +- 店铺登录限制不主动吊销既有 C 端 Token。 +- 测试库已完成 `000182`~`000194` 结构落库和注释核对;其他环境尚未执行迁移,外部系统闭环和前端浏览器验收也未完成。 + +## 发布和回滚 + +1. 测试库已执行至 `000194`;其他环境发布时仍须按迁移顺序执行并核对最终版本和表结构。 +2. 先部署数据库兼容代码、API 和 Worker,保持旧审批入口开启;再配置企微应用、模板、回调和默认发起人。 +3. 真实审批闭环通过后,先隐藏前端旧按钮,再关闭旧入口配置并重启 API。 +4. 回滚时先停止新批量任务和新审批提交,等待已领取任务结束,再回退应用版本。新增表和列暂不删除。 +5. 已产生的审批、退款、钱包流水、订单、通知、Outbox、Integration Log、设备分配和限速审计均为业务事实,不得清表回滚;使用前向修复。 diff --git a/docs/7月迭代/七月迭代需求范围确认表.md b/docs/7月迭代/七月迭代需求范围确认表.md new file mode 100644 index 0000000..a650c2e --- /dev/null +++ b/docs/7月迭代/七月迭代需求范围确认表.md @@ -0,0 +1,654 @@ +# 七月迭代需求范围确认表 + +> 用途:由需求负责人逐条确认本期是否实施、按什么口径实施,以及哪些内容明确后置或取消。 +> +> 来源:`docs/7月迭代/物联网卡管系统-需求.csv` 当前 41 条记录。其中 36 条挂在七月迭代计划,5 条已关闭。 +> +> 填写完成后,请通知后端重新审查。本表确认前,不据此修改 `complete-july-iteration-test-release` 或实施业务代码。 + +## 填写说明 + +每条需求只勾选一个“本期决定”,然后在“说明”中补充你的处理意见即可。 + +本期决定: + +- [ ] 本期做:本次迭代必须上线。 +- [ ] 已完成,只联调/验收:不再重复开发后端。 +- [ ] 后置:需求保留,但不阻塞本次上线。 +- [ ] 不做:取消或移出当前产品范围。 +- [ ] 待确认:产品口径仍不清楚,暂不实施。 + +填写示例: + +```markdown +本期决定(单选): + +- [x] 本期做 +- [ ] 已完成,只联调/验收 +- [ ] 后置 +- [ ] 不做 +- [ ] 待确认 + +- 说明:只在现有店铺列表增加联系电话精确筛选。 +``` + +--- + +## 一、新增、漏项或未被当前提案准确追踪的需求 + +### #189 换货迁移套餐退款后未正常失效 + +- 原始需求:换货后的新卡继承旧卡套餐后,对原订单退款,退款通过但套餐没有正常失效。 +- 当前判断:提案漏项;属于退款与换货交叉缺陷。 +本期决定(单选): + +- [x] 本期做 +- [ ] 已完成,只联调/验收 +- [ ] 后置 +- [ ] 不做 +- [ ] 待确认 + +- 说明:跟前端没关系;这个问题是后端查找路径有问题,按 Bug 修复即可。 + +### #188 广电管控卡弹窗提醒 + +- 原始需求:广电风险停机卡在 H5 弹窗提醒换卡并采集收货地址;描述中还包含后台主动投放、自定义营销内容、自动换货单和 ERP 对接设想。 +- 当前判断:提案漏项;“风险卡提醒”与“通用营销投放/ERP”应分别确认。 +本期决定(单选): + +- [x] 本期做 +- [ ] 已完成,只联调/验收 +- [ ] 后置 +- [ ] 不做 +- [ ] 待确认 + +- 说明:他描述的有远,我们只在已经完成的站内通知的基础上,保证创建物流换货单的时候会在C端弹窗通知用户 + +### #182 退款和代理充值列表增加提交人 + +- 原始需求:退款管理和订单/代理充值后台列表增加提交人字段。 +- 当前判断:与 #44 部分重复;可先作为简单字段独立交付,不应等待完整企微审批体系。 +本期决定(单选): + +- [x] 本期做 +- [ ] 已完成,只联调/验收 +- [ ] 后置 +- [ ] 不做 +- [ ] 待确认 + +- 说明:现在数据库里面存了提交人的ID,在列表跟详情中返回id跟名称就好了 + +### #181 订单和退款列表缺少资产标识、订单渠道 + +- 原始需求:设备类订单、退款记录未正确展示资产标识符和订单渠道。 +- 当前判断:提案漏项;属于 DTO/查询/展示字段修复。 +本期决定(单选): + +- [x] 本期做 +- [ ] 已完成,只联调/验收 +- [ ] 后置 +- [ ] 不做 +- [ ] 待确认 + +- 说明:这算是一个bug,我记得只有C端购买订单的时候会缺少订单渠道,然后退款列表跟退款详情是设备的标识没有正确放入 + +### #99 退款支持原路退回 + +- 原始需求:支持原路退款和客户收款凭证退款;原路退款前校验原收款商户退款能力,失败后允许改为凭证退款。 +- 当前判断:提案漏项,并与现提案“部分支付方式系统外退款”口径冲突;属于资金业务,需明确真实范围。 +本期决定(单选): + +- [ ] 本期做 +- [ ] 已完成,只联调/验收 +- [ ] 后置 +- [X] 不做 +- [ ] 待确认 + +- 说明: + +### #84 H5 首页隐藏设备下 ICCID + +- 原始需求:新卡管 H5 首页不再显示设备号下方的 ICCID。 +- 当前判断:提案漏项;基本属于纯前端调整。 +本期决定(单选): + +- [ ] 本期做 +- [ ] 已完成,只联调/验收 +- [ ] 后置 +- [X] 不做 +- [ ] 待确认 + +- 说明:跟我们后端没关系 + +### #52 聚水潭对接 + +- 原始需求:打通聚水潭订单、发货等数据。 +- 当前判断:提案漏项;当前没有接口清单、数据方向和验收标准,不能直接实施。 +本期决定(单选): + +- [ ] 本期做 +- [ ] 已完成,只联调/验收 +- [ ] 后置 +- [X] 不做 +- [ ] 待确认 + +- 说明:现在不做 + +### #51 不同品类资产换货及补差价 + +- 原始需求:卡与设备跨品类换货、补差价、旧套餐失效、新资产使用正确品类套餐。 +- 当前判断:当前为草稿,旧规划排除;属于独立复杂业务,不适合顺带实现。 +本期决定(单选): + +- [ ] 本期做 +- [ ] 已完成,只联调/验收 +- [ ] 后置 +- [X] 不做 +- [ ] 待确认 + +- 说明: + +### #41 代理 API 查询限制 + +- 原始需求名称存在误导,最终业务口径不是“代理跨级 API 查询限制”。 +- 最终口径:为每个店铺提供一个 C 端登录限制开关;开关开启后,该店铺名下的卡和设备不能再发起新的系统 C 端登录。 +- 建议最小范围:店铺增加配置字段并提供管理员配置入口;C 端资产校验成功后、签发短期资产令牌前按资产所属店铺校验。平台库存或未归属店铺的资产保持原行为。 +- 明确不做:不重构代理权限体系,不做全局 Token 吊销,不强制已登录用户立即下线,不迁移现有认证模块。 +本期决定(单选): + +- [X] 本期做 +- [ ] 已完成,只联调/验收 +- [ ] 后置 +- [ ] 不做 +- [ ] 待确认 + +- 说明:提供接口针对某个店铺开关;打开后,属于该店铺的资产不能登录系统 C 端。 + +### #39 代理分销码与佣金提现 + +- 原始需求:分销归属、分销码、佣金提现及合同/证照/发票材料。 +- 当前判断:现提案明确移出本期;其中“员工作为发展人”已由 #96 单独承接。 +本期决定(单选): + +- [ ] 本期做 +- [ ] 已完成,只联调/验收 +- [ ] 后置 +- [X] 不做 +- [ ] 待确认 + +- 说明: + +--- + +## 二、已有后端成果,优先确认前端联调和验收 + +### #45 换货列表新旧资产展示和独立搜索 + +- 原始需求:修正新旧资产标识混乱;新旧资产分别支持 ICCID、接入号、虚拟号搜索。 +- 当前判断:后端已完成且有测试,主要剩前端和人工验收。 +本期决定(单选): + +- [ ] 本期做 +- [X] 已完成,只联调/验收 +- [ ] 后置 +- [ ] 不做 +- [ ] 待确认 + +- 说明:现在跟我们后端没关系了 + +### #46 资产预计套餐到期时间 + +- 原始需求:显示当前生效套餐过期时间,剩余 15 天时高亮。 +- 当前实现:后端已实现“当前套餐加排队套餐后的预计最终到期时间”,且有测试。 +- 需要确认:接受当前“预计最终到期”口径,还是改回只显示当前生效套餐到期时间。 +本期决定(单选): + +- [ ] 本期做 +- [X] 已完成,只联调/验收 +- [ ] 后置 +- [ ] 不做 +- [ ] 待确认 + +- 说明:现在这个口径是对的,已经跟我们后端没有关系了,接口已经提供给前端 + +### #55 套餐分配生效条件 + +- 原始需求:套餐支持购买即生效或实名即生效,分配时可覆盖条件。 +- 当前判断:后端已完成且有测试。 +本期决定(单选): + +- [ ] 本期做 +- [X] 已完成,只联调/验收 +- [ ] 后置 +- [ ] 不做 +- [ ] 待确认 + +- 说明:已经提供给前端对接了,跟我们没关系了 + +### #60 店铺联系电话搜索 + +- 原始需求:店铺列表增加联系电话检索。 +- 当前判断:后端已沿用旧 Store 完成精确查询并有测试,符合简单需求处理方式。 +本期决定(单选): + +- [ ] 本期做 +- [X] 已完成,只联调/验收 +- [ ] 后置 +- [ ] 不做 +- [ ] 待确认 + +- 说明:已经提供给前端接口,跟后端没有关系了 + +### #73 行业卡允许未实名复机 + +- 原始需求:行业卡允许未实名复机。 +- 当前实现:旧代码已经按 `card_category=industry` 放行未实名复机。 +- 提案偏差:现提案准备改成按运营商 `realname_link_type` 判断,可能反向改变原需求。 +本期决定(单选): + +- [ ] 本期做 +- [ ] 已完成,只联调/验收 +- [ ] 后置 +- [X] 不做 +- [ ] 待确认 + +- 说明:先不管这个,只要行业卡能正确的不管有没有实名都能复机即可 + +### #86 资产详情换货标识与跳转 + +- 原始需求:详情显示新资产/旧资产换货标识,旧资产可链接新资产。 +- 当前判断:后端已完成且有测试,主要剩前端和历史数据人工抽样。 +本期决定(单选): + +- [ ] 本期做 +- [X] 已完成,只联调/验收 +- [ ] 后置 +- [ ] 不做 +- [ ] 待确认 + +- 说明:前端已经对接,现在与后端无关 + +### #38 代理信用额度 + +- 原始需求:代理可授权额度、不同额度下限、金额业务使用额度、允许显示负余额。 +- 当前判断:后端代码基本完成,但资金链自动化和真实验证延期。 +本期决定(单选): + +- [ ] 本期做 +- [X] 已完成,只联调/验收 +- [ ] 后置 +- [ ] 不做 +- [ ] 待确认 + +- 说明:接口已经提供给前端,具体测试是测试环节的事情,现在与后端无关吧,最好确认一下代码是否完成 + +### #94 状态同步优化和运营商回调 + +- 原始需求:因资产迁移优化状态同步和回调,原描述没有具体验收标准。 +- 当前判断:后端已完成大范围公共观测和四类运营商回调,但真实运营商/Gateway 验证延期。 +本期决定(单选): + +- [ ] 本期做 +- [x] 已完成,只联调/验收 +- [ ] 后置 +- [ ] 不做 +- [ ] 待确认 + +- 说明: 现在就算优化完了,有运营商回调,有时间触发,轮询还是保持老样子 + +### #96 店铺业务员/发展人 + +- 原始需求:新建店铺可选业务员,列表/详情展示,可按业务员筛选。 +- 当前判断:后端代码完成,主要剩前端和验证。 +本期决定(单选): + +- [ ] 本期做 +- [X] 已完成,只联调/验收 +- [ ] 后置 +- [ ] 不做 +- [ ] 待确认 + +- 说明:接口已经提供给前端了,与后端无关 + +### #98 换货新资产继承旧店铺 + +- 原始需求:换货时新资产默认继承旧资产归属。 +- 当前判断:旧换货 Service 已实现主要归属继承逻辑;提案任务状态滞后,不应再建新的 Domain/分配记录。 +本期决定(单选): + +- [ ] 本期做 +- [X] 已完成,只联调/验收 +- [ ] 后置 +- [ ] 不做 +- [ ] 待确认 + +- 说明:这算做完了,就不要动了,到时候我会统一说创建一个新的提案,把原来的提案废掉 + +### #43 代理系列套餐批量授权 + +- 原始需求:支持一次多选,展示建议售价和公司成本价,区分已分配/未分配套餐。 +- 当前判断:核心批量授权、价格和移除兼容能力基本已有;应轻量收口,不从头重构。 +本期决定(单选): + +- [ ] 本期做 +- [X] 已完成,只联调/验收 +- [ ] 后置 +- [ ] 不做 +- [ ] 待确认 + +- 说明:这他妈就是错误的描述,之前我们的接口本身就支持批量多选,但是前端没做,这个需求跟后端没关系 + +--- + +## 三、适合沿用现有代码做局部修改的需求 + +### #53 卡和设备实名状态筛选 + +- 原始需求:卡管理、设备管理增加已实名/未实名筛选。 +- 提案问题:增加设备实名投影、历史初始化、事件消费者和 Worker,明显超出简单筛选需求。 +- 建议最小范围:优先在现有列表 DTO、Store 查询和返回字段上补筛选;只有现有数据无法准确查询时再讨论快照。 +本期决定(单选): + +- [X] 本期做 +- [ ] 已完成,只联调/验收 +- [ ] 后置 +- [ ] 不做 +- [ ] 待确认 + +- 说明:设备有一个情况是,只要有任意一张卡实名了,就算实名 + +### #57 存在退款申请时禁止换货 + +- 原始需求:资产存在未完成退款申请时禁止换货,并提示“该资产存在退款申请”。 +- 提案问题:被绑定到尚未完成的全新退款模型和企微审批链。 +- 建议最小范围:在现有换货创建入口查询现有退款状态并拦截。 +本期决定(单选): + +- [X] 本期做 +- [ ] 已完成,只联调/验收 +- [ ] 后置 +- [ ] 不做 +- [ ] 待确认 + +- 说明:没有什么特别注意的,越简单达成需求越好 + +### #62 H5 先充值/先实名流程配置 + +- 原始需求:进入 H5 先绑定手机号,并可按资产批次选择先充值后实名或先实名后充值。 +- 当前判断:已有 `realname_policy`、单资产修改接口和 C 端购买/充值门禁,不应按从零 DDD 建设。 +本期决定(单选): + +- [x] 本期做 +- [ ] 已完成,只联调/验收 +- [ ] 后置 +- [ ] 不做 +- [ ] 待确认 + +- 说明: 这个有点奇怪,之前我跟前端的约定是这样的"目标:支持无需实名、先实名后购买、先购买后实名三种流程,并提供后台批量配置。 + +预计工时:前端2~3小时。 + +页面入口:C端资产初始化流程;后台卡列表和设备列表。 + +页面结构: + +1. C端按 effective_realname_policy 决定直接购买、先实名或购买后提示实名。 + +2. 后台卡和设备列表分别增加“批量修改实名顺序”入口。 + +3. 弹框提供无需实名、先实名后购买、先购买后实名三选一,并展示已选数量。 + +4. 修改设备下卡策略时提示“实际H5流程由设备策略决定”。 + +接口约定: + +- PATCH /api/admin/assets/{identifier}/realname-mode,入参 realname_policy:none|before_order|after_order。 + +- POST /api/admin/iot-cards/batch-update-realname-policy、POST /api/admin/devices/batch-update-realname-policy,入参 asset_ids:int64[]、realname_policy:string。 + +- C端初始化返回 effective_realname_policy:string、realname_required:bool、realname_status:int。 + +交互规则:单次最多500条;批量接口全成全败;前端不根据资产类型自行覆盖策略。 + +完成标准:三种C端流程和卡/设备批量配置均可运行,冲突数据能展示明确错误。" + +### #44 退款、充值、换货列表字段 + +- 原始需求:退款列表增加提交人/审批人;代理充值列表增加提交人/审批人;换货列表增加提交人。 +- 提案问题:将简单字段绑定到通用审批、企微节点、历史回填和新状态模型。 +- 建议最小范围:先交付已有数据能够提供的提交人/审批人字段;企微详情和历史快照另行确认。 +本期决定(单选): + +- [X] 本期做 +- [ ] 已完成,只联调/验收 +- [ ] 后置 +- [ ] 不做 +- [ ] 待确认 + +- 说明:这个很关键,提交人我们现在就按照现在的有,只是存了id没有存名称,相关接口也没有返回,至于审批人,我们没办法拿到,这是企微模版中已经做好的,所以不用加审批人,或者说如果我们能通过模版拿到审批人企微的id,同时又跟我们系统的账号绑定了的话,就能正常展示审批人,这个看怎么搞,反正不能搞复杂了,我觉得就是一个很简单的,现有的逻辑都有了,只是把审批通过从我们系统中变成到企微中 + +### #97 代理钱包余额预警 + +- 原始需求:不同代理可配置不同预警额度,达到阈值时提醒代理及其发展人。 +- 提案偏差:被改成固定 100 元且不提供阈值配置。 +本期决定(单选): + +- [X] 本期做 +- [ ] 已完成,只联调/验收 +- [ ] 后置 +- [ ] 不做 +- [ ] 待确认 + +- 说明:这个主要是发站内通知,站内通知的功能好像已经做完了,这个就是低于100元的时候提醒发展人(业务员,创建店铺的时候选择的) + +--- + +## 四、确有复杂业务,但需要重新确认原始口径 + +### #33 套餐临期提醒 + +- 原始需求:每日计算;15/7/3 天节点;企业客户企微推送业务员;代理端数量/高亮/置顶;C 端续费提醒。 +- 提案偏差:企业微信提醒被排除,只保留站内通知。 +本期决定(单选): + +- [X] 本期做 +- [ ] 已完成,只联调/验收 +- [ ] 后置 +- [ ] 不做 +- [ ] 待确认 + +- 说明:这个得做,我们只做站内提醒,还有代理端数量/高亮/置顶(我记得现在好像是单独开一个临期列表吧,后台管理跟代理端都要),然后C端那边的话就是这几个时间点站内通知弹窗提醒 + +### #34 代理在线充值与员工线下代充值 + +- 原始需求:代理提交充值后扫码支付;员工线下代充值走部门领导、财务审批并通知结果。 +- 当前判断:业务未完成;在线支付、资金入账和线下审批应分别确认。 +本期决定(单选): + +- [X] 本期做 +- [ ] 已完成,只联调/验收 +- [ ] 后置 +- [ ] 不做 +- [ ] 待确认 + +- 说明:在线充值可以后置,员工线下代充这个其实就是走企微审批,现在我记得是做了我们系统的审批,不知道怎么做的,好像是做成灵活的,未来可以接任何第三方的,现在不要考虑那么多了,先赶紧按照企微的审批做完再说别的 + +### #35 退款审核 + +- 原始需求:员工申请,部门领导和财务顺序审批,逐环节企微提醒,通过/驳回通知申请人。 +- 当前判断:业务未完成;与 #37 共用审批能力,不应建立第二套审批系统。 +本期决定(单选): + +- [X] 本期做 +- [ ] 已完成,只联调/验收 +- [ ] 后置 +- [ ] 不做 +- [ ] 待确认 + +- 说明:这个其实也是企微审批,退款本身我们已经做了,我记得退款好像本身也有审批吧,只是说我们让前端做成权限的了,谁能看见谁就能通过来着,现在就是把企微审批接进来 + +### #36 批量订购套餐 + +- 原始需求:内部员工上传 Excel;文件含资产类型、资产标识、套餐系列、套餐、代理、支付账户;校验后扣款订购并展示失败明细。 +- 提案偏差:改成单列 CSV,不选择代理,整批只选一个套餐和支付方式。 +本期决定(单选): + +- [X] 本期做 +- [ ] 已完成,只联调/验收 +- [ ] 后置 +- [ ] 不做 +- [ ] 待确认 + +- 说明:这个"改成单列 CSV,不选择代理,整批只选一个套餐和支付方式。"是对的 + +### #37 企业微信审核流转 + +- 原始需求:充值和退款多级审批、待办提醒、逐环节通知、结果通知,并接入企业微信。 +- 当前判断:只完成渠道无关审批核心,企业微信 Adapter、绑定、模板、回调和轮询均未完成。 +本期决定(单选): + +- [X] 本期做 +- [ ] 已完成,只联调/验收 +- [ ] 后置 +- [ ] 不做 +- [ ] 待确认 + +- 说明:是的剩下的东西都要完成。企微审批模板由管理员在企微后台创建,系统只配置“业务场景与企微模板”的绑定并按模板发起审批,不在本系统新建审批模板或设计审批节点。账号身份不采用用户扫码自助绑定;通过企微通讯录接口获取成员,在系统账号列表中由管理员维护系统账号对应的企微 userid。退款、员工线下代充等业务发起审批后,通过企微回调接收结果,并保留查询审批详情/轮询作为回调丢失时的补偿。是否能取得审批人 userid 并映射为系统账号名称,以企微官方接口实际返回能力为准,不能为列表展示另建复杂审批人模型。 + +### #40 下架套餐老客户续费 + +- 原始需求:正在使用下架套餐的客户仍可自行续费;新客户不可购买;代理不可代购。 +- 当前判断:未完成;有购买资格校验,但不需要迁移整个套餐模块。 +本期决定(单选): + +- [X] 本期做 +- [ ] 已完成,只联调/验收 +- [ ] 后置 +- [ ] 不做 +- [ ] 待确认 + +- 说明:这个要做,主要是提供一个续费接口,只有通过这个接口才能去购买下架后的套餐(我记得是这样设计的),然后确保C端的可购套餐列表中没有下架的套餐,其次就是可以在历史订单里面续费,应该也是用同一个接口 + +### #42 导出功能 + +- 原始需求:分别支持 lot 卡、钱包流水、套餐、退款、换货、代理充值六类导出及指定字段。 +- 提案问题:将六个独立导出需求扩大成统一字段权限平台,并依赖多个未完成业务。 +- 建议:按业务场景逐个交付,优先使用现有导出框架,不阻塞在“统一平台全部完成”。 +本期决定(单选): + +- [X] 本期做 +- [ ] 已完成,只联调/验收 +- [ ] 后置 +- [ ] 不做 +- [ ] 待确认 + +- 说明:这个没啥好说的,确保按照我们有的字段,以及业务要求的字段去导出即可,可能需要探索一下,如果有字段我们没有,需要决定一下是否要加 + +### #47 限速规则 + +- 原始需求:根据不同运营商规则,基于套餐流量设置卡/设备限速规则。 +- 提案偏差:改成后台手动选择固定档位并调用 Gateway,明确排除自动限速。 +本期决定(单选): + +- [X] 本期做 +- [ ] 已完成,只联调/验收 +- [ ] 后置 +- [ ] 不做 +- [ ] 待确认 + +- 说明:改成后台在 IoT 卡详情手动选择固定档位并调用 Gateway;Gateway 只支持按卡 ICCID 限速,设备没有限速接口,也不通过设备绑定卡间接限速 + +### #48 按资产类型限制支付方式 + +- 原始需求:卡只允许支付宝/钱包,拒绝微信;设备只允许微信/钱包,拒绝支付宝。 +- 当前判断:未完成;前端展示和后端订单/支付强校验都需要实施。 +本期决定(单选): + +- [X] 本期做 +- [ ] 已完成,只联调/验收 +- [ ] 后置 +- [ ] 不做 +- [ ] 待确认 + +- 说明:这个其实就是在system_config里面做一个参数修改而已,通过这个参数可以决定卡/设备的支付方式限制,这个我们的迭代方案中好像说明是对的,其他的就是在C端不知道有没有东西能让前端判断展示哪些支付方式了 + +### #49 设备批量分配代理和套餐系列 + +- 原始需求:上传 Excel,以设备号批量分配代理或套餐系列。 +- 提案偏差:改成 CSV、异步五态任务、对象存储和失败明细平台。 +本期决定(单选): + +- [X] 本期做 +- [ ] 已完成,只联调/验收 +- [ ] 后置 +- [ ] 不做 +- [ ] 待确认 + +- 说明:是改成csv了,其他跟现在导入没有什么区别,就是业务不一样 + +--- + +## 五、已关闭需求回归确认 + +以下 5 条在 CSV 中未挂七月计划且已关闭。默认不重新开发;如本次需要回归,请勾选。 + +### #168 停机阈值显示 + +- 原始需求:虚流量停机阈值显示为真流量总量。 +本期决定(单选): + +- [ ] 仅回归 +- [ ] 重新修改 +- [X] 不处理 + +- 说明:已经做过了 + +### #90 资产详情敏感字段展示调整 + +- 原始需求:代理/企业只显示运营商名称,不显示运营商账户;代理不显示设备实名策略和制造商。 +本期决定(单选): + +- [ ] 仅回归 +- [ ] 重新修改 +- [X] 不处理 + +- 说明: + +### #75 支付配置命名调整 + +- 原始需求:“微信配置”改名为“支付配置”,同步权限编码和路由命名。 +本期决定(单选): + +- [ ] 仅回归 +- [ ] 重新修改 +- [X] 不处理 + +- 说明: + +### #64 H5 设备支持支付宝 + +- 原始需求:H5 设备支付增加支付宝。 +本期决定(单选): + +- [ ] 仅回归 +- [ ] 重新修改 +- [x] 不处理 + +- 说明: + +### #63 授权列表滚动条和字段顺序 + +- 原始需求:代理系列授权套餐列表增加滚动条,调整后台资产字段展示顺序。 +本期决定(单选): + +- [ ] 仅回归 +- [ ] 重新修改 +- [x] 不处理 + +- 说明: + +--- + +## 六、填写完成后 + +填写完成后通知后端复核即可。后端将根据选择结果重新整理 OpenSpec 提案和实施顺序。 diff --git a/docs/7月迭代/企业微信审批官方接口调研.md b/docs/7月迭代/企业微信审批官方接口调研.md new file mode 100644 index 0000000..ea15f67 --- /dev/null +++ b/docs/7月迭代/企业微信审批官方接口调研.md @@ -0,0 +1,202 @@ +# 企业微信审批官方接口调研 + +> 调研范围:企业微信开发者中心官方文档 +> 访问日期:2026-07-24 +> 结论口径:文中“官方明确”均来自企业微信官方页面;“建议”是基于官方约束形成的系统接入方案,不代表企业微信额外承诺。 + +## 一、结论摘要 + +1. 管理员手工绑定系统账号与企业微信成员是可行的。系统应保存企业标识与 `userid` 的绑定关系,并用姓名、部门辅助管理员辨认。`userid` 是企业内唯一、对应管理端账号的成员标识;手机号和邮箱属于敏感字段,不能假定一定能取得,也不应作为唯一绑定依据。[读取成员][1] +2. 审批模板通常可以在企业微信管理后台维护,模板 ID 可从模板编辑页 URL、审批回调或审批详情取得;系统再调用“获取审批模板详情”读取控件 ID、类型、必填属性和选项 key。自建应用和代开发应用也可以通过接口创建模板,因此“模板只能由管理员在后台创建”的设想不成立,但第三方应用不支持创建模板接口。[获取审批模板详情][6] [创建审批模板][7] +3. 系统发起审批时,必须使用已绑定且位于应用可见范围内的申请人 `userid`,提交模板 ID、审批流程选择、控件值和摘要。附件须先上传为临时素材,再将返回的 `media_id` 作为附件控件的 `file_id` 提交。[提交审批申请][8] [上传临时素材][9] +4. 审批结果同步不能只依赖回调。官方明确说明回调不能保证 100% 成功,应采用“回调实时处理 + 批量获取审批单号 + 获取审批详情”的主动补偿机制。[回调配置][12] [批量获取审批单号][11] [获取审批申请详情][10] +5. 上线前必须具备正确应用 Secret、按应用缓存的 `access_token`、审批接口权限、应用可见范围、可信 IP,以及可完成签名校验和 AES 解密的回调服务。[开发前必读][15] [获取 access_token][14] [审批状态变化回调][13] + +## 二、通讯录能力与手工绑定方案 + +### 2.1 官方接口能取得什么 + +| 信息 | 官方能力 | 主要限制 | +| --- | --- | --- | +| `userid` | “读取成员”返回成员标识;“获取部门成员/详情”可按部门返回成员;“获取成员 ID 列表”可分页返回 `userid` 与部门 ID 关系 | 只能读取应用可见范围;“获取成员 ID 列表”只支持“通讯录同步 secret”调用。[读取成员][1] [获取部门成员][2] [获取成员 ID 列表][4] | +| 姓名 | 自建应用可在权限范围内通过成员接口取得;代开发自建应用需要管理员授权 | 第三方应用通常不能直接取得真实姓名,接口可能以 `userid` 代替,需要使用通讯录展示组件。[读取成员][1] | +| 部门 | 成员接口返回部门 ID;部门成员接口也返回成员所属部门列表 | 只返回应用有查看权限的部门;要取得部门名称还需结合部门列表接口。成员授权模式下,部门信息还可能固定为根部门。[读取成员][1] [获取部门列表][5] | +| 手机号、邮箱 | 接口字段存在;自建应用与代开发应用可在满足授权条件后获取 | 2022-06-20 之后新建的自建应用和代开发应用,敏感字段需管理员在应用详情选择,并由成员通过 OAuth2 授权;第三方应用不可通过“获取访问用户敏感信息”取得手机、邮箱。[读取成员][1] [获取访问用户敏感信息][3] | + +`userid` 对应企业微信管理端账号,在企业内必须唯一、不区分大小写,长度为 1~64 字节。对于企业内部自建应用,建议将 `(corp_id, userid)` 作为企业微信身份键;不要仅保存姓名,也不要用手机号或邮箱充当稳定主键。[读取成员][1] + +### 2.2 推荐的管理员手工绑定流程 + +1. 为企业微信自建应用配置足够但最小化的通讯录可见范围。 +2. 系统按部门拉取可见成员,向管理员展示 `userid`、姓名和部门。若使用通讯录同步能力,可用“获取成员 ID 列表”分页取得完整 `userid`/部门关系;该接口不能用普通应用 Secret 替代通讯录同步 Secret。[获取部门成员详情][2] [获取成员 ID 列表][4] +3. 管理员在系统账号页面选择一个企业微信成员,系统保存系统账号 ID、企业 ID、`userid`,并保存姓名/部门快照用于展示和审计。 +4. 发起审批前再次确认绑定仍有效,且该成员仍在审批调用应用的可见范围内;否则“提交审批申请”会因无权限或提单者不可见失败。[提交审批申请][8] +5. 手机号、邮箱只能作为获得成员明确授权后的辅助核对信息。没有敏感字段时,绑定流程仍应可完成。[获取访问用户敏感信息][3] + +此方案是“管理员明确选择并绑定”,不是依据姓名、手机号自动猜测成员。重名、敏感字段缺失和通讯录权限变化都使自动匹配存在误绑风险。 + +## 三、审批模板与控件配置 + +### 3.1 模板从哪里来 + +企业微信提供两条路径: + +- 管理后台创建和维护:模板 ID 可从模板编辑页面的浏览器 URL 取得,也会出现在审批状态回调和审批详情中。[获取审批模板详情][6] +- 接口创建:自建应用需被配置到“审批 - 可调用接口的应用”,代开发应用需具有“审批”权限;第三方应用暂不支持。创建成功后,管理后台和审批应用内会生成对应模板,并生效默认流程和规则配置。[创建审批模板][7] + +因此,本项目若由客户企业管理员掌控流程,优先采用“后台建模板 + 系统配置模板 ID”;若模板由平台标准化交付且应用权限满足要求,可以评估调用创建模板接口。系统不应假定存在“列出企业全部模板”的接口;官方“获取审批模板详情”要求调用方已知 `template_id`。[获取审批模板详情][6] + +### 3.2 系统应如何配置控件 + +配置模板 ID 后,系统调用: + +```text +POST https://qyapi.weixin.qq.com/cgi-bin/oa/gettemplatedetail?access_token=ACCESS_TOKEN +{"template_id":"TEMPLATE_ID"} +``` + +返回的 `template_content.controls` 包含: + +- `property.control`:控件类型,如 `Text`、`Money`、`Date`、`Selector`、`Contact`、`File`、`Table` 等; +- `property.id`:提交审批时必须使用的控件唯一 ID; +- `property.title`、`placeholder`:控件名称和填写说明; +- `property.require`:是否必填; +- `config`:日期精度、单/多选、成员/部门模式、明细子控件等配置; +- 选择控件的 `options[].key`:提交选择值时使用的稳定选项键。[获取审批模板详情][6] + +建议系统保存“业务字段 → 控件 ID/类型/选项 key”的显式映射,并在保存配置及发起审批前校验模板结构。不能仅按控件中文标题赋值,因为提交接口以控件 ID 和选项 key 为准,管理员修改模板后原映射可能失效。 + +## 四、发起审批申请 + +### 4.1 请求与关键字段 + +接口为: + +```text +POST https://qyapi.weixin.qq.com/cgi-bin/oa/applyevent?access_token=ACCESS_TOKEN +``` + +核心字段如下:[提交审批申请][8] + +| 字段 | 含义与约束 | +| --- | --- | +| `creator_userid` | 申请人 `userid`;审批将以该员工身份提交,且申请人必须在应用可见范围内 | +| `template_id` | 审批模板 ID | +| `use_template_approver` | `0` 表示接口通过 `process` 指定审批人/抄送人;`1` 表示使用后台模板流程 | +| `choose_department` | 提单部门 ID;不填时默认主部门 | +| `process.node_list` | 接口指定流程时必填;节点类型可为审批人、抄送人、办理人,并配置人员及多人处理方式 | +| `apply_data.contents` | 控件赋值数组,每项必须携带 `control`、`id`、`value`;模板必填控件必须有值 | +| `summary_list` | 审批通知卡片和审批列表摘要,最多 3 行,每行文字不超过 20 个字符 | + +若 `use_template_approver=1`,后台审批流程中不能存在“申请人自选”节点。若为 `0`,`process` 必填。接口当前不支持提交“打卡补卡”“调班”模板审批单。[提交审批申请][8] + +不同控件的 `value` 结构不同,例如文本使用 `text`,金额使用 `new_money`,日期使用 `date`,选择控件使用 `selector.options[].key`,成员控件使用 `members[].userid`,部门控件使用 `departments[].openapi_id`。具体结构必须同时服从模板详情和提交接口附录,不能用一个通用字符串值替代。[获取审批模板详情][6] [提交审批申请][8] + +提交成功返回审批单号 `sp_no`,系统应立即将其与本地业务单据关联,作为后续回调去重、详情查询和补偿对账的关键标识。[提交审批申请][8] + +### 4.2 附件 + +附件需先调用临时素材上传接口: + +```text +POST https://qyapi.weixin.qq.com/cgi-bin/media/upload?access_token=ACCESS_TOKEN&type=file +Content-Type: multipart/form-data +``` + +上传结果中的 `media_id` 仅 3 天有效;提交附件控件时将其写入 `value.files[].file_id`。审批提单后,企业微信会将其转换为长期文件。单个审批申请全局最多支持 6 个附件;普通文件上传上限为 20 MB,且所有文件必须大于 5 字节。[上传临时素材][9] [提交审批申请][8] + +## 五、审批结果回传与主动补偿 + +### 5.1 状态变化回调 + +审批回调事件名为 `sys_approval_change`。回调包含 `SpNoStr`、`SpStatus`、`TemplateId`、申请人、流程节点、备注及 `StatuChangeEvent` 等信息。官方推荐使用字符串字段 `SpNoStr` 代替原有数值字段 `SpNo`。[审批状态变化回调][13] + +申请单状态包括审批中、已通过、已驳回、已撤销、通过后撤销、已删除、已支付;状态变化类型还覆盖提单、同意、驳回、转审、催办、撤销、添加备注、退回、加签、办理和转交等。[审批状态变化回调][13] + +回调处理建议: + +1. 校验签名并解密消息;验证解密后的 `receiveid` 与企业 `corpid` 一致。[加解密方案][16] +2. 持久化必要的接收记录并快速应答,把详情拉取和业务状态更新交给异步任务。 +3. 按 `SpNoStr` 查询“获取审批申请详情”,以详情中的最终状态、流程和表单值更新本地数据,而不是仅依赖一次回调载荷。[获取审批申请详情][10] +4. 业务处理必须幂等。企业微信在网络失败或超时时会重试,且状态变化可能多次发生;本地应允许同一审批单重复对齐到最新详情。 + +企业微信服务器在 5 秒内未收到响应会断开连接并重新发起请求,总共重试 3 次,但仅针对网络连接失败或超时。官方同时明确说明无法保证 100% 回调成功,并建议不要强依赖回调、增加额外机制对齐业务数据。[回调配置][12] + +### 5.2 主动查询与轮询补偿 + +补偿链路使用两个接口: + +1. “批量获取审批单号”按审批单提交时间范围拉取 `sp_no_list`,可按模板 ID、申请人、部门、审批状态筛选;单次最多 100 条,通过 `new_cursor`/`new_next_cursor` 分页。时间跨度不能超过 31 天,频率上限 600 次/分钟。[批量获取审批单号][11] +2. 对每个审批单号调用“获取审批申请详情”,取得 `sp_status`、模板、申请人、流程、表单控件值、备注和附件等完整信息;频率上限同样为 600 次/分钟。[获取审批申请详情][10] + +建议定时任务按模板 ID 拉取最近时间窗,并保留重叠回看区间,以覆盖任务延迟、分页中断和回调丢失;本地以 `sp_no` 幂等更新。时间窗应以“提交时间”理解,因为批量接口的 `starttime`/`endtime` 定义为审批单提交时间,而不是最后状态更新时间。因此,对仍处于审批中的历史单据,还应单独维护待终态集合并周期性查询详情,不能只扫描最近新提交的单据。[批量获取审批单号][11] + +## 六、接入前置条件 + +### 6.1 凭证与权限 + +- `access_token` 必须由后端使用企业 ID 和正确应用 Secret 获取,禁止返回给前端。每个应用的 Secret 和 Token 相互独立,必须按应用分别缓存。[获取 access_token][14] +- Token 正常有效期为 7200 秒,但可能提前失效;系统需按 `expires_in` 缓存,并在失效时重新获取,不能高频调用获取接口。[获取 access_token][14] +- 自建应用必须配置到“审批 - 可调用接口的应用”;代开发应用和第三方应用必须具有“审批”权限。提交人还必须在应用可见范围内。[获取审批模板详情][6] [提交审批申请][8] +- 自 2023-12-01 起,审批接口不再支持通过系统应用 Secret 调用,存量企业暂不受影响;新接入不能依赖系统应用 Secret。[获取审批模板详情][6] +- 2022-06-20 之后新开启的通讯录同步助手及新创建的自建应用,必须在管理端配置可信 IP,只有配置的 IP 才能调用接口。[开发前必读][15] + +### 6.2 回调服务 + +自建应用需在应用详情的“设置 API 接收”中配置 `URL`、`Token`、`EncodingAESKey`,并打开审批状态通知事件;其中 Token 用于签名,43 位 `EncodingAESKey` 用于消息体加密。[审批状态变化回调][13] [回调配置][12] + +回调端点必须同时支持: + +- `GET`:校验 `msg_signature`,解密 `echostr`,在 1 秒内原样返回明文,且不能带引号、BOM 或换行; +- `POST`:校验签名,解密 XML 中的 `Encrypt`,处理明文事件并正确响应; +- AES-256-CBC、PKCS#7 填充、签名计算及 `receiveid` 校验。官方提供 Go 等语言的加解密库,宜优先使用官方库。[回调配置][12] [加解密方案][16] + +如防火墙按来源 IP 放行,可调用官方回调 IP 接口获取网段。官方提示 IP 段可能变化,建议每天定时拉取并更新防火墙。[回调配置][12] + +## 七、不成立或存在明确限制的设想 + +| 设想 | 官方结论 | +| --- | --- | +| 只要能调审批接口,就一定能读取全企业通讯录 | 不成立。通讯录接口受应用可见范围、通讯录权限和 Secret 类型限制;审批权限不等于全通讯录权限。[读取成员][1] [获取成员 ID 列表][4] | +| 总能取得员工手机号或邮箱,用它自动匹配系统账号 | 不成立。新自建/代开发应用需管理员选择敏感字段并获得成员 OAuth2 授权;第三方应用还存在更严格限制。[获取访问用户敏感信息][3] | +| `userid` 可以跨企业直接当全局用户 ID | 不成立。官方定义的是企业内唯一;本地应与企业 ID 组合存储。第三方应用的成员接口还可能返回 `open_userid`。[读取成员][1] | +| 审批模板只能在企业微信后台人工创建 | 不成立。自建和代开发应用可调用创建模板接口;但第三方应用暂不支持。[创建审批模板][7] | +| 系统可以不保存模板 ID,启动时自动枚举全部模板 | 官方“获取模板详情”接口要求已知 `template_id`,官方给出的来源是编辑页 URL、回调、审批详情或创建接口返回值;不能据此假定存在全量模板枚举能力。[获取审批模板详情][6] [创建审批模板][7] | +| 修改模板控件标题不会影响系统提交 | 有限制。提交依赖控件 `id`、类型及选择项 `key`,不是只按标题匹配;模板变化后应重新校验映射。[获取审批模板详情][6] [提交审批申请][8] | +| 使用后台模板流程时,任何流程配置都能直接发起 | 不成立。`use_template_approver=1` 时,后台流程不能含“申请人自选”节点。[提交审批申请][8] | +| 所有审批模板都能通过接口发起 | 不成立。官方明确暂不支持“打卡补卡”“调班”模板。[提交审批申请][8] | +| 回调成功配置后可保证每次审批变化都送达一次 | 不成立。官方明确无法保证 100% 回调成功,超时重试也仅有限次数;必须主动补偿并实现幂等。[回调配置][12] | +| 回调就是完整审批详情,不需要再查询 | 有限制。状态回调提供状态和流程信息;完整表单控件值以“获取审批申请详情”为准。[审批状态变化回调][13] [获取审批申请详情][10] | +| 上传得到的附件 ID 可长期暂存,审批单附件数量不限 | 不成立。临时素材 `media_id` 仅 3 天有效,单个审批申请全局最多 6 个附件。[上传临时素材][9] [提交审批申请][8] | +| 可继续使用系统应用 Secret 接入新企业 | 不成立。审批接口自 2023-12-01 起不再支持系统应用 Secret,只有存量企业暂不受影响。[获取审批模板详情][6] | + +## 八、建议的最小接入闭环 + +1. 企业管理员创建并启用自建应用,配置应用可见范围、审批可调用应用、可信 IP、回调 URL/Token/EncodingAESKey。 +2. 系统按应用维度安全保存 Secret,并在后端缓存 `access_token`。 +3. 管理员通过可见成员列表,把本地账号绑定到 `(corp_id, userid)`。 +4. 管理员填写模板 ID;系统拉取模板详情,保存并校验业务字段与控件 ID/选项 key 的映射。 +5. 发起审批时使用已绑定 `userid`;附件即时上传后随审批提交;保存返回的 `sp_no`。 +6. 回调服务验签解密、快速应答并异步按 `sp_no` 拉取详情,幂等更新本地业务状态。 +7. 定时通过“批量获取审批单号 + 获取审批申请详情”补偿回调遗漏,同时周期性刷新未终态审批单。 + +## 九、官方资料 + +以下页面均为企业微信开发者中心官方文档,访问日期均为 **2026-07-24**。 + +[1]: https://developer.work.weixin.qq.com/document/path/90196 "读取成员" +[2]: https://developer.work.weixin.qq.com/document/path/90201 "获取部门成员详情" +[3]: https://developer.work.weixin.qq.com/document/path/95833 "获取访问用户敏感信息" +[4]: https://developer.work.weixin.qq.com/document/path/96067 "获取成员ID列表" +[5]: https://developer.work.weixin.qq.com/document/path/90208 "获取部门列表" +[6]: https://developer.work.weixin.qq.com/document/path/91982 "获取审批模板详情" +[7]: https://developer.work.weixin.qq.com/document/path/97437 "创建审批模板" +[8]: https://developer.work.weixin.qq.com/document/path/91853 "提交审批申请" +[9]: https://developer.work.weixin.qq.com/document/path/90253 "上传临时素材" +[10]: https://developer.work.weixin.qq.com/document/path/91983 "获取审批申请详情" +[11]: https://developer.work.weixin.qq.com/document/path/91816 "批量获取审批单号" +[12]: https://developer.work.weixin.qq.com/document/path/90930 "回调配置" +[13]: https://developer.work.weixin.qq.com/document/path/91815 "审批申请状态变化回调通知" +[14]: https://developer.work.weixin.qq.com/document/path/91039 "获取access_token" +[15]: https://developer.work.weixin.qq.com/document/path/90664 "开发前必读" +[16]: https://developer.work.weixin.qq.com/document/path/90968 "加解密方案说明" diff --git a/docs/7月迭代/物联网卡管系统-需求.csv b/docs/7月迭代/物联网卡管系统-需求.csv index ef29dfd..be747f5 100644 --- a/docs/7月迭代/物联网卡管系统-需求.csv +++ b/docs/7月迭代/物联网卡管系统-需求.csv @@ -1,72 +1,135 @@ "","Ʒ","ģ","ƻ","Դ","Դע","û","","ձ׼","㼶","","ؼ","ȼ","Ԥƹʱ","ǰ״̬","׶","","T","B","C","˭","","ָɸ","ָ","͸","","ʱ","˭ر","ر","رԭ","޸","޸","","ظ","" +"189 ","ϵͳ(#2)","/(#0)","7·ݵƻ [2026-07-10 ~ 2026-07-31](#1)","","","ĿǨƵľײ˿ʧЧ","Ŀǰ¿˾ɿײͣʶ˿˿ͨײͶڿϲʧЧ +"index.php?m=file&f=read&t=png&fileID=157" +"index.php?m=file&f=read&t=png&fileID=158" +"index.php?m=file&f=read&t=png&fileID=159" +","","1 ","0 ","","3(#3)","","(#reviewing)","Ѽƻ(#planned)","(#feature)","","","","","2026-07-24 11:05:51","","","","","","","","","޽","2026-07-24 20:18:13","","","","" +"188 ","ϵͳ(#2)","/(#0)","7·ݵƻ [2026-07-10 ~ 2026-07-31](#1)","","","ܿؿ","ͣĿH5еѡ +ʽһϵͳԶʾͻҪͻдջַ̨ͬÿֱĻͬʱԽERPϵͳ +ʽ֧ṩܣԶ嵯ݣѡÿͻ豸ͻ͵ȡͬʱʹõܽµײƹ㣬Ʒ֪ͨӶͻѵȡʽǷҪ½һ󣬾 +","","1 ","0 ","","3(#3)","","(#reviewing)","Ѽƻ(#planned)","(#feature)","","","","","2026-07-23 17:18:34","","","","","","","","","޽","2026-07-24 20:18:13","","","","" +"182 ","ϵͳ(#2)","/(#0)","7·ݵƻ [2026-07-10 ~ 2026-07-31](#1)","","","˿Ͷֵύ˼¼","˿Ͷֵں̨бжҪһύֶ +","","1 ","0 ","","3(#3)","","(#reviewing)","Ѽƻ(#planned)","(#feature)","","","","","2026-07-23 10:47:43","","","","","","","","","޽","2026-07-24 20:18:13","","","","" +"181 ","ϵͳ(#2)","/(#0)","7·ݵƻ [2026-07-10 ~ 2026-07-31](#1)","","","б˿ʲʶȱʧ","Ŀǰб˿ҳʲʶȱʧҪΪ豸 +˿ţRF20260723085931918896 +"index.php?m=file&f=read&t=png&fileID=155" + +ţORD20260721121735544971 +"index.php?m=file&f=read&t=png&fileID=156" +","","1 ","0 ","","3(#3)","","(#reviewing)","Ѽƻ(#planned)","(#feature)","","","","","2026-07-23 09:50:32","","","","","","","","","޽","2026-07-24 20:18:13","","","","" +"168 ","ϵͳ(#2)","/(#0)","","","","ֵͣʾ","ֵͣӦΪ +","","1 ","0 ","","3(#3)","0.50 ","(#active)","ѹر(#closed)","(#feature)","","","","","2026-07-21 14:09:25","ƽ","2026-07-21 14:09:25",""," +ƠD(#huang)","2026-07-21 14:11:00","","","","ƽ","2026-07-22 14:19:48","","","","" +"99 ","ϵͳ(#2)","/(#0)","7·ݵƻ [2026-07-10 ~ 2026-07-31](#1)","","","˿֧ԭ·˻","ϵͳ֧˿ʽ + 1. ԭ·˿ + 2. ͻṩտƾ֤˿ + ͬʱѡԭ·˿ϵͳУöӦԭտ̻ǰǷ߱˿벻ִе˿̡ + + 2. Ŀ + 1. ˿ʱ֧ѡ˿ʽ + 2. ԭ·˿ǰԶУԭտ̻ǰǷ֧˿ + 3. ԭտ̻֧˿ʱΪͻṩտƾ֤˿ + 4. ˿ʽУ˿ƾ֤¼ڲ񸴺׷ݡ + + 3. ˵ + 1. Ա˿롣 + 2. ѡ˿ʽ + 3. ѡԭ·˿ϵͳִԭտ̻˿У顣 + 4. Уͨ˿˼ִ̡ + 5. Уʧܣʾԭ򣬲Ϊͻṩտƾ֤˿ + 6. ѡͻṩտƾ֤˿ϴտƾ֤˼̡ + 7. ˿ϵͳ¼˿֪ͨˡ + +","","1 ","0 ","","2(#2)","6.00 ","(#active)","Ѽƻ(#planned)","(#feature)","","","","","2026-07-16 11:50:41","ƠD","2026-07-16 12:06:36",""," +ƠD(#huang)","2026-07-16 12:06:00","","","","޽","2026-07-24 20:18:13","","","","" "98 ","ϵͳ(#2)","/(#0)","7·ݵƻ [2026-07-10 ~ 2026-07-31](#1)","","","ʲ","ĬϻѾʲȨһ𻮹ȥ -","","1 ","0 ","","3(#3)","0.50 ","(#active)","Ѽƻ(#planned)","(#feature)","","","","","2026-07-15 14:08:57","ƠD","2026-07-16 11:51:00",""," -ƠD(#huang)","2026-07-16 11:50:00","","","","ƠD","2026-07-16 11:51:00","","","","" +","","1 ","0 ","","3(#3)","2.00 ","(#active)","з(#developing)","(#feature)","","","","","2026-07-15 14:08:57","ƠD","2026-07-16 11:51:00",""," +ƠD(#huang)","2026-07-16 11:50:00","","","","ƠD","2026-07-17 15:42:34","","","","" "97 ","ϵͳ(#2)","/(#0)","7·ݵƻ [2026-07-10 ~ 2026-07-31](#1)","","","Ǯֵ","ʽſԤͬòͬԤȡﵽֵʱѴ䷢չˡ -","","1 ","0 ","","3(#3)","0.50 ","(#active)","Ѽƻ(#planned)","(#feature)","","","","","2026-07-15 11:23:35","ƠD","2026-07-16 15:40:38",""," -ƠD(#huang)","2026-07-16 15:40:00","","","","ƠD","2026-07-16 15:40:38","","","","" +","","1 ","0 ","","3(#3)","2.10 ","(#active)","з(#developing)","(#feature)","","","","","2026-07-15 11:23:35","ƠD","2026-07-16 15:40:38",""," +ƠD(#huang)","2026-07-16 15:40:00","","","","ƠD","2026-07-17 15:52:54","","","","" "96 ","ϵͳ(#2)","/(#0)","7·ݵƻ [2026-07-10 ~ 2026-07-31](#1)","","","ԱΪչ˽бʶ","½ҵԱֶΣԽָӦҵԱDZͬʱбԼչֶΡչҲΪչµص̡ -","","1 ","0 ","","2(#2)","1.00 ","(#active)","Ѽƻ(#planned)","(#feature)","","","","","2026-07-15 11:22:00","ƠD","2026-07-15 11:23:15",""," -ƠD(#huang)","2026-07-15 11:23:00","","","","","2026-07-15 11:23:42","","","","" +","","1 ","0 ","","2(#2)","2.50 ","(#active)","з(#developing)","(#feature)","","","","","2026-07-15 11:22:00","ƠD","2026-07-15 11:23:15",""," +ƠD(#huang)","2026-07-15 11:23:00","","","","ƠD","2026-07-17 15:57:47","","","","" "94 ","ϵͳ(#2)","/(#0)","7·ݵƻ [2026-07-10 ~ 2026-07-31](#1)","","","ϵͳ״̬ͬŻԼص","ʲǨҪ漰ص״̬ͬʽŻ -","","1 ","0 ","","1(#1)","40.00 ","(#active)","Ѽƻ(#planned)","(#feature)","","","","","2026-07-14 10:53:29","ƠD","2026-07-14 11:00:14",""," -ƠD(#huang)","2026-07-14 10:59:00","","","","","2026-07-14 11:01:12","","","","" +","","1 ","0 ","","1(#1)","12.00 ","(#active)","з(#developing)","(#feature)","","","","","2026-07-14 10:53:29","ƠD","2026-07-14 11:00:14",""," +ƠD(#huang)","2026-07-14 10:59:00","","","","ƠD","2026-07-17 15:59:07","","","","" +"90 ","ϵͳ(#2)","/(#0)","","","","ʲбֶ޸","Ӫ˻ֶβֱչʾҵͻȲ鿴ҪӪ˻ֶڴҵͻֱʾΪйƶйšйͨй硣 +"index.php?m=file&f=read&t=png&fileID=134" + +豸ʵ֤Ժ̾չʾʾ +"index.php?m=file&f=read&t=png&fileID=135" +","","1 ","0 ","","3(#3)","1.40 ","(#active)","ѹر(#closed)","(#feature)","","","","","2026-07-13 15:11:48","ƽ","2026-07-13 15:11:48",""," +ƠD(#huang)","2026-07-13 15:13:00","","","","ƽ","2026-07-13 16:22:48","","","","" "86 ","ϵͳ(#2)","/(#0)","7·ݵƻ [2026-07-10 ~ 2026-07-31](#1)","","","ʲлʶ","ʲҳϢ豸ϢбҪʾǷǻʶעʲʲʲҪʲ"index.php?m=file&f=read&t=png&fileID=128""index.php?m=file&f=read&t=png&fileID=129" -","","1 ","0 ","","1(#1)","2.00 ","(#active)","Ѽƻ(#planned)","(#feature)","","","","","2026-07-13 11:50:17","ƠD","2026-07-13 11:54:36",""," -ƠD(#huang)","2026-07-13 11:54:00","","","","ƠD","2026-07-13 11:54:36","","","","" +","","1 ","0 ","","1(#1)","1.00 ","(#active)","з(#developing)","(#feature)","","","","","2026-07-13 11:50:17","ƠD","2026-07-13 11:54:36",""," +ƠD(#huang)","2026-07-13 11:54:00","","","","ƠD","2026-07-17 16:03:36","","","","" +"84 ","ϵͳ(#2)","/(#0)","7·ݵƻ [2026-07-10 ~ 2026-07-31](#1)","","","¿H5ҳ豸µICCIDȥʾ","¿H5ҳ豸µICCIDȥʾ +"index.php?m=file&f=read&t=jpg&fileID=133" +","","1 ","0 ","","3(#3)","0.20 ","(#active)","Ѽƻ(#planned)","(#feature)","","","","","2026-07-11 17:17:47","ƽ","2026-07-13 15:12:41",""," +ƠD(#huang)","2026-07-13 15:12:00","","","","޽","2026-07-24 20:18:13","","","","" +"75 ","ϵͳ(#2)","/(#0)","","","","֧","΢޸Ϊ֧ã޸ȨޱԼ·ɺı +","","1 ","0 ","","3(#3)","0.50 ","(#active)","ѹر(#closed)","(#feature)","","","","","2026-07-11 14:21:59","ƽ","2026-07-11 14:21:59",""," +ƠD(#huang)","2026-07-11 14:25:00","","","","ƽ","2026-07-11 14:44:28","","","","" "73 ","ϵͳ(#2)","/(#0)","7·ݵƻ [2026-07-10 ~ 2026-07-31](#1)","","","ҵͣ","ҵδʵ -","","1 ","0 ","","3(#3)","1.00 ","(#active)","Ѽƻ(#planned)","(#feature)","","","","","2026-07-11 10:02:33","ƠD","2026-07-11 14:22:26",""," -ƠD(#huang)","2026-07-11 14:22:00","","","","ƠD","2026-07-11 14:22:26","","","","" +","","1 ","0 ","","3(#3)","1.20 ","(#active)","з(#developing)","(#feature)","","","","","2026-07-11 10:02:33","ƠD","2026-07-11 14:22:26",""," +ƠD(#huang)","2026-07-11 14:22:00","","","","ƠD","2026-07-17 16:05:54","","","","" +"64 ","ϵͳ(#2)","/(#0)","","","","¿֧ʽ","¿H5豸֧֧ +","","1 ","0 ","","3(#3)","0.20 ","(#active)","ѹر(#closed)","(#feature)","","","","","2026-07-10 09:37:24","ƽ","2026-07-10 09:37:24",""," +ƠD(#huang)","2026-07-10 09:41:00","","","","ƽ","2026-07-10 10:01:22","","","","" +"63 ","ϵͳ(#2)","/(#0)","","","","ϵȨײбӹ","ϵȨײбӹͿ̨ܺʲϢֶʾ˳ +","","1 ","0 ","","3(#3)","0.20 ","(#active)","ѹر(#closed)","(#feature)","","","","","2026-07-10 09:36:52","ƽ","2026-07-10 09:36:52",""," +ƠD(#huang)","2026-07-10 09:41:00","","","","ƽ","2026-07-10 09:58:35","","","","" "62 ","ϵͳ(#2)","/(#0)","7·ݵƻ [2026-07-10 ~ 2026-07-31](#1)","","","¿H5Ҫȳֵʵ","ûH5ҪȰֻźǿȳֵǿʵܷк̨ãһ豸Ҫǿȳֵʵһʲʵֵ -","","1 ","0 ","","3(#3)","2.00 ","(#active)","Ѽƻ(#planned)","(#feature)","","","","","2026-07-09 17:04:27","","2026-07-09 18:13:07",""," -ƠD(#huang)","2026-07-11 14:23:00","","","","ƠD","2026-07-11 14:23:36","","","","" +","","1 ","0 ","","3(#3)","3.00 ","(#active)","з(#developing)","(#feature)","","","","","2026-07-09 17:04:27","","2026-07-09 18:13:07",""," +ƠD(#huang)","2026-07-11 14:23:00","","","","ƠD","2026-07-17 16:07:55","","","","" "60 ","ϵͳ(#2)","/(#0)","7·ݵƻ [2026-07-10 ~ 2026-07-31](#1)","","","̹","бһϵ绰 -","","1 ","0 ","","3(#3)","0.10 ","(#active)","Ѽƻ(#planned)","(#feature)","","","","","2026-07-09 16:00:56","ƠD","2026-07-09 17:37:57",""," -ƠD(#huang)","2026-07-09 17:37:00","","","","ƠD","2026-07-09 17:37:57","","","","" +","","1 ","0 ","","3(#3)","1.00 ","(#active)","з(#developing)","(#feature)","","","","","2026-07-09 16:00:56","ƠD","2026-07-09 17:37:57",""," +ƠD(#huang)","2026-07-09 17:37:00","","","","ƠD","2026-07-17 16:08:34","","","","" "57 ","ϵͳ(#2)","/(#0)","7·ݵƻ [2026-07-10 ~ 2026-07-31](#1)","","","","ǰʲ˿ʱΪͨʱʲҳʾʲ˿ -","","1 ","0 ","","3(#3)","1.00 ","(#active)","Ѽƻ(#planned)","(#feature)","","","","","2026-07-09 15:07:17","ƠD","2026-07-09 17:32:56",""," -ƠD(#huang)","2026-07-09 17:32:00","","","","","2026-07-09 17:33:21","","","","" +","","1 ","0 ","","3(#3)","1.50 ","(#active)","з(#developing)","(#feature)","","","","","2026-07-09 15:07:17","ƠD","2026-07-09 17:32:56",""," +ƠD(#huang)","2026-07-09 17:32:00","","","","ƠD","2026-07-17 16:09:19","","","","" "55 ","ϵͳ(#2)","/(#0)","7·ݵƻ [2026-07-10 ~ 2026-07-31](#1)","","","ײЧ","ײʱѡЧʵЧͬʱײͷʱṩ޸ĹܣԱΪհ汾 -","","1 ","0 ","","1(#1)","4.00 ","(#active)","Ѽƻ(#planned)","(#feature)","","","","","2026-07-09 11:57:51","ƠD","2026-07-09 16:54:53",""," -ƠD(#huang)","2026-07-09 16:54:00","","","","ƠD","2026-07-09 16:54:53","","","","" -"54 ","ϵͳ(#2)","/(#0)","7·ݵƻ [2026-07-10 ~ 2026-07-31](#1)","","","ʲҳײ͵ʱʾ","дЧײͼЧײͼʱ -","","1 ","0 ","","2(#2)","","ѹر(#closed)","ѹر(#closed)","(#feature)","","","","","2026-07-09 11:56:50","closed","2026-07-09 17:26:38",""," -ƠD(#huang)","2026-07-09 17:26:00","ƠD","2026-07-09 17:26:38","ظ(#duplicate)","ƠD","2026-07-09 17:26:38","","#46 ʲϢֶ","","" +","","1 ","0 ","","1(#1)","5.00 ","(#active)","з(#developing)","(#feature)","","","","","2026-07-09 11:57:51","ƠD","2026-07-09 16:54:53",""," +ƠD(#huang)","2026-07-09 16:54:00","","","","ƠD","2026-07-17 16:11:34","","","","" "53 ","ϵͳ(#2)","/(#0)","7·ݵƻ [2026-07-10 ~ 2026-07-31](#1)","","","ʲѯֶ","lot豸ʵ/δʵɸѡѯ -","","1 ","0 ","","1(#1)","1.00 ","(#active)","Ѽƻ(#planned)","(#feature)","","","","","2026-07-09 09:39:53","ƠD","2026-07-09 16:53:29",""," -ƠD(#huang)","2026-07-09 16:53:00","","","","ƠD","2026-07-09 16:53:29","","","","" +","","1 ","0 ","","1(#1)","2.50 ","(#active)","з(#developing)","(#feature)","","","","","2026-07-09 09:39:53","ƠD","2026-07-09 16:53:29",""," +ƠD(#huang)","2026-07-09 16:53:00","","","","ƠD","2026-07-17 16:12:35","","","","" +"52 ","ϵͳ(#2)","/(#0)","7·ݵƻ [2026-07-10 ~ 2026-07-31](#1)","","","ԽӾˮ̶","ܶҪˮ̶ϣ뽫ˮ̶ͨ綩ӿڡݵ +","","1 ","0 ","","3(#3)","200.00 ","(#active)","Ѽƻ(#planned)","(#feature)","","","","","2026-07-09 08:46:31","ƠD","2026-07-09 08:46:31",""," +ƠD(#huang)","2026-07-09 17:38:00","","","","޽","2026-07-24 20:18:13","","","","" "51 ","ϵͳ(#2)","/(#0)","7·ݵƻ [2026-07-10 ~ 2026-07-31](#1)","","","ͬƷʲĻ","򻻻ڲͬʲĻҴڲ۽п豸IJײͺ豸ײͲͬڲ۵ȲԭײʧЧʲ豸Ҫʹ豸ײֱ͡ӲײͻͬײͣԹ˾˵ɱӡ ","","1 ","0 ","","1(#1)","","ݸ(#draft)","Ѽƻ(#planned)","(#feature)","","","","","2026-07-08 17:54:32","","2026-07-09 16:53:05","","","2026-07-09 16:52:00","","","","ƠD","2026-07-09 16:53:05","","","","" "49 ","ϵͳ(#2)","/(#0)","7·ݵƻ [2026-07-10 ~ 2026-07-31](#1)","","","豸ײϵ","豸ŲţҪṩexcelķʽײϵСexcleͷΪ豸 -","","1 ","0 ","","1(#1)","2.00 ","(#active)","Ѽƻ(#planned)","(#feature)","","","","","2026-07-08 17:43:58","ƠD","2026-07-08 17:43:58",""," -ƠD(#huang)","2026-07-09 16:45:00","","","","ƠD","2026-07-09 16:46:08","","","","" -"48 ","ϵͳ(#2)","/(#0)","7·ݵƻ [2026-07-10 ~ 2026-07-31](#1)","","","ݲͬʲʹò֧ͬʽ","C֧ʱ,ʲֻ֧֧ԼǮ֧,΢֧;ܾ,豸ֻ΢֧ԼǮ֧,֧֧;ܾ","","1 ","0 ","","1(#1)","1.00 ","(#active)","Ѽƻ(#planned)","(#feature)","","","","","2026-07-08 15:56:52","ƠD","2026-07-09 16:45:21",""," -ƠD(#huang)","2026-07-09 16:45:00","","","","ƠD","2026-07-09 16:45:21","","","","" -"47 ","ϵͳ(#2)","/(#0)","7·ݵƻ [2026-07-10 ~ 2026-07-31](#1)","","","ٹ","ݲͬӪٹ򣬻ײòͬĿ/豸ٹ","","1 ","0 ","","3(#3)","4.00 ","(#active)","Ѽƻ(#planned)","(#feature)","","","","","2026-07-08 15:54:39","ƠD","2026-07-09 17:30:42",""," -ƠD(#huang)","2026-07-09 17:27:00","","","","ƠD","2026-07-09 17:30:42","","","","" -"46 ","ϵͳ(#2)","/(#0)","7·ݵƻ [2026-07-10 ~ 2026-07-31](#1)","","","ʲϢֶ","ʲϢҳ濨Ϣ/豸Ϣ飬ǰЧײ͵ĹʱΪһֶʾײͻʣ15쵽ʱֶθʾ","","1 ","0 ","","2(#2)","1.00 ","(#active)","Ѽƻ(#planned)","(#feature)","","","","","2026-07-08 15:52:36","ƠD","2026-07-09 17:25:53",""," -ƠD(#huang)","2026-07-09 17:25:00","","","","ƠD","2026-07-09 17:25:53","","","","" +","","1 ","0 ","","1(#1)","5.00 ","(#active)","Ѽƻ(#planned)","(#feature)","","","","","2026-07-08 17:43:58","ƠD","2026-07-08 17:43:58",""," +ƠD(#huang)","2026-07-09 16:45:00","","","","ƠD","2026-07-17 16:13:53","","","","" +"48 ","ϵͳ(#2)","/(#0)","7·ݵƻ [2026-07-10 ~ 2026-07-31](#1)","","","ݲͬʲʹò֧ͬʽ","C֧ʱ,ʲֻ֧֧ԼǮ֧,΢֧;ܾ,豸ֻ΢֧ԼǮ֧,֧֧;ܾ","","1 ","0 ","","1(#1)","4.00 ","(#active)","Ѽƻ(#planned)","(#feature)","","","","","2026-07-08 15:56:52","ƠD","2026-07-09 16:45:21",""," +ƠD(#huang)","2026-07-09 16:45:00","","","","ƠD","2026-07-17 16:15:40","","","","" +"47 ","ϵͳ(#2)","/(#0)","7·ݵƻ [2026-07-10 ~ 2026-07-31](#1)","","","ٹ","ݲͬӪٹ򣬻ײòͬĿ/豸ٹ","","1 ","0 ","","3(#3)","3.00 ","(#active)","Ѽƻ(#planned)","(#feature)","","","","","2026-07-08 15:54:39","ƠD","2026-07-09 17:30:42",""," +ƠD(#huang)","2026-07-09 17:27:00","","","","ƠD","2026-07-17 16:16:36","","","","" +"46 ","ϵͳ(#2)","/(#0)","7·ݵƻ [2026-07-10 ~ 2026-07-31](#1)","","","ʲϢֶ","ʲϢҳ濨Ϣ/豸Ϣ飬ǰЧײ͵ĹʱΪһֶʾײͻʣ15쵽ʱֶθʾ","","1 ","0 ","","2(#2)","4.00 ","(#active)","з(#developing)","(#feature)","","","","","2026-07-08 15:52:36","ƠD","2026-07-09 17:25:53",""," +ƠD(#huang)","2026-07-09 17:25:00","","","","ƠD","2026-07-17 16:17:29","","","","" "45 ","ϵͳ(#2)","/(#0)","7·ݵƻ [2026-07-10 ~ 2026-07-31](#1)","","","","| | | | ------- | ---------------------------------------------------- | | EXC-001 | бʲʶʲʶʾ⡣ | | EXC-002 | боʲʶʲʶʾΪ ICCID | | EXC-003 | ʲѯ֧ ICCIDšš | -| EXC-004 | ʲѯ֧ ICCIDšš |","","1 ","0 ","","1(#1)","3.00 ","(#active)","Ѽƻ(#planned)","(#feature)","","","","","2026-07-08 15:51:13","ƠD","2026-07-09 16:44:46",""," -ƠD(#huang)","2026-07-09 16:44:00","","","","ƠD","2026-07-09 16:44:46","","","","" +| EXC-004 | ʲѯ֧ ICCIDšš |","","1 ","0 ","","1(#1)","2.50 ","(#active)","з(#developing)","(#feature)","","","","","2026-07-08 15:51:13","ƠD","2026-07-09 16:44:46",""," +ƠD(#huang)","2026-07-09 16:44:00","","","","ƠD","2026-07-17 16:18:30","","","","" "44 ","ϵͳ(#2)","/(#0)","7·ݵƻ [2026-07-10 ~ 2026-07-31](#1)","","","бֶ","| | ģ | ֶ | | ------- | ------------ | -------------- | | COL-001 | ˿б | ύˡ | | COL-002 | ֵб | ύˡ | -| COL-003 | Źб | ύ |","","1 ","0 ","","1(#1)","1.00 ","(#active)","Ѽƻ(#planned)","(#feature)","","","","","2026-07-08 15:51:13","ƠD","2026-07-09 16:44:20",""," -ƠD(#huang)","2026-07-09 16:44:00","","","","ƠD","2026-07-09 16:44:20","","","","" +| COL-003 | Źб | ύ |","","1 ","0 ","","1(#1)","3.00 ","(#active)","з(#developing)","(#feature)","","","","","2026-07-08 15:51:13","ƠD","2026-07-09 16:44:20",""," +ƠD(#huang)","2026-07-09 16:44:00","","","","ƠD","2026-07-17 16:19:27","","","","" "43 ","ϵͳ(#2)","/(#0)","7·ݵƻ [2026-07-10 ~ 2026-07-31](#1)","","","ϵȨ","| | | | ------- | ------------------------------------------------------------ | | AUT-001 | ϵȨ-ײбУȨײ֧һԶѡײ͡ | | AUT-002 | Ȩײʱչʾۼۡ | | AUT-003 | Ȩײʱչʾ˾ɱۡ | | AUT-004 | ѷײͺδײҪ֡ | -| AUT-005 | ϵȨʱѡײȷʾǰѾײ͡ |","","1 ","0 ","","2(#2)","5.00 ","(#active)","Ѽƻ(#planned)","(#feature)","","","","","2026-07-08 15:51:13","ƠD","2026-07-09 17:25:42",""," -ƠD(#huang)","2026-07-09 17:12:00","","","","ƠD","2026-07-09 17:25:42","","","","" +| AUT-005 | ϵȨʱѡײȷʾǰѾײ͡ |","","1 ","0 ","","2(#2)","1.50 ","(#active)","Ѽƻ(#planned)","(#feature)","","","","","2026-07-08 15:51:13","ƠD","2026-07-09 17:25:42",""," +ƠD(#huang)","2026-07-09 17:12:00","","","","ƠD","2026-07-17 16:20:11","","","","" "42 ","ϵͳ(#2)","/(#0)","7·ݵƻ [2026-07-10 ~ 2026-07-31](#1)","","","","#### 6.8.1 lot ##### ֧ײ30ʲĵֶΰտ/豸ĵе @@ -208,8 +271,8 @@ | 쵼 | | | | ֧ƾ֤ | -| ע |","","1 ","0 ","","1(#1)","16.00 ","(#active)","Ѽƻ(#planned)","(#feature)","","","","","2026-07-08 15:49:25","ƠD","2026-07-09 16:42:07",""," -ƠD(#huang)","2026-07-09 16:40:00","","","","ƠD","2026-07-09 16:42:07","","","","" +| ע |","","1 ","0 ","","1(#1)","8.00 ","(#active)","Ѽƻ(#planned)","(#feature)","","","","","2026-07-08 15:49:25","ƠD","2026-07-09 16:42:07",""," +ƠD(#huang)","2026-07-09 16:40:00","","","","ƠD","2026-07-17 16:20:58","","","","" "41 ","ϵͳ(#2)","/(#0)","7·ݵƻ [2026-07-10 ~ 2026-07-31](#1)","","","ѯƣƿֿͻ","¼ֻܿϼϢapiԽӿͻ޷ͨϼ鵽ǹ˾Ϣ | | | | ------- | -------------------------------------------------------- | | API-001 | ֧ô API ԽӲѯơ¼ͻ/޷缶ѯ | ","","1 ","0 ","","1(#1)","","ݸ(#draft)","Ѽƻ(#planned)","(#feature)","","","","","2026-07-08 15:49:25","","2026-07-09 16:42:55","","","2026-07-11 14:24:00","","","","ƠD","2026-07-11 14:25:30","","","","" @@ -218,15 +281,27 @@ | PKG-001 | ײ׼ | | PKG-002 | ײ¼ܺʹøײ͵ĿͻԿѡ | | PKG-003 | ¼ײѽֿ֧ͻԼ | -| PKG-004 | ¼ײͲɱ¹ |","","1 ","0 ","","2(#2)","3.00 ","(#active)","Ѽƻ(#planned)","(#feature)","","","","","2026-07-08 15:49:25","ƠD","2026-07-09 17:11:34",""," -ƠD(#huang)","2026-07-09 17:10:00","","","","ƠD","2026-07-09 17:11:34","","","","" +| PKG-004 | ¼ײͲɱ¹ |","","1 ","0 ","","2(#2)","4.00 ","(#active)","Ѽƻ(#planned)","(#feature)","","","","","2026-07-08 15:49:25","ƠD","2026-07-09 17:11:34",""," +ƠD(#huang)","2026-07-09 17:10:00","","","","ƠD","2026-07-17 16:21:43","","","","" +"39 ","ϵͳ(#2)","/(#0)","7·ݵƻ [2026-07-10 ~ 2026-07-31](#1)","","","Ӷ","##### ԱΪչ˽бʶϵͳ֣ + +| | | +| ------- | ---------------------------------------------------- | +| DST-001 | ½ʱԶϵ | +| DST-002 | ԱΪչ˽бʶϵͳ֣ | +| DST-003 | Ӷǰǩͬ | +| DST-004 | ӶϴӪҵִաѡ | +| DST-005 | Ӷϴ֤ | +| DST-006 | Ӷֿѡϴͷաѡ | +| DST-007 | ӶϴƱҹ˾ͬһ¡ѡ |","","1 ","0 ","","3(#3)","40.00 ","(#active)","Ѽƻ(#planned)","(#feature)","","","","","2026-07-08 15:49:25","ƠD","2026-07-09 17:43:17",""," +ƠD(#huang)","2026-07-09 17:43:00","","","","޽","2026-07-24 20:18:13","","","","" "38 ","ϵͳ(#2)","/(#0)","7·ݵƻ [2026-07-10 ~ 2026-07-31](#1)","","","ͬȴ","| | | | ------- | ------------------------------------------------------------ | | BPO-009 | ½ʱǷȨȡءƽ̨û˺ĬӵȨȡ | | BPO-010 | ͬòͬޡƽ̨ûɸݲͬĽɫòͬĶޡ | | BPO-011 | Ȩڶײֵ͡Ҫ漰ģ顣 | -| BPO-012 | Ȩȴʾƽ̨ûҲʾ |","","1 ","0 ","","2(#2)","16.00 ","(#active)","Ѽƻ(#planned)","(#feature)","","","","","2026-07-08 15:49:25","ƠD","2026-07-09 16:58:16",""," -ƠD(#huang)","2026-07-09 16:57:00","","","","ƠD","2026-07-09 16:58:16","","","","" +| BPO-012 | Ȩȴʾƽ̨ûҲʾ |","","1 ","0 ","","2(#2)","7.00 ","(#active)","з(#developing)","(#feature)","","","","","2026-07-08 15:49:25","ƠD","2026-07-09 16:58:16",""," +ƠD(#huang)","2026-07-09 16:57:00","","","","ƠD","2026-07-17 16:22:35","","","","" "37 ","ϵͳ(#2)","/(#0)","7·ݵƻ [2026-07-10 ~ 2026-07-31](#1)","","","ת","| | | | ------- | ------------------------------------------------------------ | | APR-001 | ϵͳύֵ롣 | @@ -237,8 +312,8 @@ | APR-006 | Ѱ̻ڴһʾһˡ | | APR-007 | ֪ͨͨˡ | | APR-008 | ˲غ֪ͨˣԭ | -| APR-009 | Խҵ΢̡ |","","1 ","0 ","","2(#2)","24.00 ","(#active)","Ѽƻ(#planned)","(#feature)","","","","","2026-07-08 15:49:25","ƠD","2026-07-09 16:57:35",""," -ƠD(#huang)","2026-07-09 16:57:00","","","","ƠD","2026-07-09 16:57:35","","","","" +| APR-009 | Խҵ΢̡ |","","1 ","0 ","","2(#2)","16.00 ","(#active)","Ѽƻ(#planned)","(#feature)","","","","","2026-07-08 15:49:25","ƠD","2026-07-09 16:57:35",""," +ƠD(#huang)","2026-07-09 16:57:00","","","","ƠD","2026-07-17 16:23:22","","","","" "36 ","ϵͳ(#2)","/(#0)","7·ݵƻ [2026-07-10 ~ 2026-07-31](#1)","","","ײ","1. ڲԱҳ档 2. ѡICCIDŶ/豸šײ͡ϴ Excel ļʲʶ֧ ICCID/豸 3. Excelͷת6.3ʾ @@ -266,15 +341,15 @@ excel | ײϵ | | ײ | | | -| ֧ʽ˻/Ա˻ |","","1 ","0 ","","1(#1)","4.00 ","(#active)","Ѽƻ(#planned)","(#feature)","","","","","2026-07-08 15:49:25","ƠD","2026-07-09 16:31:53",""," -ƠD(#huang)","2026-07-09 16:31:00","","","","ƠD","2026-07-09 16:31:53","","","","" +| ֧ʽ˻/Ա˻ |","","1 ","0 ","","1(#1)","8.00 ","(#active)","з(#developing)","(#feature)","","","","","2026-07-08 15:49:25","ƠD","2026-07-09 16:31:53",""," +ƠD(#huang)","2026-07-09 16:31:00","","","","ƠD","2026-07-17 16:24:11","","","","" "35 ","ϵͳ(#2)","/(#0)","7·ݵƻ [2026-07-10 ~ 2026-07-31](#1)","","","˿","ͨ΢Ѻʾ״̬ͬܣ 1. Աύ˿롣 2. ˿༶̡ 3. 쵼˳ 4. ǰɺһյϢʾ -5. ͨ򲵻غ֪ͨˡ","","1 ","0 ","","2(#2)","24.00 ","(#active)","Ѽƻ(#planned)","(#feature)","","","","","2026-07-08 15:49:25","ƠD","2026-07-09 16:56:31",""," -ƠD(#huang)","2026-07-09 16:55:00","","","","ƠD","2026-07-09 16:56:31","","","","" +5. ͨ򲵻غ֪ͨˡ","","1 ","0 ","","2(#2)","7.00 ","(#active)","з(#developing)","(#feature)","","","","","2026-07-08 15:49:25","ƠD","2026-07-09 16:56:31",""," +ƠD(#huang)","2026-07-09 16:55:00","","","","ƠD","2026-07-17 16:25:07","","","","" "34 ","ϵͳ(#2)","/(#0)","7·ݵƻ [2026-07-10 ~ 2026-07-31](#1)","","","ֵ","Լֵ 1. ϵͳύֵ롣 2. ϵͳչʾտά롣 @@ -285,8 +360,8 @@ excel 2. ύ˲쵼 3. һͨյѲ 4. ֪ͨͨˡ -5. غ֪ͨˣչʾԭ","","1 ","0 ","","2(#2)","32.00 ","(#active)","Ѽƻ(#planned)","(#feature)","","","","","2026-07-08 15:49:25","ƠD","2026-07-09 16:55:42",""," -ƠD(#huang)","2026-07-09 16:55:00","","","","ƠD","2026-07-09 16:55:42","","","","" +5. غ֪ͨˣչʾԭ","","1 ","0 ","","2(#2)","16.00 ","(#active)","Ѽƻ(#planned)","(#feature)","","","","","2026-07-08 15:49:25","ƠD","2026-07-09 16:55:42",""," +ƠD(#huang)","2026-07-09 16:55:00","","","","ƠD","2026-07-17 16:26:05","","","","" "33 ","ϵͳ(#2)","/(#0)","7·ݵƻ [2026-07-10 ~ 2026-07-31](#1)","","","ײ","1. ϵͳÿռ㿨/豸ײʣЧڡ 2. ڹݡѹ򣺰 15 졢7 졢3 ڵֱвͬʽѡ 3. ҵͻ 15 졢7 졢3 ڵбͨҵ΢͸ӦҵԱ @@ -325,5 +400,5 @@ excel /豸ţxxx ʣЧڣxx -Ϊ⵽ںӰʹãǰѡ","","1 ","0 ","","1(#1)","","(#active)","Ѽƻ(#planned)","(#feature)","","","","","2026-07-08 15:49:25","ƠD","2026-07-09 16:30:06",""," -ƠD(#huang)","2026-07-09 16:29:00","","","","ƠD","2026-07-09 16:30:06","","","","" +Ϊ⵽ںӰʹãǰѡ","","1 ","0 ","","1(#1)","8.00 ","(#active)","Ѽƻ(#planned)","(#feature)","","","","","2026-07-08 15:49:25","ƠD","2026-07-09 16:30:06",""," +ƠD(#huang)","2026-07-09 16:29:00","","","","ƠD","2026-07-17 16:26:41","","","","" diff --git a/docs/add-asset-operation-audit-log/接口回放示例.md b/docs/add-asset-operation-audit-log/接口回放示例.md index bc08911..b1467dc 100644 --- a/docs/add-asset-operation-audit-log/接口回放示例.md +++ b/docs/add-asset-operation-audit-log/接口回放示例.md @@ -4,15 +4,17 @@ ## 1. success 示例 -### 1.1 设备限速(`device_speed_limit`) +### 1.1 IoT 卡固定档位限速(`card_speed_tier`) ```bash -curl -X POST "http://127.0.0.1:8080/api/admin/devices//speed-limit" \ +curl -X PUT "http://127.0.0.1:8080/api/admin/iot-cards//speed-tier" \ -H "Authorization: Bearer " \ -H "Content-Type: application/json" \ - -d '{"speed_limit":1024}' + -d '{"code":3}' ``` +该入口只接受 IoT 卡 ICCID;设备没有限速接口,也不通过设备当前绑定卡间接限速。 + ### 1.2 资产轮询开关(`asset_polling_status`) ```bash diff --git a/docs/admin-openapi.yaml b/docs/admin-openapi.yaml index c748647..071c170 100644 --- a/docs/admin-openapi.yaml +++ b/docs/admin-openapi.yaml @@ -92,6 +92,18 @@ components: username: description: 用户名 type: string + wecom_bound: + description: 是否已绑定企业微信成员 + type: boolean + wecom_corp_id: + description: 已绑定的企业微信企业 ID + type: string + wecom_name: + description: 已绑定的企业微信成员姓名快照 + type: string + wecom_userid: + description: 已绑定的企业微信成员 userid + type: string type: object DtoAccountRoleResponse: properties: @@ -495,6 +507,21 @@ components: amount: description: 充值金额(分) type: integer + approval_instance_id: + description: 通用审批实例ID,在线充值为null + minimum: 0 + nullable: true + type: integer + approval_provider: + description: 审批渠道,企微审批为wecom + type: string + approval_status: + description: 审批状态 (0:提交中, 1:审批中, 2:已通过, 3:已拒绝, 4:已撤销, 5:通过后撤销, 6:已删除, 7:提交失败, 8:提交结果未知) + nullable: true + type: integer + approval_status_name: + description: 审批状态名称(中文) + type: string completed_at: description: 完成时间 nullable: true @@ -553,6 +580,13 @@ components: status_name: description: 状态名称(中文) type: string + submitter_id: + description: 提交人账号ID + minimum: 0 + type: integer + submitter_name: + description: 提交人账号名称 + type: string updated_at: description: 更新时间 type: string @@ -1040,6 +1074,12 @@ components: activation_status_name: description: 激活状态名称(中文) type: string + allowed_payment_methods: + description: 当前资产允许的支付方式列表 (wallet:钱包, wechat:微信, alipay:支付宝) + items: + type: string + nullable: true + type: array asset_id: description: 资产ID minimum: 0 @@ -1095,6 +1135,10 @@ components: format: date-time nullable: true type: string + current_package_id: + description: 当前主套餐ID,无主套餐时为0,可用于发起续费 + minimum: 0 + type: integer current_package_usage_id: description: 当前主套餐的套餐使用记录ID,无主套餐时为 null minimum: 0 @@ -1118,6 +1162,9 @@ components: device_type: description: 设备类型 type: string + effective_realname_policy: + description: 当前资产实际生效的实名认证策略 (none:无需实名, before_order:先实名后充值/购买, after_order:先充值/购买后实名) + type: string enable_virtual_data: description: 当前主套餐是否启用虚流量(按套餐使用记录快照返回) type: boolean @@ -1180,6 +1227,9 @@ components: realname_policy: description: 实名认证策略 (none:无需实名, before_order:先实名后充值/购买, after_order:先充值/购买后实名) type: string + realname_required: + description: 当前资产是否要求完成实名认证 + type: boolean reduction_pct: description: 展示增幅比例,公式为(real_total_mb / virtual_total_mb) - 1 type: number @@ -1392,6 +1442,192 @@ components: description: 是否已截断(换货链超过10代追溯上限时为true) type: boolean type: object + DtoAssetPackageBatchOrderResultItemResponse: + properties: + amount: + description: 成功订单金额(分) + type: integer + asset_identifier: + description: 资产标识 + type: string + line: + description: CSV行号 + type: integer + order_id: + description: 成功订单ID + minimum: 0 + type: integer + order_no: + description: 成功订单号 + type: string + reason: + description: 中文失败原因 + type: string + status: + description: 单行状态 (3:成功, 4:失败) + type: integer + status_name: + description: 单行状态名称(中文) + type: string + type: object + DtoAssetPackageBatchOrderTaskDetailResponse: + properties: + completed_at: + description: 完成时间 + nullable: true + type: string + created_at: + description: 创建时间 + type: string + creator_name: + description: 操作人姓名快照 + type: string + error_message: + description: 任务级中文错误信息 + type: string + fail_count: + description: 失败数 + type: integer + file_name: + description: 源CSV文件名 + type: string + id: + description: 任务ID + minimum: 0 + type: integer + items: + description: 逐行成功和失败明细 + items: + $ref: '#/components/schemas/DtoAssetPackageBatchOrderResultItemResponse' + nullable: true + type: array + package_code: + description: 套餐编码快照 + type: string + package_id: + description: 套餐ID + minimum: 0 + type: integer + package_name: + description: 套餐名称快照 + type: string + payment_method: + description: 支付方式 (wallet:钱包支付, offline:线下支付) + type: string + started_at: + description: 开始处理时间 + nullable: true + type: string + status: + description: 任务状态 (1:待处理, 2:处理中, 3:已完成, 4:已失败, 5:已取消) + type: integer + status_name: + description: 任务状态名称(中文) + type: string + success_count: + description: 成功数 + type: integer + task_no: + description: 任务编号 + type: string + total_count: + description: 总行数 + type: integer + updated_at: + description: 更新时间 + type: string + voucher_keys: + description: 线下支付凭证对象存储Key列表 + items: + type: string + nullable: true + type: array + type: object + DtoAssetPackageBatchOrderTaskListResponse: + properties: + items: + description: 任务列表 + items: + $ref: '#/components/schemas/DtoAssetPackageBatchOrderTaskResponse' + nullable: true + type: array + page: + description: 当前页码 + type: integer + size: + description: 每页条数 + type: integer + total: + description: 总记录数 + type: integer + type: object + DtoAssetPackageBatchOrderTaskResponse: + properties: + completed_at: + description: 完成时间 + nullable: true + type: string + created_at: + description: 创建时间 + type: string + creator_name: + description: 操作人姓名快照 + type: string + error_message: + description: 任务级中文错误信息 + type: string + fail_count: + description: 失败数 + type: integer + file_name: + description: 源CSV文件名 + type: string + id: + description: 任务ID + minimum: 0 + type: integer + package_code: + description: 套餐编码快照 + type: string + package_id: + description: 套餐ID + minimum: 0 + type: integer + package_name: + description: 套餐名称快照 + type: string + payment_method: + description: 支付方式 (wallet:钱包支付, offline:线下支付) + type: string + started_at: + description: 开始处理时间 + nullable: true + type: string + status: + description: 任务状态 (1:待处理, 2:处理中, 3:已完成, 4:已失败, 5:已取消) + type: integer + status_name: + description: 任务状态名称(中文) + type: string + success_count: + description: 成功数 + type: integer + task_no: + description: 任务编号 + type: string + total_count: + description: 总行数 + type: integer + updated_at: + description: 更新时间 + type: string + voucher_keys: + description: 线下支付凭证对象存储Key列表 + items: + type: string + nullable: true + type: array + type: object DtoAssetPackageHistoryResponse: properties: items: @@ -2342,6 +2578,37 @@ components: description: 成功数量 type: integer type: object + DtoBatchUpdateAssetRealnamePolicyRequest: + properties: + asset_ids: + description: 资产ID列表(最多500条) + items: + minimum: 0 + type: integer + maxItems: 500 + minItems: 1 + nullable: true + type: array + realname_policy: + description: 实名认证策略 (none:无需实名, before_order:先实名后充值/购买, after_order:先充值/购买后实名) + enum: + - none + - before_order + - after_order + type: string + required: + - asset_ids + - realname_policy + type: object + DtoBatchUpdateAssetRealnamePolicyResponse: + properties: + realname_policy: + description: 更新后的实名认证策略 (none:无需实名, before_order:先实名后充值/购买, after_order:先充值/购买后实名) + type: string + success_count: + description: 成功更新数量 + type: integer + type: object DtoBatchUpdateCostPriceRequest: properties: change_reason: @@ -2381,6 +2648,18 @@ components: description: 更新数量 type: integer type: object + DtoBindAccountWeComParams: + properties: + application_id: + description: 企业微信应用配置 ID + minimum: 1 + type: integer + userid: + description: 企业微信成员 userid + maxLength: 64 + minLength: 1 + type: string + type: object DtoBindCardToDeviceRequest: properties: iot_card_id: @@ -2661,11 +2940,12 @@ components: nullable: true type: array payment_method: - description: 支付方式(强充必传)(wechat:微信, alipay:支付宝) + description: 订单支付方式 (wallet:钱包, wechat:微信, alipay:支付宝) type: string required: - identifier - package_ids + - payment_method type: object DtoClientCreateOrderResponse: properties: @@ -2740,6 +3020,16 @@ components: type: object DtoClientOrderDetailResponse: properties: + asset_id: + description: 资产ID + minimum: 0 + type: integer + asset_identifier: + description: 下单时资产标识符快照(卡为ICCID,设备优先VirtualNo、其次IMEI) + type: string + asset_type: + description: 资产类型 (card:卡, device:设备) + type: string completed_at: description: 完成时间 nullable: true @@ -2789,6 +3079,9 @@ components: order_no: description: 订单号 type: string + payment_method: + description: 订单创建时选定的支付方式 (wallet:钱包, wechat:微信, alipay:支付宝) + type: string payment_status: description: 支付状态 (1:待支付, 2:已支付, 3:已取消, 4:已退款) type: integer @@ -2801,6 +3094,16 @@ components: type: object DtoClientOrderListItem: properties: + asset_id: + description: 资产ID + minimum: 0 + type: integer + asset_identifier: + description: 下单时资产标识符快照(卡为ICCID,设备优先VirtualNo、其次IMEI) + type: string + asset_type: + description: 资产类型 (card:卡, device:设备) + type: string created_at: description: 创建时间 type: string @@ -2811,12 +3114,22 @@ components: order_no: description: 订单号 type: string + package_ids: + description: 套餐ID列表,可用于发起续费 + items: + minimum: 0 + type: integer + nullable: true + type: array package_names: description: 套餐名称列表 items: type: string nullable: true type: array + payment_method: + description: 订单创建时选定的支付方式 (wallet:钱包, wechat:微信, alipay:支付宝) + type: string payment_status: description: 支付状态 (1:待支付, 2:已支付, 3:已取消, 4:已退款) type: integer @@ -2925,10 +3238,8 @@ components: description: 应用类型(微信支付必传)(official_account:公众号, miniapp:小程序) type: string payment_method: - description: 支付方式 (wallet:钱包, wechat:微信, alipay:支付宝) + description: 过渡兼容字段;为空时使用订单快照,非空时必须与订单快照一致 type: string - required: - - payment_method type: object DtoClientPayOrderResponse: properties: @@ -2957,6 +3268,12 @@ components: type: object DtoClientRechargeCheckResponse: properties: + allowed_payment_methods: + description: 当前资产允许的支付方式列表 (wallet:钱包, wechat:微信, alipay:支付宝) + items: + type: string + nullable: true + type: array force_recharge_amount: description: 强制充值金额(分) type: integer @@ -3227,7 +3544,7 @@ components: DtoCreateAdminOrderRequest: properties: identifier: - description: 资产标识符(ICCID 或 VirtualNo) + description: 资产标识符(卡支持 ICCID,设备支持 VirtualNo、IMEI 或 SN) maxLength: 100 minLength: 1 type: string @@ -3285,6 +3602,31 @@ components: - amount - payment_method type: object + DtoCreateAssetPackageBatchOrderRequest: + properties: + file_key: + description: 单列CSV对象存储Key(每行一个资产标识) + maxLength: 500 + type: string + package_id: + description: 整批统一套餐ID + minimum: 1 + type: integer + payment_method: + description: 整批统一支付方式 (wallet:钱包支付, offline:线下支付) + type: string + voucher_keys: + description: 线下支付凭证对象存储Key列表(offline时1至5个) + items: + type: string + maxItems: 5 + nullable: true + type: array + required: + - file_key + - package_id + - payment_method + type: object DtoCreateCarrierRequest: properties: carrier_code: @@ -3339,6 +3681,41 @@ components: description: 表名 type: string type: object + DtoCreateDeviceBatchAllocationRequest: + properties: + file_key: + description: 单列设备标识 CSV 对象存储 Key(每行支持 VirtualNo、IMEI 或 SN) + maxLength: 500 + minLength: 1 + type: string + operation_type: + description: 操作类型 (assign_shop:分配目标代理, assign_series:设置套餐系列) + enum: + - assign_shop + - assign_series + type: string + target_id: + description: 目标代理店铺ID或套餐系列ID,由 operation_type 决定 + minimum: 1 + type: integer + required: + - file_key + - operation_type + - target_id + type: object + DtoCreateDeviceBatchAllocationResponse: + properties: + message: + description: 提示信息 + type: string + task_id: + description: 任务ID + minimum: 0 + type: integer + task_no: + description: 任务编号 + type: string + type: object DtoCreateEnterpriseReq: properties: address: @@ -3461,7 +3838,7 @@ components: description: 导出筛选参数(JSON对象,可选) type: object scene: - description: 导出场景 (device:设备, iot_card:IoT卡, order:订单) + description: 导出场景 (device:设备, iot_card:IoT卡, order:订单, package:套餐, agent_wallet_transaction:代理主钱包流水, agent_recharge:代理充值, refund:退款, exchange:换货) type: string required: - scene @@ -4526,6 +4903,9 @@ components: type: object DtoDeviceImportResultItemDTO: properties: + device_identifier: + description: CSV原始设备标识(VirtualNo、IMEI或SN) + type: string line: description: 行号 type: integer @@ -4533,7 +4913,7 @@ components: description: 原因 type: string virtual_no: - description: 设备虚拟号 + description: 设备虚拟号(兼容原设备导入任务字段) type: string type: object DtoDeviceImportTaskDetailResponse: @@ -4572,6 +4952,12 @@ components: description: 任务ID minimum: 0 type: integer + operation_name: + description: 任务业务类型名称(中文) + type: string + operation_type: + description: 任务业务类型 (import:导入设备, assign_shop:分配目标代理, assign_series:设置套餐系列) + type: string realname_policy: description: 实名认证策略 (none:无需实名, before_order:先实名后充值/购买, after_order:先充值/购买后实名) type: string @@ -4592,12 +4978,20 @@ components: status: description: 任务状态 (1:待处理, 2:处理中, 3:已完成, 4:失败) type: integer + status_name: + description: 任务状态名称(中文) + type: string status_text: description: 任务状态文本 type: string success_count: description: 成功数 type: integer + target_id: + description: 批量分配目标店铺或套餐系列ID + minimum: 0 + nullable: true + type: integer task_no: description: 任务编号 type: string @@ -4644,6 +5038,12 @@ components: description: 任务ID minimum: 0 type: integer + operation_name: + description: 任务业务类型名称(中文) + type: string + operation_type: + description: 任务业务类型 (import:导入设备, assign_shop:分配目标代理, assign_series:设置套餐系列) + type: string realname_policy: description: 实名认证策略 (none:无需实名, before_order:先实名后充值/购买, after_order:先充值/购买后实名) type: string @@ -4658,12 +5058,20 @@ components: status: description: 任务状态 (1:待处理, 2:处理中, 3:已完成, 4:失败) type: integer + status_name: + description: 任务状态名称(中文) + type: string status_text: description: 任务状态文本 type: string success_count: description: 成功数 type: integer + target_id: + description: 批量分配目标店铺或套餐系列ID + minimum: 0 + nullable: true + type: integer task_no: description: 任务编号 type: string @@ -5362,6 +5770,13 @@ components: status_text: description: 换货状态文本 type: string + submitter_id: + description: 提交人账号ID + minimum: 0 + type: integer + submitter_name: + description: 提交人账号名称 + type: string updated_at: description: 更新时间 format: date-time @@ -5397,6 +5812,100 @@ components: - new_identifier - migrate_data type: object + DtoExpiringAssetItem: + properties: + asset_id: + description: 资产 ID + minimum: 0 + type: integer + asset_type: + description: 资产类型 (iot_card:物联网卡, device:设备) + type: string + days_until_final_expiry: + description: 距预计套餐到期的上海自然日天数,无法精确推算时为 null + nullable: true + type: integer + estimated_final_expires_at: + description: 预计套餐到期时间,无法精确推算时为 null + format: date-time + nullable: true + type: string + expiry_estimate_status: + description: 预计套餐到期推算状态 (exact:可精确推算, waiting_activation:待激活后起算, none:无参与套餐, invalid_data:数据异常) + type: string + expiry_estimate_status_name: + description: 预计套餐到期推算状态名称(中文) + type: string + expiry_level: + description: 临期高亮等级 (pink:8至15天, purple:4至7天, red:0至3天) + type: string + expiry_level_name: + description: 临期高亮等级名称(中文) + type: string + identifier: + description: 稳定资产标识,卡返回 ICCID,设备优先返回虚拟号、为空时返回 IMEI + type: string + is_expiring: + description: 是否临期(仅精确推算且剩余 0 至 15 个上海自然日时为 true) + type: boolean + is_priority: + description: 是否优先展示,剩余 0 至 3 天时为 true + type: boolean + package_id: + description: 最终到期队列末条套餐 ID + minimum: 0 + type: integer + package_name: + description: 最终到期队列末条套餐名称 + type: string + package_usage_id: + description: 最终到期队列末条主套餐使用记录 ID + minimum: 0 + type: integer + shop_id: + description: 所属店铺 ID,平台库存为 null + minimum: 0 + nullable: true + type: integer + shop_name: + description: 所属店铺名称,平台库存为空字符串 + type: string + type: object + DtoExpiringAssetListResponse: + properties: + items: + description: 临期资产列表 + items: + $ref: '#/components/schemas/DtoExpiringAssetItem' + nullable: true + type: array + page: + description: 当前页码 + type: integer + size: + description: 每页数量 + type: integer + summary: + $ref: '#/components/schemas/DtoExpiringAssetSummary' + total: + description: 符合条件的总数量 + type: integer + type: object + DtoExpiringAssetSummary: + properties: + card_count: + description: 临期物联网卡数量 + type: integer + device_count: + description: 临期设备数量 + type: integer + total_count: + description: 临期资产总数 + type: integer + window_days: + description: 临期统计窗口天数,固定为 15 + type: integer + type: object DtoExportTaskDetailResponse: properties: cancel_requested: @@ -5468,7 +5977,7 @@ components: description: 任务进度(0-100) type: integer scene: - description: 导出场景 (device:设备, iot_card:IoT卡, order:订单) + description: 导出场景 (device:设备, iot_card:IoT卡, order:订单, package:套餐, agent_wallet_transaction:代理主钱包流水, agent_recharge:代理充值, refund:退款, exchange:换货) type: string started_at: description: 开始处理时间 @@ -5571,7 +6080,7 @@ components: description: 任务进度(0-100) type: integer scene: - description: 导出场景 (device:设备, iot_card:IoT卡, order:订单) + description: 导出场景 (device:设备, iot_card:IoT卡, order:订单, package:套餐, agent_wallet_transaction:代理主钱包流水, agent_recharge:代理充值, refund:退款, exchange:换货) type: string started_at: description: 开始处理时间 @@ -5692,7 +6201,13 @@ components: minLength: 1 type: string purpose: - description: 文件用途 (iot_import:ICCID导入, export:数据导出, attachment:附件) + description: 文件用途 (iot_import:ICCID导入, export:数据导出, attachment:附件, batch_purchase:资产套餐批量订购CSV, device_batch_allocation:设备批量分配CSV) + enum: + - iot_import + - export + - attachment + - batch_purchase + - device_batch_allocation type: string required: - file_name @@ -6420,13 +6935,13 @@ components: nullable: true type: string ref_id: - description: 受控资源ID + description: 受控资源数字ID的十进制字符串;可能为空。refund、agent_recharge、wecom_approval、iot_card、device、expiring_asset、shop_fund、asset 等类型使用;仅用于资源定位,不是前端URL type: string ref_key: - description: 受控资源Key + description: 受控资源稳定Key或展示快照;可能为空。system_config 为配置Key,integration_log/card_sync 为集成标识,asset 为资产标识快照;仅用于定位或展示,不是前端URL type: string ref_type: - description: 受控资源类型 + description: 受控资源类型;可能为空。可选值及含义:system_config:系统配置, integration_log:外部集成日志, package:套餐, asset:C端资产, refund:退款, agent_recharge:代理充值, wecom_approval:企微审批, iot_card:物联网卡, device:设备, expiring_asset:临期资产列表, shop_fund:店铺资金概况, card_sync:卡同步记录。后台点击通知应调用目标解析接口,不得直接拼接路由 type: string severity: description: 通知级别 (info:提示, warning:警告, error:错误, critical:严重) @@ -6477,18 +6992,18 @@ components: DtoNotificationTargetResponse: properties: available: - description: 当前账号是否仍可访问目标 + description: 当前账号是否仍可访问目标;false 时只展示通知正文,不执行跳转 type: boolean target_id: - description: 受控数值目标ID + description: ID型目标的业务主键;前端按 target_type 映射受控页面,不得自行拼接任意URL minimum: 0 nullable: true type: integer target_key: - description: 受控稳定目标Key + description: Key型目标的稳定定位值;仅用于 integration_log 或 system_config 等白名单目标 type: string target_type: - description: 前端白名单目标类型;空表示不支持跳转 + description: 前端白名单目标类型;空表示不支持跳转。可选值:refund_detail、agent_recharge_detail、wecom_approval_detail、iot_card_detail、device_detail、expiring_asset_list、shop_fund_summary、integration_log、system_config type: string type: object DtoNotificationUnreadCountResponse: @@ -6738,7 +7253,7 @@ components: nullable: true type: integer asset_identifier: - description: 下单时资产的标识符快照(ICCID 或 VirtualNo) + description: 下单时资产的标识符快照(卡为 ICCID,设备优先使用 VirtualNo,缺失时使用 IMEI) type: string asset_type: description: 资产类型 (card:单卡, device:设备) @@ -7883,12 +8398,27 @@ components: actual_received_amount: description: 实收金额(分) type: integer + approval_instance_id: + description: 通用审批实例ID + minimum: 0 + nullable: true + type: integer + approval_provider: + description: 审批渠道,企业微信为wecom + type: string + approval_status: + description: 审批状态 (0:提交中, 1:审批中, 2:已通过, 3:已拒绝, 4:已撤销, 5:通过后撤销, 6:已删除, 7:提交失败, 8:提交结果未知) + nullable: true + type: integer + approval_status_name: + description: 审批状态名称(中文) + type: string approved_refund_amount: description: 审批实际退款金额(分) nullable: true type: integer asset_identifier: - description: 下单时资产的标识符快照(ICCID 或 VirtualNo) + description: 下单时资产的标识符快照(卡为 ICCID,设备优先使用 VirtualNo,缺失时使用 IMEI) type: string asset_reset: description: 退款后资产处理是否完成 @@ -7975,6 +8505,13 @@ components: status_name: description: 状态名称(中文) type: string + submitter_id: + description: 提交人账号ID + minimum: 0 + type: integer + submitter_name: + description: 提交人账号名称 + type: string updated_at: description: 更新时间 type: string @@ -8118,6 +8655,61 @@ components: minimum: 0 type: integer type: object + DtoSaveWeComApplicationRequest: + properties: + agent_id: + description: 企业微信自建应用 AgentID + type: integer + callback_token: + description: 回调 Token,管理端使用明文传入,服务端加密保存 + type: string + corp_id: + description: 企业微信企业 ID + type: string + encoding_aes_key: + description: 回调 EncodingAESKey,管理端使用明文传入,服务端加密保存 + type: string + name: + description: 应用展示名称 + type: string + secret: + description: 应用 Secret,管理端使用明文传入,服务端加密保存 + type: string + status: + description: 状态 (0:禁用, 1:启用) + enum: + - "0" + - "1" + type: integer + type: object + DtoSaveWeComApprovalSceneParams: + properties: + application_id: + description: 企业微信应用配置 ID + minimum: 0 + type: integer + control_mapping: + description: 业务字段与模板控件映射 + items: + $ref: '#/components/schemas/DtoWeComControlMappingItem' + nullable: true + type: array + status: + description: 状态 (0:禁用, 1:启用) + enum: + - "0" + - "1" + type: integer + template_id: + description: 企微后台已创建模板 ID + type: string + type: object + DtoSaveWeComDefaultCreatorParams: + properties: + userid: + description: 从应用当前可见成员中选择的默认发起人 userid + type: string + type: object DtoSeriesOneTimeCommissionConfigDTO: properties: commission_amount: @@ -8160,6 +8752,45 @@ components: description: 时效值(日期或月数) type: string type: object + DtoSetIotCardSpeedTierRequest: + properties: + code: + description: 固定限速档位 (-1:恢复不限速, 0:0kbps, 1:128Kbps, 2:512Kbps, 3:1Mbps, 4:2Mbps, 5:10Mbps, 6:20Mbps, 7:50Mbps, 8:100Mbps) + enum: + - "-1" + - "0" + - "1" + - "2" + - "3" + - "4" + - "5" + - "6" + - "7" + - "8" + nullable: true + type: integer + required: + - code + type: object + DtoSetIotCardSpeedTierResponse: + properties: + code: + description: 固定限速档位编码 (-1:恢复不限速, 0:0kbps, 1:128Kbps, 2:512Kbps, 3:1Mbps, 4:2Mbps, 5:10Mbps, 6:20Mbps, 7:50Mbps, 8:100Mbps) + type: integer + iccid: + description: 实际调用 Gateway 的卡 ICCID + type: string + integration_id: + description: Gateway 外部交互记录ID + type: string + iot_card_id: + description: IoT 卡ID + minimum: 0 + type: integer + speed_tier_name: + description: 固定限速档位名称(中文) + type: string + type: object DtoSetOperationPasswordRequest: properties: confirm_password: @@ -8174,15 +8805,6 @@ components: - password - confirm_password type: object - DtoSetSpeedLimitRequest: - properties: - speed_limit: - description: 限速值(KB/s) - minimum: 1 - type: integer - required: - - speed_limit - type: object DtoSetSwitchModeRequest: properties: iot_card_id: @@ -8503,6 +9125,9 @@ components: city: description: 城市 type: string + client_login_disabled: + description: 是否禁止该店铺资产发起新的 C 端登录 + type: boolean contact_name: description: 联系人姓名 type: string @@ -9062,7 +9687,10 @@ components: nullable: true type: integer module: - description: 所属模块 + description: 所属模块 (carrier_callback:运营商回调配置, c2b.payment:C端支付方式配置) + enum: + - carrier_callback + - c2b.payment type: string readonly: description: 是否只读 @@ -9794,6 +10422,10 @@ components: description: 城市 maxLength: 50 type: string + client_login_disabled: + description: 是否禁止该店铺资产发起新的 C 端登录;不传保持不变 + nullable: true + type: boolean contact_name: description: 联系人姓名 maxLength: 50 @@ -10138,6 +10770,223 @@ components: description: 总数 type: integer type: object + DtoWeComApplicationListResponse: + properties: + items: + description: 企业微信应用配置列表 + items: + $ref: '#/components/schemas/DtoWeComApplicationResponse' + nullable: true + type: array + page: + description: 当前页码 + type: integer + page_size: + description: 每页数量 + type: integer + total: + description: 总数量 + type: integer + type: object + DtoWeComApplicationResponse: + properties: + agent_id: + description: 企业微信自建应用 AgentID + type: integer + callback_token: + description: 回调 Token 明文,仅超级管理员可见 + type: string + corp_id: + description: 企业微信企业 ID + type: string + created_at: + description: 创建时间 + format: date-time + type: string + credentials_set: + description: Secret、回调 Token 和 EncodingAESKey 是否均已配置 + type: boolean + default_creator_name: + description: 默认企微审批发起人姓名快照 + type: string + default_creator_userid: + description: 代理等非企微账号发起审批时使用的默认企微成员 userid + type: string + encoding_aes_key: + description: 回调 EncodingAESKey 明文,仅超级管理员可见 + type: string + id: + description: 应用配置 ID + minimum: 0 + type: integer + last_connected_at: + description: 最近一次成功取得 access_token 的时间 + format: date-time + nullable: true + type: string + name: + description: 应用展示名称 + type: string + secret: + description: 应用 Secret 明文,仅超级管理员可见 + type: string + status: + description: 状态 (0:禁用, 1:启用) + type: integer + status_name: + description: 状态名称(中文) + type: string + updated_at: + description: 更新时间 + format: date-time + type: string + type: object + DtoWeComApprovalSceneListResponse: + properties: + items: + description: 审批场景列表 + items: + $ref: '#/components/schemas/DtoWeComApprovalSceneResponse' + nullable: true + type: array + page: + description: 当前页码 + type: integer + page_size: + description: 每页数量 + type: integer + total: + description: 总数量 + type: integer + type: object + DtoWeComApprovalSceneResponse: + properties: + application_id: + description: 企业微信应用配置 ID + minimum: 0 + type: integer + business_type: + description: 业务类型 + type: string + business_type_name: + description: 业务类型名称(中文) + type: string + control_mapping: + description: 已验证控件映射 + items: + $ref: '#/components/schemas/DtoWeComControlMappingItem' + nullable: true + type: array + id: + description: 场景配置 ID + minimum: 0 + type: integer + last_verified_at: + description: 最近模板校验时间 + format: date-time + type: string + status: + description: 状态 (0:禁用, 1:启用) + type: integer + status_name: + description: 状态名称(中文) + type: string + template_fingerprint: + description: 模板控件最小快照 SHA-256 指纹 + type: string + template_id: + description: 企微模板 ID + type: string + template_name: + description: 企微模板名称 + type: string + updated_at: + description: 更新时间 + format: date-time + type: string + type: object + DtoWeComConnectionTestResponse: + properties: + success: + description: 是否成功取得 access_token + type: boolean + type: object + DtoWeComControlMappingItem: + properties: + business_field: + description: 稳定业务字段编码 + type: string + control_id: + description: 企微模板控件 ID + type: string + control_type: + description: 企微模板控件类型,必须与模板详情一致 + type: string + option_mapping: + additionalProperties: + type: string + description: 业务枚举值到企微选择项 key 的映射;非选择控件传空对象 + nullable: true + type: object + type: object + DtoWeComMemberListResponse: + properties: + items: + description: 可见成员列表 + items: + $ref: '#/components/schemas/DtoWeComMemberResponse' + nullable: true + type: array + page: + description: 当前页码 + type: integer + page_size: + description: 每页数量 + type: integer + total: + description: 总数量 + type: integer + type: object + DtoWeComMemberResponse: + properties: + application_id: + description: 企业微信应用配置 ID + minimum: 0 + type: integer + corp_id: + description: 企业微信企业 ID + type: string + department_ids: + description: 成员所属部门 ID 列表,仅作为选择辅助快照 + items: + type: integer + nullable: true + type: array + name: + description: 企业微信成员姓名 + type: string + synced_at: + description: 最近同步时间 + format: date-time + type: string + userid: + description: 企业微信成员 userid + type: string + type: object + DtoWeComMemberSyncResponse: + properties: + application_id: + description: 企业微信应用配置 ID + minimum: 0 + type: integer + synced_at: + description: 同步完成时间 + format: date-time + type: string + synced_count: + description: 本次同步的可见成员数量 + type: integer + type: object DtoWechatAppIDResponse: properties: app_id: @@ -11239,6 +12088,79 @@ paths: summary: 修改账号状态 tags: - 账号管理 + /api/admin/accounts/{id}/wecom-binding: + put: + parameters: + - description: ID + in: path + name: id + required: true + schema: + description: ID + minimum: 0 + type: integer + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/DtoBindAccountWeComParams' + responses: + "200": + content: + application/json: + schema: + properties: + code: + description: 响应码 + example: 0 + type: integer + data: + $ref: '#/components/schemas/DtoAccountResponse' + msg: + description: 响应消息 + example: success + type: string + timestamp: + description: 时间戳 + format: date-time + type: string + required: + - code + - msg + - data + - timestamp + type: object + description: 成功 + "400": + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + description: 请求参数错误 + "401": + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + description: 未认证或认证已过期 + "403": + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + description: 无权访问 + "500": + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + description: 服务器内部错误 + security: + - BearerAuth: [] + summary: 绑定账号企业微信成员 + tags: + - 账号管理 + - 企业微信审批 /api/admin/agent-recharges: get: parameters: @@ -11800,6 +12722,219 @@ paths: summary: 分配记录详情 tags: - 资产分配记录 + /api/admin/asset-package-batch-orders: + get: + parameters: + - description: 页码(默认1) + in: query + name: page + schema: + description: 页码(默认1) + minimum: 1 + type: integer + - description: 每页条数(默认20,最大100) + in: query + name: page_size + schema: + description: 每页条数(默认20,最大100) + maximum: 100 + minimum: 1 + type: integer + - description: 任务状态 (1:待处理, 2:处理中, 3:已完成, 4:已失败, 5:已取消) + in: query + name: status + schema: + description: 任务状态 (1:待处理, 2:处理中, 3:已完成, 4:已失败, 5:已取消) + maximum: 5 + minimum: 1 + nullable: true + type: integer + responses: + "200": + content: + application/json: + schema: + properties: + code: + description: 响应码 + example: 0 + type: integer + data: + $ref: '#/components/schemas/DtoAssetPackageBatchOrderTaskListResponse' + msg: + description: 响应消息 + example: success + type: string + timestamp: + description: 时间戳 + format: date-time + type: string + required: + - code + - msg + - data + - timestamp + type: object + description: 成功 + "400": + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + description: 请求参数错误 + "401": + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + description: 未认证或认证已过期 + "403": + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + description: 无权访问 + "500": + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + description: 服务器内部错误 + security: + - BearerAuth: [] + summary: 查询资产套餐批量订购任务列表 + tags: + - 批量订购套餐 + post: + description: 上传单列CSV对象存储Key,并为整批选择一个套餐和支付方式;不选择代理。 + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/DtoCreateAssetPackageBatchOrderRequest' + responses: + "200": + content: + application/json: + schema: + properties: + code: + description: 响应码 + example: 0 + type: integer + data: + $ref: '#/components/schemas/DtoAssetPackageBatchOrderTaskResponse' + msg: + description: 响应消息 + example: success + type: string + timestamp: + description: 时间戳 + format: date-time + type: string + required: + - code + - msg + - data + - timestamp + type: object + description: 成功 + "400": + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + description: 请求参数错误 + "401": + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + description: 未认证或认证已过期 + "403": + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + description: 无权访问 + "500": + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + description: 服务器内部错误 + security: + - BearerAuth: [] + summary: 创建资产套餐批量订购任务 + tags: + - 批量订购套餐 + /api/admin/asset-package-batch-orders/{id}: + get: + parameters: + - description: 任务ID + in: path + name: id + required: true + schema: + description: 任务ID + minimum: 0 + type: integer + responses: + "200": + content: + application/json: + schema: + properties: + code: + description: 响应码 + example: 0 + type: integer + data: + $ref: '#/components/schemas/DtoAssetPackageBatchOrderTaskDetailResponse' + msg: + description: 响应消息 + example: success + type: string + timestamp: + description: 时间戳 + format: date-time + type: string + required: + - code + - msg + - data + - timestamp + type: object + description: 成功 + "400": + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + description: 请求参数错误 + "401": + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + description: 未认证或认证已过期 + "403": + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + description: 无权访问 + "500": + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + description: 服务器内部错误 + security: + - BearerAuth: [] + summary: 查询资产套餐批量订购任务详情 + tags: + - 批量订购套餐 /api/admin/assets/{identifier}/current-package: get: parameters: @@ -14701,6 +15836,15 @@ paths: minimum: 0 nullable: true type: integer + - description: 实名状态 (0:未实名, 1:已实名;任意有效绑定卡已实名即视为设备已实名) + in: query + name: real_name_status + schema: + description: 实名状态 (0:未实名, 1:已实名;任意有效绑定卡已实名即视为设备已实名) + maximum: 1 + minimum: 0 + nullable: true + type: integer - description: 店铺ID(兼容旧参数,单选;NULL表示平台库存) in: query name: shop_id @@ -15164,6 +16308,70 @@ paths: summary: 批量分配设备 tags: - 设备管理 + /api/admin/devices/batch-update-realname-policy: + post: + description: 单次最多500条;先校验整批资产及数据权限,再在单事务内全成全败更新。设备下卡存在策略冲突时,C端以设备策略为准。 + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/DtoBatchUpdateAssetRealnamePolicyRequest' + responses: + "200": + content: + application/json: + schema: + properties: + code: + description: 响应码 + example: 0 + type: integer + data: + $ref: '#/components/schemas/DtoBatchUpdateAssetRealnamePolicyResponse' + msg: + description: 响应消息 + example: success + type: string + timestamp: + description: 时间戳 + format: date-time + type: string + required: + - code + - msg + - data + - timestamp + type: object + description: 成功 + "400": + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + description: 请求参数错误 + "401": + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + description: 未认证或认证已过期 + "403": + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + description: 无权访问 + "500": + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + description: 服务器内部错误 + security: + - BearerAuth: [] + summary: 批量更新设备实名认证策略 + tags: + - 设备管理 /api/admin/devices/by-identifier/{identifier}/gateway-slots: get: description: 通过虚拟号/IMEI/SN 查询设备卡槽信息。设备必须已配置 IMEI。 @@ -15365,78 +16573,6 @@ paths: summary: 恢复出厂 tags: - 设备管理 - /api/admin/devices/by-identifier/{identifier}/speed-limit: - put: - description: 通过虚拟号/IMEI/SN 设置设备限速。设备必须已配置 IMEI。 - parameters: - - description: 设备标识符(支持虚拟号/IMEI/SN) - in: path - name: identifier - required: true - schema: - description: 设备标识符(支持虚拟号/IMEI/SN) - type: string - requestBody: - content: - application/json: - schema: - $ref: '#/components/schemas/DtoSetSpeedLimitRequest' - responses: - "200": - content: - application/json: - schema: - properties: - code: - description: 响应码 - example: 0 - type: integer - data: - $ref: '#/components/schemas/DtoEmptyResponse' - msg: - description: 响应消息 - example: success - type: string - timestamp: - description: 时间戳 - format: date-time - type: string - required: - - code - - msg - - data - - timestamp - type: object - description: 成功 - "400": - content: - application/json: - schema: - $ref: '#/components/schemas/ErrorResponse' - description: 请求参数错误 - "401": - content: - application/json: - schema: - $ref: '#/components/schemas/ErrorResponse' - description: 未认证或认证已过期 - "403": - content: - application/json: - schema: - $ref: '#/components/schemas/ErrorResponse' - description: 无权访问 - "500": - content: - application/json: - schema: - $ref: '#/components/schemas/ErrorResponse' - description: 服务器内部错误 - security: - - BearerAuth: [] - summary: 设置限速 - tags: - - 设备管理 /api/admin/devices/by-identifier/{identifier}/switch-card: post: description: 通过虚拟号/IMEI/SN 切换设备当前使用的卡。设备必须已配置 IMEI。 @@ -15741,6 +16877,70 @@ paths: summary: 批量导入设备 tags: - 设备管理 + /api/admin/devices/import/allocations: + post: + description: 复用设备导入任务。CSV只能包含一列设备标识,每行支持VirtualNo、IMEI或SN;整批选择分配目标代理或设置目标套餐系列。 + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/DtoCreateDeviceBatchAllocationRequest' + responses: + "200": + content: + application/json: + schema: + properties: + code: + description: 响应码 + example: 0 + type: integer + data: + $ref: '#/components/schemas/DtoCreateDeviceBatchAllocationResponse' + msg: + description: 响应消息 + example: success + type: string + timestamp: + description: 时间戳 + format: date-time + type: string + required: + - code + - msg + - data + - timestamp + type: object + description: 成功 + "400": + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + description: 请求参数错误 + "401": + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + description: 未认证或认证已过期 + "403": + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + description: 无权访问 + "500": + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + description: 服务器内部错误 + security: + - BearerAuth: [] + summary: 创建CSV设备批量分配任务 + tags: + - 设备管理 /api/admin/devices/import/tasks: get: description: 仅平台用户可操作。 @@ -15769,6 +16969,16 @@ paths: minimum: 1 nullable: true type: integer + - description: 任务业务类型 (import:导入设备, assign_shop:分配目标代理, assign_series:设置套餐系列) + in: query + name: operation_type + schema: + description: 任务业务类型 (import:导入设备, assign_shop:分配目标代理, assign_series:设置套餐系列) + enum: + - import + - assign_shop + - assign_series + type: string - description: 批次号(模糊查询) in: query name: batch_no @@ -17318,6 +18528,134 @@ paths: summary: 换货发货 tags: - 换货管理 + /api/admin/expiring-assets: + get: + description: 分页返回当前账号店铺权限范围内剩余 0 至 15 个上海自然日的卡和设备,并提供卡、设备数量、高亮等级及 0 至 3 天优先标识。企业账号禁止调用。 + parameters: + - description: 资产类型 (iot_card:物联网卡, device:设备) + in: query + name: asset_type + schema: + description: 资产类型 (iot_card:物联网卡, device:设备) + type: string + - description: 资产标识关键词,匹配卡 ICCID/MSISDN/虚拟号或设备虚拟号/IMEI + in: query + name: keyword + schema: + description: 资产标识关键词,匹配卡 ICCID/MSISDN/虚拟号或设备虚拟号/IMEI + type: string + - description: 店铺 ID,只能缩小当前账号的数据权限范围 + in: query + name: shop_id + schema: + description: 店铺 ID,只能缩小当前账号的数据权限范围 + minimum: 0 + nullable: true + type: integer + - description: 最终排队套餐 ID + in: query + name: package_id + schema: + description: 最终排队套餐 ID + minimum: 0 + nullable: true + type: integer + - description: 最小剩余上海自然日天数,范围 0 至 15 + in: query + name: days_min + schema: + description: 最小剩余上海自然日天数,范围 0 至 15 + nullable: true + type: integer + - description: 最大剩余上海自然日天数,范围 0 至 15 + in: query + name: days_max + schema: + description: 最大剩余上海自然日天数,范围 0 至 15 + nullable: true + type: integer + - description: 预计到期开始日期(上海自然日,格式 YYYY-MM-DD) + in: query + name: expires_from + schema: + description: 预计到期开始日期(上海自然日,格式 YYYY-MM-DD) + type: string + - description: 预计到期结束日期(上海自然日,格式 YYYY-MM-DD) + in: query + name: expires_to + schema: + description: 预计到期结束日期(上海自然日,格式 YYYY-MM-DD) + type: string + - description: 页码 + in: query + name: page + schema: + default: 1 + description: 页码 + type: integer + - description: 每页数量,最大 100 + in: query + name: page_size + schema: + default: 20 + description: 每页数量,最大 100 + type: integer + responses: + "200": + content: + application/json: + schema: + properties: + code: + description: 响应码 + example: 0 + type: integer + data: + $ref: '#/components/schemas/DtoExpiringAssetListResponse' + msg: + description: 响应消息 + example: success + type: string + timestamp: + description: 时间戳 + format: date-time + type: string + required: + - code + - msg + - data + - timestamp + type: object + description: 成功 + "400": + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + description: 请求参数错误 + "401": + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + description: 未认证或认证已过期 + "403": + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + description: 无权访问 + "500": + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + description: 服务器内部错误 + security: + - BearerAuth: [] + summary: 查询临期资产列表 + tags: + - 资产管理 /api/admin/export-tasks: get: description: 支持按 scene/status/time 过滤,分页默认 20,最大 100。 @@ -17337,11 +18675,11 @@ paths: maximum: 100 minimum: 1 type: integer - - description: 导出场景 (device:设备, iot_card:IoT卡, order:订单) + - description: 导出场景 (device:设备, iot_card:IoT卡, order:订单, package:套餐, agent_wallet_transaction:代理主钱包流水, agent_recharge:代理充值, refund:退款, exchange:换货) in: query name: scene schema: - description: 导出场景 (device:设备, iot_card:IoT卡, order:订单) + description: 导出场景 (device:设备, iot_card:IoT卡, order:订单, package:套餐, agent_wallet_transaction:代理主钱包流水, agent_recharge:代理充值, refund:退款, exchange:换货) type: string - description: 任务状态 (1:待处理, 2:处理中, 3:已完成, 4:已失败, 5:已取消) in: query @@ -17689,6 +19027,142 @@ paths: summary: 获取实名认证链接 tags: - IoT卡管理 + /api/admin/iot-cards/{iccid}/speed-tier: + put: + description: 仅对有权限的 IoT 卡按 ICCID 设置固定 Gateway 限速档位;-1 表示恢复不限速。不支持设备限速,也不会通过设备绑定关系间接限速。 + parameters: + - description: IoT 卡 ICCID + in: path + name: iccid + required: true + schema: + description: IoT 卡 ICCID + type: string + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/DtoSetIotCardSpeedTierRequest' + responses: + "200": + content: + application/json: + schema: + properties: + code: + description: 响应码 + example: 0 + type: integer + data: + $ref: '#/components/schemas/DtoSetIotCardSpeedTierResponse' + msg: + description: 响应消息 + example: success + type: string + timestamp: + description: 时间戳 + format: date-time + type: string + required: + - code + - msg + - data + - timestamp + type: object + description: 成功 + "400": + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + description: 请求参数错误 + "401": + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + description: 未认证或认证已过期 + "403": + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + description: 无权访问 + "500": + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + description: 服务器内部错误 + security: + - BearerAuth: [] + summary: 设置卡固定限速档位 + tags: + - IoT卡管理 + /api/admin/iot-cards/batch-update-realname-policy: + post: + description: 单次最多500条;先校验整批资产及数据权限,再在单事务内全成全败更新。 + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/DtoBatchUpdateAssetRealnamePolicyRequest' + responses: + "200": + content: + application/json: + schema: + properties: + code: + description: 响应码 + example: 0 + type: integer + data: + $ref: '#/components/schemas/DtoBatchUpdateAssetRealnamePolicyResponse' + msg: + description: 响应消息 + example: success + type: string + timestamp: + description: 时间戳 + format: date-time + type: string + required: + - code + - msg + - data + - timestamp + type: object + description: 成功 + "400": + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + description: 请求参数错误 + "401": + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + description: 未认证或认证已过期 + "403": + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + description: 无权访问 + "500": + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + description: 服务器内部错误 + security: + - BearerAuth: [] + summary: 批量更新卡实名认证策略 + tags: + - IoT卡管理 /api/admin/iot-cards/import: post: description: |- @@ -18199,6 +19673,15 @@ paths: minimum: 0 nullable: true type: integer + - description: 实名状态 (0:未实名, 1:已实名) + in: query + name: real_name_status + schema: + description: 实名状态 (0:未实名, 1:已实名) + maximum: 1 + minimum: 0 + nullable: true + type: integer - description: 按有效授权企业ID过滤(只返回当前授权给该企业的卡) in: query name: authorized_enterprise_id @@ -18398,7 +19881,7 @@ paths: - IoT卡管理 /api/admin/notifications: get: - description: 仅返回当前认证后台账号的未过期通知,固定按创建时间和通知 ID 倒序。 + description: 仅返回当前认证后台账号的未过期通知,固定按创建时间和通知 ID 倒序。ref_type/ref_id/ref_key 是受控资源引用,不是前端路由;点击通知时应调用 GET /api/admin/notifications/{id}/target,以返回的 target_type 和 available 决定是否跳转。 parameters: - description: 通知类别 (approval:审批, expiry:临期, sync:同步, system:系统) in: query @@ -18572,7 +20055,7 @@ paths: - 站内通知 /api/admin/notifications/{id}/target: get: - description: 先校验通知属于当前认证后台账号,再复核目标资源当前权限;只返回白名单结构化目标,不返回任意 URL。 + description: 先校验通知属于当前认证后台账号,再复核目标资源当前权限;只返回白名单结构化目标,不返回任意 URL。前端维护 target_type 到页面的白名单映射;available=false 或 target_type 为空时只展示通知正文,不执行跳转。 parameters: - description: 通知ID in: path @@ -25715,6 +27198,8 @@ paths: | 值 | 说明 | 生成路径格式 | |---|------|-------------| | iot_import | ICCID/设备导入 (Excel) | imports/YYYY/MM/DD/uuid.xlsx | + | batch_purchase | 资产套餐批量订购 (CSV) | batch-purchases/YYYY/MM/DD/uuid.csv | + | device_batch_allocation | 设备批量分配代理或套餐系列 (CSV) | device-batch-allocations/YYYY/MM/DD/uuid.csv | | export | 数据导出 | exports/YYYY/MM/DD/uuid.xlsx | | attachment | 附件上传 | attachments/YYYY/MM/DD/uuid.ext | @@ -25883,11 +27368,14 @@ paths: /api/admin/system-configs: get: parameters: - - description: 模块筛选 + - description: 模块筛选 (carrier_callback:运营商回调配置, c2b.payment:C端支付方式配置) in: query name: module schema: - description: 模块筛选 + description: 模块筛选 (carrier_callback:运营商回调配置, c2b.payment:C端支付方式配置) + enum: + - carrier_callback + - c2b.payment type: string - description: 页码 in: query @@ -26551,6 +28039,585 @@ paths: summary: 获取当前生效的支付配置 tags: - 微信支付配置管理 + /api/admin/wecom/applications: + get: + parameters: + - description: 页码,默认 1 + in: query + name: page + schema: + default: 1 + description: 页码,默认 1 + minimum: 1 + type: integer + - description: 每页数量,默认 20,最大 100 + in: query + name: page_size + schema: + default: 20 + description: 每页数量,默认 20,最大 100 + maximum: 100 + minimum: 1 + type: integer + responses: + "200": + content: + application/json: + schema: + properties: + code: + description: 响应码 + example: 0 + type: integer + data: + $ref: '#/components/schemas/DtoWeComApplicationListResponse' + msg: + description: 响应消息 + example: success + type: string + timestamp: + description: 时间戳 + format: date-time + type: string + required: + - code + - msg + - data + - timestamp + type: object + description: 成功 + "400": + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + description: 请求参数错误 + "401": + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + description: 未认证或认证已过期 + "403": + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + description: 无权访问 + "500": + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + description: 服务器内部错误 + security: + - BearerAuth: [] + summary: 查询企业微信应用配置 + tags: + - 企业微信审批 + post: + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/DtoSaveWeComApplicationRequest' + responses: + "200": + content: + application/json: + schema: + properties: + code: + description: 响应码 + example: 0 + type: integer + data: + $ref: '#/components/schemas/DtoWeComApplicationResponse' + msg: + description: 响应消息 + example: success + type: string + timestamp: + description: 时间戳 + format: date-time + type: string + required: + - code + - msg + - data + - timestamp + type: object + description: 成功 + "400": + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + description: 请求参数错误 + "401": + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + description: 未认证或认证已过期 + "403": + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + description: 无权访问 + "500": + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + description: 服务器内部错误 + security: + - BearerAuth: [] + summary: 创建或更新企业微信应用配置 + tags: + - 企业微信审批 + /api/admin/wecom/applications/{id}/default-creator: + put: + parameters: + - description: 企业微信应用配置 ID + in: path + name: id + required: true + schema: + description: 企业微信应用配置 ID + minimum: 0 + type: integer + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/DtoSaveWeComDefaultCreatorParams' + responses: + "200": + content: + application/json: + schema: + properties: + code: + description: 响应码 + example: 0 + type: integer + data: + $ref: '#/components/schemas/DtoWeComApplicationResponse' + msg: + description: 响应消息 + example: success + type: string + timestamp: + description: 时间戳 + format: date-time + type: string + required: + - code + - msg + - data + - timestamp + type: object + description: 成功 + "400": + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + description: 请求参数错误 + "401": + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + description: 未认证或认证已过期 + "403": + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + description: 无权访问 + "500": + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + description: 服务器内部错误 + security: + - BearerAuth: [] + summary: 保存企业微信应用默认审批发起人 + tags: + - 企业微信审批 + /api/admin/wecom/applications/{id}/members: + get: + parameters: + - description: 页码,默认 1 + in: query + name: page + schema: + default: 1 + description: 页码,默认 1 + minimum: 1 + type: integer + - description: 每页数量,默认 20,最大 100 + in: query + name: page_size + schema: + default: 20 + description: 每页数量,默认 20,最大 100 + maximum: 100 + minimum: 1 + type: integer + - description: 按姓名或 userid 模糊搜索 + in: query + name: keyword + schema: + description: 按姓名或 userid 模糊搜索 + maxLength: 100 + type: string + - description: ID + in: path + name: id + required: true + schema: + description: ID + minimum: 0 + type: integer + responses: + "200": + content: + application/json: + schema: + properties: + code: + description: 响应码 + example: 0 + type: integer + data: + $ref: '#/components/schemas/DtoWeComMemberListResponse' + msg: + description: 响应消息 + example: success + type: string + timestamp: + description: 时间戳 + format: date-time + type: string + required: + - code + - msg + - data + - timestamp + type: object + description: 成功 + "400": + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + description: 请求参数错误 + "401": + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + description: 未认证或认证已过期 + "403": + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + description: 无权访问 + "500": + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + description: 服务器内部错误 + security: + - BearerAuth: [] + summary: 分页查询企业微信应用可见成员 + tags: + - 企业微信审批 + /api/admin/wecom/applications/{id}/members/sync: + post: + parameters: + - description: ID + in: path + name: id + required: true + schema: + description: ID + minimum: 0 + type: integer + responses: + "200": + content: + application/json: + schema: + properties: + code: + description: 响应码 + example: 0 + type: integer + data: + $ref: '#/components/schemas/DtoWeComMemberSyncResponse' + msg: + description: 响应消息 + example: success + type: string + timestamp: + description: 时间戳 + format: date-time + type: string + required: + - code + - msg + - data + - timestamp + type: object + description: 成功 + "400": + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + description: 请求参数错误 + "401": + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + description: 未认证或认证已过期 + "403": + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + description: 无权访问 + "500": + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + description: 服务器内部错误 + security: + - BearerAuth: [] + summary: 同步企业微信应用可见成员 + tags: + - 企业微信审批 + /api/admin/wecom/applications/{id}/test: + post: + parameters: + - description: ID + in: path + name: id + required: true + schema: + description: ID + minimum: 0 + type: integer + responses: + "200": + content: + application/json: + schema: + properties: + code: + description: 响应码 + example: 0 + type: integer + data: + $ref: '#/components/schemas/DtoWeComConnectionTestResponse' + msg: + description: 响应消息 + example: success + type: string + timestamp: + description: 时间戳 + format: date-time + type: string + required: + - code + - msg + - data + - timestamp + type: object + description: 成功 + "400": + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + description: 请求参数错误 + "401": + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + description: 未认证或认证已过期 + "403": + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + description: 无权访问 + "500": + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + description: 服务器内部错误 + security: + - BearerAuth: [] + summary: 测试企业微信应用连接 + tags: + - 企业微信审批 + /api/admin/wecom/scenes: + get: + parameters: + - description: 页码,默认 1 + in: query + name: page + schema: + default: 1 + description: 页码,默认 1 + type: integer + - description: 每页数量,默认 20,最大 100 + in: query + name: page_size + schema: + default: 20 + description: 每页数量,默认 20,最大 100 + type: integer + responses: + "200": + content: + application/json: + schema: + properties: + code: + description: 响应码 + example: 0 + type: integer + data: + $ref: '#/components/schemas/DtoWeComApprovalSceneListResponse' + msg: + description: 响应消息 + example: success + type: string + timestamp: + description: 时间戳 + format: date-time + type: string + required: + - code + - msg + - data + - timestamp + type: object + description: 成功 + "400": + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + description: 请求参数错误 + "401": + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + description: 未认证或认证已过期 + "403": + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + description: 无权访问 + "500": + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + description: 服务器内部错误 + security: + - BearerAuth: [] + summary: 分页查询企业微信审批场景配置 + tags: + - 企业微信审批 + /api/admin/wecom/scenes/{business_type}: + put: + parameters: + - description: 业务类型 (refund_approval:退款审批, offline_recharge_approval:员工线下代充值审批) + in: path + name: business_type + required: true + schema: + description: 业务类型 (refund_approval:退款审批, offline_recharge_approval:员工线下代充值审批) + type: string + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/DtoSaveWeComApprovalSceneParams' + responses: + "200": + content: + application/json: + schema: + properties: + code: + description: 响应码 + example: 0 + type: integer + data: + $ref: '#/components/schemas/DtoWeComApprovalSceneResponse' + msg: + description: 响应消息 + example: success + type: string + timestamp: + description: 时间戳 + format: date-time + type: string + required: + - code + - msg + - data + - timestamp + type: object + description: 成功 + "400": + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + description: 请求参数错误 + "401": + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + description: 未认证或认证已过期 + "403": + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + description: 无权访问 + "500": + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + description: 服务器内部错误 + security: + - BearerAuth: [] + summary: 保存并校验企业微信审批场景模板映射 + tags: + - 企业微信审批 /api/auth/login: post: requestBody: @@ -27892,8 +29959,15 @@ paths: - 个人客户 - 换货 /api/c/v1/notifications: get: - description: 仅返回当前认证个人客户的未过期业务通知,固定按创建时间和通知 ID 倒序。 + description: 仅返回当前认证个人客户的未过期业务通知,可按已读状态筛选,固定按创建时间和通知 ID 倒序。当前 C 端契约只承诺列表展示和未读弹窗;ref_type/ref_id/ref_key 是资源引用与展示快照,不是URL,也不承诺可直接拼接页面路由。 parameters: + - description: 已读状态;false 仅查询未读,true 仅查询已读,不传时查询全部 + in: query + name: is_read + schema: + description: 已读状态;false 仅查询未读,true 仅查询已读,不传时查询全部 + nullable: true + type: boolean - description: 页码,默认 1,最大 10000 in: query name: page @@ -29343,6 +31417,113 @@ paths: summary: 微信支付回调 tags: - 支付回调 + /api/callback/wecom/approval/{application_id}: + get: + parameters: + - description: 企业微信回调消息签名 + in: query + name: msg_signature + required: true + schema: + description: 企业微信回调消息签名 + type: string + - description: 企业微信回调时间戳 + in: query + name: timestamp + required: true + schema: + description: 企业微信回调时间戳 + type: string + - description: 企业微信回调随机串 + in: query + name: nonce + required: true + schema: + description: 企业微信回调随机串 + type: string + - description: GET 回调地址验证使用的加密随机串 + in: query + name: echostr + schema: + description: GET 回调地址验证使用的加密随机串 + type: string + - description: 企业微信应用配置 ID + in: path + name: application_id + required: true + schema: + description: 企业微信应用配置 ID + minimum: 1 + type: integer + responses: + "400": + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + description: 请求参数错误 + "500": + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + description: 服务器内部错误 + summary: 验证企业微信审批回调地址 + tags: + - 企业微信审批回调 + post: + parameters: + - description: 企业微信回调消息签名 + in: query + name: msg_signature + required: true + schema: + description: 企业微信回调消息签名 + type: string + - description: 企业微信回调时间戳 + in: query + name: timestamp + required: true + schema: + description: 企业微信回调时间戳 + type: string + - description: 企业微信回调随机串 + in: query + name: nonce + required: true + schema: + description: 企业微信回调随机串 + type: string + - description: GET 回调地址验证使用的加密随机串 + in: query + name: echostr + schema: + description: GET 回调地址验证使用的加密随机串 + type: string + - description: 企业微信应用配置 ID + in: path + name: application_id + required: true + schema: + description: 企业微信应用配置 ID + minimum: 1 + type: integer + responses: + "400": + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + description: 请求参数错误 + "500": + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + description: 服务器内部错误 + summary: 接收企业微信审批状态变化回调 + tags: + - 企业微信审批回调 /api/open/v1/cards/realname-status: get: description: |- diff --git a/docs/asset-package-batch-order/功能总结.md b/docs/asset-package-batch-order/功能总结.md new file mode 100644 index 0000000..c04c565 --- /dev/null +++ b/docs/asset-package-batch-order/功能总结.md @@ -0,0 +1,44 @@ +# 资产套餐批量订购功能总结 + +## 交付范围 + +本功能复用现有对象存储、Asynq、统一异步任务五态和后台单笔订单 Service,为一批卡或设备统一购买一个套餐: + +- `POST /api/admin/storage/upload-url` 使用 `purpose=batch_purchase` 获取只允许 `.csv` 的上传地址和稳定 `file_key`。 +- `POST /api/admin/asset-package-batch-orders` 接收 CSV `file_key`、一个 `package_id`、整批统一的 `wallet|offline` 支付方式及可选线下凭证;请求不选择代理。 +- `GET /api/admin/asset-package-batch-orders` 分页查询任务,默认 20、最大 100,可按统一五态筛选。 +- `GET /api/admin/asset-package-batch-orders/:id` 返回任务汇总和逐行成功/失败明细。 +- 平台可查看全部任务;代理只能查看自己及下级店铺创建的任务,权限上下文缺失时失败关闭。 + +## CSV 契约 + +- 文件必须为 UTF-8 编码的 `.csv`,允许 UTF-8 BOM,最大 10MB、最多 1000 个业务行。 +- 文件只能有一列,每行填写一个资产标识。首行可使用 `资产标识`、`identifier`、`iccid` 或 `iccid/虚拟号` 表头,也可直接从第一行填写数据。 +- 空资产标识作为行级失败;多列、非法 CSV、非 UTF-8、空文件和超限作为任务级失败,在任何订单写入前终止。 +- 同一输入标识重复时仅第一行进入订单规则;不同标识解析为同一资产时复用订单幂等结果,后续行记录重复原因,不重复下单或扣款。 + +## 支付和订单规则 + +- 整批只选择一个套餐和一种支付方式,不选择代理。每行由现有订单 Service 根据资产当前归属解析结算关系、套餐授权、价格和钱包。 +- `wallet` 不允许凭证,支持超级管理员、平台和代理账号;逐行复用现有代理主钱包扣款、信用边界、成功流水、套餐激活、佣金任务和订单幂等规则。 +- `offline` 仅允许超级管理员或平台账号,必须提交 1~5 个凭证 Key;每个成功订单保存同一批凭证快照并按现有线下订单规则立即激活,不扣钱包。 +- 每行独立处理,业务失败不回滚其他成功行。全部行业务失败仍表示任务完成,通过 `success_count=0` 和 `fail_count=total_count` 表达。 + +## 任务与幂等 + +- 任务状态固定为 `1:待处理, 2:处理中, 3:已完成, 4:已失败, 5:已取消`;当前版本没有取消入口,仅保留统一状态契约。 +- Asynq 载荷只包含结构化 `task_id`,不传 CSV 字节、临时路径或预序列化 `[]byte`。 +- Worker 通过任务状态和超时时间条件领取任务;重复消息发现任务正在有效执行或已终结时直接跳过。 +- 超过任务最长执行时间仍停留在“处理中”的记录可被重新领取,用于 Worker 崩溃后恢复;未超时的处理中任务不会并发重入。 +- Worker 使用任务快照恢复创建人权限;代理创建的任务仅能处理自己及下级店铺资产,权限范围查询失败时收紧为仅自己店铺,不会按平台无限制权限执行。 +- 单行继续复用订单 Service 的资产级锁、Redis 业务键和钱包订单数据库幂等指纹,重复消费不得重复创建订单或扣款。 + +## 数据与发布 + +- 迁移 `000192_create_asset_package_batch_order_task` 新增 `tb_asset_package_batch_order_task`,保存套餐/操作者/支付/文件快照、五态、汇总及最多 1000 行 JSONB 结果;不建立外键。 +- Worker 必须配置 Redis/Asynq 和对象存储,并监听 `asset:package:batch_order` 独立队列。 +- 回滚时先隐藏创建入口并停止新任务,等待已领取任务完成。已经创建的订单、钱包流水、套餐使用和任务结果均为业务事实,不做反向删除。 + +## 当前验证边界 + +本次未运行测试、完整构建、LSP 或迁移;按用户后续明确要求已运行 OpenAPI 生成器,并执行 `gofmt`、只读一致性检查和 `git diff --check`。真实对象存储上传、Worker 消费、钱包扣款和线下订单需在后续联调流程验证。 diff --git a/docs/business-data-export/功能总结.md b/docs/business-data-export/功能总结.md new file mode 100644 index 0000000..63b63db --- /dev/null +++ b/docs/business-data-export/功能总结.md @@ -0,0 +1,59 @@ +# 业务数据导出功能总结 + +## 公共交付方式 + +业务导出复用现有 `POST /api/admin/export-tasks` 异步任务入口、DataSource 注册、Asynq 分片、对象存储和 CSV/XLSX 汇总流程。前端只提交 `scene`、`format` 和当前列表的 `query.filters`,不另外选择导出列。 + +Worker 使用任务创建时固化的用户类型、店铺和下级店铺范围,不从运行时上下文猜测权限。`Count` 与 `Fetch` 使用同一套筛选和权限条件,分片按稳定 ID 顺序读取。 + +## IoT 卡导出 + +- 场景编码:`iot_card`。 +- 继续沿用现有卡列表筛选和店铺层级权限。 +- 在原有 ICCID、MSISDN、设备、运营商、店铺、实名和网络状态列后,追加“套餐名称”、“使用流量(MB)”和“剩余流量(MB)”。 +- 套餐字段取当前生效主套餐,名称优先使用购买时快照;剩余流量由套餐真流量总量减已用量得出,超额使用时收敛为 `0`。 + +## 套餐列表导出 + +- 场景编码:`package`。 +- 支持现有套餐列表的 `package_name`、`series_id`、`status`、`shelf_status`、`package_type` 筛选。 +- 平台和超级管理员导出套餐本体数据;代理只导出已分配给自己店铺且分配状态启用的非赠送套餐,成本价和上下架状态取自当前店铺分配记录。 +- 固定输出已确认的 25 列:编码、名称、系列、类型、时长、周期、真/虚流量、虚流量配置、重置周期、到期基准、价格、状态、赠送标识、创建/更新人及时间。当前列表不返回软删除套餐,因此“删除时间”按契约保留为空列。 +- 金额从数据库的“分”转为两位小数的“元”;未配置建议售价时输出空值,不用 `0.00` 冒充已配置价格。 + +## 代理主钱包流水导出 + +- 场景编码:`agent_wallet_transaction`。 +- 仅导出主钱包流水,代理按任务创建时固化的自己及下级店铺范围读取;平台和超级管理员可读取全部有权数据。 +- 支持现有流水列表的 `shop_id`、`transaction_type`、`start_date`、`end_date`、`asset_identifier` 筛选,纯日期截止时间包含当天。 +- 固定输出 14 列:店铺、交易类型、交易金额、状态、资产类型与标识、交易时间、交易前后金额、购买套餐名称、操作人、交易 ID、关联业务订单号、支付方式。 +- 金额沿用钱包流水中的分值事实并格式化为两位小数的元;套餐名称优先读取订单项快照,历史缺失时回退流水元数据。历史操作账号即使已软删除仍保留名称展示,但不参与权限判断。 + +## 代理充值导出 + +- 场景编码:`agent_recharge`。 +- 支持现有充值列表的 `shop_id`、`status`、`start_date`、`end_date` 筛选,代理权限使用任务创建时固化的店铺范围。 +- 固定输出 18 列:充值单号、店铺、充值类型、充值/实付金额、充值前后余额、状态、支付方式、运营备注、驳回原因、三个业务时间、提交人、审批来源、审批状态和支付凭证。 +- 充值金额取充值单事实;成功入账后的实付金额和余额前后值取首条匹配的成功主钱包充值流水,缺失时留空,不用零值冒充。 +- 审批字段只读取本地通用审批实例,不逐行实时调用企业微信。支付凭证按业务需要输出明文对象存储 Key,并按原顺序以逗号连接;Integration Log 仍不得记录 Secret、access_token、media_id 或附件正文。 + +## 退款导出 + +- 场景编码:`refund`。 +- 支持退款列表的 `status`、`order_id`、`shop_id`、`asset_identifier` 筛选,代理按任务创建时固化的自己及下级店铺范围导出。 +- 固定输出 21 列:退款单号、店铺、支付订单号、资产、套餐、五项金额、退款状态与原因、审批备注、审批来源与状态、退款处理状态、申请/审批时间、提交人和退款凭证。 +- 订单号和资产标识使用退款申请快照;套餐名称优先使用指定套餐使用记录快照,否则聚合同一订单的订单项快照。原订单金额、可退金额、申请金额和实际退款金额均读取既有订单或退款事实,来源缺失时留空,不用零值冒充。 +- 退款处理状态仅从退款状态、佣金回扣和资产处理完成标记稳定推导。当前模型没有可靠的当前审批节点和逐节点审批记录结构,因此不输出无意义空列;审批来源与状态只读取本地通用审批实例,不实时调用企业微信。 +- 退款凭证按业务需要输出明文对象存储 Key。历史软删除账号仍可用于提交人名称展示,但不参与权限判断。 + +## 换货导出 + +- 场景编码:`exchange`。 +- 支持换货列表的 `status`、`flow_type`、`old_asset_keyword`、`new_asset_keyword`、`created_at_start`、`created_at_end` 筛选;新旧资产关键词继续按 AND 组合,并按卡/设备当前权威标识命中资产 ID。 +- 固定输出 15 列:换货单号、流程类型、原因、问题描述/备注、新旧资产标识、收货信息、物流信息、状态、创建人和创建时间。 +- 新旧资产列直接使用换货单已固化的权威快照,卡为完整 ICCID,设备按虚拟号、IMEI、SN 的既定优先级保存;不会在导出时重新拼接或覆盖历史快照。 +- 收货人电话、地址等业务字段按用户确认保持明文输出。代理权限使用任务创建时固化的店铺层级范围,历史软删除创建人只用于名称展示。 + +## 当前验证边界 + +本次不运行测试、完整构建、LSP 或迁移;按用户后续明确要求已运行 OpenAPI 生成器。静态收口使用 `gofmt`、只读一致性检查和 `git diff --check`;真实数据行数、筛选、分片、CSV/XLSX 文件和下载链接由后续联调验证。 diff --git a/docs/device-batch-allocation/功能总结.md b/docs/device-batch-allocation/功能总结.md new file mode 100644 index 0000000..75be401 --- /dev/null +++ b/docs/device-batch-allocation/功能总结.md @@ -0,0 +1,51 @@ +# 设备 CSV 批量分配功能总结 + +## 功能边界 + +本功能复用现有设备导入任务的对象存储、任务状态、Asynq 队列、进度统计和失败明细能力。复用的是异步任务外壳,原多列 Excel 创建设备与绑卡逻辑保持不变。 + +新增任务创建接口: + +```text +POST /api/admin/devices/import/allocations +``` + +请求参数: + +```json +{ + "file_key": "device-batch-allocations/2026/07/25/example.csv", + "operation_type": "assign_shop", + "target_id": 1001 +} +``` + +- `assign_shop`:`target_id` 为目标代理店铺 ID。 +- `assign_series`:`target_id` 为目标套餐系列 ID。 +- 每个任务只能选择一种操作和一个目标,不能逐行指定不同目标。 + +## CSV 格式 + +CSV 只能有一列,每行填写一个设备标识,支持 VirtualNo、IMEI 或 SN。首行可选使用 `device_identifier` 或 `设备标识` 表头。文件必须使用上传用途 `device_batch_allocation` 获取对象存储 Key,最多 1000 个设备且不超过 10MB。 + +## 规则复用与幂等 + +Worker 批量解析设备后调用现有业务方法: + +- 分配代理调用 `DeviceService.AllocateDevices`,沿用直属下级、平台库存、绑定卡归属同步、分配记录和资产审计规则。 +- 设置套餐系列调用 `DeviceService.BatchSetSeriesBinding`,沿用套餐系列有效性、代理授权、设备归属和资产审计规则。 + +任务创建时冻结操作者 ID、类型和店铺 ID,Worker 重建权限上下文,不能因异步执行丢失数据范围。重复消费时,已经属于目标店铺或已经绑定目标系列的设备直接按成功处理;任务完成或失败后不重复执行。CSV 内重复设备记录为跳过,不产生重复副作用。 + +## 任务查询与回滚 + +继续使用现有接口查询任务: + +```text +GET /api/admin/devices/import/tasks +GET /api/admin/devices/import/tasks/:id +``` + +响应新增 `operation_type`、`operation_name` 和 `target_id`,逐行失败原因继续通过原详情结构返回。 + +回滚应用版本前应先停止新任务入口并等待处理中的任务结束。迁移新增列保持兼容,不删除既有任务;已经完成的设备归属、套餐系列关系、分配记录和资产审计不得通过清表回滚,应使用现有回收或系列绑定接口前向修正。 diff --git a/docs/environment-variables.md b/docs/environment-variables.md index d8d903d..a4c1db7 100644 --- a/docs/environment-variables.md +++ b/docs/environment-variables.md @@ -165,6 +165,36 @@ JUNHONG_WORKER_ROLE=consumer JUNHONG_WORKER_INSTANCE_NAME=worker-consumer-1 ``` +### Gateway 服务 + +| 环境变量 | 默认值 | 说明 | +|---------|--------|------| +| `JUNHONG_GATEWAY_BASE_URL` | 默认配置文件值 | Gateway API 基础地址,配置后必须为 HTTP/HTTPS URL | +| `JUNHONG_GATEWAY_APP_ID` | 默认配置文件值 | Gateway 应用 ID | +| `JUNHONG_GATEWAY_APP_SECRET` | 默认配置文件值 | Gateway 应用密钥 | +| `JUNHONG_GATEWAY_TIMEOUT` | `60` | Gateway 请求超时秒数,允许 5~300 | + +生产环境必须通过环境变量覆盖应用身份和密钥。IoT 卡固定档位限速只向 Gateway 发送卡 ICCID 和档位编码,不支持设备限速。 + +### 企业微信 Adapter + +| 环境变量 | 默认值 | 说明 | +|---------|--------|------| +| `JUNHONG_WECOM_BASE_URL` | `https://qyapi.weixin.qq.com` | 企业微信 API 基础地址 | +| `JUNHONG_WECOM_TIMEOUT` | `10s` | 企业微信外部请求超时时间 | +| `JUNHONG_WECOM_CREDENTIAL_ENCRYPTION_KEY` | `""` | 启用企业微信功能前必填,32 字节随机密钥的 Base64 文本 | + +可使用 `openssl rand -base64 32` 生成加密密钥。密钥缺失或格式错误不会阻止不使用企微的环境启动,但企业微信配置保存、查询和连接测试会按失败关闭处理。更换密钥前必须先制定已有凭据的重新加密方案,否则历史密文将无法读取。 + +### 审批旧入口切换 + +| 环境变量 | 默认值 | 说明 | +|---------|--------|------| +| `JUNHONG_APPROVAL_LEGACY_REFUND_MANUAL_ENABLED` | `true` | 是否保留退款人工通过/拒绝入口;仅在企微真实闭环和存量清单完成后改为 `false` | +| `JUNHONG_APPROVAL_LEGACY_OFFLINE_RECHARGE_PAY_ENABLED` | `true` | 是否保留线下充值人工确认入口;仅在企微真实闭环和存量清单完成后改为 `false` | + +两个开关默认开启,避免部署新版本时中断存量旧 provider。关闭后接口仍保留路由,但返回“入口已停用”,前端只展示本地批量投影的审批渠道和状态。配置变更需要重启 API 实例,Worker 不依赖这两个旧入口开关。 + ### 限流中间件 | 环境变量 | 默认值 | 说明 | diff --git a/docs/gateway-api-reference.md b/docs/gateway-api-reference.md index 3ebf354..82a764d 100644 --- a/docs/gateway-api-reference.md +++ b/docs/gateway-api-reference.md @@ -5,8 +5,8 @@ 本文档提供 Gateway 客户端所有 API 接口的完整参考,包括请求参数、响应格式和使用示例。 **API 分类**: -- 流量卡管理(7 个接口) -- 设备管理(7 个接口) +- 流量卡管理(8 个接口) +- 设备管理(6 个接口) **基础信息**: - 协议:HTTPS @@ -270,7 +270,43 @@ fmt.Printf("实名链接: %s\n", resp.Link) --- -### 7. 批量查询(预留) +### 7. 设置流量卡固定限速档位 + +按 ICCID 设置或恢复流量卡固定限速档位。Gateway 不提供设备限速能力,禁止传入设备 ID、IMEI 或通过设备绑定关系间接调用。 + +**方法**: `SetCardSpeedTier` + +**请求参数**: +```go +type CardSpeedTierReq struct { + CardNo string `json:"cardNo" validate:"required"` + Code string `json:"code" validate:"required"` +} +``` + +| 参数 | 类型 | 必填 | 说明 | +|------|------|------|------| +| CardNo | string | ✅ | 流量卡 ICCID | +| Code | string | ✅ | 固定档位编码:`-1` 恢复不限速,`0` 0kbps,`1` 128Kbps,`2` 512Kbps,`3` 1Mbps,`4` 2Mbps,`5` 10Mbps,`6` 20Mbps,`7` 50Mbps,`8` 100Mbps | + +**响应参数**: 无(成功返回 nil,失败返回 error) + +**使用示例**: +```go +err := client.SetCardSpeedTier(ctx, &gateway.CardSpeedTierReq{ + CardNo: "898608070422D0010269", + Code: "3", +}) +if err != nil { + return err +} + +fmt.Println("流量卡固定限速档位设置成功") +``` + +--- + +### 8. 批量查询(预留) 批量查询流量卡信息(暂未实现)。 @@ -427,48 +463,7 @@ for _, slot := range resp.Slots { --- -### 3. 设置设备限速 - -设置设备的上行和下行速率限制。 - -**方法**: `SetSpeedLimit` - -**请求参数**: -```go -type SpeedLimitReq struct { - DeviceID string `json:"deviceId" validate:"required"` - UploadSpeed int `json:"uploadSpeed" validate:"required,min=1"` - DownloadSpeed int `json:"downloadSpeed" validate:"required,min=1"` - Extend string `json:"extend,omitempty"` -} -``` - -| 参数 | 类型 | 必填 | 说明 | -|------|------|------|------| -| DeviceID | string | ✅ | 设备 ID/IMEI | -| UploadSpeed | int | ✅ | 上行速率(KB/s),最小1 | -| DownloadSpeed | int | ✅ | 下行速率(KB/s),最小1 | -| Extend | string | ❌ | 扩展字段 | - -**响应参数**: 无(成功返回 nil,失败返回 error) - -**使用示例**: -```go -err := client.SetSpeedLimit(ctx, &gateway.SpeedLimitReq{ - DeviceID: "123456789012345", - UploadSpeed: 100, - DownloadSpeed: 500, -}) -if err != nil { - return err -} - -fmt.Println("限速设置成功") -``` - ---- - -### 4. 设置设备 WiFi +### 3. 设置设备 WiFi 设置设备的 WiFi 信息。 @@ -516,7 +511,7 @@ fmt.Println("WiFi设置成功") --- -### 5. 设备切换卡 +### 4. 设备切换卡 切换设备当前使用的卡到指定的目标卡。 @@ -554,7 +549,7 @@ fmt.Println("切换卡成功") --- -### 6. 设备恢复出厂设置 +### 5. 设备恢复出厂设置 将设备恢复到出厂设置状态。 @@ -589,7 +584,7 @@ fmt.Println("恢复出厂设置成功") --- -### 7. 设备重启 +### 6. 设备重启 远程重启设备。 diff --git a/docs/gateway-client-usage.md b/docs/gateway-client-usage.md index e12b20a..2b2641a 100644 --- a/docs/gateway-client-usage.md +++ b/docs/gateway-client-usage.md @@ -212,28 +212,28 @@ func (s *Service) GetDeviceSlots(ctx context.Context, imei string) ([]gateway.Sl } ``` -#### 设置设备限速 +#### 设置流量卡固定限速档位 ```go -func (s *Service) SetDeviceSpeed(ctx context.Context, imei string, uploadKBps, downloadKBps int) error { - err := s.gatewayClient.SetSpeedLimit(ctx, &gateway.SpeedLimitReq{ - DeviceID: imei, - UploadSpeed: uploadKBps, - DownloadSpeed: downloadKBps, +func (s *Service) SetCardSpeedTier(ctx context.Context, iccid string, code int) error { + err := s.gatewayClient.SetCardSpeedTier(ctx, &gateway.CardSpeedTierReq{ + CardNo: iccid, + Code: strconv.Itoa(code), }) if err != nil { - return errors.Wrap(errors.CodeGatewayError, err, "设置限速失败") + return errors.Wrap(errors.CodeGatewayError, err, "设置流量卡固定限速档位失败") } - s.logger.Info("设置限速成功", - zap.String("imei", imei), - zap.Int("upload", uploadKBps), - zap.Int("download", downloadKBps), + s.logger.Info("设置流量卡固定限速档位成功", + zap.String("iccid", iccid), + zap.Int("code", code), ) return nil } ``` +Gateway 限速对象只有流量卡 ICCID。设备没有限速接口,业务代码不得传入设备 ID、IMEI,也不得先解析设备当前绑定卡再间接限速。 + #### 设置设备 WiFi ```go diff --git a/docs/iot-card-speed-tier/功能总结.md b/docs/iot-card-speed-tier/功能总结.md new file mode 100644 index 0000000..f0e745c --- /dev/null +++ b/docs/iot-card-speed-tier/功能总结.md @@ -0,0 +1,55 @@ +# IoT 卡固定档位限速功能总结 + +## 功能边界 + +本功能只允许后台账号为有权限的 IoT 卡设置 Gateway 固定限速档位。限速对象始终是请求路径中的卡 ICCID:设备没有限速接口,系统也不会查询设备当前绑定卡后间接限速。 + +接口: + +```text +PUT /api/admin/iot-cards/:iccid/speed-tier +``` + +请求体: + +```json +{ + "code": 3 +} +``` + +## 固定档位 + +| code | 含义 | +| ---: | --- | +| -1 | 恢复不限速 | +| 0 | 0kbps | +| 1 | 128Kbps | +| 2 | 512Kbps | +| 3 | 1Mbps | +| 4 | 2Mbps | +| 5 | 10Mbps | +| 6 | 20Mbps | +| 7 | 50Mbps | +| 8 | 100Mbps | + +前端只能提交上述固定档位,不允许输入任意速率。`-1` 与 `0` 是不同操作,不能混用。 + +## 权限与调用链 + +- 仅超级管理员、平台账号和代理后台账号可调用。 +- 通过现有 `IotCardStore.GetByICCID` 查询卡并应用当前数据权限;不存在和无权限统一返回“无权限操作该资源或资源不存在”。 +- 外呼 Gateway 使用 `/flow-card/speedLimit`,请求业务参数只有 `cardNo` 和 `code`,其中 `cardNo` 必须是已校验的卡 ICCID。 +- 不根据套餐流量、运营商或设备绑定关系自动计算或转换档位。 + +## 审计与结果未知 + +每次实际外呼前先创建 `pending` Integration Log,记录卡、档位、操作者和请求关联标识;外呼完成后终结为 `success`、`failed` 或 `unknown`。资产操作审计使用 `card_speed_tier` 动作编码记录操作者、卡、档位及结果。 + +Gateway 请求超时时,外部是否已生效无法确定。接口返回可识别的结果未知错误,Integration Log 保存人工恢复策略:通过 Gateway 运维侧按 ICCID 核对当前档位后,再决定是否重试。系统不会盲目声称成功,也不会自动重复提交。 + +## 联调与回滚 + +联调需覆盖全部固定档位、越权卡、Gateway 明确失败和请求超时,并确认设备详情和设备 API 中不存在限速入口。 + +回滚时可隐藏前端入口并回退应用版本;已经产生的资产操作审计和 Integration Log 不得删除或清空。由于本功能不保存本地“当前限速档位”,不涉及数据库状态回滚。 diff --git a/docs/ur33-package-expiry-reminder/功能总结.md b/docs/ur33-package-expiry-reminder/功能总结.md new file mode 100644 index 0000000..5f52b96 --- /dev/null +++ b/docs/ur33-package-expiry-reminder/功能总结.md @@ -0,0 +1,29 @@ +# UR#33 套餐临期提醒功能总结 + +## 交付范围 + +- 新增 `GET /api/admin/expiring-assets`,平台与代理共用当前后台认证和店铺层级权限。 +- 仅返回预计最终到期状态为 `exact`、剩余 0 至 15 个上海自然日的卡和设备。 +- 返回卡、设备及合计数量,提供 `pink/purple/red` 高亮等级和 0 至 3 天优先标识。 +- 每日上海时间 03:00 扫描 15、7、3 天节点,向资产关联个人客户写入站内通知 Outbox。 +- C 端继续复用既有通知列表、未读数和弹窗机制,不新增营销、短信或企业微信提醒。 + +## 数据与幂等 + +- 临期时间复用 `internal/query/packageexpiry` 的最终到期算法,不建立临期状态快照。 +- 列表候选使用当前主套餐到期时间预筛,再批量计算当前与排队主套餐的最终到期时间。 +- 通知事件 ID 固化资产类型、资产 ID、到期日、节点和个人客户 ID;定时任务、Outbox Relay 和通知消费者重复执行均不会重复通知。 +- `000183_add_package_expiry_scan_indexes` 仅增加卡、设备主套餐到期扫描索引,不新增外键。 + +## 权限和异常边界 + +- 平台与超级管理员读取全局范围;代理只读取当前认证上下文的下级店铺范围。 +- 代理权限范围为空时查询固定返回空集,不能因空切片退化为全局读取。 +- `shop_id` 只缩小既有权限范围;企业账号禁止调用。 +- 无关联个人客户的资产正常跳过通知,不影响临期列表事实。 + +## 联调与回滚 + +- 前端按 `expiry_level` 渲染颜色,按 `is_priority` 置顶,不自行重算阈值。 +- 回滚应用版本后可删除迁移新增的两个扫描索引;已产生的通知和 Outbox 事实不得清表。 +- 本 Change 未运行测试、完整构建、LSP 和迁移;按用户后续明确要求已运行 OpenAPI 生成器,另执行 `gofmt`、只读一致性检查和 `git diff --check`。 diff --git a/docs/wecom-application-connection/功能总结.md b/docs/wecom-application-connection/功能总结.md new file mode 100644 index 0000000..fbca57a --- /dev/null +++ b/docs/wecom-application-connection/功能总结.md @@ -0,0 +1,125 @@ +# 企业微信应用连接 Adapter 功能总结 + +## 交付范围 + +本切片提供企业微信自建应用的最小连接能力,不迁移或改造通用审批核心: + +- `POST /api/admin/wecom/applications`:按 `corp_id + agent_id` 创建或更新应用配置。 +- `GET /api/admin/wecom/applications`:分页查询应用配置,默认每页 20、最大 100,向超级管理员返回可直接编辑的明文 Secret、回调 Token 和 EncodingAESKey。 +- `POST /api/admin/wecom/applications/:id/test`:失效旧缓存并测试能否取得 access_token,仅返回测试是否成功,不返回 access_token。 +- `PUT /api/admin/wecom/applications/:id/default-creator`:从应用当前可见成员中选择代理等非企微账号使用的默认审批发起人。 +- `POST /api/admin/wecom/applications/:id/members/sync`:拉取应用可见范围内的成员并原子刷新本地选择快照。 +- `GET /api/admin/wecom/applications/:id/members`:按姓名或 userid 搜索并分页选择最近同步的可见成员。 +- `PUT /api/admin/accounts/:id/wecom-binding`:把系统账号绑定到管理员明确选择的 `(corp_id, userid)`,同时保存姓名快照。 +- `PUT /api/admin/wecom/scenes/:business_type`:配置已知 `template_id` 和业务字段控件映射,保存前实时读取模板详情校验。 +- `GET /api/admin/wecom/scenes`:分页查询退款、员工线下代充值两个稳定业务场景的当前模板映射。 +- 应用凭据保存、查询和连接测试仅允许超级管理员;通讯录同步、选择和账号绑定允许超级管理员或平台账号。 + +## 凭据与配置 + +管理端按业务需要使用明文填写和读取连接参数,服务端在写入 PostgreSQL 前使用 AES-256-GCM 加密。数据库不保存明文,日志、配置审计和 Integration Log 也不记录明文、密文或 access_token。 + +启用前必须设置 `JUNHONG_WECOM_CREDENTIAL_ENCRYPTION_KEY`,值为 32 字节随机密钥的 Base64 文本。未配置或格式错误时服务仍可启动,但企业微信相关业务接口失败关闭。 + +## Token 缓存与并发控制 + +- access_token 使用应用配置 ID 隔离缓存,TTL 按企微返回有效期减去 5 分钟刷新窗口计算。 +- 缓存未命中时通过 Redis 单应用短锁限制并发回源;未取得锁的请求最多等待 2 秒读取回填结果。 +- Redis 读取或加锁故障时记录中文告警并受控直连企微,确保缓存故障不直接中断连接测试。 +- 配置更新后立即失效该应用 token 缓存。 + +## 通讯录与账号绑定 + +- 同步只读取 `userid`、姓名和部门 ID,不读取手机号或邮箱,也不做手机号、姓名自动匹配。 +- `tb_wecom_member` 仅保存应用可见成员的选择快照;同步时把不再可见的成员标记为不可见,不建立本地部门或组织模型。 +- 成员 userid 入库前统一转为小写,身份键按 `(corp_id, userid)` 管理;同一企微成员不能同时绑定多个未删除系统账号。 +- 账号列表和详情返回 `wecom_corp_id`、`wecom_userid`、`wecom_name` 和 `wecom_bound`。 +- 应用禁用或成员不再可见后,不能创建新的账号绑定;历史绑定快照保留,供审批提交前再次校验。 + +## 默认审批发起人 + +- 内部超级管理员或平台员工已绑定企微成员且仍在应用可见范围内时,审批优先以本人 userid 发起。 +- 代理和企业账号不要求绑定企微,始终使用应用配置的默认审批发起人;内部员工未绑定时也可回退到默认发起人。 +- 默认发起人只能从最近同步的应用可见成员中选择。未配置、应用禁用或成员不再可见时,新审批在业务事实写入前失败关闭。 +- 默认企微发起人只承担 `applyevent.creator_userid` 身份;本地业务单和通用审批实例继续保存真实代理或员工提交人,企微表单应通过业务字段映射展示真实申请人信息。 + +## Integration Log + +每次真实调用 `/cgi-bin/gettoken`、可见成员接口、`/cgi-bin/oa/gettemplatedetail`、临时素材上传或 `/cgi-bin/oa/applyevent` 前先创建公共 Integration Log,完成后记录 HTTP 状态、企微错误码、耗时和安全摘要。请求摘要只包含应用配置 ID、corp_id、agent_id、template_id、控件数量和附件大小等非密钥标识;响应摘要不保存 access_token、media_id、附件正文或外部响应正文。 + +## 审批场景与模板映射 + +- 稳定业务类型固定为 `refund_approval` 和 `offline_recharge_approval`,业务代码不直接写死企微模板 ID。 +- 模板必须先在企业微信后台创建;本系统不创建模板,也不保存审批节点或审批人规则。 +- 保存映射时调用模板详情,逐项校验控件 ID、控件类型和选择项 key,并要求模板所有必填控件都有映射;模板结构已变化时拒绝覆盖当前有效配置。 +- 员工线下代充值只允许映射 `recharge_no`、`shop_id`、`shop_name`、`amount`、`amount_cent`、`payment_voucher_key`、`remark`、`submitter_id`、`submitter_name`。 +- 退款只允许映射 `refund_no`、`order_id`、`order_no`、`asset_identifier`、`asset_type`、`actual_received_amount`、`requested_refund_amount`、`refund_voucher_key`、`refund_reason`、`package_usage_id`、`submitter_id`、`submitter_name`。 +- `template_snapshot` 只保存控件 ID、类型、标题、必填标识和选择项 key 的最小快照。 + +## 数据库变更 + +- 迁移 `000184_create_wecom_application` 新增 `tb_wecom_application`,以 `(corp_id, agent_id)` 作为未删除记录的唯一身份。 +- 迁移 `000185_add_wecom_member_binding` 新增 `tb_wecom_member`,并为 `tb_account` 增加企微绑定字段和唯一索引。 +- 迁移 `000186_create_wecom_approval_scene` 新增当前业务场景、模板和控件映射配置表。 +- 迁移 `000187_add_wecom_approval_submission` 增加应用默认发起人,并新增 `tb_wecom_approval_context` 保存不含凭据的模板、发起人和提交状态快照。 +- 迁移 `000188_add_wecom_approval_detail_snapshot` 增加最近企微状态、权威详情快照和同步时间。 +- 迁移 `000189_add_wecom_approval_recovery_timestamps` 增加实际提交尝试时间和最近恢复时间,周期恢复不会覆盖原始查询时间窗。 +- 迁移 `000190_add_agent_recharge_approval_instance` 为员工线下代充值记录增加唯一通用审批实例引用,不建立外键。 +- 迁移 `000191_add_refund_approval_instance` 为退款申请增加唯一通用审批实例引用,不建立外键。 +- 所有迁移都不建立数据库外键。 + +## Approval Port 与异步提交 + +- 企微 Adapter 在业务事务前重新校验场景、应用、发起人可见性和模板指纹,通过后返回不含 Secret/access_token 的短期准备结果。 +- 业务事务复用通用 Approval Port,原子保存业务申请、`tb_approval_instance`、企微渠道上下文和 `approval.submission.requested` Outbox。 +- Worker 领取事件后先条件更新为“请求处理中”,再按业务字段映射组装控件;文件控件从对象存储下载并上传企微临时素材,单单最多 6 个、单文件不超过 20MB。 +- `applyevent` 明确成功时同时保存 `sp_no` 并把通用实例置为审批中;企微明确拒绝时记提交失败;请求已发出但超时、断连或响应不可确认时记结果未知,禁止 Outbox 自动创建第二张审批单。 +- 结果未知记录明确恢复策略,主动恢复优先读取成功提交 Integration Log 中已安全保存的 `sp_no`;没有成功日志时,按应用、模板、发起人和实际提交时刻前后 5 分钟调用批量单号接口。 +- 批量查询单页最多 100 条并使用 `new_cursor/new_next_cursor` 分页;排除已关联本地实例的单号后,只有唯一候选才允许恢复关联,多候选或无候选继续保持结果未知,绝不自动重提 `applyevent`。 + +## 加密回调与标准决策 + +- 回调地址为 `GET/POST /api/callback/wecom/approval/:application_id`,企业微信后台为每个应用填写对应应用配置 ID。 +- GET 校验 `msg_signature`,使用当前应用的 Token 和 EncodingAESKey 执行 AES-256-CBC/PKCS#7 解密,并原样返回 `echostr` 明文。 +- POST 从 XML 读取 `Encrypt`,校验签名和 `receiveid=corp_id`,只接受 `sys_approval_change`;入站 Integration Log 只保存密文载荷哈希和稳定幂等键。 +- 回调在成功写入幂等记录并提交结构化 Asynq 任务后立即返回纯文本 `success`,不在 HTTP 请求内更新退款或充值状态。 +- Worker 使用 `SpNoStr` 调用 `oa/getapprovaldetail` 取得权威详情,保存最近状态和详情快照,再把 2/3/4/6/7 翻译为 approved/rejected/cancelled/revoked_after_approved/deleted 并调用现有 `SyncDecisionService`。 +- 标准终态继续由通用审批核心在单事务内写状态、决策投递事实和终态 Outbox;企微 Adapter 不直接执行退款或钱包入账。 + +## 主动恢复、轮询与读取投影 + +- Asynq Scheduler 每 2 分钟提交 `wecom:approval:recovery`;提交中状态占用超过 5 分钟时保守转为结果未知,不恢复为待提交。 +- 已关联 `sp_no` 且通用审批仍为审批中的记录,按最近成功同步时间批量扫描并提交结构化 `wecom:approval:sync` 任务,继续复用同一详情同步和标准决策链路。 +- 结果未知记录单轮最多处理 10 条,避免最坏外部超时阻塞整个周期;每次 `getapprovalinfo` 和 `getapprovaldetail` 真实外呼均写 Integration Log。 +- 未知企微回调会从权威详情取得模板、申请人和提交时间,仅在本地结果未知记录唯一匹配时恢复;真正无关的审批回调记为 ignored,不反复重试。 +- `ApprovalProjectionResolver` 只从 `latest_detail_snapshot.sp_record` 读取审批节点 userid,按应用企业 ID 分组后用一次账号批量查询映射系统账号;映射不到时账号 ID/名称为空,不影响审批状态,也不实时调用企微。 + +## 员工线下代充值审批 + +- `POST /api/admin/agent-recharges` 继续作为创建入口;当 `payment_method=offline` 时,仅平台员工或超级管理员可提交,并要求 1~5 个对象存储凭证 Key。 +- 创建前校验提交人账号、目标店铺、正常主钱包、企微应用、场景、模板和实际发起人;任一前置不完整时,不写业务单、审批实例或 Outbox。 +- 充值单保存真实系统提交人;内部员工有效绑定优先本人发起企微审批,否则使用应用默认发起人。默认发起人只作为企微 creator,不覆盖本地真实提交人。 +- 充值单、提交人/金额/凭证明文快照、通用审批实例、企微渠道上下文和 `approval.submission.requested` 在同一 GORM 事务内保存。 +- 标准 `approved` 终态通过统一钱包 `PostingService` 入账,稳定幂等键为 `topup + recharge_record_id`;`rejected/cancelled/deleted` 只终结申请,不修改钱包;`revoked_after_approved` 不自动冲正。 +- 列表和详情按一批审批实例查询 `provider/status/status_name`,不逐条访问企微。带审批实例的新申请禁止通过旧人工确认或驳回入口绕过企微;旧入口暂时仅服务存量记录,是否整体停用由后续切换任务控制。 + +## 退款企微审批 + +- `POST /api/admin/refunds` 在既有订单、金额、资产和权限校验后,先检查企微应用、退款场景、模板和实际发起人,再原子保存退款申请、真实提交人/订单/金额/凭证明文快照、通用审批实例、企微上下文和提交 Outbox。 +- 代理账号始终使用应用默认企微发起人,平台员工有效绑定优先本人、否则回退默认发起人;本地退款申请和通用审批实例始终保存真实系统提交人。 +- 标准 `approved` 终态条件更新退款单和订单,复用统一代理主钱包退款及资产钱包退款逻辑;代理主钱包按退款 ID、资产钱包按退款单号复核成功流水,重复终态不会重复回款。 +- `rejected/cancelled/deleted` 将本地申请终结为未退款状态,并在 `reject_reason` 保留对应企微终态;`revoked_after_approved` 不自动反向改账,需后续人工前向处理。 +- 佣金回扣按佣金记录锁定、唯一业务引用和状态失效实现可重入;套餐失效沿订单/换货迁移关系复用既有幂等路径。只有佣金与资产后处理都完成后,标准决策投递才标记成功,失败会释放租约等待重试。 +- 退款列表和详情批量读取审批渠道、审批状态和中文状态名,不实时调用企微。带审批实例的新退款禁止旧人工通过、拒绝或退回入口绕过企微;存量旧 provider 的入口切换由后续发布任务控制。 + +## 旧审批入口切换 + +- `JUNHONG_APPROVAL_LEGACY_REFUND_MANUAL_ENABLED` 和 `JUNHONG_APPROVAL_LEGACY_OFFLINE_RECHARGE_PAY_ENABLED` 默认均为 `true`,避免新版本部署时中断 `approval_instance_id IS NULL` 的存量旧 provider。 +- 真实企微闭环验收和存量处理完成后,发布配置可分别关闭退款 `approve/reject` 与线下充值 `offline-pay`;关闭需要重启 API,Worker 标准终态消费者不依赖这些开关。 +- 前端切换后隐藏旧操作按钮,只读展示 `approval_instance_id`、`approval_provider`、`approval_status` 和 `approval_status_name`。 +- 存量核对 SQL、关闭门禁和回滚步骤见 [存量审批切换清单](存量审批切换清单.md)。存量记录不得伪造企微实例,回滚不得删除任何审批、资金或集成事实。 + +## 已知边界 + +- 当前已包含审批准备、提交、加密回调、详情终态同步、批量单号时间窗补偿、未终态轮询、结果未知主动恢复和审批人批量读取投影。 +- 本次未运行迁移、测试、完整构建或 LSP;按用户后续明确要求已运行 OpenAPI 生成器并检查企微管理及回调契约,静态收口另执行格式化和一致性检查。 diff --git a/docs/wecom-application-connection/存量审批切换清单.md b/docs/wecom-application-connection/存量审批切换清单.md new file mode 100644 index 0000000..03b8a4c --- /dev/null +++ b/docs/wecom-application-connection/存量审批切换清单.md @@ -0,0 +1,62 @@ +# 企业微信审批存量切换清单 + +## 目标与边界 + +本清单用于把退款人工 `approve/reject` 和线下充值人工 `offline-pay` 从存量兼容模式切换为企业微信只读审批状态模式。切换只关闭旧 HTTP 入口,不删除路由、历史退款、充值、审批实例、钱包流水、Outbox 或 Integration Log,也不为存量记录伪造企业微信审批实例。 + +## 发布开关 + +两个开关默认均为 `true`,部署新版本后继续允许 `approval_instance_id IS NULL` 的存量旧 provider 完成处理: + +| 环境变量 | 默认值 | 关闭后的入口 | +|---|---|---| +| `JUNHONG_APPROVAL_LEGACY_REFUND_MANUAL_ENABLED` | `true` | 退款 `approve/reject` 返回“入口已停用” | +| `JUNHONG_APPROVAL_LEGACY_OFFLINE_RECHARGE_PAY_ENABLED` | `true` | 线下充值 `offline-pay` 返回“入口已停用” | + +配置变更后必须重启 API 实例。Worker 的企业微信标准终态消费者不读取这两个开关,不受切换影响。 + +## 切换前只读核对 + +以下 SQL 仅用于发布人员核对,不在本 Change 中执行。待审批状态值按当前模型均为 `1`。 + +```sql +-- 仍需通过旧人工入口完成的存量待审批退款 +SELECT id, refund_no, order_id, shop_id, creator, status, created_at +FROM tb_refund_request +WHERE deleted_at IS NULL + AND status = 1 + AND approval_instance_id IS NULL +ORDER BY id; + +-- 仍需通过旧人工入口完成的存量待确认线下充值 +SELECT id, recharge_no, shop_id, user_id, amount, status, created_at +FROM tb_agent_recharge_record +WHERE deleted_at IS NULL + AND payment_method = 'offline' + AND status = 1 + AND approval_instance_id IS NULL +ORDER BY id; +``` + +核对和处理规则: + +- `approval_instance_id IS NULL` 表示存量旧 provider,必须在关闭开关前通过原入口处理完成;不得补写或伪造企微实例。 +- `approval_instance_id IS NOT NULL` 表示新企微审批记录,只能由企微回调或轮询产生的标准终态处理;旧人工入口已在代码中逐单阻断。 +- 存量清单应保存退款/充值业务 ID、业务单号、当前状态、责任人和处理结果,不复制 Secret、access_token、media_id 或附件正文。 + +## 关闭门禁 + +只有同时满足以下条件才关闭旧入口: + +1. 退款和员工线下代充值已在真实企微环境完成发起、审批、回调或轮询补偿、标准终态以及资金副作用联调。 +2. 上述两份存量清单已清零,或剩余记录已有明确的前向处理方案和责任人。 +3. 前端已隐藏退款 `approve/reject` 与线下充值 `offline-pay` 操作,只读展示 `approval_instance_id`、`approval_provider`、`approval_status` 和 `approval_status_name`。 +4. API 发布配置把对应开关设为 `false`,并完成全部 API 实例滚动重启。 + +关闭后应抽查:新企微记录的列表和详情审批字段一致;旧入口返回明确停用错误;Worker 仍能消费 `approved/rejected/cancelled/deleted` 标准终态。 + +## 回滚 + +如关闭后发现前端或存量处理遗漏,将对应开关重新设为 `true` 并重启 API 实例。回滚只恢复旧入口可用性,不改变企微审批记录的逐单防绕过规则。 + +不得删除、清空或回退已经产生的审批实例、退款、充值、钱包流水、Outbox、Integration Log 或企微外部审批;不得把新企微记录改为旧 provider。问题应通过前向修复处理。 diff --git a/docs/副本IoT卡导入模板-Chc9eI72.csv b/docs/副本IoT卡导入模板-Chc9eI72.csv new file mode 100644 index 0000000..19bf06f --- /dev/null +++ b/docs/副本IoT卡导入模板-Chc9eI72.csv @@ -0,0 +1,24 @@ +ICCID(必填)联通必须是20位,MSISDN(必填),虚拟号(非必填) +89860624660030801094,861400566073546, +89860624590015958355,861400498975835, +89860624660030813982,861400566074835, +89860624590015955237,861400498975523, +89860624660030811838,861400566074620, +89860624660030811747,861400566074611, +89860624590015958967,861400498975896, +89860624660030816431,861400566075080, +89860624590015955880,861400498975588, +89860624660030816456,861400566075082, +89860624660030816431,861400566075080, +89860624660030807117,861400566074148, +89860624660030801904,861400566073627, +89860624590015958058,861400498975805, +89860624660030801615,861400566073598, +89860624590015958108,861400498975810, +89860624660030813669,861400566074803, +89860624660030812513,861400566074688, +89860624660030813503,861400566074787, +89860624660030807521,861400566074189, +89860624660030813107,861400566074747, +89860624660030807919,861400566074228, +89860624660030812687,861400566074705, diff --git a/docs/副本设备导入模板-B8BDRTVm.csv b/docs/副本设备导入模板-B8BDRTVm.csv new file mode 100644 index 0000000..f50a7b2 --- /dev/null +++ b/docs/副本设备导入模板-B8BDRTVm.csv @@ -0,0 +1,24 @@ +虚拟号(必填),SN(非必填),设备名称(非必填),设备型号(非必填),设备类型(非必填),IMEI(非必填),制造商(非必填),最大SIM槽数 (非必填),卡1 ICCID (非必填),卡2 ICCID (非必填),卡3 ICCID (非必填),卡4 ICCID (非必填) +60003080109,,Dongle005,,Dongle005,869555083143381,,1,89860624660030801094,,, +90001595835,,Dongle005,,Dongle005,869555083142417,,1,89860624590015958355,,, +60003081398,,Dongle005,,Dongle005,869555083105034,,1,89860624660030813982,,, +90001595523,,Dongle005,,Dongle005,869555083142359,,1,89860624590015955237,,, +60003081183,,Dongle005,,Dongle005,869555083109259,,1,89860624660030811838,,, +60003081174,,Dongle005,,Dongle005,869555083106321,,1,89860624660030811747,,, +90001595896,,Dongle005,,Dongle005,869555083120306,,1,89860624590015958967,,, +60003081643,,Dongle005,,Dongle005,869555083104151,,1,89860624660030816431,,, +90001595588,,Dongle005,,Dongle005,869555083145048,,1,89860624590015955880,,, +60003081645,,Dongle005,,Dongle005,869555083089592,,1,89860624660030816456,,, +60003081643,,Dongle005,,Dongle005,869555083089568,,1,89860624660030816431,,, +60003080711,,Dongle005,,Dongle005,869555083144835,,1,89860624660030807117,,, +60003080190,,Dongle005,,Dongle005,869555083100118,,1,89860624660030801904,,, +90001595805,,Dongle005,,Dongle005,869555083127673,,1,89860624590015958058,,, +60003080161,,Dongle005,,Dongle005,869555083101702,,1,89860624660030801615,,, +90001595810,,Dongle005,,Dongle005,869555083127665,,1,89860624590015958108,,, +60003081366,,Dongle005,,Dongle005,869555083108509,,1,89860624660030813669,,, +60003081251,,Dongle005,,Dongle005,869555083100134,,1,89860624660030812513,,, +60003081350,,Dongle005,,Dongle005,869555083099575,,1,89860624660030813503,,, +60003080752,,Dongle005,,Dongle005,869555083145261,,1,89860624660030807521,,, +60003081310,,Dongle005,,Dongle005,869555083100308,,1,89860624660030813107,,, +60003080791,,Dongle005,,Dongle005,869555083144884,,1,89860624660030807919,,, +60003081268,,Dongle005,,Dongle005,869555083088545,,1,89860624660030812687,,, diff --git a/internal/application/agentrecharge/approval_decision.go b/internal/application/agentrecharge/approval_decision.go new file mode 100644 index 0000000..e6d3367 --- /dev/null +++ b/internal/application/agentrecharge/approval_decision.go @@ -0,0 +1,119 @@ +package agentrecharge + +import ( + "context" + "strings" + + "gorm.io/gorm" + "gorm.io/gorm/clause" + + approvalapp "github.com/break/junhong_cmp_fiber/internal/application/approval" + walletapp "github.com/break/junhong_cmp_fiber/internal/application/wallet" + "github.com/break/junhong_cmp_fiber/internal/model" + "github.com/break/junhong_cmp_fiber/pkg/constants" + "github.com/break/junhong_cmp_fiber/pkg/errors" +) + +// ApprovalDecisionHandler 将渠道无关审批终态应用到员工线下代充值业务。 +type ApprovalDecisionHandler struct { + db *gorm.DB + posting *walletapp.PostingService +} + +// NewApprovalDecisionHandler 创建员工线下代充值审批终态消费者。 +func NewApprovalDecisionHandler(db *gorm.DB, posting *walletapp.PostingService) *ApprovalDecisionHandler { + return &ApprovalDecisionHandler{db: db, posting: posting} +} + +// Handle 幂等处理标准审批终态;只有 approved 首次入账,其他终态不修改钱包。 +func (h *ApprovalDecisionHandler) Handle(ctx context.Context, event approvalapp.TerminalDecisionEvent) error { + if h == nil || h.db == nil || h.posting == nil { + return errors.New(errors.CodeInternalError, "员工线下代充值审批终态能力未配置") + } + if event.BusinessType != constants.ApprovalBusinessTypeOfflineRecharge || event.BusinessID == 0 || event.InstanceID == 0 { + return errors.New(errors.CodeInvalidParam, "员工线下代充值审批终态参数无效") + } + return h.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + var record model.AgentRechargeRecord + if err := tx.WithContext(ctx).Clauses(clause.Locking{Strength: "UPDATE"}). + Where("id = ? AND payment_method = ?", event.BusinessID, constants.RechargeMethodOffline). + First(&record).Error; err != nil { + if err == gorm.ErrRecordNotFound { + return errors.New(errors.CodeForbidden, "无权限操作该资源或资源不存在") + } + return errors.Wrap(errors.CodeDatabaseError, err, "锁定员工线下代充值申请失败") + } + if record.ApprovalInstanceID == nil || *record.ApprovalInstanceID != event.InstanceID { + return errors.New(errors.CodeConflict, "线下代充值申请关联的审批实例不一致") + } + switch event.Decision { + case constants.ApprovalDecisionApproved: + return h.applyApproved(ctx, tx, &record, event) + case constants.ApprovalDecisionRejected: + return closeOfflineRecharge(ctx, tx, &record, constants.RechargeStatusRejected, "企业微信审批已拒绝") + case constants.ApprovalDecisionCancelled: + return closeOfflineRecharge(ctx, tx, &record, constants.RechargeStatusClosed, "企业微信审批已撤销") + case constants.ApprovalDecisionDeleted: + return closeOfflineRecharge(ctx, tx, &record, constants.RechargeStatusClosed, "企业微信审批已删除") + case constants.ApprovalDecisionRevokedAfterApproved: + return nil + default: + return errors.New(errors.CodeInvalidParam, "不支持的线下代充值审批终态") + } + }) +} + +func (h *ApprovalDecisionHandler) applyApproved( + ctx context.Context, + tx *gorm.DB, + record *model.AgentRechargeRecord, + event approvalapp.TerminalDecisionEvent, +) error { + if record.Status != constants.RechargeStatusPending && record.Status != constants.RechargeStatusCompleted { + return errors.New(errors.CodeInvalidStatus, "线下代充值申请状态不允许审批入账") + } + if record.Status == constants.RechargeStatusPending { + result := tx.WithContext(ctx).Model(&model.AgentRechargeRecord{}). + Where("id = ? AND status = ?", record.ID, constants.RechargeStatusPending). + Updates(map[string]any{ + "status": constants.RechargeStatusCompleted, "paid_at": event.OccurredAt, + "completed_at": event.OccurredAt, "updated_at": event.OccurredAt, + }) + if result.Error != nil { + return errors.Wrap(errors.CodeDatabaseError, result.Error, "完成线下代充值审批申请失败") + } + if result.RowsAffected != 1 { + return errors.New(errors.CodeConflict, "线下代充值申请状态已变化") + } + } + _, err := h.posting.PostInTx(ctx, tx, walletapp.PostingCommand{ + ShopID: record.ShopID, WalletID: record.AgentWalletID, Amount: record.Amount, + ReferenceType: constants.ReferenceTypeTopup, ReferenceID: record.ID, + TransactionType: constants.AgentTransactionTypeRecharge, + UserID: event.SubmitterAccountID, Creator: event.SubmitterAccountID, + Remark: "企业微信审批通过线下充值", CorrelationID: event.CorrelationID, + }) + return err +} + +func closeOfflineRecharge(ctx context.Context, tx *gorm.DB, record *model.AgentRechargeRecord, status int, reason string) error { + if record.Status == status { + return nil + } + if record.Status != constants.RechargeStatusPending { + return errors.New(errors.CodeInvalidStatus, "线下代充值申请状态不允许结束审批") + } + reason = strings.TrimSpace(reason) + result := tx.WithContext(ctx).Model(&model.AgentRechargeRecord{}). + Where("id = ? AND status = ?", record.ID, constants.RechargeStatusPending). + Updates(map[string]any{"status": status, "rejection_reason": reason}) + if result.Error != nil { + return errors.Wrap(errors.CodeDatabaseError, result.Error, "结束线下代充值审批申请失败") + } + if result.RowsAffected != 1 { + return errors.New(errors.CodeConflict, "线下代充值申请状态已变化") + } + return nil +} + +var _ approvalapp.BusinessDecisionHandler = (*ApprovalDecisionHandler)(nil) diff --git a/internal/application/agentrecharge/offline_creation.go b/internal/application/agentrecharge/offline_creation.go new file mode 100644 index 0000000..f76a5a4 --- /dev/null +++ b/internal/application/agentrecharge/offline_creation.go @@ -0,0 +1,188 @@ +// Package agentrecharge 收口员工线下代充值申请和审批终态业务用例。 +package agentrecharge + +import ( + "context" + "fmt" + "strings" + + "github.com/bytedance/sonic" + "gorm.io/gorm" + + approvalapp "github.com/break/junhong_cmp_fiber/internal/application/approval" + "github.com/break/junhong_cmp_fiber/internal/model" + "github.com/break/junhong_cmp_fiber/pkg/constants" + "github.com/break/junhong_cmp_fiber/pkg/errors" +) + +// CreateOfflineCommand 描述员工创建线下代充值审批申请的稳定输入。 +type CreateOfflineCommand struct { + SubmitterAccountID uint + SubmitterUserType int + ShopID uint + RechargeNo string + Amount int64 + PaymentVoucherKeys []string + Remark string +} + +// CreateOfflineResult 返回已原子保存的业务申请和初始审批状态。 +type CreateOfflineResult struct { + Record *model.AgentRechargeRecord + ShopName string + SubmitterName string + ApprovalStatus int +} + +// OfflineCreationService 创建员工线下代充值申请及唯一通用审批实例。 +type OfflineCreationService struct { + db *gorm.DB + approval approvalapp.Port +} + +// NewOfflineCreationService 创建员工线下代充值申请用例。 +func NewOfflineCreationService(db *gorm.DB, approval approvalapp.Port) *OfflineCreationService { + return &OfflineCreationService{db: db, approval: approval} +} + +// Execute 在业务写入前校验审批渠道,并在同一事务保存充值申请、审批实例和提交 Outbox。 +func (s *OfflineCreationService) Execute(ctx context.Context, command CreateOfflineCommand) (*CreateOfflineResult, error) { + if s == nil || s.db == nil || s.approval == nil { + return nil, errors.New(errors.CodeServiceUnavailable, "员工线下代充值审批能力未配置") + } + if err := validateCreateOfflineCommand(command); err != nil { + return nil, err + } + account, shop, wallet, err := s.loadCreationFacts(ctx, command) + if err != nil { + return nil, err + } + preparation, err := s.approval.Prepare(ctx, approvalapp.PrepareRequest{ + BusinessType: constants.ApprovalBusinessTypeOfflineRecharge, SubmitterAccountID: command.SubmitterAccountID, + CorrelationID: strings.TrimSpace(command.RechargeNo), + }) + if err != nil { + return nil, err + } + submitterSnapshot, requestSnapshot, err := offlineApprovalSnapshots(command, account.Username, shop.ShopName) + if err != nil { + return nil, err + } + paymentChannel := constants.RechargeMethodOffline + record := &model.AgentRechargeRecord{ + UserID: command.SubmitterAccountID, AgentWalletID: wallet.ID, ShopID: command.ShopID, + RechargeNo: strings.TrimSpace(command.RechargeNo), Amount: command.Amount, + PaymentMethod: constants.RechargeMethodOffline, PaymentChannel: &paymentChannel, + PaymentVoucherKey: model.StringJSONBArray(command.PaymentVoucherKeys), Remark: strings.TrimSpace(command.Remark), + Status: constants.RechargeStatusPending, ShopIDTag: wallet.ShopIDTag, EnterpriseIDTag: wallet.EnterpriseIDTag, + } + var approvalStatus int + err = s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + if err := tx.WithContext(ctx).Create(record).Error; err != nil { + return errors.Wrap(errors.CodeDatabaseError, err, "创建员工线下代充值申请失败") + } + reference, err := s.approval.CreateInTx(ctx, tx, approvalapp.CreateRequest{ + Preparation: preparation, BusinessType: constants.ApprovalBusinessTypeOfflineRecharge, + BusinessID: record.ID, SubmitterAccountID: command.SubmitterAccountID, + SubmitterSnapshot: submitterSnapshot, RequestSnapshot: requestSnapshot, + CorrelationID: record.RechargeNo, + }) + if err != nil { + return err + } + result := tx.WithContext(ctx).Model(&model.AgentRechargeRecord{}). + Where("id = ? AND approval_instance_id IS NULL", record.ID). + Update("approval_instance_id", reference.InstanceID) + if result.Error != nil { + return errors.Wrap(errors.CodeDatabaseError, result.Error, "关联员工线下代充值审批实例失败") + } + if result.RowsAffected != 1 { + return errors.New(errors.CodeConflict, "员工线下代充值审批实例关联已变化") + } + record.ApprovalInstanceID = &reference.InstanceID + approvalStatus = reference.Status + return nil + }) + if err != nil { + return nil, err + } + return &CreateOfflineResult{ + Record: record, ShopName: shop.ShopName, SubmitterName: account.Username, ApprovalStatus: approvalStatus, + }, nil +} + +func validateCreateOfflineCommand(command CreateOfflineCommand) error { + if command.SubmitterUserType != constants.UserTypePlatform && command.SubmitterUserType != constants.UserTypeSuperAdmin { + return errors.New(errors.CodeForbidden, "线下充值仅平台管理员可操作") + } + if command.SubmitterAccountID == 0 || command.ShopID == 0 || strings.TrimSpace(command.RechargeNo) == "" { + return errors.New(errors.CodeInvalidParam) + } + if command.Amount < constants.AgentRechargeMinAmount || command.Amount > constants.AgentRechargeMaxAmount { + return errors.New(errors.CodeInvalidParam, "充值金额超出允许范围") + } + if len(command.PaymentVoucherKeys) == 0 || len(command.PaymentVoucherKeys) > 5 { + return errors.New(errors.CodeInvalidParam, "线下充值必须上传 1 至 5 个支付凭证") + } + for _, key := range command.PaymentVoucherKeys { + if strings.TrimSpace(key) == "" { + return errors.New(errors.CodeInvalidParam, "线下充值支付凭证不能为空") + } + } + return nil +} + +func (s *OfflineCreationService) loadCreationFacts( + ctx context.Context, + command CreateOfflineCommand, +) (*model.Account, *model.Shop, *model.AgentWallet, error) { + var account model.Account + if err := s.db.WithContext(ctx).Where("id = ? AND status = ?", command.SubmitterAccountID, constants.StatusEnabled).First(&account).Error; err != nil { + if err == gorm.ErrRecordNotFound { + return nil, nil, nil, errors.New(errors.CodeForbidden, "提交人账号不可用") + } + return nil, nil, nil, errors.Wrap(errors.CodeDatabaseError, err, "查询线下代充值提交人失败") + } + var shop model.Shop + if err := s.db.WithContext(ctx).Where("id = ?", command.ShopID).First(&shop).Error; err != nil { + if err == gorm.ErrRecordNotFound { + return nil, nil, nil, errors.New(errors.CodeNotFound, "目标店铺不存在") + } + return nil, nil, nil, errors.Wrap(errors.CodeDatabaseError, err, "查询目标店铺失败") + } + var wallet model.AgentWallet + if err := s.db.WithContext(ctx). + Where("shop_id = ? AND wallet_type = ? AND status = ?", command.ShopID, constants.AgentWalletTypeMain, constants.AgentWalletStatusNormal). + First(&wallet).Error; err != nil { + if err == gorm.ErrRecordNotFound { + return nil, nil, nil, errors.New(errors.CodeWalletNotFound, "目标店铺主钱包不存在或不可用") + } + return nil, nil, nil, errors.Wrap(errors.CodeDatabaseError, err, "查询目标店铺主钱包失败") + } + return &account, &shop, &wallet, nil +} + +func offlineApprovalSnapshots(command CreateOfflineCommand, submitterName, shopName string) ([]byte, []byte, error) { + submitterSnapshot, err := sonic.Marshal(map[string]any{ + "account_id": command.SubmitterAccountID, "account_name": submitterName, + "user_type": command.SubmitterUserType, + }) + if err != nil { + return nil, nil, errors.Wrap(errors.CodeInternalError, err, "编码线下代充值提交人快照失败") + } + requestSnapshot, err := sonic.Marshal(map[string]any{ + constants.ApprovalFieldRechargeNo: strings.TrimSpace(command.RechargeNo), + constants.ApprovalFieldShopID: command.ShopID, + constants.ApprovalFieldShopName: shopName, + constants.ApprovalFieldAmount: fmt.Sprintf("%d.%02d", command.Amount/100, command.Amount%100), + constants.ApprovalFieldAmountCent: command.Amount, + constants.ApprovalFieldPaymentVoucherKey: command.PaymentVoucherKeys, + constants.ApprovalFieldRemark: strings.TrimSpace(command.Remark), + constants.ApprovalFieldSubmitterID: command.SubmitterAccountID, + constants.ApprovalFieldSubmitterName: submitterName, + }) + if err != nil { + return nil, nil, errors.Wrap(errors.CodeInternalError, err, "编码线下代充值审批业务快照失败") + } + return submitterSnapshot, requestSnapshot, nil +} diff --git a/internal/application/exchange/shipping_notification.go b/internal/application/exchange/shipping_notification.go new file mode 100644 index 0000000..97e17ae --- /dev/null +++ b/internal/application/exchange/shipping_notification.go @@ -0,0 +1,49 @@ +// Package exchange 提供换货用例的可靠通知编排。 +package exchange + +import ( + "context" + + "gorm.io/gorm" + + "github.com/break/junhong_cmp_fiber/pkg/errors" +) + +// ShippingCreatedEvent 是物流换货单创建后通知个人客户的稳定业务事件。 +type ShippingCreatedEvent struct { + ExchangeID uint + ExchangeNo string + CustomerID uint + AssetType string + AssetID uint + AssetIdentifier string + RequestID string + CorrelationID string +} + +// ShippingCreatedEventWriter 将物流换货创建通知写入可靠事件基础设施。 +type ShippingCreatedEventWriter interface { + Append(ctx context.Context, tx *gorm.DB, event ShippingCreatedEvent) error +} + +// ShippingCreatedNotifier 在换货业务事务内编排个人客户通知事件。 +type ShippingCreatedNotifier struct { + writer ShippingCreatedEventWriter +} + +// NewShippingCreatedNotifier 创建物流换货通知用例。 +func NewShippingCreatedNotifier(writer ShippingCreatedEventWriter) *ShippingCreatedNotifier { + return &ShippingCreatedNotifier{writer: writer} +} + +// Notify 在换货单事务内追加指定个人客户的可靠通知事件。 +func (n *ShippingCreatedNotifier) Notify(ctx context.Context, tx *gorm.DB, event ShippingCreatedEvent) error { + if n == nil || n.writer == nil { + return errors.New(errors.CodeInternalError, "物流换货通知事件 Writer 未配置") + } + if tx == nil || event.ExchangeID == 0 || event.ExchangeNo == "" || event.CustomerID == 0 || + event.AssetType == "" || event.AssetID == 0 || event.AssetIdentifier == "" { + return errors.New(errors.CodeInvalidParam, "物流换货通知事件参数不完整") + } + return n.writer.Append(ctx, tx, event) +} diff --git a/internal/application/notification/read.go b/internal/application/notification/read.go index d0ebaac..9c50ddb 100644 --- a/internal/application/notification/read.go +++ b/internal/application/notification/read.go @@ -103,8 +103,8 @@ func personalReadScope(db *gorm.DB, customerID uint, now time.Time) *gorm.DB { AND category IN ? AND type IN ? AND (expires_at IS NULL OR expires_at > ?)`, constants.NotificationRecipientKindPersonalCustomer, customerID, - []string{constants.NotificationCategoryApproval, constants.NotificationCategoryExpiry}, - []string{constants.NotificationTypePackageExpiring}, + []string{constants.NotificationCategoryApproval, constants.NotificationCategoryExpiry, constants.NotificationCategorySystem}, + []string{constants.NotificationTypePackageExpiring, constants.NotificationTypeExchangeShippingCreated}, now, ) } diff --git a/internal/application/packageexpiry/reminder.go b/internal/application/packageexpiry/reminder.go new file mode 100644 index 0000000..fc9a245 --- /dev/null +++ b/internal/application/packageexpiry/reminder.go @@ -0,0 +1,45 @@ +// Package packageexpiry 编排套餐临期节点提醒用例。 +package packageexpiry + +import ( + "context" + + "github.com/break/junhong_cmp_fiber/internal/model/dto" + "github.com/break/junhong_cmp_fiber/pkg/errors" +) + +// ReminderScanner 查询当天命中临期节点的资产。 +type ReminderScanner interface { + ReminderCandidates(ctx context.Context) ([]dto.ExpiringAssetItem, error) +} + +// ReminderPublisher 批量发布个人客户临期通知。 +type ReminderPublisher interface { + Publish(ctx context.Context, candidates []dto.ExpiringAssetItem) error +} + +// ReminderService 扫描并发布每日套餐临期节点提醒。 +type ReminderService struct { + scanner ReminderScanner + publisher ReminderPublisher +} + +// NewReminderService 创建套餐临期提醒用例。 +func NewReminderService(scanner ReminderScanner, publisher ReminderPublisher) *ReminderService { + return &ReminderService{scanner: scanner, publisher: publisher} +} + +// Run 执行当天 15、7、3 天节点扫描并可靠发布通知。 +func (s *ReminderService) Run(ctx context.Context) error { + if s == nil || s.scanner == nil || s.publisher == nil { + return errors.New(errors.CodeInternalError, "套餐临期提醒用例未配置") + } + candidates, err := s.scanner.ReminderCandidates(ctx) + if err != nil { + return err + } + if len(candidates) == 0 { + return nil + } + return s.publisher.Publish(ctx, candidates) +} diff --git a/internal/application/refundapproval/creation.go b/internal/application/refundapproval/creation.go new file mode 100644 index 0000000..a77155e --- /dev/null +++ b/internal/application/refundapproval/creation.go @@ -0,0 +1,151 @@ +// Package refundapproval 收口退款申请与渠道无关审批的事务边界。 +package refundapproval + +import ( + "context" + "fmt" + "strings" + + "github.com/bytedance/sonic" + "gorm.io/gorm" + + approvalapp "github.com/break/junhong_cmp_fiber/internal/application/approval" + "github.com/break/junhong_cmp_fiber/internal/model" + "github.com/break/junhong_cmp_fiber/pkg/constants" + "github.com/break/junhong_cmp_fiber/pkg/errors" +) + +// CreateCommand 描述已通过订单与金额校验的退款审批申请。 +type CreateCommand struct { + Refund *model.RefundRequest + SubmitterAccountID uint +} + +// CreateResult 返回原子保存后的退款申请和初始审批状态。 +type CreateResult struct { + Refund *model.RefundRequest + SubmitterName string + ApprovalStatus int +} + +// CreationService 原子创建退款申请、通用审批实例、企微上下文和提交 Outbox。 +type CreationService struct { + db *gorm.DB + approval approvalapp.Port +} + +// NewCreationService 创建退款审批申请用例。 +func NewCreationService(db *gorm.DB, approval approvalapp.Port) *CreationService { + return &CreationService{db: db, approval: approval} +} + +// Execute 在业务写入前校验审批渠道,并在同一事务冻结退款事实和审批事实。 +func (s *CreationService) Execute(ctx context.Context, command CreateCommand) (*CreateResult, error) { + if s == nil || s.db == nil || s.approval == nil { + return nil, errors.New(errors.CodeServiceUnavailable, "退款审批能力未配置") + } + if command.Refund == nil || command.Refund.OrderID == 0 || command.SubmitterAccountID == 0 || + command.Refund.Creator != command.SubmitterAccountID || strings.TrimSpace(command.Refund.RefundNo) == "" { + return nil, errors.New(errors.CodeInvalidParam) + } + account, err := s.loadSubmitter(ctx, command.SubmitterAccountID) + if err != nil { + return nil, err + } + preparation, err := s.approval.Prepare(ctx, approvalapp.PrepareRequest{ + BusinessType: constants.ApprovalBusinessTypeRefund, SubmitterAccountID: command.SubmitterAccountID, + CorrelationID: command.Refund.RefundNo, + }) + if err != nil { + return nil, err + } + submitterSnapshot, requestSnapshot, err := refundSnapshots(command.Refund, account) + if err != nil { + return nil, err + } + var approvalStatus int + err = s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + if err := tx.Exec("SELECT pg_advisory_xact_lock(?)", int64(command.Refund.OrderID)).Error; err != nil { + return errors.Wrap(errors.CodeDatabaseError, err, "锁定退款订单申请边界失败") + } + var activeCount int64 + if err := tx.WithContext(ctx).Model(&model.RefundRequest{}). + Where("order_id = ? AND status IN ?", command.Refund.OrderID, []int{model.RefundStatusPending, model.RefundStatusApproved}). + Count(&activeCount).Error; err != nil { + return errors.Wrap(errors.CodeDatabaseError, err, "复核订单活跃退款申请失败") + } + if activeCount > 0 { + return errors.New(errors.CodeConflict, "该订单已存在退款申请") + } + if err := tx.WithContext(ctx).Create(command.Refund).Error; err != nil { + return errors.Wrap(errors.CodeDatabaseError, err, "创建退款申请失败") + } + reference, err := s.approval.CreateInTx(ctx, tx, approvalapp.CreateRequest{ + Preparation: preparation, BusinessType: constants.ApprovalBusinessTypeRefund, + BusinessID: command.Refund.ID, SubmitterAccountID: command.SubmitterAccountID, + SubmitterSnapshot: submitterSnapshot, RequestSnapshot: requestSnapshot, + CorrelationID: command.Refund.RefundNo, + }) + if err != nil { + return err + } + result := tx.WithContext(ctx).Model(&model.RefundRequest{}). + Where("id = ? AND approval_instance_id IS NULL", command.Refund.ID). + Update("approval_instance_id", reference.InstanceID) + if result.Error != nil { + return errors.Wrap(errors.CodeDatabaseError, result.Error, "关联退款审批实例失败") + } + if result.RowsAffected != 1 { + return errors.New(errors.CodeConflict, "退款审批实例关联已变化") + } + command.Refund.ApprovalInstanceID = &reference.InstanceID + approvalStatus = reference.Status + return nil + }) + if err != nil { + return nil, err + } + return &CreateResult{Refund: command.Refund, SubmitterName: account.Username, ApprovalStatus: approvalStatus}, nil +} + +func (s *CreationService) loadSubmitter(ctx context.Context, accountID uint) (*model.Account, error) { + var account model.Account + if err := s.db.WithContext(ctx).Where("id = ? AND status = ?", accountID, constants.StatusEnabled).First(&account).Error; err != nil { + if err == gorm.ErrRecordNotFound { + return nil, errors.New(errors.CodeForbidden, "退款提交人账号不可用") + } + return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询退款提交人失败") + } + return &account, nil +} + +func refundSnapshots(refund *model.RefundRequest, account *model.Account) ([]byte, []byte, error) { + submitterSnapshot, err := sonic.Marshal(map[string]any{ + "account_id": account.ID, "account_name": account.Username, "user_type": account.UserType, + }) + if err != nil { + return nil, nil, errors.Wrap(errors.CodeInternalError, err, "编码退款提交人快照失败") + } + requestSnapshot, err := sonic.Marshal(map[string]any{ + constants.ApprovalFieldRefundNo: refund.RefundNo, + constants.ApprovalFieldOrderID: refund.OrderID, + constants.ApprovalFieldOrderNo: refund.OrderNo, + constants.ApprovalFieldAssetIdentifier: refund.AssetIdentifier, + constants.ApprovalFieldAssetType: refund.OrderType, + constants.ApprovalFieldActualReceivedAmount: formatCentAmount(refund.ActualReceivedAmount), + constants.ApprovalFieldRequestedRefundAmount: formatCentAmount(refund.RequestedRefundAmount), + constants.ApprovalFieldRefundVoucherKey: []string(refund.RefundVoucherKey), + constants.ApprovalFieldRefundReason: refund.RefundReason, + constants.ApprovalFieldPackageUsageID: refund.PackageUsageID, + constants.ApprovalFieldSubmitterID: account.ID, + constants.ApprovalFieldSubmitterName: account.Username, + }) + if err != nil { + return nil, nil, errors.Wrap(errors.CodeInternalError, err, "编码退款审批业务快照失败") + } + return submitterSnapshot, requestSnapshot, nil +} + +func formatCentAmount(amount int64) string { + return fmt.Sprintf("%d.%02d", amount/100, amount%100) +} diff --git a/internal/application/shop/create.go b/internal/application/shop/create.go index a9e423b..dd86562 100644 --- a/internal/application/shop/create.go +++ b/internal/application/shop/create.go @@ -219,7 +219,8 @@ func newShopResponse(shop *model.Shop, parentName string) *dto.ShopResponse { ParentShopName: parentName, Level: shop.Level, ContactName: shop.ContactName, ContactPhone: shop.ContactPhone, Province: shop.Province, City: shop.City, District: shop.District, Address: shop.Address, Status: shop.Status, - StatusName: constants.GetStatusName(shop.Status), CreatedAt: shop.CreatedAt.Format("2006-01-02 15:04:05"), + ClientLoginDisabled: shop.ClientLoginDisabled, + StatusName: constants.GetStatusName(shop.Status), CreatedAt: shop.CreatedAt.Format("2006-01-02 15:04:05"), UpdatedAt: shop.UpdatedAt.Format("2006-01-02 15:04:05"), } } diff --git a/internal/application/shop/update.go b/internal/application/shop/update.go index 86e892f..e2f60ea 100644 --- a/internal/application/shop/update.go +++ b/internal/application/shop/update.go @@ -38,6 +38,9 @@ func (s *UpdateService) Update(ctx context.Context, shopID uint, request *dto.Up if userType == constants.UserTypeAgent && request.BusinessOwnerAccountIDSet { return nil, errors.New(errors.CodeForbidden, "无权限设置店铺业务员") } + if userType == constants.UserTypeAgent && request.ClientLoginDisabled != nil && middleware.GetShopIDFromContext(ctx) != shopID { + return nil, errors.New(errors.CodeForbidden, "无权限修改其他店铺的C端登录限制") + } if userType != constants.UserTypeSuperAdmin && userType != constants.UserTypePlatform && userType != constants.UserTypeAgent { return nil, errors.New(errors.CodeForbidden, "无权限操作该资源或资源不存在") } @@ -59,6 +62,9 @@ func (s *UpdateService) Update(ctx context.Context, shopID uint, request *dto.Up } shop.BusinessOwnerAccountID = ownerID } + if request.ClientLoginDisabled != nil { + shop.ClientLoginDisabled = *request.ClientLoginDisabled + } shop.ShopName = request.ShopName shop.ContactName = request.ContactName shop.ContactPhone = request.ContactPhone diff --git a/internal/application/wecom/connection.go b/internal/application/wecom/connection.go new file mode 100644 index 0000000..7a72b46 --- /dev/null +++ b/internal/application/wecom/connection.go @@ -0,0 +1,303 @@ +// Package wecom 提供企业微信配置简单写与连接测试用例。 +package wecom + +import ( + "context" + stdErrors "errors" + "fmt" + "time" + + "gorm.io/gorm" + + systemconfigapp "github.com/break/junhong_cmp_fiber/internal/application/systemconfig" + "github.com/break/junhong_cmp_fiber/internal/model" + "github.com/break/junhong_cmp_fiber/internal/model/dto" + "github.com/break/junhong_cmp_fiber/pkg/constants" + "github.com/break/junhong_cmp_fiber/pkg/errors" + "github.com/break/junhong_cmp_fiber/pkg/middleware" +) + +// ApplicationRepository 定义企业微信应用配置持久化边界。 +type ApplicationRepository interface { + FindByIdentityForUpdate(ctx context.Context, tx *gorm.DB, corpID string, agentID int64) (*model.WeComApplication, error) + Create(ctx context.Context, tx *gorm.DB, application *model.WeComApplication) error + Update(ctx context.Context, tx *gorm.DB, application *model.WeComApplication) error + List(ctx context.Context, page, pageSize int) ([]model.WeComApplication, int64, error) + GetEnabled(ctx context.Context, applicationID uint) (*model.WeComApplication, error) + UpdateDefaultCreator(ctx context.Context, tx *gorm.DB, applicationID uint, userID, name string, operatorID uint, updatedAt time.Time) error +} + +// DefaultCreatorMemberFinder 定义默认审批发起人的可见成员查询边界。 +type DefaultCreatorMemberFinder interface { + GetVisible(ctx context.Context, applicationID uint, userID string) (*model.WeComMember, error) +} + +// CredentialCipher 定义企业微信敏感凭据加密边界。 +type CredentialCipher interface { + Encrypt(plaintext string) ([]byte, error) + Decrypt(ciphertext []byte) (string, error) +} + +// AccessTokenProvider 定义按应用取得及失效 access_token 的边界。 +type AccessTokenProvider interface { + GetAccessToken(ctx context.Context, applicationID uint) (string, error) + Invalidate(ctx context.Context, applicationID uint) +} + +// ConnectionService 保存加密配置并测试企业微信连接。 +type ConnectionService struct { + db *gorm.DB + repo ApplicationRepository + cipher CredentialCipher + tokens AccessTokenProvider + audit systemconfigapp.AuditWriter + members DefaultCreatorMemberFinder + now func() time.Time +} + +// SetDefaultCreatorMemberFinder 注入默认审批发起人的可见成员查询边界。 +func (s *ConnectionService) SetDefaultCreatorMemberFinder(finder DefaultCreatorMemberFinder) { + s.members = finder +} + +// NewConnectionService 创建企业微信连接用例。 +func NewConnectionService(db *gorm.DB, repo ApplicationRepository, cipher CredentialCipher, tokens AccessTokenProvider, audit systemconfigapp.AuditWriter) *ConnectionService { + return &ConnectionService{db: db, repo: repo, cipher: cipher, tokens: tokens, audit: audit, now: time.Now} +} + +// Save 创建或更新企业微信应用,并保证数据库仅保存密文凭据。 +func (s *ConnectionService) Save(ctx context.Context, request dto.SaveWeComApplicationRequest) (*dto.WeComApplicationResponse, error) { + if s == nil || s.db == nil || s.repo == nil || s.cipher == nil { + return nil, errors.New(errors.CodeWeComCredentialInvalid) + } + if middleware.GetUserTypeFromContext(ctx) != constants.UserTypeSuperAdmin { + return nil, errors.New(errors.CodeForbidden) + } + operatorID := middleware.GetUserIDFromContext(ctx) + if operatorID == 0 { + return nil, errors.New(errors.CodeInvalidParam) + } + secret, err := s.cipher.Encrypt(request.Secret) + if err != nil { + return nil, err + } + callbackToken, err := s.cipher.Encrypt(request.CallbackToken) + if err != nil { + return nil, err + } + encodingAESKey, err := s.cipher.Encrypt(request.EncodingAESKey) + if err != nil { + return nil, err + } + now := s.now().UTC() + var saved *model.WeComApplication + err = s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + if err := tx.Exec("SELECT pg_advisory_xact_lock(hashtext(?))", fmt.Sprintf("wecom:%s:%d", request.CorpID, request.AgentID)).Error; err != nil { + return err + } + existing, findErr := s.repo.FindByIdentityForUpdate(ctx, tx, request.CorpID, request.AgentID) + if findErr != nil { + return findErr + } + before := map[string]any{"configured": false} + if existing == nil { + existing = &model.WeComApplication{ + Model: gorm.Model{CreatedAt: now}, + CorpID: request.CorpID, + AgentID: request.AgentID, + CreatedBy: operatorID, + } + } else { + before = applicationAuditSnapshot(existing) + } + existing.Name = request.Name + existing.SecretCiphertext = secret + existing.CallbackTokenCiphertext = callbackToken + existing.EncodingAESKeyCiphertext = encodingAESKey + existing.Status = request.Status + existing.UpdatedBy = operatorID + existing.UpdatedAt = now + if existing.ID == 0 { + if err := s.repo.Create(ctx, tx, existing); err != nil { + return err + } + } else if err := s.repo.Update(ctx, tx, existing); err != nil { + return err + } + if s.audit != nil { + requestID := "" + if value := middleware.GetRequestIDFromContext(ctx); value != nil { + requestID = *value + } + if err := s.audit.WriteConfigChange(ctx, tx, systemconfigapp.ChangeAudit{ + OperatorID: operatorID, OperationType: "wecom_application_save", Description: "保存企业微信应用安全配置", + ConfigKey: fmt.Sprintf("wecom.application.%d", existing.ID), BeforeData: before, + AfterData: applicationAuditSnapshot(existing), RequestID: requestID, CorrelationID: requestID, + }); err != nil { + return err + } + } + saved = existing + return nil + }) + if err != nil { + var appErr *errors.AppError + if stdErrors.As(err, &appErr) { + return nil, appErr + } + return nil, errors.Wrap(errors.CodeDatabaseError, err, "保存企业微信应用配置失败") + } + if s.tokens != nil { + s.tokens.Invalidate(ctx, saved.ID) + } + response := toApplicationResponse(*saved, request.Secret, request.CallbackToken, request.EncodingAESKey) + return &response, nil +} + +// List 返回企业微信应用列表,并向超级管理员返回可直接编辑的明文凭据。 +func (s *ConnectionService) List(ctx context.Context, request dto.WeComApplicationListRequest) (*dto.WeComApplicationListResponse, error) { + if s == nil || s.repo == nil || s.cipher == nil { + return nil, errors.New(errors.CodeWeComCredentialInvalid) + } + if middleware.GetUserTypeFromContext(ctx) != constants.UserTypeSuperAdmin { + return nil, errors.New(errors.CodeForbidden) + } + if request.Page <= 0 { + request.Page = constants.DefaultPage + } + if request.PageSize <= 0 { + request.PageSize = constants.DefaultPageSize + } + if request.PageSize > constants.MaxPageSize { + return nil, errors.New(errors.CodeInvalidParam) + } + applications, total, err := s.repo.List(ctx, request.Page, request.PageSize) + if err != nil { + return nil, err + } + result := make([]dto.WeComApplicationResponse, 0, len(applications)) + for _, application := range applications { + secret, err := s.cipher.Decrypt(application.SecretCiphertext) + if err != nil { + return nil, err + } + callbackToken, err := s.cipher.Decrypt(application.CallbackTokenCiphertext) + if err != nil { + return nil, err + } + encodingAESKey, err := s.cipher.Decrypt(application.EncodingAESKeyCiphertext) + if err != nil { + return nil, err + } + result = append(result, toApplicationResponse(application, secret, callbackToken, encodingAESKey)) + } + return &dto.WeComApplicationListResponse{ + Items: result, Total: total, Page: request.Page, PageSize: request.PageSize, + }, nil +} + +// Test 强制失效旧缓存后取得一次 access_token,但绝不向调用方返回 token。 +func (s *ConnectionService) Test(ctx context.Context, applicationID uint) error { + if s == nil { + return errors.New(errors.CodeServiceUnavailable, "企业微信连接服务未配置") + } + if middleware.GetUserTypeFromContext(ctx) != constants.UserTypeSuperAdmin { + return errors.New(errors.CodeForbidden) + } + if s.tokens == nil { + return errors.New(errors.CodeServiceUnavailable, "企业微信连接服务未配置") + } + s.tokens.Invalidate(ctx, applicationID) + _, err := s.tokens.GetAccessToken(ctx, applicationID) + return err +} + +// SaveDefaultCreator 从应用当前可见成员中保存代理等账号使用的默认审批发起人。 +func (s *ConnectionService) SaveDefaultCreator(ctx context.Context, applicationID uint, request dto.SaveWeComDefaultCreatorRequest) (*dto.WeComApplicationResponse, error) { + if s == nil || s.db == nil || s.repo == nil || s.cipher == nil || s.members == nil { + return nil, errors.New(errors.CodeServiceUnavailable, "企业微信默认审批发起人服务未配置") + } + if middleware.GetUserTypeFromContext(ctx) != constants.UserTypeSuperAdmin { + return nil, errors.New(errors.CodeForbidden) + } + operatorID := middleware.GetUserIDFromContext(ctx) + if applicationID == 0 || operatorID == 0 { + return nil, errors.New(errors.CodeInvalidParam) + } + application, err := s.repo.GetEnabled(ctx, applicationID) + if err != nil { + return nil, err + } + member, err := s.members.GetVisible(ctx, applicationID, request.UserID) + if err != nil { + return nil, err + } + now := s.now().UTC() + err = s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + before := applicationAuditSnapshot(application) + if err := s.repo.UpdateDefaultCreator(ctx, tx, applicationID, member.UserID, member.Name, operatorID, now); err != nil { + return err + } + application.DefaultCreatorUserID = member.UserID + application.DefaultCreatorName = member.Name + application.UpdatedBy = operatorID + application.UpdatedAt = now + if s.audit != nil { + requestID := "" + if value := middleware.GetRequestIDFromContext(ctx); value != nil { + requestID = *value + } + return s.audit.WriteConfigChange(ctx, tx, systemconfigapp.ChangeAudit{ + OperatorID: operatorID, OperationType: "wecom_default_creator_save", Description: "保存企业微信默认审批发起人", + ConfigKey: fmt.Sprintf("wecom.application.%d.default_creator", applicationID), BeforeData: before, + AfterData: applicationAuditSnapshot(application), RequestID: requestID, CorrelationID: requestID, + }) + } + return nil + }) + if err != nil { + var appErr *errors.AppError + if stdErrors.As(err, &appErr) { + return nil, appErr + } + return nil, errors.Wrap(errors.CodeDatabaseError, err, "保存企业微信默认审批发起人失败") + } + secret, err := s.cipher.Decrypt(application.SecretCiphertext) + if err != nil { + return nil, err + } + callbackToken, err := s.cipher.Decrypt(application.CallbackTokenCiphertext) + if err != nil { + return nil, err + } + encodingAESKey, err := s.cipher.Decrypt(application.EncodingAESKeyCiphertext) + if err != nil { + return nil, err + } + response := toApplicationResponse(*application, secret, callbackToken, encodingAESKey) + return &response, nil +} + +func applicationAuditSnapshot(application *model.WeComApplication) map[string]any { + return map[string]any{ + "id": application.ID, "corp_id": application.CorpID, "agent_id": application.AgentID, + "name": application.Name, "status": application.Status, "credentials_configured": true, + "default_creator_userid": application.DefaultCreatorUserID, + "default_creator_name": application.DefaultCreatorName, + } +} + +func toApplicationResponse(application model.WeComApplication, secret, callbackToken, encodingAESKey string) dto.WeComApplicationResponse { + statusName := "禁用" + if application.Status == constants.StatusEnabled { + statusName = "启用" + } + return dto.WeComApplicationResponse{ + ID: application.ID, CorpID: application.CorpID, AgentID: application.AgentID, Name: application.Name, + Secret: secret, CallbackToken: callbackToken, EncodingAESKey: encodingAESKey, + DefaultCreatorUserID: application.DefaultCreatorUserID, DefaultCreatorName: application.DefaultCreatorName, + Status: application.Status, StatusName: statusName, + CredentialsSet: len(application.SecretCiphertext) > 0 && len(application.CallbackTokenCiphertext) > 0 && len(application.EncodingAESKeyCiphertext) > 0, + LastConnectedAt: application.LastConnectedAt, CreatedAt: application.CreatedAt, UpdatedAt: application.UpdatedAt, + } +} diff --git a/internal/application/wecom/directory.go b/internal/application/wecom/directory.go new file mode 100644 index 0000000..a0023aa --- /dev/null +++ b/internal/application/wecom/directory.go @@ -0,0 +1,129 @@ +package wecom + +import ( + "context" + "time" + + "github.com/bytedance/sonic" + + "github.com/break/junhong_cmp_fiber/internal/model" + "github.com/break/junhong_cmp_fiber/internal/model/dto" + "github.com/break/junhong_cmp_fiber/pkg/constants" + "github.com/break/junhong_cmp_fiber/pkg/errors" + "github.com/break/junhong_cmp_fiber/pkg/middleware" +) + +// DirectoryMember 是企微 Adapter 交给 Application 的最小成员快照。 +type DirectoryMember struct { + UserID string + Name string + DepartmentIDs []int64 +} + +// DirectoryProvider 定义拉取应用可见成员的外部端口。 +type DirectoryProvider interface { + ListVisibleMembers(ctx context.Context, applicationID uint) ([]DirectoryMember, error) +} + +// MemberRepository 定义可见成员快照同步和分页查询边界。 +type MemberRepository interface { + ReplaceVisible(ctx context.Context, applicationID uint, members []model.WeComMember, syncedAt time.Time) error + ListVisible(ctx context.Context, applicationID uint, page, pageSize int, keyword string) ([]model.WeComMember, int64, error) +} + +// DirectoryService 同步并分页查询企业微信应用可见成员。 +type DirectoryService struct { + applications interface { + GetEnabled(ctx context.Context, applicationID uint) (*model.WeComApplication, error) + } + provider DirectoryProvider + members MemberRepository + now func() time.Time +} + +// NewDirectoryService 创建企业微信通讯录同步用例。 +func NewDirectoryService(applications interface { + GetEnabled(ctx context.Context, applicationID uint) (*model.WeComApplication, error) +}, provider DirectoryProvider, members MemberRepository) *DirectoryService { + return &DirectoryService{applications: applications, provider: provider, members: members, now: time.Now} +} + +// Sync 拉取并替换指定应用当前可见成员快照。 +func (s *DirectoryService) Sync(ctx context.Context, applicationID uint) (*dto.WeComMemberSyncResponse, error) { + if s == nil || s.applications == nil || s.provider == nil || s.members == nil || applicationID == 0 { + return nil, errors.New(errors.CodeServiceUnavailable, "企业微信通讯录服务未配置") + } + if !canManageWeComDirectory(ctx) { + return nil, errors.New(errors.CodeForbidden) + } + application, err := s.applications.GetEnabled(ctx, applicationID) + if err != nil { + return nil, err + } + remoteMembers, err := s.provider.ListVisibleMembers(ctx, applicationID) + if err != nil { + return nil, err + } + syncedAt := s.now().UTC() + members := make([]model.WeComMember, 0, len(remoteMembers)) + for _, member := range remoteMembers { + departments, err := sonic.Marshal(member.DepartmentIDs) + if err != nil { + return nil, errors.Wrap(errors.CodeInternalError, err, "序列化企业微信成员部门失败") + } + members = append(members, model.WeComMember{ + ApplicationID: applicationID, CorpID: application.CorpID, UserID: member.UserID, + Name: member.Name, DepartmentIDs: departments, Visible: true, SyncedAt: syncedAt, + CreatedAt: syncedAt, UpdatedAt: syncedAt, + }) + } + if err := s.members.ReplaceVisible(ctx, applicationID, members, syncedAt); err != nil { + return nil, err + } + return &dto.WeComMemberSyncResponse{ApplicationID: applicationID, SyncedCount: len(members), SyncedAt: syncedAt}, nil +} + +// List 分页返回本地最近一次同步的应用可见成员。 +func (s *DirectoryService) List(ctx context.Context, applicationID uint, request dto.WeComMemberListRequest) (*dto.WeComMemberListResponse, error) { + if s == nil || s.applications == nil || s.members == nil || applicationID == 0 { + return nil, errors.New(errors.CodeServiceUnavailable, "企业微信通讯录服务未配置") + } + if !canManageWeComDirectory(ctx) { + return nil, errors.New(errors.CodeForbidden) + } + if request.Page <= 0 { + request.Page = constants.DefaultPage + } + if request.PageSize <= 0 { + request.PageSize = constants.DefaultPageSize + } + if request.PageSize > constants.MaxPageSize { + return nil, errors.New(errors.CodeInvalidParam) + } + if _, err := s.applications.GetEnabled(ctx, applicationID); err != nil { + return nil, err + } + members, total, err := s.members.ListVisible(ctx, applicationID, request.Page, request.PageSize, request.Keyword) + if err != nil { + return nil, err + } + items := make([]dto.WeComMemberResponse, 0, len(members)) + for _, member := range members { + var departmentIDs []int64 + if len(member.DepartmentIDs) > 0 { + if err := sonic.Unmarshal(member.DepartmentIDs, &departmentIDs); err != nil { + return nil, errors.Wrap(errors.CodeInternalError, err, "解析企业微信成员部门失败") + } + } + items = append(items, dto.WeComMemberResponse{ + ApplicationID: member.ApplicationID, CorpID: member.CorpID, UserID: member.UserID, + Name: member.Name, DepartmentIDs: departmentIDs, SyncedAt: member.SyncedAt, + }) + } + return &dto.WeComMemberListResponse{Items: items, Total: total, Page: request.Page, PageSize: request.PageSize}, nil +} + +func canManageWeComDirectory(ctx context.Context) bool { + userType := middleware.GetUserTypeFromContext(ctx) + return userType == constants.UserTypeSuperAdmin || userType == constants.UserTypePlatform +} diff --git a/internal/application/wecom/scene.go b/internal/application/wecom/scene.go new file mode 100644 index 0000000..435a8b2 --- /dev/null +++ b/internal/application/wecom/scene.go @@ -0,0 +1,322 @@ +package wecom + +import ( + "context" + "crypto/sha256" + "encoding/hex" + stdErrors "errors" + "strings" + "time" + + "github.com/bytedance/sonic" + "gorm.io/gorm" + + systemconfigapp "github.com/break/junhong_cmp_fiber/internal/application/systemconfig" + "github.com/break/junhong_cmp_fiber/internal/model" + "github.com/break/junhong_cmp_fiber/internal/model/dto" + "github.com/break/junhong_cmp_fiber/pkg/constants" + "github.com/break/junhong_cmp_fiber/pkg/errors" + "github.com/break/junhong_cmp_fiber/pkg/middleware" +) + +// TemplateControl 是模板详情中可供业务映射的最小控件结构。 +type TemplateControl struct { + ID string `json:"id"` + Type string `json:"type"` + Title string `json:"title"` + Required bool `json:"required"` + OptionKeys []string `json:"option_keys"` +} + +// TemplateDefinition 是企微 Adapter 返回的模板最小结构。 +type TemplateDefinition struct { + TemplateID string `json:"template_id"` + Name string `json:"name"` + Controls []TemplateControl `json:"controls"` +} + +// TemplateProvider 定义审批模板详情外部端口。 +type TemplateProvider interface { + GetTemplateDetail(ctx context.Context, applicationID uint, templateID string) (TemplateDefinition, error) +} + +// SceneRepository 定义审批场景当前配置持久化边界。 +type SceneRepository interface { + FindForUpdate(ctx context.Context, tx *gorm.DB, businessType string) (*model.WeComApprovalScene, error) + Create(ctx context.Context, tx *gorm.DB, scene *model.WeComApprovalScene) error + Update(ctx context.Context, tx *gorm.DB, scene *model.WeComApprovalScene) error + List(ctx context.Context, page, pageSize int) ([]model.WeComApprovalScene, int64, error) +} + +// SceneService 保存经企微模板详情校验的业务场景当前映射。 +type SceneService struct { + db *gorm.DB + provider TemplateProvider + repo SceneRepository + audit systemconfigapp.AuditWriter + now func() time.Time +} + +// NewSceneService 创建企业微信审批场景配置用例。 +func NewSceneService(db *gorm.DB, provider TemplateProvider, repo SceneRepository, audit systemconfigapp.AuditWriter) *SceneService { + return &SceneService{db: db, provider: provider, repo: repo, audit: audit, now: time.Now} +} + +// Save 校验模板控件后创建或替换指定稳定业务场景映射。 +func (s *SceneService) Save(ctx context.Context, businessType string, request dto.SaveWeComApprovalSceneRequest) (*dto.WeComApprovalSceneResponse, error) { + if s == nil || s.db == nil || s.provider == nil || s.repo == nil { + return nil, errors.New(errors.CodeServiceUnavailable, "企业微信审批场景服务未配置") + } + if middleware.GetUserTypeFromContext(ctx) != constants.UserTypeSuperAdmin { + return nil, errors.New(errors.CodeForbidden) + } + businessType = strings.TrimSpace(businessType) + if !validApprovalBusinessType(businessType) { + return nil, errors.New(errors.CodeInvalidParam, "不支持的审批业务类型") + } + if request.ApplicationID == 0 || strings.TrimSpace(request.TemplateID) == "" || len(request.ControlMapping) == 0 || + (request.Status != constants.StatusDisabled && request.Status != constants.StatusEnabled) { + return nil, errors.New(errors.CodeInvalidParam) + } + definition, err := s.provider.GetTemplateDetail(ctx, request.ApplicationID, strings.TrimSpace(request.TemplateID)) + if err != nil { + return nil, err + } + if err := validateSceneMapping(businessType, request.ControlMapping, definition.Controls); err != nil { + return nil, err + } + request.ControlMapping = normalizeSceneMapping(request.ControlMapping) + mappingJSON, err := sonic.Marshal(request.ControlMapping) + if err != nil { + return nil, errors.Wrap(errors.CodeInternalError, err, "编码企业微信控件映射失败") + } + snapshotJSON, err := sonic.Marshal(definition) + if err != nil { + return nil, errors.Wrap(errors.CodeInternalError, err, "编码企业微信模板快照失败") + } + fingerprintBytes := sha256.Sum256(snapshotJSON) + fingerprint := hex.EncodeToString(fingerprintBytes[:]) + operatorID := middleware.GetUserIDFromContext(ctx) + if operatorID == 0 { + return nil, errors.New(errors.CodeUnauthorized) + } + now := s.now().UTC() + var saved *model.WeComApprovalScene + err = s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + if err := tx.Exec("SELECT pg_advisory_xact_lock(hashtext(?))", "wecom-scene:"+businessType).Error; err != nil { + return err + } + existing, err := s.repo.FindForUpdate(ctx, tx, businessType) + if err != nil { + return err + } + before := map[string]any{"configured": false, "business_type": businessType} + if existing == nil { + existing = &model.WeComApprovalScene{BusinessType: businessType, CreatedBy: operatorID, CreatedAt: now} + } else { + before = sceneAuditSnapshot(existing) + } + existing.ApplicationID = request.ApplicationID + existing.TemplateID = definition.TemplateID + existing.TemplateName = definition.Name + existing.ControlMapping = mappingJSON + existing.TemplateSnapshot = snapshotJSON + existing.TemplateFingerprint = fingerprint + existing.Status = request.Status + existing.LastVerifiedAt = now + existing.UpdatedBy = operatorID + existing.UpdatedAt = now + if existing.ID == 0 { + if err := s.repo.Create(ctx, tx, existing); err != nil { + return err + } + } else if err := s.repo.Update(ctx, tx, existing); err != nil { + return err + } + if s.audit != nil { + requestID := "" + if value := middleware.GetRequestIDFromContext(ctx); value != nil { + requestID = *value + } + if err := s.audit.WriteConfigChange(ctx, tx, systemconfigapp.ChangeAudit{ + OperatorID: operatorID, OperationType: "wecom_approval_scene_save", Description: "保存企业微信审批模板映射", + ConfigKey: "wecom.approval_scene." + businessType, BeforeData: before, + AfterData: sceneAuditSnapshot(existing), RequestID: requestID, CorrelationID: requestID, + }); err != nil { + return err + } + } + saved = existing + return nil + }) + if err != nil { + var appErr *errors.AppError + if stdErrors.As(err, &appErr) { + return nil, appErr + } + return nil, errors.Wrap(errors.CodeDatabaseError, err, "保存企业微信审批场景失败") + } + return sceneResponse(*saved) +} + +// List 分页查询企业微信审批场景当前配置。 +func (s *SceneService) List(ctx context.Context, request dto.WeComApprovalSceneListRequest) (*dto.WeComApprovalSceneListResponse, error) { + if s == nil || s.repo == nil { + return nil, errors.New(errors.CodeServiceUnavailable, "企业微信审批场景服务未配置") + } + userType := middleware.GetUserTypeFromContext(ctx) + if userType != constants.UserTypeSuperAdmin && userType != constants.UserTypePlatform { + return nil, errors.New(errors.CodeForbidden) + } + if request.Page <= 0 { + request.Page = constants.DefaultPage + } + if request.PageSize <= 0 { + request.PageSize = constants.DefaultPageSize + } + if request.PageSize > constants.MaxPageSize { + return nil, errors.New(errors.CodeInvalidParam) + } + scenes, total, err := s.repo.List(ctx, request.Page, request.PageSize) + if err != nil { + return nil, err + } + items := make([]dto.WeComApprovalSceneResponse, 0, len(scenes)) + for _, scene := range scenes { + item, err := sceneResponse(scene) + if err != nil { + return nil, err + } + items = append(items, *item) + } + return &dto.WeComApprovalSceneListResponse{Items: items, Total: total, Page: request.Page, PageSize: request.PageSize}, nil +} + +func validateSceneMapping(businessType string, mapping []dto.WeComControlMappingItem, controls []TemplateControl) error { + controlByID := make(map[string]TemplateControl, len(controls)) + for _, control := range controls { + controlByID[control.ID] = control + } + mappedControls := make(map[string]struct{}, len(mapping)) + businessFields := make(map[string]struct{}, len(mapping)) + for _, item := range mapping { + item.BusinessField = strings.TrimSpace(item.BusinessField) + item.ControlID = strings.TrimSpace(item.ControlID) + if item.BusinessField == "" || item.ControlID == "" { + return errors.New(errors.CodeInvalidParam, "企业微信控件映射字段不能为空") + } + if !allowedSceneBusinessField(businessType, item.BusinessField) { + return errors.New(errors.CodeInvalidParam, "企业微信控件映射包含当前业务不支持的字段") + } + if _, exists := businessFields[item.BusinessField]; exists { + return errors.New(errors.CodeInvalidParam, "企业微信业务字段映射重复") + } + if _, exists := mappedControls[item.ControlID]; exists { + return errors.New(errors.CodeInvalidParam, "企业微信模板控件不能重复映射") + } + control, exists := controlByID[item.ControlID] + if !exists || !strings.EqualFold(control.Type, strings.TrimSpace(item.ControlType)) { + return errors.New(errors.CodeInvalidParam, "企业微信模板控件 ID 或类型已失效") + } + validOptions := make(map[string]struct{}, len(control.OptionKeys)) + for _, key := range control.OptionKeys { + validOptions[key] = struct{}{} + } + for _, key := range item.OptionMapping { + if _, exists := validOptions[key]; !exists { + return errors.New(errors.CodeInvalidParam, "企业微信模板选择项 key 已失效") + } + } + businessFields[item.BusinessField] = struct{}{} + mappedControls[item.ControlID] = struct{}{} + } + for _, control := range controls { + if control.Required { + if _, exists := mappedControls[control.ID]; !exists { + return errors.New(errors.CodeInvalidParam, "企业微信模板存在未映射的必填控件: "+control.Title) + } + } + } + return nil +} + +func allowedSceneBusinessField(businessType, businessField string) bool { + allowed := map[string]struct{}{} + switch businessType { + case constants.ApprovalBusinessTypeOfflineRecharge: + allowed = map[string]struct{}{ + constants.ApprovalFieldRechargeNo: {}, + constants.ApprovalFieldShopID: {}, + constants.ApprovalFieldShopName: {}, + constants.ApprovalFieldAmount: {}, + constants.ApprovalFieldAmountCent: {}, + constants.ApprovalFieldPaymentVoucherKey: {}, + constants.ApprovalFieldRemark: {}, + constants.ApprovalFieldSubmitterID: {}, + constants.ApprovalFieldSubmitterName: {}, + } + case constants.ApprovalBusinessTypeRefund: + allowed = map[string]struct{}{ + constants.ApprovalFieldRefundNo: {}, + constants.ApprovalFieldOrderID: {}, + constants.ApprovalFieldOrderNo: {}, + constants.ApprovalFieldAssetIdentifier: {}, + constants.ApprovalFieldAssetType: {}, + constants.ApprovalFieldActualReceivedAmount: {}, + constants.ApprovalFieldRequestedRefundAmount: {}, + constants.ApprovalFieldRefundVoucherKey: {}, + constants.ApprovalFieldRefundReason: {}, + constants.ApprovalFieldPackageUsageID: {}, + constants.ApprovalFieldSubmitterID: {}, + constants.ApprovalFieldSubmitterName: {}, + } + default: + return false + } + _, exists := allowed[businessField] + return exists +} + +func normalizeSceneMapping(mapping []dto.WeComControlMappingItem) []dto.WeComControlMappingItem { + result := make([]dto.WeComControlMappingItem, 0, len(mapping)) + for _, item := range mapping { + item.BusinessField = strings.TrimSpace(item.BusinessField) + item.ControlID = strings.TrimSpace(item.ControlID) + item.ControlType = strings.TrimSpace(item.ControlType) + if item.OptionMapping == nil { + item.OptionMapping = map[string]string{} + } + result = append(result, item) + } + return result +} + +func validApprovalBusinessType(businessType string) bool { + return businessType == constants.ApprovalBusinessTypeRefund || businessType == constants.ApprovalBusinessTypeOfflineRecharge +} + +func sceneAuditSnapshot(scene *model.WeComApprovalScene) map[string]any { + return map[string]any{ + "business_type": scene.BusinessType, "application_id": scene.ApplicationID, + "template_id": scene.TemplateID, "status": scene.Status, "last_verified_at": scene.LastVerifiedAt, + "template_fingerprint": scene.TemplateFingerprint, + } +} + +func sceneResponse(scene model.WeComApprovalScene) (*dto.WeComApprovalSceneResponse, error) { + var mapping []dto.WeComControlMappingItem + if err := sonic.Unmarshal(scene.ControlMapping, &mapping); err != nil { + return nil, errors.Wrap(errors.CodeInternalError, err, "解析企业微信控件映射失败") + } + name := "员工线下代充值审批" + if scene.BusinessType == constants.ApprovalBusinessTypeRefund { + name = "退款审批" + } + return &dto.WeComApprovalSceneResponse{ + ID: scene.ID, BusinessType: scene.BusinessType, BusinessTypeName: name, + ApplicationID: scene.ApplicationID, TemplateID: scene.TemplateID, TemplateName: scene.TemplateName, + TemplateFingerprint: scene.TemplateFingerprint, + ControlMapping: mapping, Status: scene.Status, StatusName: constants.GetStatusName(scene.Status), + LastVerifiedAt: scene.LastVerifiedAt, UpdatedAt: scene.UpdatedAt, + }, nil +} diff --git a/internal/bootstrap/handlers.go b/internal/bootstrap/handlers.go index bbd1bde..f9401ae 100644 --- a/internal/bootstrap/handlers.go +++ b/internal/bootstrap/handlers.go @@ -6,6 +6,7 @@ import ( shopApp "github.com/break/junhong_cmp_fiber/internal/application/shop" systemConfigApp "github.com/break/junhong_cmp_fiber/internal/application/systemconfig" walletApp "github.com/break/junhong_cmp_fiber/internal/application/wallet" + wecomApp "github.com/break/junhong_cmp_fiber/internal/application/wecom" "github.com/break/junhong_cmp_fiber/internal/handler/admin" "github.com/break/junhong_cmp_fiber/internal/handler/app" authHandler "github.com/break/junhong_cmp_fiber/internal/handler/auth" @@ -14,6 +15,7 @@ import ( "github.com/break/junhong_cmp_fiber/internal/infrastructure/carriercallback" "github.com/break/junhong_cmp_fiber/internal/infrastructure/integrationlog" systemConfigInfra "github.com/break/junhong_cmp_fiber/internal/infrastructure/systemconfig" + wecomInfra "github.com/break/junhong_cmp_fiber/internal/infrastructure/wecom" pollingPkg "github.com/break/junhong_cmp_fiber/internal/polling" assetQuery "github.com/break/junhong_cmp_fiber/internal/query/asset" exchangeQuery "github.com/break/junhong_cmp_fiber/internal/query/exchange" @@ -22,11 +24,14 @@ import ( shopQuery "github.com/break/junhong_cmp_fiber/internal/query/shop" systemConfigQuery "github.com/break/junhong_cmp_fiber/internal/query/systemconfig" clientOrderSvc "github.com/break/junhong_cmp_fiber/internal/service/client_order" + "github.com/break/junhong_cmp_fiber/internal/service/paymentmethod" pollingSvcPkg "github.com/break/junhong_cmp_fiber/internal/service/polling" rechargeOrderSvc "github.com/break/junhong_cmp_fiber/internal/service/recharge_order" "github.com/break/junhong_cmp_fiber/internal/store/postgres" + "github.com/break/junhong_cmp_fiber/pkg/config" "github.com/break/junhong_cmp_fiber/pkg/constants" "github.com/go-playground/validator/v10" + "go.uber.org/zap" ) func initHandlers(svc *services, deps *Dependencies) *Handlers { @@ -92,16 +97,58 @@ func initHandlers(svc *services, deps *Dependencies) *Handlers { systemConfigRegistry = systemConfigInfra.NewRegistry() } registerCarrierCallbackConfigDefinitions(systemConfigRegistry, deps.Logger) + registerPaymentMethodConfigDefinitions(systemConfigRegistry, deps.Logger) systemConfigAlerts := systemConfigInfra.NewLogAlertSink(deps.Logger) var systemConfigCache systemConfigInfra.Cache if deps.Redis != nil { systemConfigCache = systemConfigInfra.NewRedisCache(deps.Redis) } systemConfigReader := systemConfigInfra.NewReader(deps.DB, systemConfigRegistry, systemConfigCache, systemConfigAlerts) + paymentMethodPolicy := paymentmethod.NewPolicy(systemConfigReader) + clientOrderService.SetPaymentMethodPolicy(paymentMethodPolicy) systemConfigList := systemConfigQuery.NewListQuery(systemConfigReader) systemConfigUpdate := systemConfigApp.NewUpdateService( deps.DB, systemConfigRegistry, systemConfigCache, deps.SystemConfigAudit, systemConfigAlerts, nil, ) + wecomRepository := wecomInfra.NewApplicationRepository(deps.DB) + var credentialCipher *wecomInfra.CredentialCipher + wecomBaseURL := "" + wecomTimeout := constants.WeComDefaultHTTPTimeout + if cfg := config.Get(); cfg != nil { + wecomBaseURL = cfg.WeCom.BaseURL + wecomTimeout = cfg.WeCom.Timeout + cipher, err := wecomInfra.NewCredentialCipher(cfg.WeCom.CredentialEncryptionKey) + if err != nil { + if deps.Logger != nil { + deps.Logger.Warn("企业微信凭据加密密钥未配置或无效,相关接口将拒绝业务调用", zap.Error(err)) + } + } else { + credentialCipher = cipher + } + } + wecomTokens := wecomInfra.NewTokenProvider( + wecomRepository, credentialCipher, deps.Redis, integrationlog.NewRepository(deps.DB), + wecomBaseURL, wecomTimeout, deps.Logger, + ) + wecomConnections := wecomApp.NewConnectionService( + deps.DB, wecomRepository, credentialCipher, wecomTokens, deps.SystemConfigAudit, + ) + wecomMembers := wecomInfra.NewMemberRepository(deps.DB) + wecomConnections.SetDefaultCreatorMemberFinder(wecomMembers) + wecomDirectory := wecomApp.NewDirectoryService( + wecomRepository, + wecomInfra.NewDirectoryClient(wecomTokens, integrationlog.NewRepository(deps.DB), wecomBaseURL, wecomTimeout), + wecomMembers, + ) + wecomScenes := wecomApp.NewSceneService( + deps.DB, + wecomInfra.NewTemplateClient(wecomTokens, integrationlog.NewRepository(deps.DB), wecomBaseURL, wecomTimeout), + wecomInfra.NewSceneRepository(deps.DB), deps.SystemConfigAudit, + ) + wecomApprovalCallback := callback.NewWeComApprovalHandler(wecomInfra.NewCallbackService( + wecomRepository, credentialCipher, integrationlog.NewRepository(deps.DB), deps.QueueClient, + )) + svc.Account.SetWeComMemberFinder(wecomMembers) return &Handlers{ Auth: authHandler.NewHandler(svc.Auth, validate), @@ -117,9 +164,15 @@ func initHandlers(svc *services, deps *Dependencies) *Handlers { ClientAsset: func() *app.ClientAssetHandler { handler := app.NewClientAssetHandler(svc.Asset, svc.CustomerBinding, assetWalletStore, packageStore, shopPackageAllocationStore, iotCardStore, deviceStore, deps.DB, deps.Logger) handler.SetObservationSeriesDispatcher(svc.ObservationSeries) + handler.SetPaymentMethodPolicy(paymentMethodPolicy) + handler.SetForceRechargeChecker(svc.Recharge) + return handler + }(), + ClientWallet: func() *app.ClientWalletHandler { + handler := app.NewClientWalletHandler(svc.Asset, svc.CustomerBinding, assetWalletStore, assetWalletTransactionStore, rechargeOrderStore, paymentStore, svc.Recharge, personalCustomerOpenIDStore, svc.WechatConfig, deps.Redis, deps.Logger, deps.DB, iotCardStore, deviceStore) + handler.SetPaymentMethodPolicy(paymentMethodPolicy) return handler }(), - ClientWallet: app.NewClientWalletHandler(svc.Asset, svc.CustomerBinding, assetWalletStore, assetWalletTransactionStore, rechargeOrderStore, paymentStore, svc.Recharge, personalCustomerOpenIDStore, svc.WechatConfig, deps.Redis, deps.Logger, deps.DB, iotCardStore, deviceStore), ClientOrder: app.NewClientOrderHandler(clientOrderService, deps.Logger), ClientExchange: app.NewClientExchangeHandler(svc.Exchange), ClientRealname: func() *app.ClientRealnameHandler { @@ -189,12 +242,13 @@ func initHandlers(svc *services, deps *Dependencies) *Handlers { carriercallback.NewCUCCRealnameRemovalTranslator(), carriercallback.NewCUCCCardResolver(deps.DB), integrationlog.NewRepository(deps.DB), systemConfigReader, deps.Logger, ), - PollingConfig: admin.NewPollingConfigHandler(svc.PollingConfig), - PollingConcurrency: admin.NewPollingConcurrencyHandler(svc.PollingConcurrency), - PollingMonitoring: admin.NewPollingMonitoringHandler(svc.PollingMonitoring), - PollingAlert: admin.NewPollingAlertHandler(svc.PollingAlert), - PollingCleanup: admin.NewPollingCleanupHandler(svc.PollingCleanup), - PollingManualTrigger: admin.NewPollingManualTriggerHandler(svc.PollingManualTrigger), + WeComApprovalCallback: wecomApprovalCallback, + PollingConfig: admin.NewPollingConfigHandler(svc.PollingConfig), + PollingConcurrency: admin.NewPollingConcurrencyHandler(svc.PollingConcurrency), + PollingMonitoring: admin.NewPollingMonitoringHandler(svc.PollingMonitoring), + PollingAlert: admin.NewPollingAlertHandler(svc.PollingAlert), + PollingCleanup: admin.NewPollingCleanupHandler(svc.PollingCleanup), + PollingManualTrigger: admin.NewPollingManualTriggerHandler(svc.PollingManualTrigger), Asset: func() *admin.AssetHandler { pollingQueueMgr := pollingPkg.NewPollingQueueManager(deps.Redis, constants.PollingShardCount, deps.Logger) assetPollingSvc := pollingSvcPkg.NewAssetPollingService( @@ -208,6 +262,7 @@ func initHandlers(svc *services, deps *Dependencies) *Handlers { h := admin.NewAssetHandler(svc.Asset, svc.AssetAudit, svc.Device, svc.IotCard, svc.StopResumeService, assetPollingSvc, assetQuery.NewExchangeTraceQuery(deps.DB, deps.Logger)) h.SetLifecycleService(svc.AssetLifecycle) h.SetObservationSeriesDispatcher(svc.ObservationSeries) + h.SetPackageExpiryQuery(packageExpiry) return h }(), AssetLifecycle: admin.NewAssetLifecycleHandler(svc.AssetLifecycle), @@ -220,9 +275,16 @@ func initHandlers(svc *services, deps *Dependencies) *Handlers { AgentRecharge: admin.NewAgentRechargeHandler(svc.AgentRecharge, validate), Refund: admin.NewRefundHandler(svc.Refund), OrderPackageInvalidate: admin.NewOrderPackageInvalidateHandler(svc.OrderPackageInvalidate), + AssetPackageBatchOrder: admin.NewAssetPackageBatchOrderHandler(svc.AssetPackageBatchOrder, validate), ClientWechat: app.NewClientWechatHandler(svc.WechatConfig, deps.Redis, deps.Logger), SuperAdmin: admin.NewSuperAdminHandler(svc.OperationPassword), SystemConfig: admin.NewSystemConfigHandler(systemConfigList, systemConfigUpdate), - AgentOpenAPI: openapiHandler.NewHandler(svc.AgentOpenAPI, validate), + WeCom: func() *admin.WeComHandler { + handler := admin.NewWeComHandler(wecomConnections, validate) + handler.SetDirectoryService(wecomDirectory) + handler.SetSceneService(wecomScenes) + return handler + }(), + AgentOpenAPI: openapiHandler.NewHandler(svc.AgentOpenAPI, validate), } } diff --git a/internal/bootstrap/payment_method_config.go b/internal/bootstrap/payment_method_config.go new file mode 100644 index 0000000..bbbc861 --- /dev/null +++ b/internal/bootstrap/payment_method_config.go @@ -0,0 +1,36 @@ +package bootstrap + +import ( + "go.uber.org/zap" + + "github.com/break/junhong_cmp_fiber/internal/infrastructure/systemconfig" + "github.com/break/junhong_cmp_fiber/internal/service/paymentmethod" + "github.com/break/junhong_cmp_fiber/pkg/constants" +) + +func registerPaymentMethodConfigDefinitions(registry *systemconfig.Registry, logger *zap.Logger) { + if registry == nil { + return + } + definitions := []systemconfig.Definition{ + {Key: constants.SystemConfigPaymentAllowedCard, Module: constants.SystemConfigModulePayment, ValueType: constants.SystemConfigTypeJSON, DefaultValue: `["wallet","wechat","alipay"]`, Description: "卡资产允许的C端支付方式", Control: "payment_methods", Validator: paymentmethod.ValidateConfigValue}, + {Key: constants.SystemConfigPaymentAllowedDevice, Module: constants.SystemConfigModulePayment, ValueType: constants.SystemConfigTypeJSON, DefaultValue: `["wallet","wechat","alipay"]`, Description: "设备资产允许的C端支付方式", Control: "payment_methods", Validator: paymentmethod.ValidateConfigValue}, + } + for _, definition := range definitions { + if existing, exists := registry.Get(definition.Key); exists { + if existing.ValueType != definition.ValueType || existing.Module != definition.Module { + logPaymentMethodConfigRegistrationError(logger, definition.Key, "配置 Key 已被其他类型或模块注册") + } + continue + } + if err := registry.Register(definition); err != nil { + logPaymentMethodConfigRegistrationError(logger, definition.Key, err.Error()) + } + } +} + +func logPaymentMethodConfigRegistrationError(logger *zap.Logger, key, reason string) { + if logger != nil { + logger.Error("注册支付方式系统配置失败,C端支付将失败关闭", zap.String("config_key", key), zap.String("reason", reason)) + } +} diff --git a/internal/bootstrap/services.go b/internal/bootstrap/services.go index bba0e94..62a4808 100644 --- a/internal/bootstrap/services.go +++ b/internal/bootstrap/services.go @@ -5,12 +5,19 @@ import ( "go.uber.org/zap" + agentrechargeApp "github.com/break/junhong_cmp_fiber/internal/application/agentrecharge" + approvalApp "github.com/break/junhong_cmp_fiber/internal/application/approval" cardObservationApp "github.com/break/junhong_cmp_fiber/internal/application/cardobservation" + exchangeApp "github.com/break/junhong_cmp_fiber/internal/application/exchange" + refundapprovalApp "github.com/break/junhong_cmp_fiber/internal/application/refundapproval" walletapp "github.com/break/junhong_cmp_fiber/internal/application/wallet" + approvalInfra "github.com/break/junhong_cmp_fiber/internal/infrastructure/approval" cardObservationInfra "github.com/break/junhong_cmp_fiber/internal/infrastructure/cardobservation" + exchangeInfra "github.com/break/junhong_cmp_fiber/internal/infrastructure/exchange" "github.com/break/junhong_cmp_fiber/internal/infrastructure/integrationlog" "github.com/break/junhong_cmp_fiber/internal/infrastructure/messaging/outbox" walletinfra "github.com/break/junhong_cmp_fiber/internal/infrastructure/wallet" + wecomInfra "github.com/break/junhong_cmp_fiber/internal/infrastructure/wecom" "github.com/break/junhong_cmp_fiber/internal/polling" accountSvc "github.com/break/junhong_cmp_fiber/internal/service/account" accountAuditSvc "github.com/break/junhong_cmp_fiber/internal/service/account_audit" @@ -26,11 +33,13 @@ import ( commissionWithdrawalSettingSvc "github.com/break/junhong_cmp_fiber/internal/service/commission_withdrawal_setting" customerBindingSvc "github.com/break/junhong_cmp_fiber/internal/service/customer_binding" "github.com/break/junhong_cmp_fiber/internal/store/postgres" + "github.com/break/junhong_cmp_fiber/pkg/config" "github.com/break/junhong_cmp_fiber/pkg/constants" "github.com/break/junhong_cmp_fiber/pkg/payment" "github.com/break/junhong_cmp_fiber/pkg/queue" assetSvc "github.com/break/junhong_cmp_fiber/internal/service/asset" + assetPackageBatchOrderSvc "github.com/break/junhong_cmp_fiber/internal/service/asset_package_batch_order" assetWalletSvc "github.com/break/junhong_cmp_fiber/internal/service/asset_wallet" deviceSvc "github.com/break/junhong_cmp_fiber/internal/service/device" deviceImportSvc "github.com/break/junhong_cmp_fiber/internal/service/device_import" @@ -65,6 +74,7 @@ import ( ) type services struct { + Approval *approvalApp.CreationService Account *accountSvc.Service AccountAudit *accountAuditSvc.Service AssetAudit *assetAuditSvc.Service @@ -120,6 +130,7 @@ type services struct { AgentOpenAPI *agentOpenAPISvc.Service CustomerBinding *customerBindingSvc.Service OrderPackageInvalidate *orderPackageInvalidateSvc.Service + AssetPackageBatchOrder *assetPackageBatchOrderSvc.Service ObservationSeries cardObservationApp.BestEffortSeriesDispatcher CardObservation *cardObservationApp.Service CardObservationSeries *cardObservationApp.SeriesAttemptService @@ -155,6 +166,7 @@ func initServices(s *stores, deps *Dependencies) *services { cardObservationInfra.NewCacheInvalidator(deps.Redis, deps.Logger), ) iotCard.SetCardObservationService(cardObservationService) + iotCard.SetSpeedTierIntegrationLog(integrationlog.NewRepository(deps.DB)) seriesCoordinator := cardObservationInfra.NewSeriesCoordinator(deps.Redis) seriesIntegration := integrationlog.NewRepository(deps.DB) seriesTrigger := cardObservationApp.NewSeriesTrigger( @@ -251,7 +263,8 @@ func initServices(s *stores, deps *Dependencies) *services { deps.Redis, deps.Logger, ) - agentRechargeService.SetAgentWalletPostingService(walletapp.NewPostingService(walletinfra.NewCreditEventWriter(walletOutbox), nil)) + agentWalletPosting := walletapp.NewPostingService(walletinfra.NewCreditEventWriter(walletOutbox), nil) + agentRechargeService.SetAgentWalletPostingService(agentWalletPosting) refundService := refundSvc.New( deps.DB, s.RefundRequest, @@ -268,11 +281,45 @@ func initServices(s *stores, deps *Dependencies) *services { deps.Logger, ) refundService.SetAgentWalletRefundService(walletapp.NewRefundService(walletinfra.NewRefundEventWriter(walletOutbox), nil)) + exchangeService := exchangeSvc.New(deps.DB, s.ExchangeOrder, s.IotCard, s.Device, s.AssetWallet, s.AssetWalletTransaction, s.PackageUsage, s.PackageUsageDailyRecord, s.ResourceTag, customerBinding, deps.Logger) + exchangeService.SetShippingCreatedNotifier(exchangeApp.NewShippingCreatedNotifier(exchangeInfra.NewShippingNotificationWriter(outbox.NewRepository()))) assetService := assetSvc.New(deps.DB, s.Device, s.IotCard, s.PackageUsage, s.Package, s.PackageSeries, s.DeviceSimBinding, s.Shop, deps.Redis, iotCard, deps.GatewayClient, s.AssetIdentifier, s.Order, s.OrderItem, s.ExchangeOrder, assetAudit) agentOpenAPI := agentOpenAPISvc.New(assetService, packageService, orderService, shopCommission, stopResumeService, device, s.IotCard, s.PackageUsage, s.Package, s.PackageSeries, s.AgentWallet, s.DeviceSimBinding, s.Device) agentOpenAPI.SetObservationSeriesDispatcher(observationSeries) + wecomApplicationRepository := wecomInfra.NewApplicationRepository(deps.DB) + wecomSceneRepository := wecomInfra.NewSceneRepository(deps.DB) + wecomMemberRepository := wecomInfra.NewMemberRepository(deps.DB) + wecomBaseURL := "" + wecomTimeout := constants.WeComDefaultHTTPTimeout + var wecomCredentialCipher *wecomInfra.CredentialCipher + if cfg := config.Get(); cfg != nil { + wecomBaseURL = cfg.WeCom.BaseURL + wecomTimeout = cfg.WeCom.Timeout + wecomCredentialCipher, _ = wecomInfra.NewCredentialCipher(cfg.WeCom.CredentialEncryptionKey) + } + wecomIntegrationRepository := integrationlog.NewRepository(deps.DB) + wecomTokenProvider := wecomInfra.NewTokenProvider( + wecomApplicationRepository, wecomCredentialCipher, deps.Redis, wecomIntegrationRepository, + wecomBaseURL, wecomTimeout, deps.Logger, + ) + approvalCreationService := approvalApp.NewCreationService( + wecomInfra.NewApprovalProvider( + deps.DB, wecomSceneRepository, wecomApplicationRepository, wecomMemberRepository, + wecomInfra.NewTemplateClient(wecomTokenProvider, wecomIntegrationRepository, wecomBaseURL, wecomTimeout), + ), + approvalInfra.NewRepositoryProvider(), + approvalInfra.NewSubmissionEventWriter(outbox.NewRepository()), + nil, + ) + agentRechargeService.SetOfflineCreationService( + agentrechargeApp.NewOfflineCreationService(deps.DB, approvalCreationService), + ) + refundService.SetRefundApprovalCreationService( + refundapprovalApp.NewCreationService(deps.DB, approvalCreationService), + ) return &services{ + Approval: approvalCreationService, Account: account, AccountAudit: accountAudit, AssetAudit: assetAudit, @@ -337,7 +384,7 @@ func initServices(s *stores, deps *Dependencies) *services { CommissionStats: commissionStatsSvc.New(s.ShopSeriesCommissionStats), PurchaseValidation: purchaseValidation, Order: orderService, - Exchange: exchangeSvc.New(deps.DB, s.ExchangeOrder, s.IotCard, s.Device, s.AssetWallet, s.AssetWalletTransaction, s.PackageUsage, s.PackageUsageDailyRecord, s.ResourceTag, customerBinding, deps.Logger), + Exchange: exchangeService, Recharge: rechargeSvc.New(deps.DB, s.AssetWallet, s.AssetWalletTransaction, s.IotCard, s.Device, s.ShopSeriesAllocation, s.PackageSeries, s.CommissionRecord, wechatConfig, paymentLoader, deps.Logger), PollingConfig: pollingSvc.NewConfigService(s.PollingConfig, deps.Redis, deps.Logger), PollingConcurrency: pollingSvc.NewConcurrencyService(s.PollingConcurrencyConfig, deps.Redis), @@ -358,6 +405,7 @@ func initServices(s *stores, deps *Dependencies) *services { Refund: refundService, CustomerBinding: customerBinding, OrderPackageInvalidate: orderPackageInvalidateSvc.New(s.OrderPackageInvalidateTask, deps.QueueClient), + AssetPackageBatchOrder: assetPackageBatchOrderSvc.New(s.AssetPackageBatchOrderTask, s.Package, deps.QueueClient), ObservationSeries: observationSeries, CardObservation: cardObservationService, CardObservationSeries: cardObservationSeries, diff --git a/internal/bootstrap/stores.go b/internal/bootstrap/stores.go index a6f3e7e..2aebbf8 100644 --- a/internal/bootstrap/stores.go +++ b/internal/bootstrap/stores.go @@ -68,6 +68,8 @@ type stores struct { RefundRequest *postgres.RefundStore // 订单套餐失效任务 OrderPackageInvalidateTask *postgres.OrderPackageInvalidateTaskStore + // 资产套餐批量订购任务 + AssetPackageBatchOrderTask *postgres.AssetPackageBatchOrderTaskStore // 流量系统 CardDailyUsage *postgres.CardDailyUsageStore // 资产标识符注册表 @@ -128,14 +130,15 @@ func initStores(deps *Dependencies) *stores { AgentWalletTransaction: postgres.NewAgentWalletTransactionStore(deps.DB, deps.Redis), AgentRecharge: postgres.NewAgentRechargeStore(deps.DB, deps.Redis), // 资产钱包系统 - AssetWallet: postgres.NewAssetWalletStore(deps.DB, deps.Redis), - AssetWalletTransaction: postgres.NewAssetWalletTransactionStore(deps.DB, deps.Redis), - RechargeOrder: postgres.NewRechargeOrderStore(deps.DB, deps.Redis), - Payment: postgres.NewPaymentStore(deps.DB, deps.Redis), - WechatConfig: postgres.NewWechatConfigStore(deps.DB, deps.Redis), + AssetWallet: postgres.NewAssetWalletStore(deps.DB, deps.Redis), + AssetWalletTransaction: postgres.NewAssetWalletTransactionStore(deps.DB, deps.Redis), + RechargeOrder: postgres.NewRechargeOrderStore(deps.DB, deps.Redis), + Payment: postgres.NewPaymentStore(deps.DB, deps.Redis), + WechatConfig: postgres.NewWechatConfigStore(deps.DB, deps.Redis), RefundRequest: postgres.NewRefundStore(deps.DB), CardDailyUsage: postgres.NewCardDailyUsageStore(deps.DB), AssetIdentifier: postgres.NewAssetIdentifierStore(deps.DB), OrderPackageInvalidateTask: postgres.NewOrderPackageInvalidateTaskStore(deps.DB), + AssetPackageBatchOrderTask: postgres.NewAssetPackageBatchOrderTaskStore(deps.DB), } } diff --git a/internal/bootstrap/types.go b/internal/bootstrap/types.go index 8413893..a958958 100644 --- a/internal/bootstrap/types.go +++ b/internal/bootstrap/types.go @@ -57,6 +57,7 @@ type Handlers struct { CMCCRealnameCallback *callback.CMCCRealnameHandler CUCCRealnameCallback *callback.CUCCRealnameHandler CUCCRealnameRemovalCallback *callback.CUCCRealnameRemovalHandler + WeComApprovalCallback *callback.WeComApprovalHandler PollingConfig *admin.PollingConfigHandler PollingConcurrency *admin.PollingConcurrencyHandler PollingMonitoring *admin.PollingMonitoringHandler @@ -70,9 +71,11 @@ type Handlers struct { AgentRecharge *admin.AgentRechargeHandler Refund *admin.RefundHandler OrderPackageInvalidate *admin.OrderPackageInvalidateHandler + AssetPackageBatchOrder *admin.AssetPackageBatchOrderHandler ClientWechat *app.ClientWechatHandler SuperAdmin *admin.SuperAdminHandler SystemConfig *admin.SystemConfigHandler + WeCom *admin.WeComHandler AgentOpenAPI *openapiHandler.Handler } diff --git a/internal/bootstrap/worker.go b/internal/bootstrap/worker.go index 95a628e..ac49f0f 100644 --- a/internal/bootstrap/worker.go +++ b/internal/bootstrap/worker.go @@ -17,6 +17,7 @@ type WorkerDependencies struct { Redis *redis.Client Logger *zap.Logger AsynqClient *asynq.Client // Worker 特有:用于 Scheduler 提交任务 + QueueClient *queue.Client // 统一业务任务客户端,用于 Worker 内产生后续任务 StorageService *storage.Service // 对象存储(可选) GatewayClient *gateway.Client // Gateway 客户端(可选) } diff --git a/internal/bootstrap/worker_services.go b/internal/bootstrap/worker_services.go index 11f9aee..bfc69b1 100644 --- a/internal/bootstrap/worker_services.go +++ b/internal/bootstrap/worker_services.go @@ -10,10 +10,12 @@ import ( assetAuditSvc "github.com/break/junhong_cmp_fiber/internal/service/asset_audit" "github.com/break/junhong_cmp_fiber/internal/service/commission_calculation" "github.com/break/junhong_cmp_fiber/internal/service/commission_stats" + deviceSvc "github.com/break/junhong_cmp_fiber/internal/service/device" iotCardSvc "github.com/break/junhong_cmp_fiber/internal/service/iot_card" orderSvc "github.com/break/junhong_cmp_fiber/internal/service/order" packagepkg "github.com/break/junhong_cmp_fiber/internal/service/package" pollingSvc "github.com/break/junhong_cmp_fiber/internal/service/polling" + purchaseValidationSvc "github.com/break/junhong_cmp_fiber/internal/service/purchase_validation" "github.com/break/junhong_cmp_fiber/pkg/queue" ) @@ -102,7 +104,8 @@ func initWorkerServices(stores *queue.WorkerStores, deps *WorkerDependencies) *q cardObservationInfra.NewSeriesAttemptLogger(cardObservationIntegration), ) - // 初始化订单服务(仅用于超时自动取消,不需要微信支付和队列客户端) + // 初始化订单服务,供超时取消和批量订购共同复用现有订单规则。 + purchaseValidation := purchaseValidationSvc.New(deps.DB, stores.IotCard, stores.Device, stores.Package, stores.ShopPackageAllocation) orderService := orderSvc.New( deps.DB, deps.Redis, @@ -111,7 +114,7 @@ func initWorkerServices(stores *queue.WorkerStores, deps *WorkerDependencies) *q stores.AgentWallet, stores.AssetWallet, nil, // paymentStore: 超时取消不需要 - nil, // purchaseValidationService: 超时取消不需要 + purchaseValidation, stores.ShopPackageAllocation, stores.ShopSeriesAllocation, stores.IotCard, @@ -122,7 +125,7 @@ func initWorkerServices(stores *queue.WorkerStores, deps *WorkerDependencies) *q nil, // wechatConfigService: 超时取消不需要 nil, // wechatPayment: 超时取消不需要 nil, // paymentLoader: 超时取消不需要 - nil, // queueClient: 超时取消不触发分佣 + deps.QueueClient, deps.Logger, stores.AssetIdentifier, stores.PersonalCustomer, @@ -131,6 +134,7 @@ func initWorkerServices(stores *queue.WorkerStores, deps *WorkerDependencies) *q walletOutbox := outbox.NewRepository() walletDebitEvents := walletinfra.NewDebitEventWriter(walletOutbox) orderService.SetAgentWalletReservationService(walletapp.NewReservationService(walletinfra.NewReservationEventWriter(walletOutbox), walletDebitEvents, nil)) + orderService.SetAgentWalletDebitService(walletapp.NewDebitService(walletDebitEvents, nil)) // 创建停复机服务并注入回调:流量耗尽自动停机、套餐激活/重置/支付后自动复机 stopResumeService := iotCardSvc.NewStopResumeService( @@ -148,19 +152,26 @@ func initWorkerServices(stores *queue.WorkerStores, deps *WorkerDependencies) *q activationService.SetResumeCallback(stopResumeService) orderService.SetResumeCallback(stopResumeService) resetService.SetResumeCallback(stopResumeService) + deviceBatchAllocator := deviceSvc.New( + deps.DB, deps.Redis, stores.Device, stores.DeviceSimBinding, stores.IotCard, stores.Shop, + stores.AssetAllocationRecord, stores.ShopPackageAllocation, stores.ShopSeriesAllocation, + stores.PackageSeries, deps.GatewayClient, stores.AssetIdentifier, assetAudit, nil, nil, + ) return &queue.WorkerServices{ - CardObservation: cardObservationService, - CardObservationSeries: cardObservationSeriesService, - ObservationSeriesEvents: observationSeriesEvents, - CommissionCalculation: commissionCalculationService, - CommissionStats: commissionStatsService, - UsageService: usageService, - ActivationService: activationService, - ResetService: resetService, - AlertService: alertService, - CleanupService: cleanupService, - StopResumeService: stopResumeService, - OrderExpirer: orderService, + CardObservation: cardObservationService, + CardObservationSeries: cardObservationSeriesService, + ObservationSeriesEvents: observationSeriesEvents, + CommissionCalculation: commissionCalculationService, + CommissionStats: commissionStatsService, + UsageService: usageService, + ActivationService: activationService, + ResetService: resetService, + AlertService: alertService, + CleanupService: cleanupService, + StopResumeService: stopResumeService, + OrderExpirer: orderService, + AssetPackageOrderCreator: orderService, + DeviceBatchAllocator: deviceBatchAllocator, } } diff --git a/internal/bootstrap/worker_stores.go b/internal/bootstrap/worker_stores.go index 73d64b3..915a8ae 100644 --- a/internal/bootstrap/worker_stores.go +++ b/internal/bootstrap/worker_stores.go @@ -6,102 +6,108 @@ import ( ) type workerStores struct { - AssetOperationLog *postgres.AssetOperationLogStore - IotCardImportTask *postgres.IotCardImportTaskStore - IotCard *postgres.IotCardStore - DeviceImportTask *postgres.DeviceImportTaskStore - ExportTask *postgres.ExportTaskStore - ExportShardTask *postgres.ExportShardTaskStore - Device *postgres.DeviceStore - DeviceSimBinding *postgres.DeviceSimBindingStore - ShopSeriesCommissionStats *postgres.ShopSeriesCommissionStatsStore - ShopPackageAllocation *postgres.ShopPackageAllocationStore - CommissionRecord *postgres.CommissionRecordStore - Shop *postgres.ShopStore - ShopSeriesAllocation *postgres.ShopSeriesAllocationStore - PackageSeries *postgres.PackageSeriesStore - Order *postgres.OrderStore - OrderItem *postgres.OrderItemStore - Package *postgres.PackageStore - PackageUsage *postgres.PackageUsageStore - PackageUsageDailyRecord *postgres.PackageUsageDailyRecordStore - PollingAlertRule *postgres.PollingAlertRuleStore - PollingAlertHistory *postgres.PollingAlertHistoryStore - DataCleanupConfig *postgres.DataCleanupConfigStore - DataCleanupLog *postgres.DataCleanupLogStore - AgentWallet *postgres.AgentWalletStore - AgentWalletTransaction *postgres.AgentWalletTransactionStore - AssetWallet *postgres.AssetWalletStore - AssetIdentifier *postgres.AssetIdentifierStore + AssetOperationLog *postgres.AssetOperationLogStore + AssetAllocationRecord *postgres.AssetAllocationRecordStore + IotCardImportTask *postgres.IotCardImportTaskStore + IotCard *postgres.IotCardStore + DeviceImportTask *postgres.DeviceImportTaskStore + ExportTask *postgres.ExportTaskStore + ExportShardTask *postgres.ExportShardTaskStore + Device *postgres.DeviceStore + DeviceSimBinding *postgres.DeviceSimBindingStore + ShopSeriesCommissionStats *postgres.ShopSeriesCommissionStatsStore + ShopPackageAllocation *postgres.ShopPackageAllocationStore + CommissionRecord *postgres.CommissionRecordStore + Shop *postgres.ShopStore + ShopSeriesAllocation *postgres.ShopSeriesAllocationStore + PackageSeries *postgres.PackageSeriesStore + Order *postgres.OrderStore + OrderItem *postgres.OrderItemStore + Package *postgres.PackageStore + PackageUsage *postgres.PackageUsageStore + PackageUsageDailyRecord *postgres.PackageUsageDailyRecordStore + PollingAlertRule *postgres.PollingAlertRuleStore + PollingAlertHistory *postgres.PollingAlertHistoryStore + DataCleanupConfig *postgres.DataCleanupConfigStore + DataCleanupLog *postgres.DataCleanupLogStore + AgentWallet *postgres.AgentWalletStore + AgentWalletTransaction *postgres.AgentWalletTransactionStore + AssetWallet *postgres.AssetWalletStore + AssetIdentifier *postgres.AssetIdentifierStore PersonalCustomer *postgres.PersonalCustomerStore PersonalCustomerPhone *postgres.PersonalCustomerPhoneStore OrderPackageInvalidateTask *postgres.OrderPackageInvalidateTaskStore + AssetPackageBatchOrderTask *postgres.AssetPackageBatchOrderTaskStore } func initWorkerStores(deps *WorkerDependencies) *queue.WorkerStores { stores := &workerStores{ - AssetOperationLog: postgres.NewAssetOperationLogStore(deps.DB), - IotCardImportTask: postgres.NewIotCardImportTaskStore(deps.DB, deps.Redis), - IotCard: postgres.NewIotCardStore(deps.DB, deps.Redis), - DeviceImportTask: postgres.NewDeviceImportTaskStore(deps.DB, deps.Redis), - ExportTask: postgres.NewExportTaskStore(deps.DB, deps.Redis), - ExportShardTask: postgres.NewExportShardTaskStore(deps.DB, deps.Redis), - Device: postgres.NewDeviceStore(deps.DB, deps.Redis), - DeviceSimBinding: postgres.NewDeviceSimBindingStore(deps.DB, deps.Redis), - ShopSeriesCommissionStats: postgres.NewShopSeriesCommissionStatsStore(deps.DB), - ShopPackageAllocation: postgres.NewShopPackageAllocationStore(deps.DB), - CommissionRecord: postgres.NewCommissionRecordStore(deps.DB, deps.Redis), - Shop: postgres.NewShopStore(deps.DB, deps.Redis), - ShopSeriesAllocation: postgres.NewShopSeriesAllocationStore(deps.DB), - PackageSeries: postgres.NewPackageSeriesStore(deps.DB), - Order: postgres.NewOrderStore(deps.DB, deps.Redis), - OrderItem: postgres.NewOrderItemStore(deps.DB, deps.Redis), - Package: postgres.NewPackageStore(deps.DB), - PackageUsage: postgres.NewPackageUsageStore(deps.DB, deps.Redis), - PackageUsageDailyRecord: postgres.NewPackageUsageDailyRecordStore(deps.DB, deps.Redis), - PollingAlertRule: postgres.NewPollingAlertRuleStore(deps.DB), - PollingAlertHistory: postgres.NewPollingAlertHistoryStore(deps.DB), - DataCleanupConfig: postgres.NewDataCleanupConfigStore(deps.DB), - DataCleanupLog: postgres.NewDataCleanupLogStore(deps.DB), - AgentWallet: postgres.NewAgentWalletStore(deps.DB, deps.Redis), - AgentWalletTransaction: postgres.NewAgentWalletTransactionStore(deps.DB, deps.Redis), - AssetWallet: postgres.NewAssetWalletStore(deps.DB, deps.Redis), - AssetIdentifier: postgres.NewAssetIdentifierStore(deps.DB), + AssetOperationLog: postgres.NewAssetOperationLogStore(deps.DB), + AssetAllocationRecord: postgres.NewAssetAllocationRecordStore(deps.DB, deps.Redis), + IotCardImportTask: postgres.NewIotCardImportTaskStore(deps.DB, deps.Redis), + IotCard: postgres.NewIotCardStore(deps.DB, deps.Redis), + DeviceImportTask: postgres.NewDeviceImportTaskStore(deps.DB, deps.Redis), + ExportTask: postgres.NewExportTaskStore(deps.DB, deps.Redis), + ExportShardTask: postgres.NewExportShardTaskStore(deps.DB, deps.Redis), + Device: postgres.NewDeviceStore(deps.DB, deps.Redis), + DeviceSimBinding: postgres.NewDeviceSimBindingStore(deps.DB, deps.Redis), + ShopSeriesCommissionStats: postgres.NewShopSeriesCommissionStatsStore(deps.DB), + ShopPackageAllocation: postgres.NewShopPackageAllocationStore(deps.DB), + CommissionRecord: postgres.NewCommissionRecordStore(deps.DB, deps.Redis), + Shop: postgres.NewShopStore(deps.DB, deps.Redis), + ShopSeriesAllocation: postgres.NewShopSeriesAllocationStore(deps.DB), + PackageSeries: postgres.NewPackageSeriesStore(deps.DB), + Order: postgres.NewOrderStore(deps.DB, deps.Redis), + OrderItem: postgres.NewOrderItemStore(deps.DB, deps.Redis), + Package: postgres.NewPackageStore(deps.DB), + PackageUsage: postgres.NewPackageUsageStore(deps.DB, deps.Redis), + PackageUsageDailyRecord: postgres.NewPackageUsageDailyRecordStore(deps.DB, deps.Redis), + PollingAlertRule: postgres.NewPollingAlertRuleStore(deps.DB), + PollingAlertHistory: postgres.NewPollingAlertHistoryStore(deps.DB), + DataCleanupConfig: postgres.NewDataCleanupConfigStore(deps.DB), + DataCleanupLog: postgres.NewDataCleanupLogStore(deps.DB), + AgentWallet: postgres.NewAgentWalletStore(deps.DB, deps.Redis), + AgentWalletTransaction: postgres.NewAgentWalletTransactionStore(deps.DB, deps.Redis), + AssetWallet: postgres.NewAssetWalletStore(deps.DB, deps.Redis), + AssetIdentifier: postgres.NewAssetIdentifierStore(deps.DB), PersonalCustomer: postgres.NewPersonalCustomerStore(deps.DB, deps.Redis), PersonalCustomerPhone: postgres.NewPersonalCustomerPhoneStore(deps.DB), OrderPackageInvalidateTask: postgres.NewOrderPackageInvalidateTaskStore(deps.DB), + AssetPackageBatchOrderTask: postgres.NewAssetPackageBatchOrderTaskStore(deps.DB), } return &queue.WorkerStores{ - AssetOperationLog: stores.AssetOperationLog, - IotCardImportTask: stores.IotCardImportTask, - IotCard: stores.IotCard, - DeviceImportTask: stores.DeviceImportTask, - ExportTask: stores.ExportTask, - ExportShardTask: stores.ExportShardTask, - Device: stores.Device, - DeviceSimBinding: stores.DeviceSimBinding, - ShopSeriesCommissionStats: stores.ShopSeriesCommissionStats, - ShopPackageAllocation: stores.ShopPackageAllocation, - CommissionRecord: stores.CommissionRecord, - Shop: stores.Shop, - ShopSeriesAllocation: stores.ShopSeriesAllocation, - PackageSeries: stores.PackageSeries, - Order: stores.Order, - OrderItem: stores.OrderItem, - Package: stores.Package, - PackageUsage: stores.PackageUsage, - PackageUsageDailyRecord: stores.PackageUsageDailyRecord, - PollingAlertRule: stores.PollingAlertRule, - PollingAlertHistory: stores.PollingAlertHistory, - DataCleanupConfig: stores.DataCleanupConfig, - DataCleanupLog: stores.DataCleanupLog, - AgentWallet: stores.AgentWallet, - AgentWalletTransaction: stores.AgentWalletTransaction, - AssetWallet: stores.AssetWallet, - AssetIdentifier: stores.AssetIdentifier, + AssetOperationLog: stores.AssetOperationLog, + AssetAllocationRecord: stores.AssetAllocationRecord, + IotCardImportTask: stores.IotCardImportTask, + IotCard: stores.IotCard, + DeviceImportTask: stores.DeviceImportTask, + ExportTask: stores.ExportTask, + ExportShardTask: stores.ExportShardTask, + Device: stores.Device, + DeviceSimBinding: stores.DeviceSimBinding, + ShopSeriesCommissionStats: stores.ShopSeriesCommissionStats, + ShopPackageAllocation: stores.ShopPackageAllocation, + CommissionRecord: stores.CommissionRecord, + Shop: stores.Shop, + ShopSeriesAllocation: stores.ShopSeriesAllocation, + PackageSeries: stores.PackageSeries, + Order: stores.Order, + OrderItem: stores.OrderItem, + Package: stores.Package, + PackageUsage: stores.PackageUsage, + PackageUsageDailyRecord: stores.PackageUsageDailyRecord, + PollingAlertRule: stores.PollingAlertRule, + PollingAlertHistory: stores.PollingAlertHistory, + DataCleanupConfig: stores.DataCleanupConfig, + DataCleanupLog: stores.DataCleanupLog, + AgentWallet: stores.AgentWallet, + AgentWalletTransaction: stores.AgentWalletTransaction, + AssetWallet: stores.AssetWallet, + AssetIdentifier: stores.AssetIdentifier, PersonalCustomer: stores.PersonalCustomer, PersonalCustomerPhone: stores.PersonalCustomerPhone, OrderPackageInvalidateTask: stores.OrderPackageInvalidateTask, + AssetPackageBatchOrderTask: stores.AssetPackageBatchOrderTask, } } diff --git a/internal/exporter/agent_recharge_scene.go b/internal/exporter/agent_recharge_scene.go new file mode 100644 index 0000000..b341d81 --- /dev/null +++ b/internal/exporter/agent_recharge_scene.go @@ -0,0 +1,190 @@ +package exporter + +import ( + "context" + "time" + + "gorm.io/gorm" + + "github.com/break/junhong_cmp_fiber/pkg/constants" +) + +// AgentRechargeDataSource 代理充值导出数据源。 +type AgentRechargeDataSource struct { + db *gorm.DB +} + +// NewAgentRechargeDataSource 创建代理充值导出数据源。 +func NewAgentRechargeDataSource(db *gorm.DB) *AgentRechargeDataSource { + return &AgentRechargeDataSource{db: db} +} + +// Scene 返回导出场景编码。 +func (s *AgentRechargeDataSource) Scene() string { + return constants.ExportTaskSceneAgentRecharge +} + +// Count 统计代理充值导出行数。 +func (s *AgentRechargeDataSource) Count(ctx context.Context, params ExportParams) (int, error) { + var total int64 + query := s.applyFilters(s.baseQuery(ctx), params) + if err := query.Count(&total).Error; err != nil { + return 0, err + } + return int(total), nil +} + +// Headers 返回代理充值导出表头。 +func (s *AgentRechargeDataSource) Headers(context.Context, ExportParams) ([]string, error) { + return []string{ + "充值单号", "店铺名称", "充值类型", "充值金额(元)", "实付金额(元)", "充值前余额(元)", "充值后余额(元)", + "状态", "支付方式", "运营备注", "驳回原因", "创建时间", "支付时间", "完成时间", "提交人", "审批来源", "审批状态", "支付凭证", + }, nil +} + +// Fetch 按 offset/limit 查询代理充值导出数据。 +func (s *AgentRechargeDataSource) Fetch(ctx context.Context, params ExportParams, offset, limit int) ([][]string, error) { + if limit <= 0 { + return [][]string{}, nil + } + + var items []agentRechargeExportRow + query := s.applyFilters(s.baseQuery(ctx), params). + Select(` + r.recharge_no, + r.amount, + r.payment_method, + r.status, + r.remark, + COALESCE(r.rejection_reason, '') AS rejection_reason, + r.created_at, + r.paid_at, + r.completed_at, + COALESCE(sh.shop_name, '') AS shop_name, + COALESCE(ac.username, '') AS submitter_name, + ai.provider AS approval_provider, + ai.status AS approval_status, + wt.amount AS actual_amount, + wt.balance_before, + wt.balance_after, + COALESCE((SELECT string_agg(voucher.value, ',' ORDER BY voucher.ordinality) + FROM jsonb_array_elements_text(COALESCE(r.payment_voucher_key, '[]'::jsonb)) WITH ORDINALITY AS voucher(value, ordinality)), '') AS voucher_keys + `). + Joins("LEFT JOIN tb_shop AS sh ON sh.id = r.shop_id"). + Joins("LEFT JOIN tb_account AS ac ON ac.id = r.user_id"). + Joins("LEFT JOIN tb_approval_instance AS ai ON ai.id = r.approval_instance_id"). + Joins(`LEFT JOIN LATERAL ( + SELECT t.amount, t.balance_before, t.balance_after + FROM tb_agent_wallet_transaction AS t + WHERE t.reference_type = ? + AND t.reference_id = r.id + AND t.transaction_type = ? + AND t.status = ? + AND t.deleted_at IS NULL + ORDER BY t.id ASC + LIMIT 1 + ) AS wt ON TRUE`, constants.ReferenceTypeTopup, constants.AgentTransactionTypeRecharge, constants.TransactionStatusSuccess). + Order("r.id ASC"). + Limit(limit). + Offset(offset) + if err := query.Scan(&items).Error; err != nil { + return nil, err + } + + rows := make([][]string, 0, len(items)) + for _, item := range items { + rows = append(rows, []string{ + item.RechargeNo, + item.ShopName, + formatRechargeType(item.PaymentMethod), + formatMoneyYuan(item.Amount), + formatOptionalMoneyYuan(item.ActualAmount), + formatOptionalMoneyYuan(item.BalanceBefore), + formatOptionalMoneyYuan(item.BalanceAfter), + constants.GetRechargeStatusName(item.Status), + constants.GetBusinessPaymentMethodName(item.PaymentMethod), + item.Remark, + item.RejectionReason, + item.CreatedAt.Format(exportTimeLayout), + formatOptionalTime(item.PaidAt), + formatOptionalTime(item.CompletedAt), + item.SubmitterName, + formatApprovalProvider(item.ApprovalProvider), + formatOptionalApprovalStatus(item.ApprovalStatus), + item.VoucherKeys, + }) + } + return rows, nil +} + +func (s *AgentRechargeDataSource) baseQuery(ctx context.Context) *gorm.DB { + return s.db.WithContext(ctx).Table("tb_agent_recharge_record AS r").Where("r.deleted_at IS NULL") +} + +func (s *AgentRechargeDataSource) applyFilters(query *gorm.DB, params ExportParams) *gorm.DB { + query = applyExportShopScope(query, params, "r.shop_id") + if shopID, ok := filterUint(params.Filters, "shop_id"); ok { + query = query.Where("r.shop_id = ?", shopID) + } + if status, ok := filterInt(params.Filters, "status"); ok { + query = query.Where("r.status = ?", status) + } + if start, ok := filterTime(params.Filters, "start_date"); ok { + query = query.Where("r.created_at >= ?", start) + } + if end, ok := filterEndDate(params.Filters, "end_date"); ok { + query = query.Where("r.created_at <= ?", end) + } + return query +} + +type agentRechargeExportRow struct { + RechargeNo string `gorm:"column:recharge_no"` + ShopName string `gorm:"column:shop_name"` + Amount int64 `gorm:"column:amount"` + ActualAmount *int64 `gorm:"column:actual_amount"` + BalanceBefore *int64 `gorm:"column:balance_before"` + BalanceAfter *int64 `gorm:"column:balance_after"` + PaymentMethod string `gorm:"column:payment_method"` + Status int `gorm:"column:status"` + Remark string `gorm:"column:remark"` + RejectionReason string `gorm:"column:rejection_reason"` + CreatedAt time.Time `gorm:"column:created_at"` + PaidAt *time.Time `gorm:"column:paid_at"` + CompletedAt *time.Time `gorm:"column:completed_at"` + SubmitterName string `gorm:"column:submitter_name"` + ApprovalProvider *string `gorm:"column:approval_provider"` + ApprovalStatus *int `gorm:"column:approval_status"` + VoucherKeys string `gorm:"column:voucher_keys"` +} + +func formatRechargeType(paymentMethod string) string { + if paymentMethod == constants.RechargeMethodOffline { + return "员工线下代充值" + } + return "在线充值" +} + +func formatOptionalMoneyYuan(value *int64) string { + if value == nil { + return "" + } + return formatMoneyYuan(*value) +} + +func formatApprovalProvider(provider *string) string { + if provider == nil || *provider == "" { + return "" + } + if *provider == constants.IntegrationProviderWeCom { + return "企业微信" + } + return *provider +} + +func formatOptionalApprovalStatus(status *int) string { + if status == nil { + return "" + } + return constants.GetApprovalStatusName(*status) +} diff --git a/internal/exporter/agent_wallet_transaction_scene.go b/internal/exporter/agent_wallet_transaction_scene.go new file mode 100644 index 0000000..71f895d --- /dev/null +++ b/internal/exporter/agent_wallet_transaction_scene.go @@ -0,0 +1,168 @@ +package exporter + +import ( + "context" + "strconv" + "time" + + "gorm.io/gorm" + + "github.com/break/junhong_cmp_fiber/pkg/constants" +) + +// AgentWalletTransactionDataSource 代理主钱包流水导出数据源。 +type AgentWalletTransactionDataSource struct { + db *gorm.DB +} + +// NewAgentWalletTransactionDataSource 创建代理主钱包流水导出数据源。 +func NewAgentWalletTransactionDataSource(db *gorm.DB) *AgentWalletTransactionDataSource { + return &AgentWalletTransactionDataSource{db: db} +} + +// Scene 返回导出场景编码。 +func (s *AgentWalletTransactionDataSource) Scene() string { + return constants.ExportTaskSceneAgentWalletTransaction +} + +// Count 统计代理主钱包流水导出行数。 +func (s *AgentWalletTransactionDataSource) Count(ctx context.Context, params ExportParams) (int, error) { + var total int64 + query := s.applyFilters(s.baseQuery(ctx), params) + if err := query.Count(&total).Error; err != nil { + return 0, err + } + return int(total), nil +} + +// Headers 返回代理主钱包流水导出表头。 +func (s *AgentWalletTransactionDataSource) Headers(context.Context, ExportParams) ([]string, error) { + return []string{ + "店铺名称", "交易类型", "交易金额(元)", "状态", "资产类型", "资产标识", "交易时间", + "交易前金额(元)", "交易后金额(元)", "购买套餐名称", "操作人", "交易ID", "关联业务订单号", "交易渠道/支付方式", + }, nil +} + +// Fetch 按 offset/limit 查询代理主钱包流水导出数据。 +func (s *AgentWalletTransactionDataSource) Fetch(ctx context.Context, params ExportParams, offset, limit int) ([][]string, error) { + if limit <= 0 { + return [][]string{}, nil + } + + var items []agentWalletTransactionExportRow + query := s.applyFilters(s.baseQuery(ctx), params). + Select(` + t.id, + t.transaction_type, + t.amount, + t.status, + t.asset_type, + t.asset_identifier, + t.created_at, + t.balance_before, + t.balance_after, + COALESCE(sh.shop_name, '') AS shop_name, + COALESCE(items.package_names, t.metadata ->> 'package_name', '') AS package_name, + COALESCE(ac.username, '') AS operator_name, + COALESCE( + CASE t.reference_type + WHEN ? THEN o.order_no + WHEN ? THEN ar.recharge_no + WHEN ? THEN rr.refund_no + WHEN ? THEN wr.withdrawal_no + WHEN ? THEN ex.exchange_no + END, + '' + ) AS business_order_no, + COALESCE(t.metadata ->> 'payment_method', '') AS payment_method + `, constants.ReferenceTypeOrder, constants.ReferenceTypeTopup, constants.ReferenceTypeRefund, constants.ReferenceTypeWithdrawal, constants.ReferenceTypeExchange). + Joins("LEFT JOIN tb_shop AS sh ON sh.id = t.shop_id"). + Joins("LEFT JOIN tb_account AS ac ON ac.id = COALESCE(NULLIF(t.creator, 0), t.user_id)"). + Joins("LEFT JOIN tb_order AS o ON t.reference_type = ? AND o.id = t.reference_id AND o.deleted_at IS NULL", constants.ReferenceTypeOrder). + Joins("LEFT JOIN tb_agent_recharge_record AS ar ON t.reference_type = ? AND ar.id = t.reference_id AND ar.deleted_at IS NULL", constants.ReferenceTypeTopup). + Joins("LEFT JOIN tb_refund_request AS rr ON t.reference_type = ? AND rr.id = t.reference_id AND rr.deleted_at IS NULL", constants.ReferenceTypeRefund). + Joins("LEFT JOIN tb_commission_withdrawal_request AS wr ON t.reference_type = ? AND wr.id = t.reference_id AND wr.deleted_at IS NULL", constants.ReferenceTypeWithdrawal). + Joins("LEFT JOIN tb_exchange_order AS ex ON t.reference_type = ? AND ex.id = t.reference_id AND ex.deleted_at IS NULL", constants.ReferenceTypeExchange). + Joins(`LEFT JOIN LATERAL ( + SELECT string_agg(oi.package_name, ',' ORDER BY oi.id) AS package_names + FROM tb_order_item AS oi + WHERE t.reference_type = ? + AND oi.order_id = t.reference_id + AND oi.deleted_at IS NULL + ) AS items ON TRUE`, constants.ReferenceTypeOrder). + Order("t.id ASC"). + Limit(limit). + Offset(offset) + if err := query.Scan(&items).Error; err != nil { + return nil, err + } + + rows := make([][]string, 0, len(items)) + for _, item := range items { + paymentMethod := item.PaymentMethod + if paymentMethod == "" && item.TransactionType == constants.AgentTransactionTypeDeduct { + paymentMethod = constants.PaymentMethodWallet + } + rows = append(rows, []string{ + item.ShopName, + constants.GetAgentTransactionTypeName(item.TransactionType), + formatMoneyYuan(item.Amount), + constants.GetTransactionStatusName(item.Status), + constants.GetWalletAssetTypeName(item.AssetType), + item.AssetIdentifier, + item.CreatedAt.Format(exportTimeLayout), + formatMoneyYuan(item.BalanceBefore), + formatMoneyYuan(item.BalanceAfter), + item.PackageName, + item.OperatorName, + strconv.FormatUint(uint64(item.ID), 10), + item.BusinessOrderNo, + constants.GetBusinessPaymentMethodName(paymentMethod), + }) + } + return rows, nil +} + +func (s *AgentWalletTransactionDataSource) baseQuery(ctx context.Context) *gorm.DB { + return s.db.WithContext(ctx). + Table("tb_agent_wallet_transaction AS t"). + Joins("INNER JOIN tb_agent_wallet AS w ON w.id = t.agent_wallet_id AND w.wallet_type = ? AND w.deleted_at IS NULL", constants.AgentWalletTypeMain). + Where("t.deleted_at IS NULL") +} + +func (s *AgentWalletTransactionDataSource) applyFilters(query *gorm.DB, params ExportParams) *gorm.DB { + query = applyExportShopScope(query, params, "t.shop_id") + if shopID, ok := filterUint(params.Filters, "shop_id"); ok { + query = query.Where("t.shop_id = ?", shopID) + } + if transactionType, ok := filterString(params.Filters, "transaction_type"); ok { + query = query.Where("t.transaction_type = ?", transactionType) + } + if start, ok := filterTime(params.Filters, "start_date"); ok { + query = query.Where("t.created_at >= ?", start) + } + if end, ok := filterEndDate(params.Filters, "end_date"); ok { + query = query.Where("t.created_at <= ?", end) + } + if assetIdentifier, ok := filterString(params.Filters, "asset_identifier"); ok { + query = query.Where("t.asset_identifier = ?", assetIdentifier) + } + return query +} + +type agentWalletTransactionExportRow struct { + ID uint `gorm:"column:id"` + ShopName string `gorm:"column:shop_name"` + TransactionType string `gorm:"column:transaction_type"` + Amount int64 `gorm:"column:amount"` + Status int `gorm:"column:status"` + AssetType string `gorm:"column:asset_type"` + AssetIdentifier string `gorm:"column:asset_identifier"` + CreatedAt time.Time `gorm:"column:created_at"` + BalanceBefore int64 `gorm:"column:balance_before"` + BalanceAfter int64 `gorm:"column:balance_after"` + PackageName string `gorm:"column:package_name"` + OperatorName string `gorm:"column:operator_name"` + BusinessOrderNo string `gorm:"column:business_order_no"` + PaymentMethod string `gorm:"column:payment_method"` +} diff --git a/internal/exporter/exchange_scene.go b/internal/exporter/exchange_scene.go new file mode 100644 index 0000000..1bfd8f8 --- /dev/null +++ b/internal/exporter/exchange_scene.go @@ -0,0 +1,176 @@ +package exporter + +import ( + "context" + "time" + + "gorm.io/gorm" + + "github.com/break/junhong_cmp_fiber/pkg/constants" +) + +// ExchangeDataSource 换货记录导出数据源。 +type ExchangeDataSource struct { + db *gorm.DB +} + +// NewExchangeDataSource 创建换货记录导出数据源。 +func NewExchangeDataSource(db *gorm.DB) *ExchangeDataSource { + return &ExchangeDataSource{db: db} +} + +// Scene 返回导出场景编码。 +func (s *ExchangeDataSource) Scene() string { + return constants.ExportTaskSceneExchange +} + +// Count 统计换货记录导出行数。 +func (s *ExchangeDataSource) Count(ctx context.Context, params ExportParams) (int, error) { + var total int64 + query := s.applyFilters(s.baseQuery(ctx), params) + if err := query.Count(&total).Error; err != nil { + return 0, err + } + return int(total), nil +} + +// Headers 返回换货记录导出表头。 +func (s *ExchangeDataSource) Headers(context.Context, ExportParams) ([]string, error) { + return []string{ + "换货单号", "换货类型", "换货原因", "问题描述/备注", "旧资产类型", "旧资产标识符", "新资产标识符", + "收货人姓名", "收货人电话", "收货地址", "快递公司", "快递单号", "状态", "创建人", "创建时间", + }, nil +} + +// Fetch 按 offset/limit 查询换货记录导出数据。 +func (s *ExchangeDataSource) Fetch(ctx context.Context, params ExportParams, offset, limit int) ([][]string, error) { + if limit <= 0 { + return [][]string{}, nil + } + + var items []exchangeExportRow + query := s.applyFilters(s.baseQuery(ctx), params). + Select(` + e.exchange_no, + e.flow_type, + e.exchange_reason, + COALESCE(e.remark, '') AS remark, + e.old_asset_type, + e.old_asset_identifier, + e.new_asset_identifier, + e.recipient_name, + e.recipient_phone, + e.recipient_address, + e.express_company, + e.express_no, + e.status, + e.created_at, + COALESCE(ac.username, '') AS creator_name + `). + Joins("LEFT JOIN tb_account AS ac ON ac.id = e.creator"). + Order("e.id ASC"). + Limit(limit). + Offset(offset) + if err := query.Scan(&items).Error; err != nil { + return nil, err + } + + rows := make([][]string, 0, len(items)) + for _, item := range items { + rows = append(rows, []string{ + item.ExchangeNo, + constants.GetExchangeFlowTypeName(item.FlowType), + item.ExchangeReason, + item.Remark, + formatExchangeAssetType(item.OldAssetType), + item.OldAssetIdentifier, + item.NewAssetIdentifier, + item.RecipientName, + item.RecipientPhone, + item.RecipientAddress, + item.ExpressCompany, + item.ExpressNo, + constants.GetExchangeStatusName(item.Status), + item.CreatorName, + item.CreatedAt.Format(exportTimeLayout), + }) + } + return rows, nil +} + +func (s *ExchangeDataSource) baseQuery(ctx context.Context) *gorm.DB { + return s.db.WithContext(ctx).Table("tb_exchange_order AS e").Where("e.deleted_at IS NULL") +} + +func (s *ExchangeDataSource) applyFilters(query *gorm.DB, params ExportParams) *gorm.DB { + query = applyExportShopScope(query, params, "e.shop_id") + if status, ok := filterInt(params.Filters, "status"); ok { + query = query.Where("e.status = ?", status) + } + if flowType, ok := filterString(params.Filters, "flow_type"); ok { + query = query.Where("COALESCE(NULLIF(e.flow_type, ''), ?) = ?", constants.ExchangeFlowTypeShipping, flowType) + } + query = applyExchangeAssetKeyword(query, "old", filterValue(params.Filters, "old_asset_keyword")) + query = applyExchangeAssetKeyword(query, "new", filterValue(params.Filters, "new_asset_keyword")) + if start, ok := filterTime(params.Filters, "created_at_start"); ok { + query = query.Where("e.created_at >= ?", start) + } + if end, ok := filterTime(params.Filters, "created_at_end"); ok { + query = query.Where("e.created_at <= ?", end) + } + return query +} + +func applyExchangeAssetKeyword(query *gorm.DB, side, keyword string) *gorm.DB { + if keyword == "" { + return query + } + like := "%" + keyword + "%" + cardIDs := query.Session(&gorm.Session{NewDB: true}).Table("tb_iot_card").Select("id"). + Where("deleted_at IS NULL"). + Where("iccid LIKE ? OR msisdn LIKE ? OR virtual_no LIKE ?", like, like, like) + deviceIDs := query.Session(&gorm.Session{NewDB: true}).Table("tb_device").Select("id"). + Where("deleted_at IS NULL"). + Where("virtual_no LIKE ? OR imei LIKE ? OR sn LIKE ?", like, like, like) + prefix := "e." + side + return query.Where( + "("+prefix+"_asset_type = ? AND "+prefix+"_asset_id IN (?)) OR ("+prefix+"_asset_type = ? AND "+prefix+"_asset_id IN (?))", + constants.ExchangeAssetTypeIotCard, cardIDs, constants.ExchangeAssetTypeDevice, deviceIDs, + ) +} + +func filterValue(filters map[string]any, key string) string { + value, _ := filterString(filters, key) + return value +} + +func formatExchangeAssetType(assetType string) string { + switch assetType { + case constants.ExchangeAssetTypeIotCard: + return "物联网卡" + case constants.ExchangeAssetTypeDevice: + return "设备" + case "": + return "" + default: + return "未知" + } +} + +type exchangeExportRow struct { + ExchangeNo string `gorm:"column:exchange_no"` + FlowType string `gorm:"column:flow_type"` + ExchangeReason string `gorm:"column:exchange_reason"` + Remark string `gorm:"column:remark"` + OldAssetType string `gorm:"column:old_asset_type"` + OldAssetIdentifier string `gorm:"column:old_asset_identifier"` + NewAssetIdentifier string `gorm:"column:new_asset_identifier"` + RecipientName string `gorm:"column:recipient_name"` + RecipientPhone string `gorm:"column:recipient_phone"` + RecipientAddress string `gorm:"column:recipient_address"` + ExpressCompany string `gorm:"column:express_company"` + ExpressNo string `gorm:"column:express_no"` + Status int `gorm:"column:status"` + CreatorName string `gorm:"column:creator_name"` + CreatedAt time.Time `gorm:"column:created_at"` +} diff --git a/internal/exporter/filter_helpers.go b/internal/exporter/filter_helpers.go index e80c2cf..bdb3901 100644 --- a/internal/exporter/filter_helpers.go +++ b/internal/exporter/filter_helpers.go @@ -255,6 +255,18 @@ func filterTime(filters map[string]any, key string) (time.Time, bool) { return time.Time{}, false } +// filterEndDate 解析截止时间;纯日期输入覆盖到当天结束,带时分秒输入保持原值。 +func filterEndDate(filters map[string]any, key string) (time.Time, bool) { + text, ok := filterString(filters, key) + if !ok { + return time.Time{}, false + } + if parsed, err := time.ParseInLocation("2006-01-02", text, time.Local); err == nil { + return parsed.Add(24*time.Hour - time.Nanosecond), true + } + return filterTime(filters, key) +} + func formatOptionalUint(value *uint) string { if value == nil { return "" diff --git a/internal/exporter/iot_card_scene.go b/internal/exporter/iot_card_scene.go index 4e448e5..c33be8e 100644 --- a/internal/exporter/iot_card_scene.go +++ b/internal/exporter/iot_card_scene.go @@ -2,6 +2,7 @@ package exporter import ( "context" + "strconv" "time" "gorm.io/gorm" @@ -36,7 +37,7 @@ func (s *IotCardDataSource) Count(ctx context.Context, params ExportParams) (int // Headers 返回 IoT 卡导出表头。 func (s *IotCardDataSource) Headers(ctx context.Context, params ExportParams) ([]string, error) { - return []string{"ICCID", "MSISDN", "绑定设备虚拟号", "运营商", "店铺名称", "绑定设备名称", "是否实名", "实名时间", "网络状态"}, nil + return []string{"ICCID", "MSISDN", "绑定设备虚拟号", "运营商", "店铺名称", "绑定设备名称", "是否实名", "实名时间", "网络状态", "套餐名称", "使用流量(MB)", "剩余流量(MB)"}, nil } // Fetch 按 offset/limit 查询 IoT 卡导出数据。 @@ -56,7 +57,10 @@ func (s *IotCardDataSource) Fetch(ctx context.Context, params ExportParams, offs COALESCE(d.device_name, '') AS device_name, c.real_name_status, c.first_realname_at, - c.network_status + c.network_status, + COALESCE(pkg.package_name, '') AS package_name, + COALESCE(pkg.data_usage_mb, 0) AS data_usage_mb, + COALESCE(pkg.data_limit_mb, 0) AS data_limit_mb `). Order("c.id ASC"). Limit(limit). @@ -77,6 +81,9 @@ func (s *IotCardDataSource) Fetch(ctx context.Context, params ExportParams, offs formatRealNameVerified(item.RealNameStatus), formatOptionalTime(item.FirstRealnameAt), constants.GetNetworkStatusName(item.NetworkStatus), + item.PackageName, + strconv.FormatInt(item.DataUsageMB, 10), + strconv.FormatInt(remainingPackageDataMB(item.DataLimitMB, item.DataUsageMB), 10), }) } return rows, nil @@ -100,6 +107,22 @@ func (s *IotCardDataSource) baseQuery(ctx context.Context) *gorm.DB { LIMIT 1 ) AS d ON TRUE `, constants.BindStatusBound). + Joins(` + LEFT JOIN LATERAL ( + SELECT + COALESCE(NULLIF(pu.package_name, ''), p.package_name, '') AS package_name, + pu.data_usage_mb, + pu.data_limit_mb + FROM tb_package_usage AS pu + LEFT JOIN tb_package AS p ON p.id = pu.package_id AND p.deleted_at IS NULL + WHERE pu.iot_card_id = c.id + AND pu.status = ? + AND pu.master_usage_id IS NULL + AND pu.deleted_at IS NULL + ORDER BY pu.priority ASC, pu.activated_at ASC, pu.id ASC + LIMIT 1 + ) AS pkg ON TRUE + `, constants.PackageUsageStatusActive). Where("c.deleted_at IS NULL") } @@ -206,6 +229,17 @@ type iotCardExportRow struct { RealNameStatus int `gorm:"column:real_name_status"` FirstRealnameAt *time.Time `gorm:"column:first_realname_at"` NetworkStatus int `gorm:"column:network_status"` + PackageName string `gorm:"column:package_name"` + DataUsageMB int64 `gorm:"column:data_usage_mb"` + DataLimitMB int64 `gorm:"column:data_limit_mb"` +} + +func remainingPackageDataMB(limit, used int64) int64 { + remaining := limit - used + if remaining < 0 { + return 0 + } + return remaining } func formatRealNameVerified(status int) string { diff --git a/internal/exporter/package_scene.go b/internal/exporter/package_scene.go new file mode 100644 index 0000000..23e1c8c --- /dev/null +++ b/internal/exporter/package_scene.go @@ -0,0 +1,228 @@ +package exporter + +import ( + "context" + "strconv" + "time" + + "gorm.io/gorm" + + "github.com/break/junhong_cmp_fiber/pkg/constants" +) + +// PackageDataSource 套餐导出数据源。 +type PackageDataSource struct { + db *gorm.DB +} + +// NewPackageDataSource 创建套餐导出数据源。 +func NewPackageDataSource(db *gorm.DB) *PackageDataSource { + return &PackageDataSource{db: db} +} + +// Scene 返回导出场景编码。 +func (s *PackageDataSource) Scene() string { + return constants.ExportTaskScenePackage +} + +// Count 统计套餐导出行数。 +func (s *PackageDataSource) Count(ctx context.Context, params ExportParams) (int, error) { + var total int64 + query := s.applyFilters(s.baseQuery(ctx, params), params) + if err := query.Count(&total).Error; err != nil { + return 0, err + } + return int(total), nil +} + +// Headers 返回套餐导出固定表头。 +func (s *PackageDataSource) Headers(context.Context, ExportParams) ([]string, error) { + return []string{ + "套餐编码", "套餐名称", "套餐系列名称", "套餐类型", "套餐时长(月)", + "套餐时长说明", "套餐周期类型", "套餐天数", "真流量额度(MB)", "虚流量额度(MB)", + "是否启用虚流量", "虚流量比例", "流量重置周期", "到期时间基准", "成本价(元)", + "建议售价(元)", "价格配置状态", "状态", "上架状态", "是否赠送套餐", + "创建人ID", "更新人ID", "创建时间", "更新时间", "删除时间", + }, nil +} + +// Fetch 按 offset/limit 查询套餐导出数据。 +func (s *PackageDataSource) Fetch(ctx context.Context, params ExportParams, offset, limit int) ([][]string, error) { + if limit <= 0 { + return [][]string{}, nil + } + + var items []packageExportRow + query := s.applyFilters(s.baseQuery(ctx, params), params). + Joins("LEFT JOIN tb_package_series AS ps ON ps.id = p.series_id AND ps.deleted_at IS NULL"). + Select(s.selectColumns(params)). + Order("p.id ASC"). + Limit(limit). + Offset(offset) + if err := query.Scan(&items).Error; err != nil { + return nil, err + } + + rows := make([][]string, 0, len(items)) + for _, item := range items { + rows = append(rows, buildPackageExportRow(item)) + } + return rows, nil +} + +func (s *PackageDataSource) baseQuery(ctx context.Context, params ExportParams) *gorm.DB { + query := s.db.WithContext(ctx).Table("tb_package AS p").Where("p.deleted_at IS NULL") + switch params.UserType { + case constants.UserTypeSuperAdmin, constants.UserTypePlatform: + return query + case constants.UserTypeAgent: + if params.CreatorShopID == nil || *params.CreatorShopID == 0 { + return query.Where("1 = 0") + } + return query. + Joins(`INNER JOIN tb_shop_package_allocation AS a + ON a.package_id = p.id + AND a.shop_id = ? + AND a.status = ? + AND a.deleted_at IS NULL`, *params.CreatorShopID, constants.StatusEnabled). + Where("p.is_gift = ?", false) + default: + return query.Where("1 = 0") + } +} + +func (s *PackageDataSource) applyFilters(query *gorm.DB, params ExportParams) *gorm.DB { + if packageName, ok := filterString(params.Filters, "package_name"); ok { + query = query.Where("p.package_name LIKE ?", "%"+packageName+"%") + } + if seriesID, ok := filterUint(params.Filters, "series_id"); ok { + query = query.Where("p.series_id = ?", seriesID) + } + if status, ok := filterInt(params.Filters, "status"); ok { + query = query.Where("p.status = ?", status) + } + if shelfStatus, ok := filterInt(params.Filters, "shelf_status"); ok { + if params.UserType == constants.UserTypeAgent { + query = query.Where("a.shelf_status = ?", shelfStatus) + } else { + query = query.Where("p.shelf_status = ?", shelfStatus) + } + } + if packageType, ok := filterString(params.Filters, "package_type"); ok { + query = query.Where("p.package_type = ?", packageType) + } + return query +} + +func (s *PackageDataSource) selectColumns(params ExportParams) string { + costPriceColumn := "p.cost_price" + shelfStatusColumn := "p.shelf_status" + if params.UserType == constants.UserTypeAgent { + costPriceColumn = "a.cost_price" + shelfStatusColumn = "a.shelf_status" + } + return ` + p.package_code, + p.package_name, + COALESCE(ps.series_name, '') AS series_name, + p.package_type, + p.duration_months, + p.calendar_type, + p.duration_days, + p.real_data_mb, + p.virtual_data_mb, + p.enable_virtual_data, + p.virtual_ratio, + p.data_reset_cycle, + p.expiry_base, + ` + costPriceColumn + ` AS cost_price, + p.suggested_retail_price, + p.price_config_status, + p.status, + ` + shelfStatusColumn + ` AS shelf_status, + p.is_gift, + p.creator, + p.updater, + p.created_at, + p.updated_at` +} + +type packageExportRow struct { + PackageCode string `gorm:"column:package_code"` + PackageName string `gorm:"column:package_name"` + SeriesName string `gorm:"column:series_name"` + PackageType string `gorm:"column:package_type"` + DurationMonths int `gorm:"column:duration_months"` + CalendarType string `gorm:"column:calendar_type"` + DurationDays int `gorm:"column:duration_days"` + RealDataMB int64 `gorm:"column:real_data_mb"` + VirtualDataMB int64 `gorm:"column:virtual_data_mb"` + EnableVirtualData bool `gorm:"column:enable_virtual_data"` + VirtualRatio float64 `gorm:"column:virtual_ratio"` + DataResetCycle string `gorm:"column:data_reset_cycle"` + ExpiryBase string `gorm:"column:expiry_base"` + CostPrice int64 `gorm:"column:cost_price"` + SuggestedRetailPrice int64 `gorm:"column:suggested_retail_price"` + PriceConfigStatus int `gorm:"column:price_config_status"` + Status int `gorm:"column:status"` + ShelfStatus int `gorm:"column:shelf_status"` + IsGift bool `gorm:"column:is_gift"` + Creator uint `gorm:"column:creator"` + Updater uint `gorm:"column:updater"` + CreatedAt time.Time `gorm:"column:created_at"` + UpdatedAt time.Time `gorm:"column:updated_at"` +} + +func buildPackageExportRow(item packageExportRow) []string { + return []string{ + item.PackageCode, + item.PackageName, + item.SeriesName, + constants.GetPackageTypeName(item.PackageType), + strconv.Itoa(item.DurationMonths), + formatPackageDurationDescription(item), + constants.GetPackageCalendarTypeName(item.CalendarType), + strconv.Itoa(item.DurationDays), + strconv.FormatInt(item.RealDataMB, 10), + strconv.FormatInt(item.VirtualDataMB, 10), + formatYesNo(item.EnableVirtualData), + strconv.FormatFloat(item.VirtualRatio, 'f', 6, 64), + constants.GetPackageDataResetCycleName(item.DataResetCycle), + constants.GetPackageExpiryBaseName(item.ExpiryBase), + formatMoneyYuan(item.CostPrice), + formatPackageSuggestedRetailPrice(item), + constants.GetPackagePriceConfigStatusName(item.PriceConfigStatus), + constants.GetStatusName(item.Status), + constants.GetShelfStatusName(item.ShelfStatus), + formatYesNo(item.IsGift), + strconv.FormatUint(uint64(item.Creator), 10), + strconv.FormatUint(uint64(item.Updater), 10), + item.CreatedAt.Format(exportTimeLayout), + item.UpdatedAt.Format(exportTimeLayout), + "", + } +} + +func formatPackageDurationDescription(item packageExportRow) string { + if item.CalendarType == constants.PackageCalendarTypeByDay && item.DurationDays > 0 { + return strconv.Itoa(item.DurationDays) + "天" + } + if item.DurationMonths > 0 { + return strconv.Itoa(item.DurationMonths) + "个月" + } + return "" +} + +func formatPackageSuggestedRetailPrice(item packageExportRow) string { + if item.PriceConfigStatus == constants.PackagePriceConfigStatusUnconfigured { + return "" + } + return formatMoneyYuan(item.SuggestedRetailPrice) +} + +func formatYesNo(value bool) string { + if value { + return "是" + } + return "否" +} diff --git a/internal/exporter/refund_scene.go b/internal/exporter/refund_scene.go new file mode 100644 index 0000000..2066b94 --- /dev/null +++ b/internal/exporter/refund_scene.go @@ -0,0 +1,206 @@ +package exporter + +import ( + "context" + "time" + + "gorm.io/gorm" + + "github.com/break/junhong_cmp_fiber/internal/model" + "github.com/break/junhong_cmp_fiber/pkg/constants" +) + +// RefundDataSource 退款记录导出数据源。 +type RefundDataSource struct { + db *gorm.DB +} + +// NewRefundDataSource 创建退款记录导出数据源。 +func NewRefundDataSource(db *gorm.DB) *RefundDataSource { + return &RefundDataSource{db: db} +} + +// Scene 返回导出场景编码。 +func (s *RefundDataSource) Scene() string { + return constants.ExportTaskSceneRefund +} + +// Count 统计退款记录导出行数。 +func (s *RefundDataSource) Count(ctx context.Context, params ExportParams) (int, error) { + var total int64 + query := s.applyFilters(s.baseQuery(ctx), params) + if err := query.Count(&total).Error; err != nil { + return 0, err + } + return int(total), nil +} + +// Headers 返回退款记录导出表头。 +func (s *RefundDataSource) Headers(context.Context, ExportParams) ([]string, error) { + return []string{ + "退款单号", "代理店铺名称", "关联的支付订单号", "资产类型", "资产标识", "套餐名称", "原订单金额(元)", + "实收金额(元)", "可退金额(元)", "申请退款金额(元)", "实际退款金额(元)", "状态", "退款原因", "审批备注", + "审批来源", "审批状态", "退款处理状态", "退款申请时间", "退款审批时间", "提交人", "退款凭证", + }, nil +} + +// Fetch 按 offset/limit 查询退款记录导出数据。 +func (s *RefundDataSource) Fetch(ctx context.Context, params ExportParams, offset, limit int) ([][]string, error) { + if limit <= 0 { + return [][]string{}, nil + } + + var items []refundExportRow + query := s.applyFilters(s.baseQuery(ctx), params). + Select(` + r.refund_no, + r.order_no, + r.order_type, + r.asset_identifier, + r.actual_received_amount, + r.requested_refund_amount, + r.approved_refund_amount, + r.status, + r.refund_reason, + r.remark, + r.commission_deducted, + r.asset_reset, + r.created_at, + r.processed_at, + COALESCE(sh.shop_name, '') AS shop_name, + o.total_amount AS original_amount, + o.actual_paid_amount AS refundable_amount, + COALESCE(pu.package_name, items.package_names, '') AS package_name, + COALESCE(ac.username, '') AS submitter_name, + ai.provider AS approval_provider, + ai.status AS approval_status, + COALESCE((SELECT string_agg(voucher.value, ',' ORDER BY voucher.ordinality) + FROM jsonb_array_elements_text(COALESCE(r.refund_voucher_key, '[]'::jsonb)) WITH ORDINALITY AS voucher(value, ordinality)), '') AS voucher_keys + `). + Joins("LEFT JOIN tb_shop AS sh ON sh.id = r.shop_id"). + Joins("LEFT JOIN tb_order AS o ON o.id = r.order_id AND o.deleted_at IS NULL"). + Joins("LEFT JOIN tb_package_usage AS pu ON pu.id = r.package_usage_id AND pu.deleted_at IS NULL"). + Joins("LEFT JOIN tb_account AS ac ON ac.id = r.creator"). + Joins("LEFT JOIN tb_approval_instance AS ai ON ai.id = r.approval_instance_id"). + Joins(`LEFT JOIN LATERAL ( + SELECT string_agg(oi.package_name, ',' ORDER BY oi.id) AS package_names + FROM tb_order_item AS oi + WHERE oi.order_id = r.order_id AND oi.deleted_at IS NULL + ) AS items ON TRUE`). + Order("r.id ASC"). + Limit(limit). + Offset(offset) + if err := query.Scan(&items).Error; err != nil { + return nil, err + } + + rows := make([][]string, 0, len(items)) + for _, item := range items { + rows = append(rows, []string{ + item.RefundNo, + item.ShopName, + item.OrderNo, + formatRefundAssetType(item.OrderType), + item.AssetIdentifier, + item.PackageName, + formatOptionalMoneyYuan(item.OriginalAmount), + formatMoneyYuan(item.ActualReceivedAmount), + formatOptionalMoneyYuan(item.RefundableAmount), + formatMoneyYuan(item.RequestedRefundAmount), + formatOptionalMoneyYuan(item.ApprovedRefundAmount), + constants.GetRefundStatusName(item.Status), + item.RefundReason, + item.Remark, + formatRefundApprovalSource(item.ApprovalProvider), + formatOptionalApprovalStatus(item.ApprovalStatus), + formatRefundProcessingStatus(item.Status, item.CommissionDeducted, item.AssetReset), + item.CreatedAt.Format(exportTimeLayout), + formatOptionalTime(item.ProcessedAt), + item.SubmitterName, + item.VoucherKeys, + }) + } + return rows, nil +} + +func (s *RefundDataSource) baseQuery(ctx context.Context) *gorm.DB { + return s.db.WithContext(ctx).Table("tb_refund_request AS r").Where("r.deleted_at IS NULL") +} + +func (s *RefundDataSource) applyFilters(query *gorm.DB, params ExportParams) *gorm.DB { + query = applyExportShopScope(query, params, "r.shop_id") + if status, ok := filterInt(params.Filters, "status"); ok { + query = query.Where("r.status = ?", status) + } + if orderID, ok := filterUint(params.Filters, "order_id"); ok { + query = query.Where("r.order_id = ?", orderID) + } + if shopID, ok := filterUint(params.Filters, "shop_id"); ok { + query = query.Where("r.shop_id = ?", shopID) + } + if identifier, ok := filterString(params.Filters, "asset_identifier"); ok { + query = query.Where("r.asset_identifier = ?", identifier) + } + return query +} + +type refundExportRow struct { + RefundNo string `gorm:"column:refund_no"` + ShopName string `gorm:"column:shop_name"` + OrderNo string `gorm:"column:order_no"` + OrderType string `gorm:"column:order_type"` + AssetIdentifier string `gorm:"column:asset_identifier"` + PackageName string `gorm:"column:package_name"` + OriginalAmount *int64 `gorm:"column:original_amount"` + ActualReceivedAmount int64 `gorm:"column:actual_received_amount"` + RefundableAmount *int64 `gorm:"column:refundable_amount"` + RequestedRefundAmount int64 `gorm:"column:requested_refund_amount"` + ApprovedRefundAmount *int64 `gorm:"column:approved_refund_amount"` + Status int `gorm:"column:status"` + RefundReason string `gorm:"column:refund_reason"` + Remark string `gorm:"column:remark"` + ApprovalProvider *string `gorm:"column:approval_provider"` + ApprovalStatus *int `gorm:"column:approval_status"` + CommissionDeducted bool `gorm:"column:commission_deducted"` + AssetReset bool `gorm:"column:asset_reset"` + CreatedAt time.Time `gorm:"column:created_at"` + ProcessedAt *time.Time `gorm:"column:processed_at"` + SubmitterName string `gorm:"column:submitter_name"` + VoucherKeys string `gorm:"column:voucher_keys"` +} + +func formatRefundAssetType(orderType string) string { + switch orderType { + case model.OrderTypeSingleCard: + return "物联网卡" + case model.OrderTypeDevice: + return "设备" + case "": + return "" + default: + return "未知" + } +} + +func formatRefundApprovalSource(provider *string) string { + if provider == nil || *provider == "" { + return "历史审批" + } + return formatApprovalProvider(provider) +} + +func formatRefundProcessingStatus(status int, commissionDeducted, assetReset bool) string { + switch status { + case model.RefundStatusPending, model.RefundStatusReturned: + return "待审批" + case model.RefundStatusRejected: + return "无需处理" + case model.RefundStatusApproved: + if commissionDeducted && assetReset { + return "已完成" + } + return "处理中" + default: + return "未知" + } +} diff --git a/internal/exporter/registry.go b/internal/exporter/registry.go index a736f6f..fac6704 100644 --- a/internal/exporter/registry.go +++ b/internal/exporter/registry.go @@ -31,6 +31,11 @@ func NewDefaultRegistry(db *gorm.DB) *Registry { NewDeviceDataSource(db), NewIotCardDataSource(db), NewOrderDataSource(db), + NewPackageDataSource(db), + NewAgentWalletTransactionDataSource(db), + NewAgentRechargeDataSource(db), + NewRefundDataSource(db), + NewExchangeDataSource(db), ) } @@ -59,7 +64,14 @@ func (r *Registry) Scenes() []string { // IsSupportedScene 判断是否为受支持的场景。 func IsSupportedScene(scene string) bool { switch scene { - case constants.ExportTaskSceneDevice, constants.ExportTaskSceneIotCard, constants.ExportTaskSceneOrder: + case constants.ExportTaskSceneDevice, + constants.ExportTaskSceneIotCard, + constants.ExportTaskSceneOrder, + constants.ExportTaskScenePackage, + constants.ExportTaskSceneAgentWalletTransaction, + constants.ExportTaskSceneAgentRecharge, + constants.ExportTaskSceneRefund, + constants.ExportTaskSceneExchange: return true default: return false diff --git a/internal/gateway/device.go b/internal/gateway/device.go index 45f60b0..5f003c2 100644 --- a/internal/gateway/device.go +++ b/internal/gateway/device.go @@ -27,14 +27,6 @@ func (c *Client) GetSlotInfo(ctx context.Context, req *DeviceInfoReq) (*SlotInfo return doRequestWithResponse[SlotInfoResp](c, ctx, "/device/slot-info", req) } -// SetSpeedLimit 设置设备限速 -// 设置设备的统一限速值(单位 KB/s) -// POST /device/speed-limit -func (c *Client) SetSpeedLimit(ctx context.Context, req *SpeedLimitReq) error { - _, err := c.doRequest(ctx, "/device/speed-limit", req) - return err -} - // SetWiFi 设置设备 WiFi // Gateway 实际要求 cardNo 传设备 IMEI,并搭配内层 params 下发 WiFi 名称和密码 // POST /device/wifi-config diff --git a/internal/gateway/flow_card.go b/internal/gateway/flow_card.go index 36512c1..55f5f87 100644 --- a/internal/gateway/flow_card.go +++ b/internal/gateway/flow_card.go @@ -45,6 +45,19 @@ func (c *Client) GetRealnameLink(ctx context.Context, req *CardStatusReq) (*Real return doRequestWithResponse[RealnameLinkResp](c, ctx, "/flow-card/RealNameVerification", req) } +// SetCardSpeedTier 设置或恢复流量卡固定限速档位。 +// POST /flow-card/speedLimit +func (c *Client) SetCardSpeedTier(ctx context.Context, req *CardSpeedTierReq) error { + if req == nil || req.CardNo == "" || req.Code == "" { + return errors.New(errors.CodeInvalidParam, "流量卡号和限速档位不能为空") + } + // 限速是外部状态写入,网络错误或超时后实际结果不可确定,禁止沿用查询接口的自动重试。 + requestClient := *c + requestClient.maxRetries = 0 + _, err := requestClient.doRequest(ctx, "/flow-card/speedLimit", req) + return err +} + // BatchQuery 批量查询(预留接口,暂未实现) func (c *Client) BatchQuery(ctx context.Context, req *BatchQueryReq) (*BatchQueryResp, error) { return nil, errors.New(errors.CodeGatewayError, "批量查询接口暂未实现") diff --git a/internal/gateway/models.go b/internal/gateway/models.go index 67c4428..e47b5bf 100644 --- a/internal/gateway/models.go +++ b/internal/gateway/models.go @@ -187,12 +187,10 @@ type DeviceInfoResp struct { Extend string `json:"extend,omitempty" description:"扩展字段(广电国网特殊参数)"` } -// SpeedLimitReq 是设置设备限速的请求 -type SpeedLimitReq struct { - CardNo string `json:"cardNo,omitempty" description:"流量卡号(与 DeviceID 二选一)"` - DeviceID string `json:"deviceId,omitempty" description:"设备 ID/IMEI(与 CardNo 二选一)"` - SpeedLimit int `json:"speedLimit" validate:"required,min=1" required:"true" minimum:"1" description:"限速值(KB/s)"` - Extend string `json:"extend,omitempty" description:"扩展字段(广电国网特殊参数)"` +// CardSpeedTierReq 是流量卡固定限速档位请求。 +type CardSpeedTierReq struct { + CardNo string `json:"cardNo" validate:"required" required:"true" description:"流量卡 ICCID"` + Code string `json:"code" validate:"required" required:"true" description:"限速档位编码 (-1:恢复不限速, 0:0kbps, 1:128Kbps, 2:512Kbps, 3:1Mbps, 4:2Mbps, 5:10Mbps, 6:20Mbps, 7:50Mbps, 8:100Mbps)"` } // WiFiParams 是设置设备 WiFi 的内层参数 diff --git a/internal/handler/admin/account.go b/internal/handler/admin/account.go index 578dd1b..d393e01 100644 --- a/internal/handler/admin/account.go +++ b/internal/handler/admin/account.go @@ -77,6 +77,24 @@ func (h *AccountHandler) Update(c *fiber.Ctx) error { return response.Success(c, account) } +// BindWeCom 绑定账号与企业微信应用可见成员。 +// PUT /api/admin/accounts/:id/wecom-binding +func (h *AccountHandler) BindWeCom(c *fiber.Ctx) error { + id, err := strconv.ParseUint(c.Params("id"), 10, 64) + if err != nil || id == 0 { + return errors.New(errors.CodeInvalidParam, "无效的账号 ID") + } + var request dto.BindAccountWeComRequest + if err := c.BodyParser(&request); err != nil { + return errors.New(errors.CodeInvalidParam) + } + result, err := h.service.BindWeCom(c.UserContext(), uint(id), request) + if err != nil { + return err + } + return response.Success(c, result) +} + // Delete 删除账号 // DELETE /api/admin/accounts/:id func (h *AccountHandler) Delete(c *fiber.Ctx) error { diff --git a/internal/handler/admin/asset.go b/internal/handler/admin/asset.go index 213f3b5..f46ba43 100644 --- a/internal/handler/admin/asset.go +++ b/internal/handler/admin/asset.go @@ -10,6 +10,7 @@ import ( "github.com/gofiber/fiber/v2" dto "github.com/break/junhong_cmp_fiber/internal/model/dto" + packageExpiryQuery "github.com/break/junhong_cmp_fiber/internal/query/packageexpiry" assetService "github.com/break/junhong_cmp_fiber/internal/service/asset" assetAuditSvc "github.com/break/junhong_cmp_fiber/internal/service/asset_audit" deviceService "github.com/break/junhong_cmp_fiber/internal/service/device" @@ -17,8 +18,10 @@ import ( pollingSvc "github.com/break/junhong_cmp_fiber/internal/service/polling" "github.com/break/junhong_cmp_fiber/pkg/constants" "github.com/break/junhong_cmp_fiber/pkg/errors" + "github.com/break/junhong_cmp_fiber/pkg/logger" "github.com/break/junhong_cmp_fiber/pkg/middleware" "github.com/break/junhong_cmp_fiber/pkg/response" + "go.uber.org/zap" ) // AssetHandler 资产管理处理器 @@ -33,6 +36,7 @@ type AssetHandler struct { assetLifecycleService AssetLifecycleService exchangeTraceQuery AssetExchangeTraceResolver observationSeries cardObservationApp.BestEffortSeriesDispatcher + packageExpiryQuery *packageExpiryQuery.Query } // SetObservationSeriesDispatcher 注入后台实时状态的观测序列端口。 @@ -40,6 +44,11 @@ func (h *AssetHandler) SetObservationSeriesDispatcher(dispatcher cardObservation h.observationSeries = dispatcher } +// SetPackageExpiryQuery 注入临期资产分页查询。 +func (h *AssetHandler) SetPackageExpiryQuery(query *packageExpiryQuery.Query) { + h.packageExpiryQuery = query +} + // AssetExchangeTraceResolver 定义资产详情换货链路读取用例。 type AssetExchangeTraceResolver interface { Resolve(ctx context.Context, assetType string, assetID uint) (*dto.AssetExchangeTrace, error) @@ -105,6 +114,27 @@ func (h *AssetHandler) Resolve(c *fiber.Ctx) error { return response.Success(c, result) } +// ListExpiring 查询当前权限范围内的临期资产列表和数量汇总。 +// GET /api/admin/expiring-assets +func (h *AssetHandler) ListExpiring(c *fiber.Ctx) error { + var request dto.ExpiringAssetListRequest + if err := c.QueryParser(&request); err != nil { + logger.GetAppLogger().Warn("临期资产列表参数解析失败", + zap.String("method", c.Method()), zap.String("path", c.Path()), zap.Error(err)) + return errors.New(errors.CodeInvalidParam) + } + if h.packageExpiryQuery == nil { + return errors.New(errors.CodeInternalError, "套餐临期查询未配置") + } + result, err := h.packageExpiryQuery.List(c.UserContext(), request) + if err != nil { + return err + } + return response.Success(c, dto.ExpiringAssetListResponse{ + Items: result.Items, Total: result.Total, Page: result.Page, Size: result.Size, Summary: result.Summary, + }) +} + // RealtimeStatus 获取资产实时状态 // GET /api/admin/assets/:identifier/realtime-status func (h *AssetHandler) RealtimeStatus(c *fiber.Ctx) error { @@ -463,6 +493,10 @@ func (h *AssetHandler) UpdatePollingStatus(c *fiber.Ctx) error { // UpdateRealnamePolicy 更新资产实名认证策略 // PATCH /api/admin/assets/:identifier/realname-mode func (h *AssetHandler) UpdateRealnamePolicy(c *fiber.Ctx) error { + if middleware.GetUserTypeFromContext(c.UserContext()) == constants.UserTypeEnterprise { + return errors.New(errors.CodeForbidden, "企业账号无权修改资产实名认证策略") + } + identifier := c.Params("identifier") if identifier == "" { return errors.New(errors.CodeInvalidParam) diff --git a/internal/handler/admin/asset_package_batch_order.go b/internal/handler/admin/asset_package_batch_order.go new file mode 100644 index 0000000..534838e --- /dev/null +++ b/internal/handler/admin/asset_package_batch_order.go @@ -0,0 +1,92 @@ +package admin + +import ( + "strconv" + + "github.com/go-playground/validator/v10" + "github.com/gofiber/fiber/v2" + + "github.com/break/junhong_cmp_fiber/internal/model" + "github.com/break/junhong_cmp_fiber/internal/model/dto" + batchOrderSvc "github.com/break/junhong_cmp_fiber/internal/service/asset_package_batch_order" + "github.com/break/junhong_cmp_fiber/pkg/constants" + "github.com/break/junhong_cmp_fiber/pkg/errors" + "github.com/break/junhong_cmp_fiber/pkg/middleware" + "github.com/break/junhong_cmp_fiber/pkg/response" +) + +// AssetPackageBatchOrderHandler 资产套餐批量订购 Handler。 +type AssetPackageBatchOrderHandler struct { + service *batchOrderSvc.Service + validator *validator.Validate +} + +// NewAssetPackageBatchOrderHandler 创建资产套餐批量订购 Handler。 +func NewAssetPackageBatchOrderHandler(service *batchOrderSvc.Service, validator *validator.Validate) *AssetPackageBatchOrderHandler { + return &AssetPackageBatchOrderHandler{service: service, validator: validator} +} + +// Create 创建资产套餐批量订购任务。 +// POST /api/admin/asset-package-batch-orders +func (h *AssetPackageBatchOrderHandler) Create(c *fiber.Ctx) error { + var req dto.CreateAssetPackageBatchOrderRequest + if err := c.BodyParser(&req); err != nil { + return errors.New(errors.CodeInvalidParam, "请求参数解析失败") + } + if err := h.validator.Struct(&req); err != nil { + return errors.New(errors.CodeInvalidParam) + } + userType := middleware.GetUserTypeFromContext(c.UserContext()) + if req.PaymentMethod == model.PaymentMethodOffline && userType != constants.UserTypeSuperAdmin && userType != constants.UserTypePlatform { + return errors.New(errors.CodeForbidden, "只有平台可以使用线下支付") + } + if req.PaymentMethod == model.PaymentMethodWallet && userType != constants.UserTypeSuperAdmin && userType != constants.UserTypePlatform && userType != constants.UserTypeAgent { + return errors.New(errors.CodeForbidden, "无权创建批量订购任务") + } + result, err := h.service.Create(c.UserContext(), &req) + if err != nil { + return err + } + return response.Success(c, result) +} + +// List 查询资产套餐批量订购任务列表。 +// GET /api/admin/asset-package-batch-orders +func (h *AssetPackageBatchOrderHandler) List(c *fiber.Ctx) error { + if !canAccessAssetPackageBatchOrders(middleware.GetUserTypeFromContext(c.UserContext())) { + return errors.New(errors.CodeForbidden, "无权查询批量订购任务") + } + var req dto.ListAssetPackageBatchOrderRequest + if err := c.QueryParser(&req); err != nil { + return errors.New(errors.CodeInvalidParam, "请求参数解析失败") + } + if err := h.validator.Struct(&req); err != nil { + return errors.New(errors.CodeInvalidParam) + } + result, err := h.service.List(c.UserContext(), &req) + if err != nil { + return err + } + return response.Success(c, result) +} + +// Get 查询资产套餐批量订购任务详情。 +// GET /api/admin/asset-package-batch-orders/:id +func (h *AssetPackageBatchOrderHandler) Get(c *fiber.Ctx) error { + if !canAccessAssetPackageBatchOrders(middleware.GetUserTypeFromContext(c.UserContext())) { + return errors.New(errors.CodeForbidden, "无权查询批量订购任务") + } + id, err := strconv.ParseUint(c.Params("id"), 10, 64) + if err != nil || id == 0 { + return errors.New(errors.CodeInvalidParam, "任务ID无效") + } + result, err := h.service.GetByID(c.UserContext(), uint(id)) + if err != nil { + return err + } + return response.Success(c, result) +} + +func canAccessAssetPackageBatchOrders(userType int) bool { + return userType == constants.UserTypeSuperAdmin || userType == constants.UserTypePlatform || userType == constants.UserTypeAgent +} diff --git a/internal/handler/admin/device.go b/internal/handler/admin/device.go index 6eac714..85b49b6 100644 --- a/internal/handler/admin/device.go +++ b/internal/handler/admin/device.go @@ -35,6 +35,20 @@ func (h *DeviceHandler) List(c *fiber.Ctx) error { return response.SuccessWithPagination(c, result.List, result.Total, result.Page, result.PageSize) } +// BatchUpdateRealnamePolicy 批量更新设备实名认证策略。 +// POST /api/admin/devices/batch-update-realname-policy +func (h *DeviceHandler) BatchUpdateRealnamePolicy(c *fiber.Ctx) error { + var req dto.BatchUpdateAssetRealnamePolicyRequest + if err := c.BodyParser(&req); err != nil { + return errors.New(errors.CodeInvalidParam) + } + result, err := h.service.BatchUpdateRealnamePolicy(c.UserContext(), &req) + if err != nil { + return err + } + return response.Success(c, result) +} + func (h *DeviceHandler) Delete(c *fiber.Ctx) error { userType := middleware.GetUserTypeFromContext(c.UserContext()) if userType != constants.UserTypeSuperAdmin && userType != constants.UserTypePlatform { @@ -226,26 +240,6 @@ func (h *DeviceHandler) GetGatewaySlots(c *fiber.Ctx) error { return response.Success(c, resp) } -// SetSpeedLimit 设置设备限速 -// PUT /api/admin/devices/by-identifier/:identifier/speed-limit -func (h *DeviceHandler) SetSpeedLimit(c *fiber.Ctx) error { - identifier := c.Params("identifier") - if identifier == "" { - return errors.New(errors.CodeInvalidParam, "设备标识符不能为空") - } - - var req dto.SetSpeedLimitRequest - if err := c.BodyParser(&req); err != nil { - return errors.New(errors.CodeInvalidParam, "请求参数解析失败") - } - - if err := h.service.GatewaySetSpeedLimit(c.UserContext(), identifier, &req); err != nil { - return err - } - - return response.Success(c, nil) -} - // SetWiFi 设置设备 WiFi // PUT /api/admin/devices/by-identifier/:identifier/wifi func (h *DeviceHandler) SetWiFi(c *fiber.Ctx) error { diff --git a/internal/handler/admin/device_import.go b/internal/handler/admin/device_import.go index 6e373ae..e1e62f6 100644 --- a/internal/handler/admin/device_import.go +++ b/internal/handler/admin/device_import.go @@ -46,6 +46,20 @@ func (h *DeviceImportHandler) Import(c *fiber.Ctx) error { return response.Success(c, result) } +// CreateAllocation 创建单列 CSV 设备批量分配任务。 +// POST /api/admin/devices/import/allocations +func (h *DeviceImportHandler) CreateAllocation(c *fiber.Ctx) error { + var req dto.CreateDeviceBatchAllocationRequest + if err := c.BodyParser(&req); err != nil { + return errors.New(errors.CodeInvalidParam, "请求参数解析失败") + } + result, err := h.service.CreateBatchAllocationTask(c.UserContext(), &req) + if err != nil { + return err + } + return response.Success(c, result) +} + func (h *DeviceImportHandler) List(c *fiber.Ctx) error { userType := middleware.GetUserTypeFromContext(c.UserContext()) if userType != constants.UserTypeSuperAdmin && userType != constants.UserTypePlatform { diff --git a/internal/handler/admin/iot_card.go b/internal/handler/admin/iot_card.go index 3446f0e..83595d5 100644 --- a/internal/handler/admin/iot_card.go +++ b/internal/handler/admin/iot_card.go @@ -35,6 +35,38 @@ func (h *IotCardHandler) ListStandalone(c *fiber.Ctx) error { return response.SuccessWithPagination(c, result.List, result.Total, result.Page, result.PageSize) } +// BatchUpdateRealnamePolicy 批量更新卡实名认证策略。 +// POST /api/admin/iot-cards/batch-update-realname-policy +func (h *IotCardHandler) BatchUpdateRealnamePolicy(c *fiber.Ctx) error { + var req dto.BatchUpdateAssetRealnamePolicyRequest + if err := c.BodyParser(&req); err != nil { + return errors.New(errors.CodeInvalidParam) + } + result, err := h.service.BatchUpdateRealnamePolicy(c.UserContext(), &req) + if err != nil { + return err + } + return response.Success(c, result) +} + +// SetSpeedTier 设置或恢复 IoT 卡固定限速档位。 +// PUT /api/admin/iot-cards/:iccid/speed-tier +func (h *IotCardHandler) SetSpeedTier(c *fiber.Ctx) error { + iccid := c.Params("iccid") + if iccid == "" { + return errors.New(errors.CodeInvalidParam, "ICCID 不能为空") + } + var req dto.SetIotCardSpeedTierRequest + if err := c.BodyParser(&req); err != nil { + return errors.New(errors.CodeInvalidParam, "请求参数解析失败") + } + result, err := h.service.SetSpeedTier(c.UserContext(), iccid, req.Code) + if err != nil { + return err + } + return response.Success(c, result) +} + func (h *IotCardHandler) AllocateCards(c *fiber.Ctx) error { var req dto.AllocateStandaloneCardsRequest if err := c.BodyParser(&req); err != nil { diff --git a/internal/handler/admin/wecom.go b/internal/handler/admin/wecom.go new file mode 100644 index 0000000..8f54d37 --- /dev/null +++ b/internal/handler/admin/wecom.go @@ -0,0 +1,197 @@ +package admin + +import ( + "strconv" + + "github.com/go-playground/validator/v10" + "github.com/gofiber/fiber/v2" + + wecomapp "github.com/break/junhong_cmp_fiber/internal/application/wecom" + "github.com/break/junhong_cmp_fiber/internal/model/dto" + "github.com/break/junhong_cmp_fiber/pkg/errors" + "github.com/break/junhong_cmp_fiber/pkg/response" +) + +// WeComHandler 提供企业微信自建应用连接配置接口。 +type WeComHandler struct { + service *wecomapp.ConnectionService + directory *wecomapp.DirectoryService + scenes *wecomapp.SceneService + validator *validator.Validate +} + +// SetSceneService 注入企业微信审批场景配置用例。 +func (h *WeComHandler) SetSceneService(service *wecomapp.SceneService) { + h.scenes = service +} + +// NewWeComHandler 创建企业微信连接配置 Handler。 +func NewWeComHandler(service *wecomapp.ConnectionService, validate *validator.Validate) *WeComHandler { + return &WeComHandler{service: service, validator: validate} +} + +// SetDirectoryService 注入企业微信通讯录同步用例。 +func (h *WeComHandler) SetDirectoryService(service *wecomapp.DirectoryService) { + h.directory = service +} + +// Save 创建或更新企业微信应用连接配置。 +// POST /api/admin/wecom/applications +func (h *WeComHandler) Save(c *fiber.Ctx) error { + if h == nil || h.service == nil || h.validator == nil { + return errors.New(errors.CodeServiceUnavailable, "企业微信连接服务未配置") + } + var request dto.SaveWeComApplicationRequest + if err := c.BodyParser(&request); err != nil { + return errors.New(errors.CodeInvalidParam) + } + if err := h.validator.Struct(&request); err != nil { + return errors.New(errors.CodeInvalidParam) + } + result, err := h.service.Save(c.UserContext(), request) + if err != nil { + return err + } + return response.Success(c, result) +} + +// List 查询企业微信应用连接配置。 +// GET /api/admin/wecom/applications +func (h *WeComHandler) List(c *fiber.Ctx) error { + if h == nil || h.service == nil || h.validator == nil { + return errors.New(errors.CodeServiceUnavailable, "企业微信连接服务未配置") + } + var request dto.WeComApplicationListRequest + if err := c.QueryParser(&request); err != nil { + return errors.New(errors.CodeInvalidParam) + } + if err := h.validator.Struct(&request); err != nil { + return errors.New(errors.CodeInvalidParam) + } + result, err := h.service.List(c.UserContext(), request) + if err != nil { + return err + } + return response.Success(c, result) +} + +// Test 测试指定企业微信应用能否成功取得 access_token。 +// POST /api/admin/wecom/applications/:id/test +func (h *WeComHandler) Test(c *fiber.Ctx) error { + if h == nil || h.service == nil { + return errors.New(errors.CodeServiceUnavailable, "企业微信连接服务未配置") + } + id, err := strconv.ParseUint(c.Params("id"), 10, 64) + if err != nil || id == 0 { + return errors.New(errors.CodeInvalidParam, "企业微信应用配置 ID 无效") + } + if err := h.service.Test(c.UserContext(), uint(id)); err != nil { + return err + } + return response.Success(c, dto.WeComConnectionTestResponse{Success: true}) +} + +// SaveDefaultCreator 保存应用默认审批发起人。 +// PUT /api/admin/wecom/applications/:id/default-creator +func (h *WeComHandler) SaveDefaultCreator(c *fiber.Ctx) error { + if h == nil || h.service == nil || h.validator == nil { + return errors.New(errors.CodeServiceUnavailable, "企业微信连接服务未配置") + } + id, err := strconv.ParseUint(c.Params("id"), 10, 64) + if err != nil || id == 0 { + return errors.New(errors.CodeInvalidParam, "企业微信应用配置 ID 无效") + } + var request dto.SaveWeComDefaultCreatorRequest + if err := c.BodyParser(&request); err != nil { + return errors.New(errors.CodeInvalidParam) + } + if err := h.validator.Struct(&request); err != nil { + return errors.New(errors.CodeInvalidParam) + } + result, err := h.service.SaveDefaultCreator(c.UserContext(), uint(id), request) + if err != nil { + return err + } + return response.Success(c, result) +} + +// SyncMembers 同步指定应用当前可见成员。 +// POST /api/admin/wecom/applications/:id/members/sync +func (h *WeComHandler) SyncMembers(c *fiber.Ctx) error { + if h == nil || h.directory == nil { + return errors.New(errors.CodeServiceUnavailable, "企业微信通讯录服务未配置") + } + id, err := strconv.ParseUint(c.Params("id"), 10, 64) + if err != nil || id == 0 { + return errors.New(errors.CodeInvalidParam, "企业微信应用配置 ID 无效") + } + result, err := h.directory.Sync(c.UserContext(), uint(id)) + if err != nil { + return err + } + return response.Success(c, result) +} + +// ListMembers 分页查询指定应用最近同步的可见成员。 +// GET /api/admin/wecom/applications/:id/members +func (h *WeComHandler) ListMembers(c *fiber.Ctx) error { + if h == nil || h.directory == nil || h.validator == nil { + return errors.New(errors.CodeServiceUnavailable, "企业微信通讯录服务未配置") + } + id, err := strconv.ParseUint(c.Params("id"), 10, 64) + if err != nil || id == 0 { + return errors.New(errors.CodeInvalidParam, "企业微信应用配置 ID 无效") + } + var request dto.WeComMemberListRequest + if err := c.QueryParser(&request); err != nil { + return errors.New(errors.CodeInvalidParam) + } + if err := h.validator.Struct(&request); err != nil { + return errors.New(errors.CodeInvalidParam) + } + result, err := h.directory.List(c.UserContext(), uint(id), request) + if err != nil { + return err + } + return response.Success(c, result) +} + +// SaveScene 保存并校验稳定业务类型的企微模板控件映射。 +// PUT /api/admin/wecom/scenes/:business_type +func (h *WeComHandler) SaveScene(c *fiber.Ctx) error { + if h == nil || h.scenes == nil || h.validator == nil { + return errors.New(errors.CodeServiceUnavailable, "企业微信审批场景服务未配置") + } + var request dto.SaveWeComApprovalSceneRequest + if err := c.BodyParser(&request); err != nil { + return errors.New(errors.CodeInvalidParam) + } + if err := h.validator.Struct(&request); err != nil { + return errors.New(errors.CodeInvalidParam) + } + result, err := h.scenes.Save(c.UserContext(), c.Params("business_type"), request) + if err != nil { + return err + } + return response.Success(c, result) +} + +// ListScenes 分页查询企业微信审批场景当前配置。 +// GET /api/admin/wecom/scenes +func (h *WeComHandler) ListScenes(c *fiber.Ctx) error { + if h == nil || h.scenes == nil || h.validator == nil { + return errors.New(errors.CodeServiceUnavailable, "企业微信审批场景服务未配置") + } + var request dto.WeComApprovalSceneListRequest + if err := c.QueryParser(&request); err != nil { + return errors.New(errors.CodeInvalidParam) + } + if err := h.validator.Struct(&request); err != nil { + return errors.New(errors.CodeInvalidParam) + } + result, err := h.scenes.List(c.UserContext(), request) + if err != nil { + return err + } + return response.Success(c, result) +} diff --git a/internal/handler/app/client_asset.go b/internal/handler/app/client_asset.go index 16d54b3..cb6896e 100644 --- a/internal/handler/app/client_asset.go +++ b/internal/handler/app/client_asset.go @@ -15,6 +15,7 @@ import ( customerBinding "github.com/break/junhong_cmp_fiber/internal/service/customer_binding" packagepkg "github.com/break/junhong_cmp_fiber/internal/service/package" "github.com/break/junhong_cmp_fiber/internal/service/packageprice" + rechargeSvc "github.com/break/junhong_cmp_fiber/internal/service/recharge" "github.com/break/junhong_cmp_fiber/internal/store" "github.com/break/junhong_cmp_fiber/internal/store/postgres" "github.com/break/junhong_cmp_fiber/pkg/constants" @@ -39,6 +40,21 @@ type ClientAssetHandler struct { db *gorm.DB logger *zap.Logger observationSeries cardObservationApp.BestEffortSeriesDispatcher + paymentMethodPolicy ClientPaymentMethodPolicy + forceRechargeChecker ClientForceRechargeChecker +} + +// ClientPaymentMethodPolicy 提供 C 端支付方式读取和校验能力。 +type ClientPaymentMethodPolicy interface { + AllowedMethods(ctx context.Context, assetType string) ([]string, error) + AllowedRechargeMethods(ctx context.Context, assetType string) ([]string, error) + EnsureAllowed(ctx context.Context, assetType, paymentMethod string) error + EnsureRechargeAllowed(ctx context.Context, assetType, paymentMethod string) error +} + +// ClientForceRechargeChecker 提供 C 端资产当前强充资格的统一判定。 +type ClientForceRechargeChecker interface { + GetRechargeCheck(ctx context.Context, resourceType string, resourceID uint) (*rechargeSvc.ForceRechargeRequirement, error) } // SetObservationSeriesDispatcher 注入读取型后台观测序列端口。 @@ -46,6 +62,16 @@ func (h *ClientAssetHandler) SetObservationSeriesDispatcher(dispatcher cardObser h.observationSeries = dispatcher } +// SetPaymentMethodPolicy 注入 C 端支付方式策略。 +func (h *ClientAssetHandler) SetPaymentMethodPolicy(policy ClientPaymentMethodPolicy) { + h.paymentMethodPolicy = policy +} + +// SetForceRechargeChecker 注入复用现有一次性佣金状态语义的强充判定服务。 +func (h *ClientAssetHandler) SetForceRechargeChecker(checker ClientForceRechargeChecker) { + h.forceRechargeChecker = checker +} + // NewClientAssetHandler 创建 C 端资产信息处理器 func NewClientAssetHandler( assetService *asset.Service, @@ -158,6 +184,34 @@ func (h *ClientAssetHandler) GetAssetInfo(c *fiber.Ctx) error { if err != nil { return err } + currentPackageID, err := h.getCurrentPackageID(resolved.SkipPermissionCtx, resolved.Asset.CurrentPackageUsageID) + if err != nil { + return err + } + if h.paymentMethodPolicy == nil { + return errors.New(errors.CodeNoPaymentConfig) + } + if h.forceRechargeChecker == nil { + return errors.New(errors.CodeNoPaymentConfig) + } + resourceType := resolved.Asset.AssetType + if resourceType == "card" { + resourceType = constants.ResourceTypeIotCard + } + forceRecharge, err := h.forceRechargeChecker.GetRechargeCheck(resolved.SkipPermissionCtx, resourceType, resolved.Asset.AssetID) + if err != nil { + return err + } + allowedPaymentMethods, err := h.paymentMethodPolicy.AllowedMethods(resolved.SkipPermissionCtx, resolved.Asset.AssetType) + if err != nil { + return err + } + if forceRecharge.NeedForceRecharge { + allowedPaymentMethods, err = h.paymentMethodPolicy.AllowedRechargeMethods(resolved.SkipPermissionCtx, resolved.Asset.AssetType) + if err != nil { + return err + } + } phone, _ := middleware.GetCustomerPhone(c) @@ -172,11 +226,16 @@ func (h *ClientAssetHandler) GetAssetInfo(c *fiber.Ctx) error { StatusName: resolved.Asset.StatusName, RealNameStatus: resolved.Asset.RealNameStatus, RealNameStatusName: constants.GetRealNameStatusName(resolved.Asset.RealNameStatus), + RealnamePolicy: resolved.Asset.RealnamePolicy, + EffectiveRealnamePolicy: resolved.Asset.RealnamePolicy, + RealnameRequired: resolved.Asset.RealnamePolicy != constants.RealnamePolicyNone, CarrierName: resolved.Asset.CarrierName, Generation: strconv.Itoa(resolved.Generation), WalletBalance: resolved.WalletBalance, + AllowedPaymentMethods: allowedPaymentMethods, ActivatedAt: resolved.Asset.ActivatedAt, CurrentPackage: resolved.Asset.CurrentPackage, + CurrentPackageID: currentPackageID, CurrentPackageUsageID: resolved.Asset.CurrentPackageUsageID, CurrentPackageActivatedAt: resolved.Asset.CurrentPackageActivatedAt, CurrentPackageExpiresAt: resolved.Asset.CurrentPackageExpiresAt, @@ -226,6 +285,20 @@ func (h *ClientAssetHandler) GetAssetInfo(c *fiber.Ctx) error { return response.Success(c, resp) } +func (h *ClientAssetHandler) getCurrentPackageID(ctx context.Context, usageID *uint) (uint, error) { + if usageID == nil || *usageID == 0 { + return 0, nil + } + var usage model.PackageUsage + if err := h.db.WithContext(ctx).Select("package_id").First(&usage, *usageID).Error; err != nil { + if err == gorm.ErrRecordNotFound { + return 0, nil + } + return 0, errors.Wrap(errors.CodeDatabaseError, err, "查询当前套餐引用失败") + } + return usage.PackageID, nil +} + func (h *ClientAssetHandler) dispatchAssetReadObservations(ctx context.Context, asset *dto.AssetResolveResponse) { if h.observationSeries == nil || asset == nil { return @@ -301,8 +374,9 @@ func (h *ClientAssetHandler) GetAvailablePackages(c *fiber.Ctx) error { PageSize: constants.MaxPageSize, OrderBy: "id DESC", }, map[string]any{ - "series_id": *resolved.Asset.SeriesID, - "status": constants.StatusEnabled, + "series_id": *resolved.Asset.SeriesID, + "status": constants.StatusEnabled, + "shelf_status": constants.ShelfStatusOn, }) if err != nil { return errors.Wrap(errors.CodeDatabaseError, err, "查询可购套餐失败") diff --git a/internal/handler/app/client_wallet.go b/internal/handler/app/client_wallet.go index ad6ee5b..8b625d5 100644 --- a/internal/handler/app/client_wallet.go +++ b/internal/handler/app/client_wallet.go @@ -45,6 +45,12 @@ type ClientWalletHandler struct { db *gorm.DB iotCardStore *postgres.IotCardStore deviceStore *postgres.DeviceStore + paymentMethodPolicy ClientPaymentMethodPolicy +} + +// SetPaymentMethodPolicy 注入 C 端支付方式策略。 +func (h *ClientWalletHandler) SetPaymentMethodPolicy(policy ClientPaymentMethodPolicy) { + h.paymentMethodPolicy = policy } // NewClientWalletHandler 创建 C 端钱包处理器 @@ -243,14 +249,22 @@ func (h *ClientWalletHandler) GetRechargeCheck(c *fiber.Ctx) error { if err != nil { return err } + if h.paymentMethodPolicy == nil { + return errors.New(errors.CodeNoPaymentConfig) + } + allowedPaymentMethods, err := h.paymentMethodPolicy.AllowedRechargeMethods(resolved.SkipPermissionCtx, resolved.Asset.AssetType) + if err != nil { + return err + } resp := &dto.ClientRechargeCheckResponse{ - NeedForceRecharge: check.NeedForceRecharge, - ForceRechargeAmount: check.ForceRechargeAmount, - TriggerType: check.TriggerType, - MinAmount: check.MinAmount, - MaxAmount: check.MaxAmount, - Message: check.Message, + NeedForceRecharge: check.NeedForceRecharge, + ForceRechargeAmount: check.ForceRechargeAmount, + TriggerType: check.TriggerType, + MinAmount: check.MinAmount, + MaxAmount: check.MaxAmount, + Message: check.Message, + AllowedPaymentMethods: allowedPaymentMethods, } return response.Success(c, resp) @@ -273,6 +287,12 @@ func (h *ClientWalletHandler) CreateRecharge(c *fiber.Ctx) error { if resolved.Asset.RealnamePolicy == constants.RealnamePolicyBeforeOrder && resolved.Asset.RealNameStatus != 1 { return errors.New(errors.CodeNeedRealname) } + if h.paymentMethodPolicy == nil { + return errors.New(errors.CodeNoPaymentConfig) + } + if err := h.paymentMethodPolicy.EnsureRechargeAllowed(resolved.SkipPermissionCtx, resolved.Asset.AssetType, req.PaymentMethod); err != nil { + return err + } wallet, err := h.getOrCreateWallet(resolved) if err != nil { @@ -302,9 +322,10 @@ func (h *ClientWalletHandler) CreateRecharge(c *fiber.Ctx) error { switch req.PaymentMethod { case constants.RechargeMethodAlipay: return h.createAlipayRecharge(c, resolved, config, wallet, req) - default: - // 微信充值(默认):app_type 必填 + case constants.RechargeMethodWechat: return h.createWechatRecharge(c, resolved, config, wallet, req) + default: + return errors.New(errors.CodePaymentMethodUnavailable) } } @@ -585,7 +606,6 @@ func (h *ClientWalletHandler) resolveAssetFromIdentifier(c *fiber.Ctx, identifie }, nil } - func (h *ClientWalletHandler) getAssetGeneration(ctx context.Context, assetType string, assetID uint) (int, error) { switch assetType { case "card": diff --git a/internal/handler/callback/wecom_approval.go b/internal/handler/callback/wecom_approval.go new file mode 100644 index 0000000..894609a --- /dev/null +++ b/internal/handler/callback/wecom_approval.go @@ -0,0 +1,61 @@ +package callback + +import ( + "strconv" + + "github.com/gofiber/fiber/v2" + + wecominfra "github.com/break/junhong_cmp_fiber/internal/infrastructure/wecom" + "github.com/break/junhong_cmp_fiber/pkg/errors" +) + +// WeComApprovalHandler 提供企业微信审批加密回调入口。 +type WeComApprovalHandler struct { + service *wecominfra.CallbackService +} + +// NewWeComApprovalHandler 创建企业微信审批回调 Handler。 +func NewWeComApprovalHandler(service *wecominfra.CallbackService) *WeComApprovalHandler { + return &WeComApprovalHandler{service: service} +} + +// Verify 验证企业微信审批回调 URL。 +// GET /api/callback/wecom/approval/:application_id +func (h *WeComApprovalHandler) Verify(c *fiber.Ctx) error { + applicationID, err := parseWeComApplicationID(c.Params("application_id")) + if err != nil { + return err + } + plaintext, err := h.service.VerifyURL( + c.UserContext(), applicationID, c.Query("msg_signature"), c.Query("timestamp"), c.Query("nonce"), c.Query("echostr"), + ) + if err != nil { + return err + } + c.Set(fiber.HeaderContentType, fiber.MIMETextPlainCharsetUTF8) + return c.Send(plaintext) +} + +// Receive 接收企业微信审批状态变化事件并快速返回 success。 +// POST /api/callback/wecom/approval/:application_id +func (h *WeComApprovalHandler) Receive(c *fiber.Ctx) error { + applicationID, err := parseWeComApplicationID(c.Params("application_id")) + if err != nil { + return err + } + if err := h.service.Receive( + c.UserContext(), applicationID, c.Query("msg_signature"), c.Query("timestamp"), c.Query("nonce"), c.Body(), + ); err != nil { + return err + } + c.Set(fiber.HeaderContentType, fiber.MIMETextPlainCharsetUTF8) + return c.SendString("success") +} + +func parseWeComApplicationID(value string) (uint, error) { + id, err := strconv.ParseUint(value, 10, 64) + if err != nil || id == 0 { + return 0, errors.New(errors.CodeInvalidParam, "企业微信应用配置 ID 无效") + } + return uint(id), nil +} diff --git a/internal/infrastructure/exchange/shipping_notification.go b/internal/infrastructure/exchange/shipping_notification.go new file mode 100644 index 0000000..d65e2f8 --- /dev/null +++ b/internal/infrastructure/exchange/shipping_notification.go @@ -0,0 +1,49 @@ +// Package exchange 提供换货业务对公共基础设施的 Adapter。 +package exchange + +import ( + "context" + "fmt" + "strconv" + + "gorm.io/gorm" + + exchangeapp "github.com/break/junhong_cmp_fiber/internal/application/exchange" + notificationapp "github.com/break/junhong_cmp_fiber/internal/application/notification" + "github.com/break/junhong_cmp_fiber/internal/infrastructure/messaging/outbox" + "github.com/break/junhong_cmp_fiber/pkg/constants" + "github.com/break/junhong_cmp_fiber/pkg/errors" +) + +// ShippingNotificationWriter 将物流换货创建事实转换为个人客户通知 Outbox。 +type ShippingNotificationWriter struct { + outbox *outbox.Repository +} + +// NewShippingNotificationWriter 创建物流换货通知 Outbox Adapter。 +func NewShippingNotificationWriter(repository *outbox.Repository) *ShippingNotificationWriter { + return &ShippingNotificationWriter{outbox: repository} +} + +// Append 在换货业务事务中追加稳定且可重复提交的个人客户通知事件。 +func (w *ShippingNotificationWriter) Append(ctx context.Context, tx *gorm.DB, event exchangeapp.ShippingCreatedEvent) error { + if w == nil || w.outbox == nil { + return errors.New(errors.CodeInternalError, "物流换货通知 Outbox Writer 未配置") + } + exchangeID := strconv.FormatUint(uint64(event.ExchangeID), 10) + assetID := strconv.FormatUint(uint64(event.AssetID), 10) + eventID := fmt.Sprintf("exchange-created:%d:%d", event.ExchangeID, event.CustomerID) + _, err := w.outbox.AppendIdempotent(ctx, tx, outbox.Envelope{ + EventID: eventID, EventType: constants.OutboxEventTypePersonalCustomerDirectNotification, + PayloadVersion: constants.NotificationPayloadVersionV1, + AggregateType: "exchange", AggregateID: exchangeID, + ResourceType: event.AssetType, ResourceID: assetID, + BusinessKey: eventID, RequestID: event.RequestID, CorrelationID: event.CorrelationID, + Payload: notificationapp.PersonalCustomerDirectPayload{ + RecipientID: event.CustomerID, NotificationType: constants.NotificationTypeExchangeShippingCreated, + TemplateData: map[string]string{}, RefType: constants.NotificationRefTypeAsset, + RefID: assetID, RefKey: event.AssetIdentifier, + }, + }) + return err +} diff --git a/internal/infrastructure/messaging/outbox/relay.go b/internal/infrastructure/messaging/outbox/relay.go index a83a6da..3f1adc4 100644 --- a/internal/infrastructure/messaging/outbox/relay.go +++ b/internal/infrastructure/messaging/outbox/relay.go @@ -15,7 +15,6 @@ import ( "github.com/break/junhong_cmp_fiber/internal/model" "github.com/break/junhong_cmp_fiber/pkg/constants" - "github.com/break/junhong_cmp_fiber/pkg/queue" ) // DeliveryEnvelope 是 Relay 原样传播到 Asynq 的公共结构化信封。 @@ -63,11 +62,16 @@ func Permanent(err error) error { // QueuePublisher 使用项目统一队列客户端发布结构化信封。 type QueuePublisher struct { - client *queue.Client + client TaskEnqueuer +} + +// TaskEnqueuer 定义 Outbox Relay 所需的最小队列提交能力,避免基础设施包反向依赖队列处理器装配。 +type TaskEnqueuer interface { + EnqueueTask(ctx context.Context, taskType string, payload interface{}, opts ...asynq.Option) error } // NewQueuePublisher 创建项目统一队列客户端 Adapter。 -func NewQueuePublisher(client *queue.Client) *QueuePublisher { +func NewQueuePublisher(client TaskEnqueuer) *QueuePublisher { return &QueuePublisher{client: client} } diff --git a/internal/infrastructure/notification/registry.go b/internal/infrastructure/notification/registry.go index f1be751..b4a8dd6 100644 --- a/internal/infrastructure/notification/registry.go +++ b/internal/infrastructure/notification/registry.go @@ -70,6 +70,28 @@ func NewRegistry() *Registry { constants.NotificationRefTypeAsset: {}, }, }, + constants.NotificationTypeExchangeShippingCreated: { + Type: constants.NotificationTypeExchangeShippingCreated, Category: constants.NotificationCategorySystem, + Severity: constants.NotificationSeverityInfo, + TitleTemplate: "换货申请待处理", + BodyTemplate: "您有一条物流换货申请待处理,请及时填写收货信息。", + TemplateFields: map[string]struct{}{}, + RecipientKinds: map[string]struct{}{constants.NotificationRecipientKindPersonalCustomer: {}}, + AllowedRefTypes: map[string]struct{}{ + constants.NotificationRefTypeAsset: {}, + }, + }, + constants.NotificationTypeAgentMainWalletLowBalance: { + Type: constants.NotificationTypeAgentMainWalletLowBalance, Category: constants.NotificationCategorySystem, + Severity: constants.NotificationSeverityWarning, + TitleTemplate: "店铺主钱包余额不足", + BodyTemplate: "店铺主钱包余额低于 100 元,请及时关注。", + TemplateFields: map[string]struct{}{}, + RecipientKinds: map[string]struct{}{constants.NotificationRecipientKindAccount: {}}, + AllowedRefTypes: map[string]struct{}{ + constants.NotificationRefTypeShopFund: {}, + }, + }, }} } diff --git a/internal/infrastructure/packageexpiry/reminder_publisher.go b/internal/infrastructure/packageexpiry/reminder_publisher.go new file mode 100644 index 0000000..3a559a9 --- /dev/null +++ b/internal/infrastructure/packageexpiry/reminder_publisher.go @@ -0,0 +1,141 @@ +// Package packageexpiry 提供套餐临期提醒的 PostgreSQL 与 Outbox Adapter。 +package packageexpiry + +import ( + "context" + "fmt" + "strconv" + "time" + + "gorm.io/gorm" + + notificationapp "github.com/break/junhong_cmp_fiber/internal/application/notification" + "github.com/break/junhong_cmp_fiber/internal/infrastructure/messaging/outbox" + "github.com/break/junhong_cmp_fiber/internal/model/dto" + "github.com/break/junhong_cmp_fiber/pkg/constants" + "github.com/break/junhong_cmp_fiber/pkg/errors" +) + +var shanghaiLocation = time.FixedZone("Asia/Shanghai", 8*60*60) + +// ReminderPublisher 将临期节点事实转换为个人客户通知 Outbox。 +type ReminderPublisher struct { + db *gorm.DB + outbox *outbox.Repository +} + +// NewReminderPublisher 创建套餐临期通知 Outbox Adapter。 +func NewReminderPublisher(db *gorm.DB, repository *outbox.Repository) *ReminderPublisher { + return &ReminderPublisher{db: db, outbox: repository} +} + +type recipientRow struct { + AssetID uint + CustomerID uint +} + +// Publish 批量解析资产关联个人客户,并在同一事务内幂等追加通知事件。 +func (p *ReminderPublisher) Publish(ctx context.Context, candidates []dto.ExpiringAssetItem) error { + if p == nil || p.db == nil || p.outbox == nil { + return errors.New(errors.CodeInternalError, "套餐临期通知 Publisher 未配置") + } + recipients, err := p.loadRecipients(ctx, candidates) + if err != nil { + return err + } + return p.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + for _, candidate := range candidates { + if candidate.DaysUntilFinalExpiry == nil || candidate.EstimatedFinalExpiresAt == nil { + continue + } + key := recipientKey(candidate.AssetType, candidate.AssetID) + for customerID := range recipients[key] { + if err := p.appendNotification(ctx, tx, candidate, customerID); err != nil { + return err + } + } + } + return nil + }) +} + +func (p *ReminderPublisher) loadRecipients(ctx context.Context, candidates []dto.ExpiringAssetItem) (map[string]map[uint]struct{}, error) { + cardIDs := make([]uint, 0) + deviceIDs := make([]uint, 0) + for _, candidate := range candidates { + if candidate.AssetType == constants.AssetTypeIotCard { + cardIDs = append(cardIDs, candidate.AssetID) + } else if candidate.AssetType == constants.AssetTypeDevice { + deviceIDs = append(deviceIDs, candidate.AssetID) + } + } + result := make(map[string]map[uint]struct{}) + queries := []struct { + assetType string + ids []uint + sql string + }{ + {constants.AssetTypeIotCard, cardIDs, ` + SELECT c.id AS asset_id, b.customer_id + FROM tb_iot_card c + JOIN tb_personal_customer_device b ON c.virtual_no <> '' AND b.virtual_no = c.virtual_no + WHERE c.id IN ? AND c.deleted_at IS NULL AND b.deleted_at IS NULL AND b.status = 1`}, + {constants.AssetTypeIotCard, cardIDs, ` + SELECT c.id AS asset_id, b.customer_id + FROM tb_iot_card c + JOIN tb_personal_customer_iccid b ON c.virtual_no = '' AND (b.iccid = c.iccid OR (b.iccid_19 <> '' AND b.iccid_19 = c.iccid_19)) + WHERE c.id IN ? AND c.deleted_at IS NULL AND b.deleted_at IS NULL AND b.status = 1`}, + {constants.AssetTypeDevice, deviceIDs, ` + SELECT d.id AS asset_id, b.customer_id + FROM tb_device d + JOIN tb_personal_customer_device b ON b.virtual_no = CASE WHEN d.virtual_no <> '' THEN d.virtual_no ELSE d.imei END + WHERE d.id IN ? AND d.deleted_at IS NULL AND b.deleted_at IS NULL AND b.status = 1`}, + } + for _, query := range queries { + if len(query.ids) == 0 { + continue + } + var rows []recipientRow + if err := p.db.WithContext(ctx).Raw(query.sql, query.ids).Scan(&rows).Error; err != nil { + return nil, errors.Wrap(errors.CodeDatabaseError, err, "批量查询临期资产关联客户失败") + } + for _, row := range rows { + key := recipientKey(query.assetType, row.AssetID) + if result[key] == nil { + result[key] = make(map[uint]struct{}) + } + result[key][row.CustomerID] = struct{}{} + } + } + return result, nil +} + +func (p *ReminderPublisher) appendNotification(ctx context.Context, tx *gorm.DB, candidate dto.ExpiringAssetItem, customerID uint) error { + node := *candidate.DaysUntilFinalExpiry + expiryDate := candidate.EstimatedFinalExpiresAt.In(shanghaiLocation).Format("20060102") + assetCode := "c" + if candidate.AssetType == constants.AssetTypeDevice { + assetCode = "d" + } + eventID := fmt.Sprintf("pex:%s:%d:%s:%d:%d", assetCode, candidate.AssetID, expiryDate, node, customerID) + assetID := strconv.FormatUint(uint64(candidate.AssetID), 10) + _, err := p.outbox.AppendIdempotent(ctx, tx, outbox.Envelope{ + EventID: eventID, EventType: constants.OutboxEventTypePersonalCustomerDirectNotification, + PayloadVersion: constants.NotificationPayloadVersionV1, + AggregateType: "package_expiry", AggregateID: strconv.FormatUint(uint64(candidate.PackageUsageID), 10), + ResourceType: candidate.AssetType, ResourceID: assetID, BusinessKey: eventID, + Payload: notificationapp.PersonalCustomerDirectPayload{ + RecipientID: customerID, NotificationType: constants.NotificationTypePackageExpiring, + TemplateData: map[string]string{}, RefType: constants.NotificationRefTypeAsset, + RefID: assetID, RefKey: candidate.Identifier, ExpiresAt: candidate.EstimatedFinalExpiresAt, + }, + }) + if err != nil { + return errors.Wrap(errors.CodeDatabaseError, err, "写入套餐临期通知事件失败") + } + return nil +} + +func recipientKey(assetType string, assetID uint) string { + return fmt.Sprintf("%s:%d", assetType, assetID) +} diff --git a/internal/infrastructure/systemconfig/reader.go b/internal/infrastructure/systemconfig/reader.go index 214e0d3..f4525c5 100644 --- a/internal/infrastructure/systemconfig/reader.go +++ b/internal/infrastructure/systemconfig/reader.go @@ -96,6 +96,44 @@ func (r *Reader) Get(ctx context.Context, key string) (string, error) { return value, nil } +// GetStrict 严格读取单个已注册配置;已落库的非法值或数据库读取失败不会回退默认值。 +// 该入口用于支付方式等必须失败关闭的关键配置,未落库时仍返回代码注册的首次初始化默认值。 +func (r *Reader) GetStrict(ctx context.Context, key string) (string, error) { + definition, registered := r.registry.Get(key) + if !registered { + return "", stderrors.New("系统配置 Key 未注册") + } + cacheKey := constants.RedisSystemConfigKey(key) + if r.cache != nil { + if value, err := r.cache.Get(ctx, cacheKey); err == nil { + if ValidateValue(definition, value) == nil { + return value, nil + } + r.warn(ctx, "SYSTEM_CONFIG_CACHE_INVALID", key, "关键系统配置缓存值非法,已回源 PostgreSQL") + } else if err != redis.Nil { + r.warn(ctx, "SYSTEM_CONFIG_CACHE_READ_FAILED", key, "关键系统配置缓存读取失败,已回源 PostgreSQL") + } + } + var record model.SystemConfig + err := r.db.WithContext(ctx).Where("config_key = ?", key).First(&record).Error + if err == gorm.ErrRecordNotFound { + return definition.DefaultValue, nil + } + if err != nil { + return "", err + } + if ValidateValue(definition, record.ConfigValue) != nil { + r.warn(ctx, "SYSTEM_CONFIG_DATABASE_VALUE_INVALID", key, "关键系统配置数据库值非法,已失败关闭") + return "", stderrors.New("系统配置数据库值非法") + } + if r.cache != nil { + if err := r.cache.Set(ctx, cacheKey, record.ConfigValue, constants.SystemConfigCacheTTL); err != nil { + r.warn(ctx, "SYSTEM_CONFIG_CACHE_WRITE_FAILED", key, "关键系统配置缓存回填失败") + } + } + return record.ConfigValue, nil +} + // ListItem 是查询层组装 DTO 所需的稳定投影。 type ListItem struct { Definition Definition diff --git a/internal/infrastructure/systemconfig/registry.go b/internal/infrastructure/systemconfig/registry.go index 4232d30..7036fb7 100644 --- a/internal/infrastructure/systemconfig/registry.go +++ b/internal/infrastructure/systemconfig/registry.go @@ -28,6 +28,7 @@ type Definition struct { EnumValues []string Min *int64 Max *int64 + Validator func(string) error } // Registry 保存可写配置的代码权威定义。 @@ -127,12 +128,19 @@ func ValidateValue(definition Definition, value string) error { return stderrors.New("系统配置类型不受支持") } if len(definition.EnumValues) > 0 { + matched := false for _, allowed := range definition.EnumValues { if value == allowed { - return nil + matched = true + break } } - return stderrors.New("系统配置值不在允许枚举中") + if !matched { + return stderrors.New("系统配置值不在允许枚举中") + } + } + if definition.Validator != nil { + return definition.Validator(value) } return nil } diff --git a/internal/infrastructure/wallet/debit_consumer.go b/internal/infrastructure/wallet/debit_consumer.go index 1f29e32..a409290 100644 --- a/internal/infrastructure/wallet/debit_consumer.go +++ b/internal/infrastructure/wallet/debit_consumer.go @@ -2,10 +2,12 @@ package wallet import ( "context" + "strconv" "github.com/bytedance/sonic" "gorm.io/gorm" + notificationapp "github.com/break/junhong_cmp_fiber/internal/application/notification" walletapp "github.com/break/junhong_cmp_fiber/internal/application/wallet" "github.com/break/junhong_cmp_fiber/internal/infrastructure/messaging/outbox" "github.com/break/junhong_cmp_fiber/internal/model" @@ -16,17 +18,18 @@ import ( // DebitEventConsumer 校验已投递的代理主钱包扣款事件具有对应权威资金流水。 // 后续余额预警等消费者在此稳定接缝上扩展,不需要回读或改写订单扣款事务。 type DebitEventConsumer struct { - db *gorm.DB + db *gorm.DB + outbox *outbox.Repository } // NewDebitEventConsumer 创建代理主钱包扣款事件消费者。 func NewDebitEventConsumer(db *gorm.DB) *DebitEventConsumer { - return &DebitEventConsumer{db: db} + return &DebitEventConsumer{db: db, outbox: outbox.NewRepository()} } // Consume 校验事件载荷及不可变流水,保证未知或损坏事件不会被静默确认。 func (c *DebitEventConsumer) Consume(ctx context.Context, envelope outbox.DeliveryEnvelope) error { - if c == nil || c.db == nil { + if c == nil || c.db == nil || c.outbox == nil { return errors.New(errors.CodeInternalError, "代理主钱包扣款事件消费者未配置") } if envelope.EventType != constants.OutboxEventTypeAgentMainWalletDebited || @@ -37,14 +40,21 @@ func (c *DebitEventConsumer) Consume(ctx context.Context, envelope outbox.Delive if err := sonic.Unmarshal(envelope.Payload, &event); err != nil { return errors.Wrap(errors.CodeInvalidParam, err, "代理主钱包扣款事件载荷无法解析") } - if event.EventID != envelope.EventID || event.WalletID == 0 || event.ReferenceID == 0 || + if event.EventID != envelope.EventID || event.WalletID == 0 || event.ShopID == 0 || event.ReferenceID == 0 || event.ReferenceType != constants.ReferenceTypeOrder || event.TransactionType != constants.AgentTransactionTypeDeduct || event.Amount <= 0 { return errors.New(errors.CodeInvalidParam, "代理主钱包扣款事件载荷不完整") } + return c.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + return c.consumeInTx(ctx, tx, event) + }) +} + +// consumeInTx 复核权威扣款流水,并在余额首次跨过固定阈值时追加业务员通知事件。 +func (c *DebitEventConsumer) consumeInTx(ctx context.Context, tx *gorm.DB, event walletapp.DebitedEvent) error { var transaction model.AgentWalletTransaction - err := c.db.WithContext(ctx).Unscoped(). + err := tx.WithContext(ctx).Unscoped(). Where("agent_wallet_id = ? AND reference_type = ? AND reference_id = ? AND transaction_type = ? AND status = ?", event.WalletID, event.ReferenceType, event.ReferenceID, constants.AgentTransactionTypeDeduct, constants.TransactionStatusSuccess). First(&transaction).Error @@ -58,5 +68,57 @@ func (c *DebitEventConsumer) Consume(ctx context.Context, envelope outbox.Delive transaction.BalanceAfter != event.BalanceAfter { return errors.New(errors.CodeInternalError, "代理主钱包扣款事件与权威资金流水不一致") } + if event.BalanceBefore < constants.AgentMainWalletLowBalanceThreshold || + event.BalanceAfter >= constants.AgentMainWalletLowBalanceThreshold { + return nil + } + + recipientID, err := resolveBusinessOwnerAccountID(ctx, tx, event.ShopID) + if err != nil { + return err + } + if recipientID == 0 { + return nil + } + shopID := strconv.FormatUint(uint64(event.ShopID), 10) + notificationEventID := "wallet-low:order:" + strconv.FormatUint(uint64(event.ReferenceID), 10) + _, err = c.outbox.AppendIdempotent(ctx, tx, outbox.Envelope{ + EventID: notificationEventID, EventType: constants.OutboxEventTypeAdminDirectNotification, + PayloadVersion: constants.NotificationPayloadVersionV1, + AggregateType: "agent_wallet", AggregateID: strconv.FormatUint(uint64(event.WalletID), 10), + ResourceType: constants.NotificationRefTypeShopFund, ResourceID: shopID, + BusinessKey: notificationEventID, RequestID: event.RequestID, CorrelationID: event.CorrelationID, + Payload: notificationapp.AdminDirectPayload{ + RecipientID: recipientID, NotificationType: constants.NotificationTypeAgentMainWalletLowBalance, + RefType: constants.NotificationRefTypeShopFund, RefID: shopID, + }, + }) + if err != nil { + return errors.Wrap(errors.CodeDatabaseError, err, "写入主钱包低余额通知事件失败") + } return nil } + +// resolveBusinessOwnerAccountID 返回店铺当前启用的平台业务员账号;无有效归属时返回零值。 +func resolveBusinessOwnerAccountID(ctx context.Context, tx *gorm.DB, shopID uint) (uint, error) { + var shop model.Shop + err := tx.WithContext(ctx).Select("business_owner_account_id").Where("id = ?", shopID).Take(&shop).Error + if err == gorm.ErrRecordNotFound || shop.BusinessOwnerAccountID == nil { + return 0, nil + } + if err != nil { + return 0, errors.Wrap(errors.CodeDatabaseError, err, "查询店铺业务员归属失败") + } + + var account model.Account + err = tx.WithContext(ctx).Select("id"). + Where("id = ? AND status = ? AND user_type = ?", *shop.BusinessOwnerAccountID, constants.StatusEnabled, constants.UserTypePlatform). + Take(&account).Error + if err == gorm.ErrRecordNotFound { + return 0, nil + } + if err != nil { + return 0, errors.Wrap(errors.CodeDatabaseError, err, "校验店铺业务员账号失败") + } + return account.ID, nil +} diff --git a/internal/infrastructure/wecom/application_repository.go b/internal/infrastructure/wecom/application_repository.go new file mode 100644 index 0000000..3e81b75 --- /dev/null +++ b/internal/infrastructure/wecom/application_repository.go @@ -0,0 +1,109 @@ +package wecom + +import ( + "context" + "time" + + "gorm.io/gorm" + "gorm.io/gorm/clause" + + "github.com/break/junhong_cmp_fiber/internal/model" + "github.com/break/junhong_cmp_fiber/pkg/constants" + "github.com/break/junhong_cmp_fiber/pkg/errors" +) + +// ApplicationRepository 持久化企业微信应用配置。 +type ApplicationRepository struct { + db *gorm.DB +} + +// NewApplicationRepository 创建企业微信应用 Repository。 +func NewApplicationRepository(db *gorm.DB) *ApplicationRepository { + return &ApplicationRepository{db: db} +} + +// FindByIdentityForUpdate 在事务中锁定企业与应用唯一配置。 +func (r *ApplicationRepository) FindByIdentityForUpdate(ctx context.Context, tx *gorm.DB, corpID string, agentID int64) (*model.WeComApplication, error) { + var application model.WeComApplication + err := tx.WithContext(ctx).Clauses(clause.Locking{Strength: "UPDATE"}). + Where("corp_id = ? AND agent_id = ?", corpID, agentID).First(&application).Error + if err == gorm.ErrRecordNotFound { + return nil, nil + } + if err != nil { + return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询企业微信应用配置失败") + } + return &application, nil +} + +// Create 创建企业微信应用配置。 +func (r *ApplicationRepository) Create(ctx context.Context, tx *gorm.DB, application *model.WeComApplication) error { + if err := tx.WithContext(ctx).Create(application).Error; err != nil { + return errors.Wrap(errors.CodeDatabaseError, err, "创建企业微信应用配置失败") + } + return nil +} + +// Update 更新企业微信应用配置及密文凭据。 +func (r *ApplicationRepository) Update(ctx context.Context, tx *gorm.DB, application *model.WeComApplication) error { + updates := map[string]any{ + "name": application.Name, "secret_ciphertext": application.SecretCiphertext, + "callback_token_ciphertext": application.CallbackTokenCiphertext, + "encoding_aes_key_ciphertext": application.EncodingAESKeyCiphertext, + "default_creator_userid": application.DefaultCreatorUserID, + "default_creator_name": application.DefaultCreatorName, + "status": application.Status, "updated_by": application.UpdatedBy, "updated_at": application.UpdatedAt, + } + if err := tx.WithContext(ctx).Model(&model.WeComApplication{}).Where("id = ?", application.ID).Updates(updates).Error; err != nil { + return errors.Wrap(errors.CodeDatabaseError, err, "更新企业微信应用配置失败") + } + return nil +} + +// UpdateDefaultCreator 更新应用默认审批发起人快照。 +func (r *ApplicationRepository) UpdateDefaultCreator(ctx context.Context, tx *gorm.DB, applicationID uint, userID, name string, operatorID uint, updatedAt time.Time) error { + if err := tx.WithContext(ctx).Model(&model.WeComApplication{}).Where("id = ? AND deleted_at IS NULL", applicationID).Updates(map[string]any{ + "default_creator_userid": userID, + "default_creator_name": name, + "updated_by": operatorID, + "updated_at": updatedAt, + }).Error; err != nil { + return errors.Wrap(errors.CodeDatabaseError, err, "更新企业微信默认审批发起人失败") + } + return nil +} + +// List 分页查询企业微信应用配置及密文凭据。 +func (r *ApplicationRepository) List(ctx context.Context, page, pageSize int) ([]model.WeComApplication, int64, error) { + query := r.db.WithContext(ctx).Model(&model.WeComApplication{}) + var total int64 + if err := query.Count(&total).Error; err != nil { + return nil, 0, errors.Wrap(errors.CodeDatabaseError, err, "统计企业微信应用配置失败") + } + var applications []model.WeComApplication + if err := query.Order("id ASC").Offset((page - 1) * pageSize).Limit(pageSize).Find(&applications).Error; err != nil { + return nil, 0, errors.Wrap(errors.CodeDatabaseError, err, "查询企业微信应用列表失败") + } + return applications, total, nil +} + +// GetEnabled 查询启用的企业微信应用及密文凭据。 +func (r *ApplicationRepository) GetEnabled(ctx context.Context, applicationID uint) (*model.WeComApplication, error) { + var application model.WeComApplication + if err := r.db.WithContext(ctx).Where("id = ? AND status = ?", applicationID, constants.StatusEnabled).First(&application).Error; err != nil { + if err == gorm.ErrRecordNotFound { + return nil, errors.New(errors.CodeWeComApplicationNotFound) + } + return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询企业微信应用配置失败") + } + return &application, nil +} + +// MarkConnected 记录最近一次成功取得 access_token 的时间。 +func (r *ApplicationRepository) MarkConnected(ctx context.Context, applicationID uint, connectedAt time.Time) error { + if err := r.db.WithContext(ctx).Model(&model.WeComApplication{}).Where("id = ?", applicationID). + Update("last_connected_at", connectedAt.UTC()).Error; err != nil { + return errors.Wrap(errors.CodeDatabaseError, err, "更新企业微信最近连接时间失败") + } + return nil +} diff --git a/internal/infrastructure/wecom/approval_attachment_uploader.go b/internal/infrastructure/wecom/approval_attachment_uploader.go new file mode 100644 index 0000000..999ea17 --- /dev/null +++ b/internal/infrastructure/wecom/approval_attachment_uploader.go @@ -0,0 +1,166 @@ +package wecom + +import ( + "bytes" + "context" + "io" + "mime/multipart" + "net/http" + "net/url" + "os" + "path/filepath" + "strconv" + "strings" + "time" + + "github.com/bytedance/sonic" + + "github.com/break/junhong_cmp_fiber/internal/infrastructure/integrationlog" + "github.com/break/junhong_cmp_fiber/pkg/constants" + "github.com/break/junhong_cmp_fiber/pkg/errors" + "github.com/break/junhong_cmp_fiber/pkg/storage" +) + +// ApprovalFileReference 是业务快照中可上传到企微的对象存储引用。 +type ApprovalFileReference struct { + StorageKey string `json:"storage_key"` + FileName string `json:"file_name"` +} + +// ApprovalAttachmentUploader 将对象存储文件上传为企微临时素材。 +type ApprovalAttachmentUploader struct { + tokens DirectoryTokenProvider + integration TokenIntegrationLog + storage *storage.Service + httpClient *http.Client + baseURL string + now func() time.Time +} + +// NewApprovalAttachmentUploader 创建企业微信审批附件上传客户端。 +func NewApprovalAttachmentUploader(tokens DirectoryTokenProvider, integration TokenIntegrationLog, storageService *storage.Service, baseURL string, timeout time.Duration) *ApprovalAttachmentUploader { + if timeout <= 0 { + timeout = constants.WeComDefaultHTTPTimeout + } + return &ApprovalAttachmentUploader{ + tokens: tokens, integration: integration, storage: storageService, + httpClient: &http.Client{Timeout: timeout}, baseURL: strings.TrimRight(baseURL, "/"), now: time.Now, + } +} + +// Upload 下载对象存储文件并上传为企微临时素材;Integration Log 不记录文件正文或 media_id。 +func (u *ApprovalAttachmentUploader) Upload(ctx context.Context, applicationID, instanceID uint, reference ApprovalFileReference) (string, error) { + if u == nil || u.tokens == nil || u.integration == nil || u.storage == nil || u.httpClient == nil { + return "", errors.New(errors.CodeServiceUnavailable, "企业微信审批附件上传服务未配置") + } + reference.StorageKey = strings.TrimSpace(reference.StorageKey) + if reference.StorageKey == "" { + return "", errors.New(errors.CodeInvalidParam, "审批附件对象存储 Key 不能为空") + } + localPath, cleanup, err := u.storage.DownloadToTemp(ctx, reference.StorageKey) + if err != nil { + return "", errors.Wrap(errors.CodeServiceUnavailable, err, "下载审批附件失败") + } + defer cleanup() + file, err := os.Open(localPath) + if err != nil { + return "", errors.Wrap(errors.CodeServiceUnavailable, err, "打开审批附件失败") + } + defer file.Close() + info, err := file.Stat() + if err != nil || info.Size() <= 5 || info.Size() > constants.WeComApprovalMaxAttachmentBytes { + return "", errors.New(errors.CodeInvalidParam, "审批附件大小必须大于 5 字节且不超过 20MB") + } + fileName := filepath.Base(strings.TrimSpace(reference.FileName)) + if fileName == "." || fileName == "" { + fileName = filepath.Base(reference.StorageKey) + } + token, err := u.tokens.GetAccessToken(ctx, applicationID) + if err != nil { + return "", err + } + endpoint, err := url.Parse(u.baseURL + "/cgi-bin/media/upload") + if err != nil || endpoint.Scheme == "" || endpoint.Host == "" { + return "", errors.New(errors.CodeWeComCredentialInvalid, "企业微信 API 地址配置无效") + } + query := endpoint.Query() + query.Set("access_token", token) + query.Set("type", "file") + endpoint.RawQuery = query.Encode() + request, err := newAttachmentUploadRequest(ctx, endpoint.String(), fileName, file) + if err != nil { + return "", err + } + resourceID := strconv.FormatUint(uint64(instanceID), 10) + attempt, err := u.integration.Start(ctx, integrationlog.Attempt{ + Provider: constants.IntegrationProviderWeCom, Direction: constants.IntegrationDirectionOutbound, + Operation: constants.IntegrationOperationWeComAttachmentUpload, ResourceType: constants.WeComApprovalInstanceResourceType, + ResourceID: &resourceID, RequestSummary: map[string]any{ + "application_id": applicationID, "file_name": fileName, "file_bytes": info.Size(), + }, + }) + if err != nil { + return "", err + } + startedAt := u.now() + response, requestErr := u.httpClient.Do(request) + if requestErr != nil { + message := "企业微信审批附件上传失败" + _, _ = u.integration.Complete(ctx, attempt.IntegrationID, integrationlog.Completion{ + Result: constants.IntegrationResultFailed, ProviderCode: "request_failed", ProviderMessage: message, + ResponseSummary: map[string]any{"success": false}, DurationMS: u.now().Sub(startedAt).Milliseconds(), + }) + return "", errors.Wrap(errors.CodeServiceUnavailable, requestErr, message) + } + defer response.Body.Close() + body, err := io.ReadAll(io.LimitReader(response.Body, constants.WeComMaxResponseBodyBytes+1)) + var result struct { + ErrCode int64 `json:"errcode"` + ErrMsg string `json:"errmsg"` + MediaID string `json:"media_id"` + } + if err != nil || int64(len(body)) > constants.WeComMaxResponseBodyBytes || sonic.Unmarshal(body, &result) != nil || + response.StatusCode < http.StatusOK || response.StatusCode >= http.StatusMultipleChoices || result.ErrCode != 0 || strings.TrimSpace(result.MediaID) == "" { + message := strings.TrimSpace(result.ErrMsg) + if message == "" { + message = "企业微信审批附件上传响应无效" + } + _, _ = u.integration.Complete(ctx, attempt.IntegrationID, integrationlog.Completion{ + Result: constants.IntegrationResultFailed, HTTPStatus: response.StatusCode, + ProviderCode: strconv.FormatInt(result.ErrCode, 10), ProviderMessage: message, + ResponseSummary: map[string]any{"success": false, "errcode": result.ErrCode}, DurationMS: u.now().Sub(startedAt).Milliseconds(), + }) + return "", errors.New(errors.CodeServiceUnavailable, "上传企业微信审批附件失败") + } + _, err = u.integration.Complete(ctx, attempt.IntegrationID, integrationlog.Completion{ + Result: constants.IntegrationResultSuccess, HTTPStatus: response.StatusCode, + ProviderCode: strconv.FormatInt(result.ErrCode, 10), ProviderMessage: result.ErrMsg, + ResponseSummary: map[string]any{"success": true, "media_id_set": true}, DurationMS: u.now().Sub(startedAt).Milliseconds(), + }) + if err != nil { + return "", err + } + return strings.TrimSpace(result.MediaID), nil +} + +// newAttachmentUploadRequest 在内存上限受文件大小校验约束的前提下组装 multipart 请求,避免流式写入 goroutine 泄漏。 +func newAttachmentUploadRequest(ctx context.Context, endpoint, fileName string, file *os.File) (*http.Request, error) { + var body bytes.Buffer + multipartWriter := multipart.NewWriter(&body) + part, err := multipartWriter.CreateFormFile("media", fileName) + if err != nil { + return nil, errors.Wrap(errors.CodeInternalError, err, "创建企业微信附件表单失败") + } + if _, err := io.Copy(part, file); err != nil { + return nil, errors.Wrap(errors.CodeServiceUnavailable, err, "读取审批附件失败") + } + if err := multipartWriter.Close(); err != nil { + return nil, errors.Wrap(errors.CodeInternalError, err, "完成企业微信附件表单失败") + } + request, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(body.Bytes())) + if err != nil { + return nil, errors.Wrap(errors.CodeInternalError, err, "创建企业微信附件上传请求失败") + } + request.Header.Set("Content-Type", multipartWriter.FormDataContentType()) + return request, nil +} diff --git a/internal/infrastructure/wecom/approval_context_repository.go b/internal/infrastructure/wecom/approval_context_repository.go new file mode 100644 index 0000000..12d1686 --- /dev/null +++ b/internal/infrastructure/wecom/approval_context_repository.go @@ -0,0 +1,395 @@ +package wecom + +import ( + "context" + "strconv" + "strings" + "time" + + "github.com/bytedance/sonic" + "gorm.io/datatypes" + "gorm.io/gorm" + "gorm.io/gorm/clause" + + "github.com/break/junhong_cmp_fiber/internal/model" + "github.com/break/junhong_cmp_fiber/pkg/constants" + "github.com/break/junhong_cmp_fiber/pkg/errors" +) + +// ApprovalSubmissionRecord 聚合 Worker 提交企微审批所需的本地事实。 +type ApprovalSubmissionRecord struct { + Instance model.ApprovalInstance + Context model.WeComApprovalContext +} + +// ApprovalRecoveryRecord 是主动恢复扫描需要的本地最小事实。 +type ApprovalRecoveryRecord struct { + InstanceID uint + ApplicationID uint + TemplateID string + CreatorUserID string + SPNo string + SubmissionStatus int + SubmissionAttemptedAt time.Time +} + +// ApprovalContextRepository 管理企微提交的领取、终态和结果未知状态。 +type ApprovalContextRepository struct { + db *gorm.DB + now func() time.Time +} + +// NewApprovalContextRepository 创建企微审批渠道上下文 Repository。 +func NewApprovalContextRepository(db *gorm.DB) *ApprovalContextRepository { + return &ApprovalContextRepository{db: db, now: time.Now} +} + +// ClaimSubmission 将待提交上下文原子置为请求处理中,阻止并发或重投重复提单。 +func (r *ApprovalContextRepository) ClaimSubmission(ctx context.Context, instanceID uint) (*ApprovalSubmissionRecord, bool, error) { + if r == nil || r.db == nil || instanceID == 0 { + return nil, false, errors.New(errors.CodeInvalidParam, "企业微信审批提交参数无效") + } + now := r.now().UTC() + result := r.db.WithContext(ctx).Model(&model.WeComApprovalContext{}). + Where("approval_instance_id = ? AND submission_status = ?", instanceID, constants.WeComSubmissionStatusReady). + Updates(map[string]any{ + "submission_status": constants.WeComSubmissionStatusSending, "submission_attempted_at": now, + "last_error": "", "updated_at": now, + }) + if result.Error != nil { + return nil, false, errors.Wrap(errors.CodeDatabaseError, result.Error, "领取企业微信审批提交任务失败") + } + record, err := r.Get(ctx, instanceID) + if err != nil { + return nil, false, err + } + return record, result.RowsAffected == 1, nil +} + +// PromoteStaleSendingToUnknown 将超出租约的提交中记录保守转为结果未知,禁止直接重新提交。 +func (r *ApprovalContextRepository) PromoteStaleSendingToUnknown(ctx context.Context, cutoff time.Time) error { + if r == nil || r.db == nil || cutoff.IsZero() { + return errors.New(errors.CodeInvalidParam, "企业微信审批恢复参数无效") + } + now := r.now().UTC() + return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + var stale []model.WeComApprovalContext + if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}). + Where("submission_status = ? AND updated_at <= ?", constants.WeComSubmissionStatusSending, cutoff.UTC()). + Order("id ASC").Limit(constants.WeComApprovalRecoveryBatchSize).Find(&stale).Error; err != nil { + return errors.Wrap(errors.CodeDatabaseError, err, "查询超时的企业微信审批提交失败") + } + if len(stale) == 0 { + return nil + } + instanceIDs := make([]uint, 0, len(stale)) + for _, channelContext := range stale { + instanceIDs = append(instanceIDs, channelContext.ApprovalInstanceID) + } + result := tx.Model(&model.WeComApprovalContext{}). + Where("approval_instance_id IN ? AND submission_status = ?", instanceIDs, constants.WeComSubmissionStatusSending). + Updates(map[string]any{ + "submission_status": constants.WeComSubmissionStatusUnknown, + "submission_attempted_at": gorm.Expr("COALESCE(submission_attempted_at, updated_at)"), + "last_error": "企业微信审批提交处理中断,已进入结果未知恢复", + "updated_at": now, + }) + if result.Error != nil { + return errors.Wrap(errors.CodeDatabaseError, result.Error, "标记企业微信审批提交结果未知失败") + } + if err := tx.Model(&model.ApprovalInstance{}). + Where("id IN ? AND status = ?", instanceIDs, constants.ApprovalStatusSubmitting). + Updates(map[string]any{ + "status": constants.ApprovalStatusSubmissionUnknown, "status_changed_at": now, + "version": gorm.Expr("version + 1"), "updated_at": now, + }).Error; err != nil { + return errors.Wrap(errors.CodeDatabaseError, err, "同步通用审批提交结果未知状态失败") + } + return nil + }) +} + +// ListUnknownRecovery 查询到期且尚未关联审批单号的结果未知记录。 +func (r *ApprovalContextRepository) ListUnknownRecovery(ctx context.Context, cutoff time.Time, limit int) ([]ApprovalRecoveryRecord, error) { + if limit <= 0 || limit > constants.WeComApprovalRecoveryBatchSize { + return nil, errors.New(errors.CodeInvalidParam, "企业微信审批恢复批量大小无效") + } + var records []ApprovalRecoveryRecord + err := r.db.WithContext(ctx).Table("tb_wecom_approval_context AS wc"). + Select(`wc.approval_instance_id AS instance_id, wc.application_id, wc.template_id, wc.creator_userid, + wc.sp_no, wc.submission_status, COALESCE(wc.submission_attempted_at, wc.updated_at) AS submission_attempted_at`). + Where("wc.submission_status = ? AND wc.sp_no = '' AND (wc.last_recovery_at IS NULL OR wc.last_recovery_at <= ?)", + constants.WeComSubmissionStatusUnknown, cutoff.UTC()). + Order("wc.id ASC").Limit(limit).Scan(&records).Error + if err != nil { + return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询企业微信结果未知审批失败") + } + return records, nil +} + +// ClaimUnknownRecovery 领取一次结果未知恢复尝试,阻止多个 Worker 同时查询同一提交。 +func (r *ApprovalContextRepository) ClaimUnknownRecovery(ctx context.Context, instanceID uint, cutoff time.Time) (bool, error) { + now := r.now().UTC() + result := r.db.WithContext(ctx).Model(&model.WeComApprovalContext{}). + Where("approval_instance_id = ? AND submission_status = ? AND sp_no = '' AND (last_recovery_at IS NULL OR last_recovery_at <= ?)", + instanceID, constants.WeComSubmissionStatusUnknown, cutoff.UTC()). + UpdateColumn("last_recovery_at", now) + if result.Error != nil { + return false, errors.Wrap(errors.CodeDatabaseError, result.Error, "领取企业微信结果未知恢复任务失败") + } + return result.RowsAffected == 1, nil +} + +// ListPendingSync 查询已提交且仍未终态、需要再次拉取权威详情的审批。 +func (r *ApprovalContextRepository) ListPendingSync(ctx context.Context, cutoff time.Time, limit int) ([]ApprovalRecoveryRecord, error) { + if limit <= 0 || limit > constants.WeComApprovalRecoveryBatchSize { + return nil, errors.New(errors.CodeInvalidParam, "企业微信审批轮询批量大小无效") + } + var records []ApprovalRecoveryRecord + err := r.db.WithContext(ctx).Table("tb_wecom_approval_context AS wc"). + Select(`wc.approval_instance_id AS instance_id, wc.application_id, wc.template_id, wc.creator_userid, + wc.sp_no, wc.submission_status, COALESCE(wc.submission_attempted_at, wc.updated_at) AS submission_attempted_at`). + Joins("JOIN tb_approval_instance AS ai ON ai.id = wc.approval_instance_id"). + Where("wc.submission_status = ? AND wc.sp_no <> '' AND ai.status = ? AND (wc.last_synced_at IS NULL OR wc.last_synced_at <= ?)", + constants.WeComSubmissionStatusSubmitted, constants.ApprovalStatusPending, cutoff.UTC()). + Order("COALESCE(wc.last_synced_at, wc.created_at) ASC, wc.id ASC").Limit(limit).Scan(&records).Error + if err != nil { + return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询待轮询企业微信审批失败") + } + return records, nil +} + +// FindSuccessfulSubmissionSPNo 从已成功的安全 Integration Log 摘要恢复本地未保存的审批单号。 +func (r *ApprovalContextRepository) FindSuccessfulSubmissionSPNo(ctx context.Context, instanceID uint) (string, error) { + resourceID := strconv.FormatUint(uint64(instanceID), 10) + var log model.IntegrationLog + err := r.db.WithContext(ctx). + Where("provider = ? AND operation = ? AND direction = ? AND resource_id = ? AND result = ?", + constants.IntegrationProviderWeCom, constants.IntegrationOperationWeComApprovalSubmit, + constants.IntegrationDirectionOutbound, resourceID, constants.IntegrationResultSuccess). + Order("id DESC").First(&log).Error + if err == gorm.ErrRecordNotFound { + return "", nil + } + if err != nil { + return "", errors.Wrap(errors.CodeDatabaseError, err, "查询企业微信审批提交日志失败") + } + var summary struct { + SPNo string `json:"sp_no"` + } + if sonic.Unmarshal(log.ResponseSummary, &summary) != nil { + return "", nil + } + return strings.TrimSpace(summary.SPNo), nil +} + +// ExistingSPNos 批量过滤已经关联到本地审批实例的企微审批单号。 +func (r *ApprovalContextRepository) ExistingSPNos(ctx context.Context, applicationID uint, spNos []string) (map[string]struct{}, error) { + result := make(map[string]struct{}) + if len(spNos) == 0 { + return result, nil + } + var values []string + if err := r.db.WithContext(ctx).Model(&model.WeComApprovalContext{}). + Where("application_id = ? AND sp_no IN ?", applicationID, spNos).Pluck("sp_no", &values).Error; err != nil { + return nil, errors.Wrap(errors.CodeDatabaseError, err, "批量查询已关联企业微信审批单号失败") + } + for _, value := range values { + result[value] = struct{}{} + } + return result, nil +} + +// FindUniqueUnknownByFingerprint 按应用、模板、发起人和提交时间唯一定位结果未知实例。 +func (r *ApprovalContextRepository) FindUniqueUnknownByFingerprint( + ctx context.Context, + applicationID uint, + templateID string, + creatorUserID string, + submittedAt time.Time, +) (*ApprovalRecoveryRecord, error) { + if applicationID == 0 || strings.TrimSpace(templateID) == "" || strings.TrimSpace(creatorUserID) == "" || submittedAt.IsZero() { + return nil, nil + } + var records []ApprovalRecoveryRecord + err := r.db.WithContext(ctx).Table("tb_wecom_approval_context AS wc"). + Select(`wc.approval_instance_id AS instance_id, wc.application_id, wc.template_id, wc.creator_userid, + wc.sp_no, wc.submission_status, COALESCE(wc.submission_attempted_at, wc.updated_at) AS submission_attempted_at`). + Where(`wc.application_id = ? AND wc.template_id = ? AND wc.creator_userid = ? + AND wc.submission_status = ? AND wc.sp_no = '' + AND COALESCE(wc.submission_attempted_at, wc.updated_at) BETWEEN ? AND ?`, + applicationID, strings.TrimSpace(templateID), strings.TrimSpace(creatorUserID), constants.WeComSubmissionStatusUnknown, + submittedAt.Add(-constants.WeComApprovalRecoveryWindow).UTC(), submittedAt.Add(constants.WeComApprovalRecoveryWindow).UTC()). + Order("wc.id ASC").Limit(2).Scan(&records).Error + if err != nil { + return nil, errors.Wrap(errors.CodeDatabaseError, err, "匹配企业微信结果未知审批失败") + } + if len(records) != 1 { + return nil, nil + } + return &records[0], nil +} + +// RecoverSubmitted 将唯一确认的企微审批单号原子关联回结果未知实例。 +func (r *ApprovalContextRepository) RecoverSubmitted(ctx context.Context, instanceID uint, spNo string) (bool, error) { + spNo = strings.TrimSpace(spNo) + if instanceID == 0 || spNo == "" { + return false, errors.New(errors.CodeInvalidParam, "企业微信审批恢复关联参数无效") + } + now := r.now().UTC() + recovered := false + err := r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + contextResult := tx.Model(&model.WeComApprovalContext{}). + Where("approval_instance_id = ? AND submission_status = ? AND sp_no = ''", instanceID, constants.WeComSubmissionStatusUnknown). + Updates(map[string]any{ + "submission_status": constants.WeComSubmissionStatusSubmitted, "sp_no": spNo, + "last_error": "", "updated_at": now, + }) + if contextResult.Error != nil { + return errors.Wrap(errors.CodeDatabaseError, contextResult.Error, "恢复企业微信审批单号失败") + } + if contextResult.RowsAffected == 0 { + return nil + } + instanceResult := tx.Model(&model.ApprovalInstance{}). + Where("id = ? AND status = ?", instanceID, constants.ApprovalStatusSubmissionUnknown). + Updates(map[string]any{ + "external_ref": spNo, "status": constants.ApprovalStatusPending, + "status_changed_at": now, "version": gorm.Expr("version + 1"), "updated_at": now, + }) + if instanceResult.Error != nil { + return errors.Wrap(errors.CodeDatabaseError, instanceResult.Error, "同步恢复后的通用审批状态失败") + } + if instanceResult.RowsAffected != 1 { + return errors.New(errors.CodeConflict, "通用审批恢复状态已变化") + } + recovered = true + return nil + }) + return recovered, err +} + +// Get 读取通用审批实例及对应企微渠道上下文。 +func (r *ApprovalContextRepository) Get(ctx context.Context, instanceID uint) (*ApprovalSubmissionRecord, error) { + var instance model.ApprovalInstance + if err := r.db.WithContext(ctx).Where("id = ?", instanceID).First(&instance).Error; err != nil { + if err == gorm.ErrRecordNotFound { + return nil, errors.New(errors.CodeForbidden, "无权限操作该资源或资源不存在") + } + return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询通用审批实例失败") + } + var channelContext model.WeComApprovalContext + if err := r.db.WithContext(ctx).Where("approval_instance_id = ?", instanceID).First(&channelContext).Error; err != nil { + if err == gorm.ErrRecordNotFound { + return nil, errors.New(errors.CodeInvalidStatus, "企业微信审批渠道上下文不存在") + } + return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询企业微信审批渠道上下文失败") + } + return &ApprovalSubmissionRecord{Instance: instance, Context: channelContext}, nil +} + +// GetBySPNo 按企微审批单号读取本地通用实例和渠道上下文。 +func (r *ApprovalContextRepository) GetBySPNo(ctx context.Context, applicationID uint, spNo string) (*ApprovalSubmissionRecord, error) { + var channelContext model.WeComApprovalContext + if err := r.db.WithContext(ctx).Where("application_id = ? AND sp_no = ?", applicationID, spNo).First(&channelContext).Error; err != nil { + if err == gorm.ErrRecordNotFound { + return nil, errors.New(errors.CodeForbidden, "无权限操作该资源或资源不存在") + } + return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询企业微信审批渠道上下文失败") + } + return r.Get(ctx, channelContext.ApprovalInstanceID) +} + +// FindBySPNo 按企微审批单号查找本地记录;无关联时返回空,供回调区分无关审批。 +func (r *ApprovalContextRepository) FindBySPNo(ctx context.Context, applicationID uint, spNo string) (*ApprovalSubmissionRecord, error) { + var channelContext model.WeComApprovalContext + if err := r.db.WithContext(ctx).Where("application_id = ? AND sp_no = ?", applicationID, spNo).First(&channelContext).Error; err != nil { + if err == gorm.ErrRecordNotFound { + return nil, nil + } + return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询企业微信审批渠道上下文失败") + } + return r.Get(ctx, channelContext.ApprovalInstanceID) +} + +// SaveLatestDetail 保存最近一次权威企微审批详情,供终态同步和读取投影复用。 +func (r *ApprovalContextRepository) SaveLatestDetail(ctx context.Context, instanceID uint, spStatus int, snapshot []byte) error { + now := r.now().UTC() + if err := r.db.WithContext(ctx).Model(&model.WeComApprovalContext{}).Where("approval_instance_id = ?", instanceID).Updates(map[string]any{ + "latest_sp_status": spStatus, "latest_detail_snapshot": datatypes.JSON(snapshot), "last_synced_at": now, "updated_at": now, + }).Error; err != nil { + return errors.Wrap(errors.CodeDatabaseError, err, "保存企业微信审批详情快照失败") + } + return nil +} + +// ReleaseForRetry 将尚未调用 applyevent 的安全失败恢复为待提交。 +func (r *ApprovalContextRepository) ReleaseForRetry(ctx context.Context, instanceID uint, message string) error { + result := r.db.WithContext(ctx).Model(&model.WeComApprovalContext{}). + Where("approval_instance_id = ? AND submission_status = ?", instanceID, constants.WeComSubmissionStatusSending). + Updates(map[string]any{"submission_status": constants.WeComSubmissionStatusReady, "last_error": message, "updated_at": r.now().UTC()}) + if result.Error != nil { + return errors.Wrap(errors.CodeDatabaseError, result.Error, "恢复企业微信审批待提交状态失败") + } + return nil +} + +// MarkSubmitted 原子保存企微 sp_no,并把通用审批实例置为审批中。 +func (r *ApprovalContextRepository) MarkSubmitted(ctx context.Context, instanceID uint, spNo string) error { + now := r.now().UTC() + return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + contextResult := tx.Model(&model.WeComApprovalContext{}). + Where("approval_instance_id = ? AND submission_status = ?", instanceID, constants.WeComSubmissionStatusSending). + Updates(map[string]any{"submission_status": constants.WeComSubmissionStatusSubmitted, "sp_no": spNo, "last_error": "", "updated_at": now}) + if contextResult.Error != nil { + return errors.Wrap(errors.CodeDatabaseError, contextResult.Error, "保存企业微信审批单号失败") + } + if contextResult.RowsAffected != 1 { + return errors.New(errors.CodeConflict, "企业微信审批提交状态已变化") + } + instanceResult := tx.Model(&model.ApprovalInstance{}). + Where("id = ? AND status = ?", instanceID, constants.ApprovalStatusSubmitting). + Updates(map[string]any{ + "external_ref": spNo, "status": constants.ApprovalStatusPending, + "status_changed_at": now, "version": gorm.Expr("version + 1"), "updated_at": now, + }) + if instanceResult.Error != nil { + return errors.Wrap(errors.CodeDatabaseError, instanceResult.Error, "更新通用审批提交状态失败") + } + if instanceResult.RowsAffected != 1 { + return errors.New(errors.CodeConflict, "通用审批提交状态已变化") + } + return nil + }) +} + +// MarkFailed 将企微明确拒绝的提交记录为提交失败。 +func (r *ApprovalContextRepository) MarkFailed(ctx context.Context, instanceID uint, message string) error { + return r.markSubmissionState(ctx, instanceID, constants.WeComSubmissionStatusFailed, constants.ApprovalStatusSubmissionFailed, message) +} + +// MarkUnknown 将请求已发出但无法确认结果的提交记录为结果未知。 +func (r *ApprovalContextRepository) MarkUnknown(ctx context.Context, instanceID uint, message string) error { + return r.markSubmissionState(ctx, instanceID, constants.WeComSubmissionStatusUnknown, constants.ApprovalStatusSubmissionUnknown, message) +} + +// markSubmissionState 在同一事务中同步企微渠道状态与通用审批状态,避免两侧事实分裂。 +func (r *ApprovalContextRepository) markSubmissionState(ctx context.Context, instanceID uint, channelStatus, approvalStatus int, message string) error { + now := r.now().UTC() + return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + if err := tx.Model(&model.WeComApprovalContext{}). + Where("approval_instance_id = ? AND submission_status = ?", instanceID, constants.WeComSubmissionStatusSending). + Updates(map[string]any{"submission_status": channelStatus, "last_error": message, "updated_at": now}).Error; err != nil { + return errors.Wrap(errors.CodeDatabaseError, err, "更新企业微信审批提交结果失败") + } + if err := tx.Model(&model.ApprovalInstance{}). + Where("id = ? AND status = ?", instanceID, constants.ApprovalStatusSubmitting). + Updates(map[string]any{ + "status": approvalStatus, "status_changed_at": now, + "version": gorm.Expr("version + 1"), "updated_at": now, + }).Error; err != nil { + return errors.Wrap(errors.CodeDatabaseError, err, "更新通用审批提交结果失败") + } + return nil + }) +} diff --git a/internal/infrastructure/wecom/approval_detail_client.go b/internal/infrastructure/wecom/approval_detail_client.go new file mode 100644 index 0000000..898257a --- /dev/null +++ b/internal/infrastructure/wecom/approval_detail_client.go @@ -0,0 +1,130 @@ +package wecom + +import ( + "bytes" + "context" + "io" + "net/http" + "net/url" + "strconv" + "strings" + "time" + + "github.com/bytedance/sonic" + + "github.com/break/junhong_cmp_fiber/internal/infrastructure/integrationlog" + "github.com/break/junhong_cmp_fiber/pkg/constants" + "github.com/break/junhong_cmp_fiber/pkg/errors" +) + +// ApprovalDetail 是企微审批详情的权威状态和安全原始 JSON 快照。 +type ApprovalDetail struct { + SPNo string + SPStatus int + Snapshot []byte +} + +// ApprovalDetailClient 获取企业微信审批申请详情。 +type ApprovalDetailClient struct { + tokens DirectoryTokenProvider + integration TokenIntegrationLog + httpClient *http.Client + baseURL string + now func() time.Time +} + +// NewApprovalDetailClient 创建企业微信审批详情客户端。 +func NewApprovalDetailClient(tokens DirectoryTokenProvider, integration TokenIntegrationLog, baseURL string, timeout time.Duration) *ApprovalDetailClient { + if timeout <= 0 { + timeout = constants.WeComDefaultHTTPTimeout + } + return &ApprovalDetailClient{tokens: tokens, integration: integration, httpClient: &http.Client{Timeout: timeout}, baseURL: strings.TrimRight(baseURL, "/"), now: time.Now} +} + +// Get 获取审批详情并记录一次真实外呼。 +func (c *ApprovalDetailClient) Get(ctx context.Context, applicationID uint, spNo string) (ApprovalDetail, error) { + token, err := c.tokens.GetAccessToken(ctx, applicationID) + if err != nil { + return ApprovalDetail{}, err + } + request, err := c.newRequest(ctx, token, spNo) + if err != nil { + return ApprovalDetail{}, err + } + resourceID := strconv.FormatUint(uint64(applicationID), 10) + attempt, err := c.integration.Start(ctx, integrationlog.Attempt{ + Provider: constants.IntegrationProviderWeCom, Direction: constants.IntegrationDirectionOutbound, + Operation: constants.IntegrationOperationWeComApprovalDetail, ResourceType: constants.WeComApprovalInstanceResourceType, + ResourceID: &resourceID, ExternalID: &spNo, RequestSummary: map[string]any{"application_id": applicationID, "sp_no": spNo}, + }) + if err != nil { + return ApprovalDetail{}, err + } + startedAt := c.now() + response, err := c.httpClient.Do(request) + if err != nil { + return ApprovalDetail{}, c.completeFailure(ctx, attempt.IntegrationID, 0, "request_failed", "企业微信审批详情请求失败", startedAt) + } + defer response.Body.Close() + body, err := io.ReadAll(io.LimitReader(response.Body, constants.WeComDirectoryMaxResponseBodyBytes+1)) + if err != nil || int64(len(body)) > constants.WeComDirectoryMaxResponseBodyBytes { + return ApprovalDetail{}, c.completeFailure(ctx, attempt.IntegrationID, response.StatusCode, "invalid_response", "企业微信审批详情响应无效", startedAt) + } + var payload map[string]any + if err := sonic.Unmarshal(body, &payload); err != nil { + return ApprovalDetail{}, c.completeFailure(ctx, attempt.IntegrationID, response.StatusCode, "invalid_response", "企业微信审批详情响应无效", startedAt) + } + errCode := numberToInt64(payload["errcode"]) + errMsg, _ := payload["errmsg"].(string) + info, _ := payload["info"].(map[string]any) + status := int(numberToInt64(info["sp_status"])) + if response.StatusCode < http.StatusOK || response.StatusCode >= http.StatusMultipleChoices || errCode != 0 || info == nil { + return ApprovalDetail{}, c.completeFailure(ctx, attempt.IntegrationID, response.StatusCode, strconv.FormatInt(errCode, 10), errMsg, startedAt) + } + snapshot, err := sonic.Marshal(info) + if err != nil { + return ApprovalDetail{}, errors.Wrap(errors.CodeInternalError, err, "编码企业微信审批详情快照失败") + } + _, err = c.integration.Complete(ctx, attempt.IntegrationID, integrationlog.Completion{ + Result: constants.IntegrationResultSuccess, HTTPStatus: response.StatusCode, + ProviderCode: strconv.FormatInt(errCode, 10), ProviderMessage: errMsg, + ResponseSummary: map[string]any{"sp_no": spNo, "sp_status": status}, DurationMS: c.now().Sub(startedAt).Milliseconds(), + }) + return ApprovalDetail{SPNo: spNo, SPStatus: status, Snapshot: snapshot}, err +} + +// newRequest 组装按字符串审批单号查询权威详情的企微请求。 +func (c *ApprovalDetailClient) newRequest(ctx context.Context, token, spNo string) (*http.Request, error) { + endpoint, err := url.Parse(c.baseURL + "/cgi-bin/oa/getapprovaldetail") + if err != nil || endpoint.Scheme == "" || endpoint.Host == "" { + return nil, errors.New(errors.CodeWeComCredentialInvalid, "企业微信 API 地址配置无效") + } + query := endpoint.Query() + query.Set("access_token", token) + endpoint.RawQuery = query.Encode() + body, err := sonic.Marshal(map[string]string{"sp_no": spNo}) + if err != nil { + return nil, errors.Wrap(errors.CodeInternalError, err, "编码企业微信审批详情请求失败") + } + request, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint.String(), bytes.NewReader(body)) + if err != nil { + return nil, errors.Wrap(errors.CodeInternalError, err, "创建企业微信审批详情请求失败") + } + request.Header.Set("Content-Type", "application/json") + return request, nil +} + +// completeFailure 先终结 Integration Log,再向 Asynq 返回可重试的统一错误。 +func (c *ApprovalDetailClient) completeFailure(ctx context.Context, integrationID string, status int, providerCode, message string, startedAt time.Time) error { + if strings.TrimSpace(message) == "" { + message = "企业微信审批详情接口返回失败" + } + _, err := c.integration.Complete(ctx, integrationID, integrationlog.Completion{ + Result: constants.IntegrationResultFailed, HTTPStatus: status, ProviderCode: providerCode, + ProviderMessage: message, ResponseSummary: map[string]any{"success": false}, DurationMS: c.now().Sub(startedAt).Milliseconds(), + }) + if err != nil { + return err + } + return errors.New(errors.CodeServiceUnavailable, "获取企业微信审批详情失败") +} diff --git a/internal/infrastructure/wecom/approval_detail_task.go b/internal/infrastructure/wecom/approval_detail_task.go new file mode 100644 index 0000000..269fbd4 --- /dev/null +++ b/internal/infrastructure/wecom/approval_detail_task.go @@ -0,0 +1,159 @@ +package wecom + +import ( + "context" + "strings" + "time" + + "github.com/bytedance/sonic" + "github.com/hibiken/asynq" + + approvalapp "github.com/break/junhong_cmp_fiber/internal/application/approval" + "github.com/break/junhong_cmp_fiber/internal/infrastructure/integrationlog" + "github.com/break/junhong_cmp_fiber/pkg/constants" + "github.com/break/junhong_cmp_fiber/pkg/errors" +) + +// ApprovalDetailTaskHandler 拉取企微审批详情并翻译为现有标准决策。 +type ApprovalDetailTaskHandler struct { + details *ApprovalDetailClient + contexts *ApprovalContextRepository + decisions *approvalapp.SyncDecisionService + integration TokenIntegrationLog +} + +// NewApprovalDetailTaskHandler 创建企业微信审批详情同步任务 Handler。 +func NewApprovalDetailTaskHandler(details *ApprovalDetailClient, contexts *ApprovalContextRepository, decisions *approvalapp.SyncDecisionService, integration TokenIntegrationLog) *ApprovalDetailTaskHandler { + return &ApprovalDetailTaskHandler{details: details, contexts: contexts, decisions: decisions, integration: integration} +} + +// Handle 处理回调或后续轮询提交的详情同步任务。 +func (h *ApprovalDetailTaskHandler) Handle(ctx context.Context, task *asynq.Task) error { + if h == nil || h.details == nil || h.contexts == nil || h.decisions == nil || h.integration == nil { + return errors.New(errors.CodeServiceUnavailable, "企业微信审批详情同步任务未配置") + } + var payload ApprovalDetailSyncTask + if err := sonic.Unmarshal(task.Payload(), &payload); err != nil || payload.ApplicationID == 0 || strings.TrimSpace(payload.SPNo) == "" { + return errors.New(errors.CodeInvalidParam, "企业微信审批详情同步任务载荷无效") + } + if !validApprovalSyncSource(payload.Source) { + return errors.New(errors.CodeInvalidParam, "企业微信审批详情同步来源无效") + } + detail, err := h.details.Get(ctx, payload.ApplicationID, payload.SPNo) + if err != nil { + return err + } + record, err := h.contexts.FindBySPNo(ctx, payload.ApplicationID, payload.SPNo) + if err != nil { + return err + } + if record == nil { + record, err = h.recoverUnknownCallback(ctx, payload, detail) + if err != nil { + return err + } + if record == nil { + return h.completeIgnoredCallback(ctx, payload) + } + } + if err := h.contexts.SaveLatestDetail(ctx, record.Instance.ID, detail.SPStatus, detail.Snapshot); err != nil { + return err + } + decisions := weComDecisions(detail.SPStatus) + for _, decision := range decisions { + if _, err := h.decisions.Execute(ctx, approvalapp.SyncDecisionCommand{ + InstanceID: record.Instance.ID, Decision: decision, DecisionSnapshot: detail.Snapshot, Source: payload.Source, + }); err != nil { + return err + } + } + if strings.TrimSpace(payload.IntegrationID) != "" { + _, err = h.integration.Complete(ctx, payload.IntegrationID, integrationlog.Completion{ + Result: constants.IntegrationResultCompleted, ProviderCode: "processed", + ProviderMessage: "企业微信审批回调已完成权威详情同步", + ResponseSummary: map[string]any{"sp_no": payload.SPNo, "sp_status": detail.SPStatus, "decisions": decisions}, + DurationMS: 0, StateChanged: len(decisions) > 0, + }) + } + return err +} + +func (h *ApprovalDetailTaskHandler) recoverUnknownCallback(ctx context.Context, payload ApprovalDetailSyncTask, detail ApprovalDetail) (*ApprovalSubmissionRecord, error) { + if payload.Source != constants.ApprovalSyncSourceCallback { + return nil, nil + } + fingerprint := approvalDetailFingerprint(detail.Snapshot) + candidate, err := h.contexts.FindUniqueUnknownByFingerprint( + ctx, payload.ApplicationID, fingerprint.TemplateID, fingerprint.CreatorUserID, fingerprint.SubmittedAt, + ) + if err != nil || candidate == nil { + return nil, err + } + recovered, err := h.contexts.RecoverSubmitted(ctx, candidate.InstanceID, payload.SPNo) + if err != nil || !recovered { + return nil, err + } + return h.contexts.Get(ctx, candidate.InstanceID) +} + +func (h *ApprovalDetailTaskHandler) completeIgnoredCallback(ctx context.Context, payload ApprovalDetailSyncTask) error { + if strings.TrimSpace(payload.IntegrationID) == "" { + return nil + } + _, err := h.integration.Complete(ctx, payload.IntegrationID, integrationlog.Completion{ + Result: constants.IntegrationResultIgnored, ProviderCode: "unrelated_approval", + ProviderMessage: "企业微信审批单未关联本系统业务,已忽略", + ResponseSummary: map[string]any{"sp_no": payload.SPNo, "ignored": true}, + }) + return err +} + +type approvalFingerprint struct { + TemplateID string + CreatorUserID string + SubmittedAt time.Time +} + +func approvalDetailFingerprint(snapshot []byte) approvalFingerprint { + var value struct { + TemplateID string `json:"template_id"` + ApplyTime int64 `json:"apply_time"` + Applyer struct { + UserID string `json:"userid"` + } `json:"applyer"` + } + if sonic.Unmarshal(snapshot, &value) != nil || value.ApplyTime <= 0 { + return approvalFingerprint{} + } + return approvalFingerprint{ + TemplateID: strings.TrimSpace(value.TemplateID), CreatorUserID: strings.TrimSpace(value.Applyer.UserID), + SubmittedAt: time.Unix(value.ApplyTime, 0).UTC(), + } +} + +func validApprovalSyncSource(source string) bool { + switch source { + case constants.ApprovalSyncSourceCallback, constants.ApprovalSyncSourcePolling, constants.ApprovalSyncSourceManual: + return true + default: + return false + } +} + +// weComDecisions 只翻译现有审批核心支持的企微标准终态;通过后撤销按领域允许顺序补齐两个事实。 +func weComDecisions(status int) []string { + switch status { + case 2: + return []string{constants.ApprovalDecisionApproved} + case 3: + return []string{constants.ApprovalDecisionRejected} + case 4: + return []string{constants.ApprovalDecisionCancelled} + case 6: + return []string{constants.ApprovalDecisionApproved, constants.ApprovalDecisionRevokedAfterApproved} + case 7: + return []string{constants.ApprovalDecisionDeleted} + default: + return nil + } +} diff --git a/internal/infrastructure/wecom/approval_form.go b/internal/infrastructure/wecom/approval_form.go new file mode 100644 index 0000000..084bca4 --- /dev/null +++ b/internal/infrastructure/wecom/approval_form.go @@ -0,0 +1,176 @@ +package wecom + +import ( + "context" + "fmt" + "strings" + + "github.com/bytedance/sonic" + + wecomapp "github.com/break/junhong_cmp_fiber/internal/application/wecom" + "github.com/break/junhong_cmp_fiber/internal/model/dto" + "github.com/break/junhong_cmp_fiber/pkg/constants" + "github.com/break/junhong_cmp_fiber/pkg/errors" +) + +type approvalAttachmentPort interface { + Upload(ctx context.Context, applicationID, instanceID uint, reference ApprovalFileReference) (string, error) +} + +type approvalFormBuildResult struct { + Contents []map[string]any +} + +type attachmentPreparationError struct { + err error +} + +func (e *attachmentPreparationError) Error() string { + return e.err.Error() +} + +func (e *attachmentPreparationError) Unwrap() error { + return e.err +} + +// buildApprovalForm 按后台已校验映射将渠道无关业务快照组装为企微控件值。 +func buildApprovalForm(ctx context.Context, applicationID, instanceID uint, mappingJSON, templateJSON, snapshotJSON []byte, attachments approvalAttachmentPort) (approvalFormBuildResult, error) { + var mappings []dto.WeComControlMappingItem + if err := sonic.Unmarshal(mappingJSON, &mappings); err != nil || len(mappings) == 0 { + return approvalFormBuildResult{}, errors.New(errors.CodeInvalidStatus, "企业微信审批控件映射无效") + } + var snapshot map[string]any + if err := sonic.Unmarshal(snapshotJSON, &snapshot); err != nil || snapshot == nil { + return approvalFormBuildResult{}, errors.New(errors.CodeInvalidStatus, "审批业务快照无效") + } + var template wecomapp.TemplateDefinition + if err := sonic.Unmarshal(templateJSON, &template); err != nil { + return approvalFormBuildResult{}, errors.New(errors.CodeInvalidStatus, "企业微信审批模板快照无效") + } + requiredControls := make(map[string]bool, len(template.Controls)) + for _, control := range template.Controls { + requiredControls[control.ID] = control.Required + } + contents := make([]map[string]any, 0, len(mappings)) + attachmentCount := 0 + for _, mapping := range mappings { + raw, exists := lookupSnapshotValue(snapshot, mapping.BusinessField) + if !exists || raw == nil { + if requiredControls[mapping.ControlID] { + return approvalFormBuildResult{}, errors.New(errors.CodeInvalidStatus, "审批业务快照缺少模板必填字段: "+mapping.BusinessField) + } + continue + } + value, count, err := buildControlValue(ctx, applicationID, instanceID, mapping, raw, attachments) + if err != nil { + return approvalFormBuildResult{}, err + } + attachmentCount += count + if attachmentCount > constants.WeComApprovalMaxAttachmentCount { + return approvalFormBuildResult{}, errors.New(errors.CodeInvalidParam, "单张企业微信审批单最多支持 6 个附件") + } + contents = append(contents, map[string]any{ + "control": mapping.ControlType, "id": mapping.ControlID, "value": value, + }) + } + return approvalFormBuildResult{Contents: contents}, nil +} + +// lookupSnapshotValue 支持用点分路径读取嵌套业务快照,避免模板映射绑定具体 DTO。 +func lookupSnapshotValue(snapshot map[string]any, path string) (any, bool) { + segments := strings.Split(strings.TrimSpace(path), ".") + var current any = snapshot + for _, segment := range segments { + object, ok := current.(map[string]any) + if !ok { + return nil, false + } + current, ok = object[segment] + if !ok { + return nil, false + } + } + return current, true +} + +// buildControlValue 按企微控件类型生成官方要求的 value 结构,复杂结构允许业务快照直接传入对象。 +func buildControlValue(ctx context.Context, applicationID, instanceID uint, mapping dto.WeComControlMappingItem, raw any, attachments approvalAttachmentPort) (map[string]any, int, error) { + if object, ok := raw.(map[string]any); ok && !strings.EqualFold(mapping.ControlType, "File") { + return object, 0, nil + } + switch strings.ToLower(strings.TrimSpace(mapping.ControlType)) { + case "text", "textarea": + return map[string]any{"text": fmt.Sprint(raw)}, 0, nil + case "number": + return map[string]any{"new_number": fmt.Sprint(raw)}, 0, nil + case "money": + return map[string]any{"new_money": fmt.Sprint(raw)}, 0, nil + case "date": + return map[string]any{"date": map[string]any{"type": "day", "s_timestamp": fmt.Sprint(raw)}}, 0, nil + case "selector": + businessValue := fmt.Sprint(raw) + key := mapping.OptionMapping[businessValue] + if key == "" { + return nil, 0, errors.New(errors.CodeInvalidStatus, "审批业务枚举值没有企微选项映射: "+mapping.BusinessField) + } + return map[string]any{"selector": map[string]any{"type": "single", "options": []map[string]string{{"key": key}}}}, 0, nil + case "contact": + return map[string]any{"members": []map[string]string{{"userid": fmt.Sprint(raw)}}}, 0, nil + case "department": + return map[string]any{"departments": []map[string]string{{"openapi_id": fmt.Sprint(raw)}}}, 0, nil + case "file": + if attachments == nil { + return nil, 0, errors.New(errors.CodeServiceUnavailable, "企业微信审批附件上传服务未配置") + } + references, err := parseApprovalFileReferences(raw) + if err != nil { + return nil, 0, err + } + files := make([]map[string]string, 0, len(references)) + for _, reference := range references { + mediaID, err := attachments.Upload(ctx, applicationID, instanceID, reference) + if err != nil { + return nil, 0, &attachmentPreparationError{err: err} + } + files = append(files, map[string]string{"file_id": mediaID}) + } + return map[string]any{"files": files}, len(files), nil + default: + return nil, 0, errors.New(errors.CodeInvalidStatus, "暂不支持的企业微信审批控件类型: "+mapping.ControlType) + } +} + +// parseApprovalFileReferences 兼容现有退款和线下充值的字符串 Key 列表,以及带文件名的结构化引用。 +func parseApprovalFileReferences(raw any) ([]ApprovalFileReference, error) { + if storageKey, ok := raw.(string); ok && strings.TrimSpace(storageKey) != "" { + return []ApprovalFileReference{{StorageKey: strings.TrimSpace(storageKey)}}, nil + } + bytes, err := sonic.Marshal(raw) + if err != nil { + return nil, errors.Wrap(errors.CodeInvalidParam, err, "编码审批附件引用失败") + } + var references []ApprovalFileReference + if err := sonic.Unmarshal(bytes, &references); err != nil { + var storageKeys []string + if keyErr := sonic.Unmarshal(bytes, &storageKeys); keyErr == nil && len(storageKeys) > 0 { + references = make([]ApprovalFileReference, 0, len(storageKeys)) + for _, storageKey := range storageKeys { + if strings.TrimSpace(storageKey) != "" { + references = append(references, ApprovalFileReference{StorageKey: strings.TrimSpace(storageKey)}) + } + } + if len(references) > 0 { + return references, nil + } + } + var single ApprovalFileReference + if singleErr := sonic.Unmarshal(bytes, &single); singleErr != nil { + return nil, errors.New(errors.CodeInvalidParam, "审批附件必须提供对象存储引用") + } + references = []ApprovalFileReference{single} + } + if len(references) == 0 { + return nil, errors.New(errors.CodeInvalidParam, "审批附件不能为空") + } + return references, nil +} diff --git a/internal/infrastructure/wecom/approval_info_client.go b/internal/infrastructure/wecom/approval_info_client.go new file mode 100644 index 0000000..a6f9dcf --- /dev/null +++ b/internal/infrastructure/wecom/approval_info_client.go @@ -0,0 +1,195 @@ +package wecom + +import ( + "bytes" + "context" + "io" + "net/http" + "net/url" + "strconv" + "strings" + "time" + + "github.com/bytedance/sonic" + + "github.com/break/junhong_cmp_fiber/internal/infrastructure/integrationlog" + "github.com/break/junhong_cmp_fiber/pkg/constants" + "github.com/break/junhong_cmp_fiber/pkg/errors" +) + +// ApprovalInfoQuery 描述按审批单提交时间查询审批单号的过滤条件。 +type ApprovalInfoQuery struct { + ApplicationID uint + StartTime time.Time + EndTime time.Time + TemplateID string + CreatorUserID string + Cursor string + Size int +} + +// ApprovalInfoPage 是企业微信批量审批单号接口的一页结果。 +type ApprovalInfoPage struct { + SPNos []string + NextCursor string +} + +// ApprovalInfoClient 按提交时间窗批量获取企业微信审批单号。 +type ApprovalInfoClient struct { + tokens DirectoryTokenProvider + integration TokenIntegrationLog + httpClient *http.Client + baseURL string + now func() time.Time +} + +// NewApprovalInfoClient 创建企业微信批量审批单号客户端。 +func NewApprovalInfoClient(tokens DirectoryTokenProvider, integration TokenIntegrationLog, baseURL string, timeout time.Duration) *ApprovalInfoClient { + if timeout <= 0 { + timeout = constants.WeComDefaultHTTPTimeout + } + return &ApprovalInfoClient{ + tokens: tokens, integration: integration, httpClient: &http.Client{Timeout: timeout}, + baseURL: strings.TrimRight(baseURL, "/"), now: time.Now, + } +} + +// List 获取一页审批单号,并为每次真实外呼写入 Integration Log。 +func (c *ApprovalInfoClient) List(ctx context.Context, input ApprovalInfoQuery) (ApprovalInfoPage, error) { + if c == nil || c.tokens == nil || c.integration == nil || c.httpClient == nil { + return ApprovalInfoPage{}, errors.New(errors.CodeServiceUnavailable, "企业微信批量审批单号客户端未配置") + } + if err := validateApprovalInfoQuery(input); err != nil { + return ApprovalInfoPage{}, err + } + token, err := c.tokens.GetAccessToken(ctx, input.ApplicationID) + if err != nil { + return ApprovalInfoPage{}, err + } + request, err := c.newRequest(ctx, token, input) + if err != nil { + return ApprovalInfoPage{}, err + } + resourceID := strconv.FormatUint(uint64(input.ApplicationID), 10) + attempt, err := c.integration.Start(ctx, integrationlog.Attempt{ + Provider: constants.IntegrationProviderWeCom, Direction: constants.IntegrationDirectionOutbound, + Operation: constants.IntegrationOperationWeComApprovalInfo, ResourceType: constants.WeComApprovalInstanceResourceType, + ResourceID: &resourceID, RequestSummary: map[string]any{ + "application_id": input.ApplicationID, "starttime": input.StartTime.Unix(), "endtime": input.EndTime.Unix(), + "template_id": input.TemplateID, "creator_userid": input.CreatorUserID, "size": input.Size, + "cursor_present": strings.TrimSpace(input.Cursor) != "", + }, + }) + if err != nil { + return ApprovalInfoPage{}, err + } + startedAt := c.now() + response, err := c.httpClient.Do(request) + if err != nil { + return ApprovalInfoPage{}, c.completeFailure(ctx, attempt.IntegrationID, 0, "request_failed", "企业微信批量审批单号请求失败", startedAt) + } + defer response.Body.Close() + body, err := io.ReadAll(io.LimitReader(response.Body, constants.WeComMaxResponseBodyBytes+1)) + if err != nil || int64(len(body)) > constants.WeComMaxResponseBodyBytes { + return ApprovalInfoPage{}, c.completeFailure(ctx, attempt.IntegrationID, response.StatusCode, "invalid_response", "企业微信批量审批单号响应无效", startedAt) + } + var result struct { + ErrCode int64 `json:"errcode"` + ErrMsg string `json:"errmsg"` + SPNoList []string `json:"sp_no_list"` + NewNextCursor string `json:"new_next_cursor"` + } + if err := sonic.Unmarshal(body, &result); err != nil { + return ApprovalInfoPage{}, c.completeFailure(ctx, attempt.IntegrationID, response.StatusCode, "invalid_response", "企业微信批量审批单号响应无效", startedAt) + } + if response.StatusCode < http.StatusOK || response.StatusCode >= http.StatusMultipleChoices || result.ErrCode != 0 { + return ApprovalInfoPage{}, c.completeFailure(ctx, attempt.IntegrationID, response.StatusCode, strconv.FormatInt(result.ErrCode, 10), result.ErrMsg, startedAt) + } + spNos := normalizeSPNos(result.SPNoList) + _, err = c.integration.Complete(ctx, attempt.IntegrationID, integrationlog.Completion{ + Result: constants.IntegrationResultSuccess, HTTPStatus: response.StatusCode, + ProviderCode: strconv.FormatInt(result.ErrCode, 10), ProviderMessage: result.ErrMsg, + ResponseSummary: map[string]any{ + "sp_no_count": len(spNos), "next_cursor_present": strings.TrimSpace(result.NewNextCursor) != "", + }, + DurationMS: c.now().Sub(startedAt).Milliseconds(), + }) + return ApprovalInfoPage{SPNos: spNos, NextCursor: strings.TrimSpace(result.NewNextCursor)}, err +} + +func validateApprovalInfoQuery(input ApprovalInfoQuery) error { + if input.ApplicationID == 0 || input.StartTime.IsZero() || input.EndTime.IsZero() || !input.StartTime.Before(input.EndTime) { + return errors.New(errors.CodeInvalidParam, "企业微信批量审批单号查询参数无效") + } + if input.EndTime.Sub(input.StartTime) > constants.WeComApprovalInfoMaxWindow { + return errors.New(errors.CodeInvalidParam, "企业微信批量审批单号查询时间跨度不能超过 31 天") + } + if input.Size <= 0 || input.Size > constants.WeComApprovalInfoMaxPageSize { + return errors.New(errors.CodeInvalidParam, "企业微信批量审批单号查询每页数量无效") + } + return nil +} + +// newRequest 组装企业微信批量审批单号请求,筛选值仅来自本地稳定提交快照。 +func (c *ApprovalInfoClient) newRequest(ctx context.Context, token string, input ApprovalInfoQuery) (*http.Request, error) { + endpoint, err := url.Parse(c.baseURL + "/cgi-bin/oa/getapprovalinfo") + if err != nil || endpoint.Scheme == "" || endpoint.Host == "" { + return nil, errors.New(errors.CodeWeComCredentialInvalid, "企业微信 API 地址配置无效") + } + query := endpoint.Query() + query.Set("access_token", token) + endpoint.RawQuery = query.Encode() + filters := make([]map[string]string, 0, 2) + if value := strings.TrimSpace(input.TemplateID); value != "" { + filters = append(filters, map[string]string{"key": "template_id", "value": value}) + } + if value := strings.TrimSpace(input.CreatorUserID); value != "" { + filters = append(filters, map[string]string{"key": "creator", "value": value}) + } + payload := map[string]any{ + "starttime": input.StartTime.Unix(), "endtime": input.EndTime.Unix(), + "new_cursor": strings.TrimSpace(input.Cursor), "size": input.Size, "filters": filters, + } + body, err := sonic.Marshal(payload) + if err != nil { + return nil, errors.Wrap(errors.CodeInternalError, err, "编码企业微信批量审批单号请求失败") + } + request, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint.String(), bytes.NewReader(body)) + if err != nil { + return nil, errors.Wrap(errors.CodeInternalError, err, "创建企业微信批量审批单号请求失败") + } + request.Header.Set("Content-Type", "application/json") + return request, nil +} + +func (c *ApprovalInfoClient) completeFailure(ctx context.Context, integrationID string, status int, providerCode, message string, startedAt time.Time) error { + if strings.TrimSpace(message) == "" { + message = "企业微信批量审批单号接口返回失败" + } + _, err := c.integration.Complete(ctx, integrationID, integrationlog.Completion{ + Result: constants.IntegrationResultFailed, HTTPStatus: status, ProviderCode: providerCode, + ProviderMessage: message, ResponseSummary: map[string]any{"success": false}, + DurationMS: c.now().Sub(startedAt).Milliseconds(), + }) + if err != nil { + return err + } + return errors.New(errors.CodeServiceUnavailable, "获取企业微信审批单号失败") +} + +func normalizeSPNos(values []string) []string { + seen := make(map[string]struct{}, len(values)) + result := make([]string, 0, len(values)) + for _, value := range values { + value = strings.TrimSpace(value) + if value == "" { + continue + } + if _, exists := seen[value]; exists { + continue + } + seen[value] = struct{}{} + result = append(result, value) + } + return result +} diff --git a/internal/infrastructure/wecom/approval_projection.go b/internal/infrastructure/wecom/approval_projection.go new file mode 100644 index 0000000..69092b1 --- /dev/null +++ b/internal/infrastructure/wecom/approval_projection.go @@ -0,0 +1,200 @@ +package wecom + +import ( + "context" + "strings" + + "github.com/bytedance/sonic" + "gorm.io/gorm" + + "github.com/break/junhong_cmp_fiber/internal/model" + approvalquery "github.com/break/junhong_cmp_fiber/internal/query/approval" + "github.com/break/junhong_cmp_fiber/pkg/constants" + "github.com/break/junhong_cmp_fiber/pkg/errors" +) + +// ApprovalApproverProjection 是企微审批节点成员到系统账号的可选映射。 +type ApprovalApproverProjection struct { + WeComUserID string `json:"wecom_userid"` + AccountID *uint `json:"account_id,omitempty"` + AccountName string `json:"account_name"` +} + +// ApprovalChannelProjection 是通用审批 Query 返回的企微本地只读扩展。 +type ApprovalChannelProjection struct { + SPNo string `json:"sp_no"` + SPStatus int `json:"sp_status"` + Approvers []ApprovalApproverProjection `json:"approvers"` +} + +// ApprovalProjectionResolver 从已同步详情快照批量投影审批人,不调用企业微信接口。 +type ApprovalProjectionResolver struct { + db *gorm.DB +} + +// NewApprovalProjectionResolver 创建企业微信审批读取投影 Resolver。 +func NewApprovalProjectionResolver(db *gorm.DB) *ApprovalProjectionResolver { + return &ApprovalProjectionResolver{db: db} +} + +// Resolve 按当前页审批实例批量读取快照,并批量映射同企业下的系统账号。 +func (r *ApprovalProjectionResolver) Resolve( + ctx context.Context, + _ approvalquery.Viewer, + references []approvalquery.ExtensionReference, +) (map[uint]any, error) { + result := make(map[uint]any) + if len(references) == 0 { + return result, nil + } + if r == nil || r.db == nil { + return nil, errors.New(errors.CodeInternalError, "企业微信审批读取投影未配置") + } + instanceIDs := weComExtensionInstanceIDs(references) + if len(instanceIDs) == 0 { + return result, nil + } + var contexts []model.WeComApprovalContext + if err := r.db.WithContext(ctx).Where("approval_instance_id IN ?", instanceIDs).Find(&contexts).Error; err != nil { + return nil, errors.Wrap(errors.CodeDatabaseError, err, "批量查询企业微信审批详情快照失败") + } + applicationIDs := make([]uint, 0, len(contexts)) + for _, channelContext := range contexts { + applicationIDs = append(applicationIDs, channelContext.ApplicationID) + } + corpByApplication, err := r.loadApplicationCorps(ctx, applicationIDs) + if err != nil { + return nil, err + } + userIDsByCorp := make(map[string][]string) + approversByInstance := make(map[uint][]string, len(contexts)) + for _, channelContext := range contexts { + userIDs := approvalNodeUserIDs(channelContext.LatestDetailSnapshot) + approversByInstance[channelContext.ApprovalInstanceID] = userIDs + corpID := corpByApplication[channelContext.ApplicationID] + userIDsByCorp[corpID] = append(userIDsByCorp[corpID], userIDs...) + } + accounts, err := r.loadBoundAccounts(ctx, userIDsByCorp) + if err != nil { + return nil, err + } + for _, channelContext := range contexts { + corpID := corpByApplication[channelContext.ApplicationID] + approvers := make([]ApprovalApproverProjection, 0, len(approversByInstance[channelContext.ApprovalInstanceID])) + for _, userID := range approversByInstance[channelContext.ApprovalInstanceID] { + item := ApprovalApproverProjection{WeComUserID: userID} + if account, exists := accounts[weComAccountKey(corpID, userID)]; exists { + accountID := account.ID + item.AccountID = &accountID + item.AccountName = account.Username + } + approvers = append(approvers, item) + } + result[channelContext.ApprovalInstanceID] = ApprovalChannelProjection{ + SPNo: channelContext.SPNo, SPStatus: channelContext.LatestSPStatus, Approvers: approvers, + } + } + return result, nil +} + +func weComExtensionInstanceIDs(references []approvalquery.ExtensionReference) []uint { + seen := make(map[uint]struct{}, len(references)) + ids := make([]uint, 0, len(references)) + for _, reference := range references { + if reference.InstanceID == 0 || reference.Provider != constants.IntegrationProviderWeCom { + continue + } + if _, exists := seen[reference.InstanceID]; exists { + continue + } + seen[reference.InstanceID] = struct{}{} + ids = append(ids, reference.InstanceID) + } + return ids +} + +func (r *ApprovalProjectionResolver) loadApplicationCorps(ctx context.Context, applicationIDs []uint) (map[uint]string, error) { + result := make(map[uint]string) + if len(applicationIDs) == 0 { + return result, nil + } + var applications []model.WeComApplication + if err := r.db.WithContext(ctx).Select("id", "corp_id").Where("id IN ?", applicationIDs).Find(&applications).Error; err != nil { + return nil, errors.Wrap(errors.CodeDatabaseError, err, "批量查询企业微信应用企业标识失败") + } + for _, application := range applications { + result[application.ID] = application.CorpID + } + return result, nil +} + +func (r *ApprovalProjectionResolver) loadBoundAccounts(ctx context.Context, userIDsByCorp map[string][]string) (map[string]model.Account, error) { + result := make(map[string]model.Account) + query := r.db.WithContext(ctx).Model(&model.Account{}).Where("1 = 0") + hasCondition := false + for corpID, userIDs := range userIDsByCorp { + corpID = strings.TrimSpace(corpID) + userIDs = uniqueStrings(userIDs) + if corpID == "" || len(userIDs) == 0 { + continue + } + query = query.Or("wecom_corp_id = ? AND wecom_userid IN ? AND status = ?", corpID, userIDs, constants.StatusEnabled) + hasCondition = true + } + if !hasCondition { + return result, nil + } + var accounts []model.Account + if err := query.Select("id", "username", "wecom_corp_id", "wecom_userid").Find(&accounts).Error; err != nil { + return nil, errors.Wrap(errors.CodeDatabaseError, err, "批量映射企业微信审批人账号失败") + } + for _, account := range accounts { + result[weComAccountKey(account.WeComCorpID, account.WeComUserID)] = account + } + return result, nil +} + +func approvalNodeUserIDs(snapshot []byte) []string { + var detail struct { + SPRecords []struct { + Details []struct { + Approver struct { + UserID string `json:"userid"` + } `json:"approver"` + } `json:"details"` + } `json:"sp_record"` + } + if sonic.Unmarshal(snapshot, &detail) != nil { + return []string{} + } + values := make([]string, 0) + for _, record := range detail.SPRecords { + for _, node := range record.Details { + values = append(values, node.Approver.UserID) + } + } + return uniqueStrings(values) +} + +func uniqueStrings(values []string) []string { + seen := make(map[string]struct{}, len(values)) + result := make([]string, 0, len(values)) + for _, value := range values { + value = strings.TrimSpace(value) + if value == "" { + continue + } + if _, exists := seen[value]; exists { + continue + } + seen[value] = struct{}{} + result = append(result, value) + } + return result +} + +func weComAccountKey(corpID, userID string) string { + return strings.TrimSpace(corpID) + "\x00" + strings.TrimSpace(userID) +} + +var _ approvalquery.ChannelExtensionResolver = (*ApprovalProjectionResolver)(nil) diff --git a/internal/infrastructure/wecom/approval_provider.go b/internal/infrastructure/wecom/approval_provider.go new file mode 100644 index 0000000..2f6c9d0 --- /dev/null +++ b/internal/infrastructure/wecom/approval_provider.go @@ -0,0 +1,152 @@ +package wecom + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "strings" + + "github.com/bytedance/sonic" + "gorm.io/gorm" + + approvalapp "github.com/break/junhong_cmp_fiber/internal/application/approval" + wecomapp "github.com/break/junhong_cmp_fiber/internal/application/wecom" + "github.com/break/junhong_cmp_fiber/internal/model" + "github.com/break/junhong_cmp_fiber/pkg/constants" + "github.com/break/junhong_cmp_fiber/pkg/errors" +) + +// ApprovalProvider 实现通用 Approval Port 的企微准备与渠道上下文写入。 +type ApprovalProvider struct { + db *gorm.DB + scenes *SceneRepository + applications *ApplicationRepository + members *MemberRepository + templates wecomapp.TemplateProvider +} + +type approvalPreparationContext struct { + ApplicationID uint `json:"application_id"` + BusinessType string `json:"business_type"` + TemplateID string `json:"template_id"` + CreatorUserID string `json:"creator_userid"` + CreatorName string `json:"creator_name"` + CreatorSource string `json:"creator_source"` + ControlMapping []map[string]any `json:"control_mapping"` + TemplateSnapshot wecomapp.TemplateDefinition `json:"template_snapshot"` + TemplateFingerprint string `json:"template_fingerprint"` +} + +// NewApprovalProvider 创建企业微信通用审批渠道 Adapter。 +func NewApprovalProvider(db *gorm.DB, scenes *SceneRepository, applications *ApplicationRepository, members *MemberRepository, templates wecomapp.TemplateProvider) *ApprovalProvider { + return &ApprovalProvider{db: db, scenes: scenes, applications: applications, members: members, templates: templates} +} + +// Prepare 在业务事务前校验场景、模板和可用的企微发起人。 +func (p *ApprovalProvider) Prepare(ctx context.Context, request approvalapp.PrepareRequest) (approvalapp.ProviderPreparation, error) { + if p == nil || p.db == nil || p.scenes == nil || p.applications == nil || p.members == nil || p.templates == nil { + return approvalapp.ProviderPreparation{}, errors.New(errors.CodeServiceUnavailable, "企业微信审批 Adapter 未配置") + } + scene, err := p.scenes.GetEnabled(ctx, strings.TrimSpace(request.BusinessType)) + if err != nil { + return approvalapp.ProviderPreparation{}, err + } + application, err := p.applications.GetEnabled(ctx, scene.ApplicationID) + if err != nil { + return approvalapp.ProviderPreparation{}, err + } + creator, source, err := p.resolveCreator(ctx, application, request.SubmitterAccountID) + if err != nil { + return approvalapp.ProviderPreparation{}, err + } + definition, err := p.templates.GetTemplateDetail(ctx, scene.ApplicationID, scene.TemplateID) + if err != nil { + return approvalapp.ProviderPreparation{}, err + } + fingerprint, err := templateFingerprint(definition) + if err != nil { + return approvalapp.ProviderPreparation{}, err + } + if fingerprint != scene.TemplateFingerprint { + return approvalapp.ProviderPreparation{}, errors.New(errors.CodeInvalidStatus, "企业微信审批模板已变化,请管理员重新校验场景配置") + } + var mapping []map[string]any + if err := sonic.Unmarshal(scene.ControlMapping, &mapping); err != nil || len(mapping) == 0 { + return approvalapp.ProviderPreparation{}, errors.New(errors.CodeInvalidStatus, "企业微信审批控件映射无效") + } + channelContext, err := sonic.Marshal(approvalPreparationContext{ + ApplicationID: scene.ApplicationID, BusinessType: scene.BusinessType, TemplateID: scene.TemplateID, + CreatorUserID: creator.UserID, CreatorName: creator.Name, CreatorSource: source, + ControlMapping: mapping, TemplateSnapshot: definition, TemplateFingerprint: fingerprint, + }) + if err != nil { + return approvalapp.ProviderPreparation{}, errors.Wrap(errors.CodeInternalError, err, "编码企业微信审批准备结果失败") + } + return approvalapp.ProviderPreparation{Provider: constants.IntegrationProviderWeCom, ChannelContext: channelContext}, nil +} + +// CreateContextInTx 在调用方事务中保存不含凭据的企微提交快照。 +func (p *ApprovalProvider) CreateContextInTx(ctx context.Context, tx *gorm.DB, preparation approvalapp.ProviderPreparation, instanceID uint) error { + if p == nil || tx == nil || preparation.Provider != constants.IntegrationProviderWeCom || instanceID == 0 { + return errors.New(errors.CodeInvalidParam, "企业微信审批渠道上下文参数无效") + } + var prepared approvalPreparationContext + if err := sonic.Unmarshal(preparation.ChannelContext, &prepared); err != nil { + return errors.Wrap(errors.CodeInvalidParam, err, "解析企业微信审批准备结果失败") + } + mappingJSON, err := sonic.Marshal(prepared.ControlMapping) + if err != nil { + return errors.Wrap(errors.CodeInternalError, err, "编码企业微信审批控件映射失败") + } + templateJSON, err := sonic.Marshal(prepared.TemplateSnapshot) + if err != nil { + return errors.Wrap(errors.CodeInternalError, err, "编码企业微信审批模板快照失败") + } + record := model.WeComApprovalContext{ + ApprovalInstanceID: instanceID, ApplicationID: prepared.ApplicationID, BusinessType: prepared.BusinessType, + TemplateID: prepared.TemplateID, CreatorUserID: prepared.CreatorUserID, CreatorName: prepared.CreatorName, + CreatorSource: prepared.CreatorSource, ControlMapping: mappingJSON, TemplateSnapshot: templateJSON, + TemplateFingerprint: prepared.TemplateFingerprint, SubmissionStatus: constants.WeComSubmissionStatusReady, + } + if err := tx.WithContext(ctx).Create(&record).Error; err != nil { + return errors.Wrap(errors.CodeDatabaseError, err, "创建企业微信审批渠道上下文失败") + } + return nil +} + +// resolveCreator 保留真实业务提交人,同时按账号类型选择企微本人或应用默认成员作为接口发起人。 +func (p *ApprovalProvider) resolveCreator(ctx context.Context, application *model.WeComApplication, accountID uint) (*model.WeComMember, string, error) { + var account model.Account + if err := p.db.WithContext(ctx).Select("id", "user_type", "wecom_corp_id", "wecom_userid").Where("id = ?", accountID).First(&account).Error; err != nil { + if err == gorm.ErrRecordNotFound { + return nil, "", errors.New(errors.CodeForbidden, "无权限操作该资源或资源不存在") + } + return nil, "", errors.Wrap(errors.CodeDatabaseError, err, "查询审批真实提交人失败") + } + useBoundCreator := account.UserType == constants.UserTypeSuperAdmin || account.UserType == constants.UserTypePlatform + if useBoundCreator && strings.EqualFold(account.WeComCorpID, application.CorpID) && strings.TrimSpace(account.WeComUserID) != "" { + member, err := p.members.GetVisible(ctx, application.ID, account.WeComUserID) + if err == nil { + return member, constants.WeComCreatorSourceBound, nil + } + } + if strings.TrimSpace(application.DefaultCreatorUserID) == "" { + return nil, "", errors.New(errors.CodeInvalidStatus, "企业微信应用尚未配置默认审批发起人") + } + member, err := p.members.GetVisible(ctx, application.ID, application.DefaultCreatorUserID) + if err != nil { + return nil, "", errors.New(errors.CodeInvalidStatus, "企业微信默认审批发起人已不可用,请管理员重新配置") + } + return member, constants.WeComCreatorSourceDefault, nil +} + +func templateFingerprint(definition wecomapp.TemplateDefinition) (string, error) { + snapshot, err := sonic.Marshal(definition) + if err != nil { + return "", errors.Wrap(errors.CodeInternalError, err, "编码企业微信模板快照失败") + } + hash := sha256.Sum256(snapshot) + return hex.EncodeToString(hash[:]), nil +} + +var _ approvalapp.ProviderPort = (*ApprovalProvider)(nil) diff --git a/internal/infrastructure/wecom/approval_recovery_task.go b/internal/infrastructure/wecom/approval_recovery_task.go new file mode 100644 index 0000000..d67b15a --- /dev/null +++ b/internal/infrastructure/wecom/approval_recovery_task.go @@ -0,0 +1,155 @@ +package wecom + +import ( + "context" + "strings" + "time" + + "github.com/hibiken/asynq" + + "github.com/break/junhong_cmp_fiber/pkg/constants" + "github.com/break/junhong_cmp_fiber/pkg/errors" + "github.com/break/junhong_cmp_fiber/pkg/queue" +) + +// ApprovalRecoveryTaskHandler 周期恢复结果未知审批并轮询未终态详情。 +type ApprovalRecoveryTaskHandler struct { + contexts *ApprovalContextRepository + infos *ApprovalInfoClient + queue *queue.Client + now func() time.Time +} + +// NewApprovalRecoveryTaskHandler 创建企业微信审批主动恢复任务 Handler。 +func NewApprovalRecoveryTaskHandler(contexts *ApprovalContextRepository, infos *ApprovalInfoClient, queueClient *queue.Client) *ApprovalRecoveryTaskHandler { + return &ApprovalRecoveryTaskHandler{contexts: contexts, infos: infos, queue: queueClient, now: time.Now} +} + +// Handle 扫描未终态和结果未知记录;结果未知只查询关联,绝不重新调用 applyevent。 +func (h *ApprovalRecoveryTaskHandler) Handle(ctx context.Context, _ *asynq.Task) error { + if h == nil || h.contexts == nil || h.infos == nil || h.queue == nil { + return errors.New(errors.CodeServiceUnavailable, "企业微信审批主动恢复任务未配置") + } + now := h.now().UTC() + if err := h.contexts.PromoteStaleSendingToUnknown(ctx, now.Add(-constants.WeComApprovalSendingLease)); err != nil { + return err + } + if err := h.enqueuePendingSync(ctx, now); err != nil { + return err + } + return h.recoverUnknown(ctx, now) +} + +func (h *ApprovalRecoveryTaskHandler) enqueuePendingSync(ctx context.Context, now time.Time) error { + records, err := h.contexts.ListPendingSync(ctx, now.Add(-constants.WeComApprovalPollingInterval), constants.WeComApprovalRecoveryBatchSize) + if err != nil { + return err + } + for _, record := range records { + if err := h.enqueueDetailSync(ctx, record.ApplicationID, record.SPNo); err != nil { + return err + } + } + return nil +} + +func (h *ApprovalRecoveryTaskHandler) recoverUnknown(ctx context.Context, now time.Time) error { + cutoff := now.Add(-constants.WeComApprovalPollingInterval) + records, err := h.contexts.ListUnknownRecovery(ctx, cutoff, constants.WeComApprovalUnknownRecoveryBatchSize) + if err != nil { + return err + } + for _, record := range records { + claimed, err := h.contexts.ClaimUnknownRecovery(ctx, record.InstanceID, cutoff) + if err != nil { + return err + } + if !claimed { + continue + } + spNo, err := h.contexts.FindSuccessfulSubmissionSPNo(ctx, record.InstanceID) + if err != nil { + return err + } + if spNo == "" { + spNo, err = h.findUniqueSPNo(ctx, record, now) + if err != nil { + return err + } + } + if spNo == "" { + continue + } + recovered, err := h.contexts.RecoverSubmitted(ctx, record.InstanceID, spNo) + if err != nil { + return err + } + if recovered { + if err := h.enqueueDetailSync(ctx, record.ApplicationID, spNo); err != nil { + return err + } + } + } + return nil +} + +// findUniqueSPNo 使用提交时间附近的固定窄窗口分页查询,并只接受唯一未关联候选。 +func (h *ApprovalRecoveryTaskHandler) findUniqueSPNo(ctx context.Context, record ApprovalRecoveryRecord, now time.Time) (string, error) { + startTime := record.SubmissionAttemptedAt.Add(-constants.WeComApprovalRecoveryWindow) + endTime := record.SubmissionAttemptedAt.Add(constants.WeComApprovalRecoveryWindow) + if endTime.After(now) { + endTime = now + } + if !startTime.Before(endTime) { + return "", nil + } + cursor := "" + seenCursors := make(map[string]struct{}) + seenCandidates := make(map[string]struct{}) + unbound := make([]string, 0, 2) + for { + page, err := h.infos.List(ctx, ApprovalInfoQuery{ + ApplicationID: record.ApplicationID, StartTime: startTime, EndTime: endTime, + TemplateID: record.TemplateID, CreatorUserID: record.CreatorUserID, + Cursor: cursor, Size: constants.WeComApprovalInfoMaxPageSize, + }) + if err != nil { + return "", err + } + existing, err := h.contexts.ExistingSPNos(ctx, record.ApplicationID, page.SPNos) + if err != nil { + return "", err + } + for _, candidate := range page.SPNos { + if _, seen := seenCandidates[candidate]; seen { + continue + } + seenCandidates[candidate] = struct{}{} + if _, exists := existing[candidate]; !exists { + unbound = append(unbound, candidate) + if len(unbound) > 1 { + return "", nil + } + } + } + next := strings.TrimSpace(page.NextCursor) + if next == "" { + break + } + if _, exists := seenCursors[next]; exists { + return "", errors.New(errors.CodeServiceUnavailable, "企业微信批量审批单号分页游标重复") + } + seenCursors[next] = struct{}{} + cursor = next + } + if len(unbound) != 1 { + return "", nil + } + return unbound[0], nil +} + +func (h *ApprovalRecoveryTaskHandler) enqueueDetailSync(ctx context.Context, applicationID uint, spNo string) error { + return h.queue.EnqueueTask(ctx, constants.TaskTypeWeComApprovalSync, ApprovalDetailSyncTask{ + ApplicationID: applicationID, SPNo: spNo, Source: constants.ApprovalSyncSourcePolling, + }) +} diff --git a/internal/infrastructure/wecom/approval_submission_client.go b/internal/infrastructure/wecom/approval_submission_client.go new file mode 100644 index 0000000..6256fba --- /dev/null +++ b/internal/infrastructure/wecom/approval_submission_client.go @@ -0,0 +1,189 @@ +package wecom + +import ( + "bytes" + "context" + "io" + "net/http" + "net/url" + "strconv" + "strings" + "time" + + "github.com/bytedance/sonic" + + "github.com/break/junhong_cmp_fiber/internal/infrastructure/integrationlog" + "github.com/break/junhong_cmp_fiber/pkg/constants" + "github.com/break/junhong_cmp_fiber/pkg/errors" +) + +const ( + submissionOutcomeSuccess = "success" + submissionOutcomeFailed = "failed" + submissionOutcomeUnknown = "unknown" +) + +// ApprovalSubmitRequest 是调用企微 applyevent 的安全请求。 +type ApprovalSubmitRequest struct { + InstanceID uint + ApplicationID uint + TemplateID string + CreatorUserID string + CorrelationID string + Contents []map[string]any +} + +// ApprovalSubmitResult 描述企微是否明确创建审批单。 +type ApprovalSubmitResult struct { + Outcome string + SPNo string + Message string + SafeToRetry bool +} + +// ApprovalSubmissionClient 调用企微 applyevent 并记录每次真实外呼。 +type ApprovalSubmissionClient struct { + tokens DirectoryTokenProvider + integration TokenIntegrationLog + httpClient *http.Client + baseURL string + now func() time.Time +} + +// NewApprovalSubmissionClient 创建企业微信审批提交客户端。 +func NewApprovalSubmissionClient(tokens DirectoryTokenProvider, integration TokenIntegrationLog, baseURL string, timeout time.Duration) *ApprovalSubmissionClient { + if timeout <= 0 { + timeout = constants.WeComDefaultHTTPTimeout + } + return &ApprovalSubmissionClient{ + tokens: tokens, integration: integration, httpClient: &http.Client{Timeout: timeout}, + baseURL: strings.TrimRight(baseURL, "/"), now: time.Now, + } +} + +// Submit 使用企微后台模板流程提交审批申请,绝不在结果未知时自动重发。 +func (c *ApprovalSubmissionClient) Submit(ctx context.Context, input ApprovalSubmitRequest) (ApprovalSubmitResult, error) { + if c == nil || c.tokens == nil || c.integration == nil || c.httpClient == nil { + return ApprovalSubmitResult{Outcome: submissionOutcomeFailed, Message: "企业微信审批提交客户端未配置", SafeToRetry: true}, nil + } + token, err := c.tokens.GetAccessToken(ctx, input.ApplicationID) + if err != nil { + return ApprovalSubmitResult{Outcome: submissionOutcomeFailed, Message: "取得企业微信 access_token 失败", SafeToRetry: true}, err + } + request, err := c.newSubmitRequest(ctx, token, input) + if err != nil { + return ApprovalSubmitResult{Outcome: submissionOutcomeFailed, Message: "创建企业微信审批提交请求失败", SafeToRetry: true}, err + } + resourceID := strconv.FormatUint(uint64(input.InstanceID), 10) + correlationID := strings.TrimSpace(input.CorrelationID) + attempt, err := c.integration.Start(ctx, integrationlog.Attempt{ + Provider: constants.IntegrationProviderWeCom, Direction: constants.IntegrationDirectionOutbound, + Operation: constants.IntegrationOperationWeComApprovalSubmit, ResourceType: constants.WeComApprovalInstanceResourceType, + ResourceID: &resourceID, RequestSummary: map[string]any{ + "application_id": input.ApplicationID, "template_id": input.TemplateID, + "creator_source_configured": input.CreatorUserID != "", "control_count": len(input.Contents), + }, CorrelationID: optionalIntegrationString(correlationID), + }) + if err != nil { + return ApprovalSubmitResult{Outcome: submissionOutcomeFailed, Message: "写入企业微信审批提交日志失败", SafeToRetry: true}, err + } + startedAt := c.now() + response, err := c.httpClient.Do(request) + if err != nil { + message := "企业微信审批提交请求结果未知" + _, completeErr := c.integration.Complete(ctx, attempt.IntegrationID, integrationlog.Completion{ + Result: constants.IntegrationResultUnknown, ProviderCode: "request_unknown", ProviderMessage: message, + ResponseSummary: map[string]any{"success": false}, DurationMS: c.now().Sub(startedAt).Milliseconds(), + RecoveryStrategy: "按申请时间窗批量获取审批单号并核对详情,确认不存在后才允许受控重提", + }) + if completeErr != nil { + return ApprovalSubmitResult{Outcome: submissionOutcomeUnknown, Message: message}, completeErr + } + return ApprovalSubmitResult{Outcome: submissionOutcomeUnknown, Message: message}, nil + } + defer response.Body.Close() + responseBody, readErr := io.ReadAll(io.LimitReader(response.Body, constants.WeComMaxResponseBodyBytes+1)) + if readErr != nil || int64(len(responseBody)) > constants.WeComMaxResponseBodyBytes { + message := "企业微信审批提交响应无法确认" + _, completeErr := c.integration.Complete(ctx, attempt.IntegrationID, integrationlog.Completion{ + Result: constants.IntegrationResultUnknown, HTTPStatus: response.StatusCode, ProviderCode: "invalid_response", + ProviderMessage: message, ResponseSummary: map[string]any{"success": false}, + DurationMS: c.now().Sub(startedAt).Milliseconds(), + RecoveryStrategy: "按申请时间窗批量获取审批单号并核对详情,确认不存在后才允许受控重提", + }) + if completeErr != nil { + return ApprovalSubmitResult{Outcome: submissionOutcomeUnknown, Message: message}, completeErr + } + return ApprovalSubmitResult{Outcome: submissionOutcomeUnknown, Message: message}, nil + } + var result struct { + ErrCode int64 `json:"errcode"` + ErrMsg string `json:"errmsg"` + SPNo string `json:"sp_no"` + } + if err := sonic.Unmarshal(responseBody, &result); err != nil { + result.ErrMsg = "企业微信审批提交响应格式无效" + result.ErrCode = int64(response.StatusCode) + } + if response.StatusCode < http.StatusOK || response.StatusCode >= http.StatusMultipleChoices || result.ErrCode != 0 || strings.TrimSpace(result.SPNo) == "" { + providerCode := strconv.FormatInt(result.ErrCode, 10) + if result.ErrCode == 0 { + providerCode = strconv.Itoa(response.StatusCode) + } + message := strings.TrimSpace(result.ErrMsg) + if message == "" { + message = "企业微信明确拒绝审批提交" + } + _, completeErr := c.integration.Complete(ctx, attempt.IntegrationID, integrationlog.Completion{ + Result: constants.IntegrationResultFailed, HTTPStatus: response.StatusCode, ProviderCode: providerCode, + ProviderMessage: message, ResponseSummary: map[string]any{"success": false, "errcode": result.ErrCode}, + DurationMS: c.now().Sub(startedAt).Milliseconds(), + }) + if completeErr != nil { + return ApprovalSubmitResult{Outcome: submissionOutcomeFailed, Message: message}, completeErr + } + return ApprovalSubmitResult{Outcome: submissionOutcomeFailed, Message: message}, nil + } + _, err = c.integration.Complete(ctx, attempt.IntegrationID, integrationlog.Completion{ + Result: constants.IntegrationResultSuccess, HTTPStatus: response.StatusCode, + ProviderCode: strconv.FormatInt(result.ErrCode, 10), ProviderMessage: result.ErrMsg, + ResponseSummary: map[string]any{"success": true, "sp_no": strings.TrimSpace(result.SPNo)}, + DurationMS: c.now().Sub(startedAt).Milliseconds(), StateChanged: true, + }) + return ApprovalSubmitResult{Outcome: submissionOutcomeSuccess, SPNo: strings.TrimSpace(result.SPNo)}, err +} + +// newSubmitRequest 只组装本次企微审批请求,调用前不会产生外部副作用。 +func (c *ApprovalSubmissionClient) newSubmitRequest(ctx context.Context, token string, input ApprovalSubmitRequest) (*http.Request, error) { + payload := map[string]any{ + "creator_userid": input.CreatorUserID, + "template_id": input.TemplateID, + "use_template_approver": 1, + "apply_data": map[string]any{"contents": input.Contents}, + "summary_list": []map[string]any{{"summary_info": []map[string]string{{"text": "业务审批申请", "lang": "zh_CN"}}}}, + } + body, err := sonic.Marshal(payload) + if err != nil { + return nil, errors.Wrap(errors.CodeInternalError, err, "编码企业微信审批提交请求失败") + } + endpoint, err := url.Parse(c.baseURL + "/cgi-bin/oa/applyevent") + if err != nil || endpoint.Scheme == "" || endpoint.Host == "" { + return nil, errors.New(errors.CodeWeComCredentialInvalid, "企业微信 API 地址配置无效") + } + query := endpoint.Query() + query.Set("access_token", token) + endpoint.RawQuery = query.Encode() + request, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint.String(), bytes.NewReader(body)) + if err != nil { + return nil, errors.Wrap(errors.CodeInternalError, err, "创建企业微信审批提交请求失败") + } + request.Header.Set("Content-Type", "application/json") + return request, nil +} + +func optionalIntegrationString(value string) *string { + if value == "" { + return nil + } + return &value +} diff --git a/internal/infrastructure/wecom/approval_submission_consumer.go b/internal/infrastructure/wecom/approval_submission_consumer.go new file mode 100644 index 0000000..ce7d2d9 --- /dev/null +++ b/internal/infrastructure/wecom/approval_submission_consumer.go @@ -0,0 +1,100 @@ +package wecom + +import ( + "context" + stdErrors "errors" + + "github.com/bytedance/sonic" + "github.com/hibiken/asynq" + + approvalapp "github.com/break/junhong_cmp_fiber/internal/application/approval" + "github.com/break/junhong_cmp_fiber/internal/infrastructure/messaging/outbox" + "github.com/break/junhong_cmp_fiber/pkg/constants" + "github.com/break/junhong_cmp_fiber/pkg/errors" +) + +// ApprovalSubmissionConsumer 消费通用审批提交 Outbox,并保证结果未知时不盲目重提。 +type ApprovalSubmissionConsumer struct { + repository *ApprovalContextRepository + client *ApprovalSubmissionClient + attachments approvalAttachmentPort +} + +// NewApprovalSubmissionConsumer 创建企业微信审批提交消费者。 +func NewApprovalSubmissionConsumer(repository *ApprovalContextRepository, client *ApprovalSubmissionClient, attachments approvalAttachmentPort) *ApprovalSubmissionConsumer { + return &ApprovalSubmissionConsumer{repository: repository, client: client, attachments: attachments} +} + +// Consume 校验通用事件并提交一次企业微信审批申请。 +func (c *ApprovalSubmissionConsumer) Consume(ctx context.Context, envelope outbox.DeliveryEnvelope) error { + if c == nil || c.repository == nil || c.client == nil { + return errors.New(errors.CodeServiceUnavailable, "企业微信审批提交消费者未配置") + } + if envelope.PayloadVersion != constants.ApprovalSubmissionPayloadVersionV1 { + return stdErrors.Join(asynq.SkipRetry, errors.New(errors.CodeInvalidParam, "不支持的审批提交事件版本")) + } + var event approvalapp.SubmissionRequestedEvent + if err := sonic.Unmarshal(envelope.Payload, &event); err != nil { + return stdErrors.Join(asynq.SkipRetry, errors.New(errors.CodeInvalidParam, "审批提交事件载荷无效")) + } + if event.InstanceID == 0 || event.Provider != constants.IntegrationProviderWeCom { + return stdErrors.Join(asynq.SkipRetry, errors.New(errors.CodeInvalidParam, "审批提交事件渠道或实例无效")) + } + record, claimed, err := c.repository.ClaimSubmission(ctx, event.InstanceID) + if err != nil { + return err + } + if !claimed { + return nil + } + form, err := buildApprovalForm( + ctx, record.Context.ApplicationID, event.InstanceID, + record.Context.ControlMapping, record.Context.TemplateSnapshot, record.Instance.RequestSnapshot, c.attachments, + ) + if err != nil { + var attachmentErr *attachmentPreparationError + if stdErrors.As(err, &attachmentErr) { + if releaseErr := c.repository.ReleaseForRetry(ctx, event.InstanceID, "审批附件准备失败,等待重试"); releaseErr != nil { + return releaseErr + } + return err + } + if markErr := c.repository.MarkFailed(ctx, event.InstanceID, err.Error()); markErr != nil { + return markErr + } + return nil + } + result, submitErr := c.client.Submit(ctx, ApprovalSubmitRequest{ + InstanceID: event.InstanceID, ApplicationID: record.Context.ApplicationID, + TemplateID: record.Context.TemplateID, CreatorUserID: record.Context.CreatorUserID, + CorrelationID: event.CorrelationID, Contents: form.Contents, + }) + switch result.Outcome { + case submissionOutcomeSuccess: + if err := c.repository.MarkSubmitted(ctx, event.InstanceID, result.SPNo); err != nil { + return err + } + case submissionOutcomeUnknown: + if err := c.repository.MarkUnknown(ctx, event.InstanceID, result.Message); err != nil { + return err + } + case submissionOutcomeFailed: + if result.SafeToRetry { + if err := c.repository.ReleaseForRetry(ctx, event.InstanceID, result.Message); err != nil { + return err + } + if submitErr != nil { + return submitErr + } + return errors.New(errors.CodeServiceUnavailable, result.Message) + } + if err := c.repository.MarkFailed(ctx, event.InstanceID, result.Message); err != nil { + return err + } + default: + return errors.New(errors.CodeInternalError, "企业微信审批提交结果无效") + } + return submitErr +} + +var _ outbox.EventConsumer = (*ApprovalSubmissionConsumer)(nil) diff --git a/internal/infrastructure/wecom/callback_crypto.go b/internal/infrastructure/wecom/callback_crypto.go new file mode 100644 index 0000000..7fe88fa --- /dev/null +++ b/internal/infrastructure/wecom/callback_crypto.go @@ -0,0 +1,85 @@ +package wecom + +import ( + "bytes" + "crypto/aes" + "crypto/cipher" + "crypto/sha1" + "crypto/subtle" + "encoding/base64" + "encoding/binary" + "encoding/hex" + "sort" + "strings" + + "github.com/break/junhong_cmp_fiber/pkg/errors" +) + +// CallbackCrypto 实现企微回调要求的 SHA-1 签名校验和 AES-256-CBC 解密。 +type CallbackCrypto struct { + token string + aesKey []byte + receiveID string +} + +// NewCallbackCrypto 创建企业微信回调加密器。 +func NewCallbackCrypto(token, encodingAESKey, receiveID string) (*CallbackCrypto, error) { + key, err := base64.StdEncoding.DecodeString(strings.TrimSpace(encodingAESKey) + "=") + if err != nil || len(key) != 32 || strings.TrimSpace(token) == "" || strings.TrimSpace(receiveID) == "" { + return nil, errors.New(errors.CodeWeComCredentialInvalid, "企业微信回调 Token、EncodingAESKey 或 receiveid 无效") + } + return &CallbackCrypto{token: token, aesKey: key, receiveID: receiveID}, nil +} + +// VerifyAndDecrypt 校验签名并解密企业微信回调密文。 +func (c *CallbackCrypto) VerifyAndDecrypt(signature, timestamp, nonce, encrypted string) ([]byte, error) { + if c == nil || !c.validSignature(signature, timestamp, nonce, encrypted) { + return nil, errors.New(errors.CodeUnauthorized, "企业微信回调签名无效") + } + ciphertext, err := base64.StdEncoding.DecodeString(encrypted) + if err != nil || len(ciphertext) == 0 || len(ciphertext)%aes.BlockSize != 0 { + return nil, errors.New(errors.CodeInvalidParam, "企业微信回调密文无效") + } + block, err := aes.NewCipher(c.aesKey) + if err != nil { + return nil, errors.Wrap(errors.CodeInternalError, err, "初始化企业微信回调解密器失败") + } + plaintext := make([]byte, len(ciphertext)) + cipher.NewCBCDecrypter(block, c.aesKey[:aes.BlockSize]).CryptBlocks(plaintext, ciphertext) + plaintext, err = removePKCS7Padding(plaintext) + if err != nil || len(plaintext) < 20 { + return nil, errors.New(errors.CodeInvalidParam, "企业微信回调填充无效") + } + messageLength := int(binary.BigEndian.Uint32(plaintext[16:20])) + if messageLength < 0 || 20+messageLength > len(plaintext) { + return nil, errors.New(errors.CodeInvalidParam, "企业微信回调消息长度无效") + } + message := plaintext[20 : 20+messageLength] + receiveID := plaintext[20+messageLength:] + if subtle.ConstantTimeCompare(receiveID, []byte(c.receiveID)) != 1 { + return nil, errors.New(errors.CodeUnauthorized, "企业微信回调 receiveid 不匹配") + } + return append([]byte(nil), message...), nil +} + +func (c *CallbackCrypto) validSignature(signature, timestamp, nonce, encrypted string) bool { + parts := []string{c.token, timestamp, nonce, encrypted} + sort.Strings(parts) + hash := sha1.Sum([]byte(strings.Join(parts, ""))) + expected := hex.EncodeToString(hash[:]) + return subtle.ConstantTimeCompare([]byte(strings.ToLower(signature)), []byte(expected)) == 1 +} + +func removePKCS7Padding(value []byte) ([]byte, error) { + if len(value) == 0 { + return nil, errors.New(errors.CodeInvalidParam) + } + padding := int(value[len(value)-1]) + if padding <= 0 || padding > 32 || padding > len(value) { + return nil, errors.New(errors.CodeInvalidParam) + } + if !bytes.Equal(value[len(value)-padding:], bytes.Repeat([]byte{byte(padding)}, padding)) { + return nil, errors.New(errors.CodeInvalidParam) + } + return value[:len(value)-padding], nil +} diff --git a/internal/infrastructure/wecom/callback_service.go b/internal/infrastructure/wecom/callback_service.go new file mode 100644 index 0000000..501819f --- /dev/null +++ b/internal/infrastructure/wecom/callback_service.go @@ -0,0 +1,124 @@ +package wecom + +import ( + "context" + "encoding/xml" + "strconv" + "strings" + + "github.com/break/junhong_cmp_fiber/internal/infrastructure/integrationlog" + "github.com/break/junhong_cmp_fiber/internal/model" + "github.com/break/junhong_cmp_fiber/pkg/constants" + "github.com/break/junhong_cmp_fiber/pkg/errors" + "github.com/break/junhong_cmp_fiber/pkg/queue" +) + +type callbackIntegrationLog interface { + RecordInbound(ctx context.Context, input integrationlog.InboundAttempt) (*model.IntegrationLog, bool, error) +} + +type encryptedCallbackEnvelope struct { + Encrypt string `xml:"Encrypt"` +} + +type approvalChangeEvent struct { + Event string `xml:"Event"` + SPNo string `xml:"ApprovalInfo>SpNoStr"` + SPStatus int `xml:"ApprovalInfo>SpStatus"` + TemplateID string `xml:"ApprovalInfo>TemplateId"` + StatusChangeType int `xml:"ApprovalInfo>StatuChangeEvent"` +} + +// ApprovalDetailSyncTask 是回调快速响应后提交给 Worker 的结构化任务载荷。 +type ApprovalDetailSyncTask struct { + ApplicationID uint `json:"application_id"` + SPNo string `json:"sp_no"` + Source string `json:"source"` + IntegrationID string `json:"integration_id"` +} + +// CallbackService 校验解密企微回调、记录入站幂等事实并异步拉取详情。 +type CallbackService struct { + applications *ApplicationRepository + cipher *CredentialCipher + integration callbackIntegrationLog + queue *queue.Client +} + +// NewCallbackService 创建企业微信审批回调服务。 +func NewCallbackService(applications *ApplicationRepository, cipher *CredentialCipher, integration callbackIntegrationLog, queueClient *queue.Client) *CallbackService { + return &CallbackService{applications: applications, cipher: cipher, integration: integration, queue: queueClient} +} + +// VerifyURL 校验企微回调 URL 并返回 echostr 明文。 +func (s *CallbackService) VerifyURL(ctx context.Context, applicationID uint, signature, timestamp, nonce, echo string) ([]byte, error) { + crypto, err := s.callbackCrypto(ctx, applicationID) + if err != nil { + return nil, err + } + return crypto.VerifyAndDecrypt(signature, timestamp, nonce, echo) +} + +// Receive 校验并解密审批事件,持久化入站事实后用 struct 载荷提交详情同步任务。 +func (s *CallbackService) Receive(ctx context.Context, applicationID uint, signature, timestamp, nonce string, body []byte) error { + if s == nil || s.integration == nil || s.queue == nil { + return errors.New(errors.CodeServiceUnavailable, "企业微信审批回调服务未配置") + } + var envelope encryptedCallbackEnvelope + if err := xml.Unmarshal(body, &envelope); err != nil || strings.TrimSpace(envelope.Encrypt) == "" { + return errors.New(errors.CodeInvalidParam, "企业微信回调 XML 无效") + } + crypto, err := s.callbackCrypto(ctx, applicationID) + if err != nil { + return err + } + plaintext, err := crypto.VerifyAndDecrypt(signature, timestamp, nonce, envelope.Encrypt) + if err != nil { + return err + } + var event approvalChangeEvent + if err := xml.Unmarshal(plaintext, &event); err != nil || event.Event != "sys_approval_change" || strings.TrimSpace(event.SPNo) == "" { + return errors.New(errors.CodeInvalidParam, "企业微信审批回调事件无效") + } + resourceID := strconv.FormatUint(uint64(applicationID), 10) + log, created, err := s.integration.RecordInbound(ctx, integrationlog.InboundAttempt{ + IdempotencyKey: applicationCallbackIdempotencyKey(applicationID, signature), + Provider: constants.IntegrationProviderWeCom, Operation: constants.IntegrationOperationWeComApprovalCallback, + ExternalID: event.SPNo, ResourceType: constants.WeComApprovalInstanceResourceType, ResourceID: &resourceID, + RawPayload: body, ContentType: "application/xml", + }) + if err != nil { + return err + } + if !created && log.Result != constants.IntegrationResultPending { + return nil + } + return s.queue.EnqueueTask(ctx, constants.TaskTypeWeComApprovalSync, ApprovalDetailSyncTask{ + ApplicationID: applicationID, SPNo: event.SPNo, Source: constants.ApprovalSyncSourceCallback, + IntegrationID: log.IntegrationID, + }) +} + +// callbackCrypto 每次从数据库密文解出当前回调凭据,使凭据轮换无需重启进程。 +func (s *CallbackService) callbackCrypto(ctx context.Context, applicationID uint) (*CallbackCrypto, error) { + if s == nil || s.applications == nil || s.cipher == nil || applicationID == 0 { + return nil, errors.New(errors.CodeServiceUnavailable, "企业微信审批回调服务未配置") + } + application, err := s.applications.GetEnabled(ctx, applicationID) + if err != nil { + return nil, err + } + token, err := s.cipher.Decrypt(application.CallbackTokenCiphertext) + if err != nil { + return nil, err + } + aesKey, err := s.cipher.Decrypt(application.EncodingAESKeyCiphertext) + if err != nil { + return nil, err + } + return NewCallbackCrypto(token, aesKey, application.CorpID) +} + +func applicationCallbackIdempotencyKey(applicationID uint, signature string) string { + return "wecom-approval:" + strconv.FormatUint(uint64(applicationID), 10) + ":" + strings.ToLower(strings.TrimSpace(signature)) +} diff --git a/internal/infrastructure/wecom/credential_cipher.go b/internal/infrastructure/wecom/credential_cipher.go new file mode 100644 index 0000000..031cb43 --- /dev/null +++ b/internal/infrastructure/wecom/credential_cipher.go @@ -0,0 +1,61 @@ +// Package wecom 提供企业微信外部系统 Adapter。 +package wecom + +import ( + "crypto/aes" + "crypto/cipher" + "crypto/rand" + "encoding/base64" + "io" + + "github.com/break/junhong_cmp_fiber/pkg/errors" +) + +var credentialAAD = []byte("junhong-wecom-credential-v1") + +// CredentialCipher 使用 AES-256-GCM 加解密企业微信敏感凭据。 +type CredentialCipher struct { + aead cipher.AEAD +} + +// NewCredentialCipher 从 32 字节 Base64 密钥创建凭据加密器。 +func NewCredentialCipher(encodedKey string) (*CredentialCipher, error) { + key, err := base64.StdEncoding.DecodeString(encodedKey) + if err != nil || len(key) != 32 { + return nil, errors.New(errors.CodeWeComCredentialInvalid, "企业微信凭据加密密钥必须是 32 字节 Base64") + } + block, err := aes.NewCipher(key) + if err != nil { + return nil, errors.Wrap(errors.CodeWeComCredentialInvalid, err, "初始化企业微信凭据加密器失败") + } + aead, err := cipher.NewGCM(block) + if err != nil { + return nil, errors.Wrap(errors.CodeWeComCredentialInvalid, err, "初始化企业微信凭据加密模式失败") + } + return &CredentialCipher{aead: aead}, nil +} + +// Encrypt 加密单个敏感凭据,返回 nonce 与密文组合。 +func (c *CredentialCipher) Encrypt(plaintext string) ([]byte, error) { + if c == nil || c.aead == nil || plaintext == "" { + return nil, errors.New(errors.CodeWeComCredentialInvalid) + } + nonce := make([]byte, c.aead.NonceSize()) + if _, err := io.ReadFull(rand.Reader, nonce); err != nil { + return nil, errors.Wrap(errors.CodeInternalError, err, "生成企业微信凭据随机数失败") + } + return c.aead.Seal(nonce, nonce, []byte(plaintext), credentialAAD), nil +} + +// Decrypt 解密单个企业微信敏感凭据。 +func (c *CredentialCipher) Decrypt(ciphertext []byte) (string, error) { + if c == nil || c.aead == nil || len(ciphertext) <= c.aead.NonceSize() { + return "", errors.New(errors.CodeWeComCredentialInvalid) + } + nonce := ciphertext[:c.aead.NonceSize()] + plaintext, err := c.aead.Open(nil, nonce, ciphertext[c.aead.NonceSize():], credentialAAD) + if err != nil { + return "", errors.Wrap(errors.CodeWeComCredentialInvalid, err, "解密企业微信凭据失败") + } + return string(plaintext), nil +} diff --git a/internal/infrastructure/wecom/directory_client.go b/internal/infrastructure/wecom/directory_client.go new file mode 100644 index 0000000..42fc88d --- /dev/null +++ b/internal/infrastructure/wecom/directory_client.go @@ -0,0 +1,185 @@ +package wecom + +import ( + "context" + "io" + "net/http" + "net/url" + "strconv" + "strings" + "time" + + "github.com/bytedance/sonic" + + wecomapp "github.com/break/junhong_cmp_fiber/internal/application/wecom" + "github.com/break/junhong_cmp_fiber/internal/infrastructure/integrationlog" + "github.com/break/junhong_cmp_fiber/pkg/constants" + "github.com/break/junhong_cmp_fiber/pkg/errors" + "github.com/break/junhong_cmp_fiber/pkg/middleware" +) + +// DirectoryTokenProvider 定义通讯录客户端取得应用 access_token 的边界。 +type DirectoryTokenProvider interface { + GetAccessToken(ctx context.Context, applicationID uint) (string, error) +} + +// DirectoryClient 拉取企业微信应用可见成员,不读取手机号或邮箱。 +type DirectoryClient struct { + tokens DirectoryTokenProvider + integration TokenIntegrationLog + httpClient *http.Client + baseURL string + now func() time.Time +} + +// NewDirectoryClient 创建企业微信通讯录客户端。 +func NewDirectoryClient(tokens DirectoryTokenProvider, integration TokenIntegrationLog, baseURL string, timeout time.Duration) *DirectoryClient { + if timeout <= 0 { + timeout = constants.WeComDefaultHTTPTimeout + } + return &DirectoryClient{ + tokens: tokens, integration: integration, httpClient: &http.Client{Timeout: timeout}, + baseURL: strings.TrimRight(baseURL, "/"), now: time.Now, + } +} + +// ListVisibleMembers 拉取根部门及其子部门中当前应用可见的成员。 +func (c *DirectoryClient) ListVisibleMembers(ctx context.Context, applicationID uint) ([]wecomapp.DirectoryMember, error) { + if c == nil || c.tokens == nil || c.integration == nil || c.httpClient == nil || applicationID == 0 { + return nil, errors.New(errors.CodeServiceUnavailable, "企业微信通讯录服务未配置") + } + token, err := c.tokens.GetAccessToken(ctx, applicationID) + if err != nil { + return nil, err + } + request, err := c.newListRequest(ctx, token) + if err != nil { + return nil, err + } + resourceID := strconv.FormatUint(uint64(applicationID), 10) + requestID := middleware.GetRequestIDFromContext(ctx) + attempt, err := c.integration.Start(ctx, integrationlog.Attempt{ + Provider: constants.IntegrationProviderWeCom, Direction: constants.IntegrationDirectionOutbound, + Operation: constants.IntegrationOperationWeComVisibleMembers, ResourceType: constants.WeComApplicationResourceType, + ResourceID: &resourceID, RequestSummary: map[string]any{ + "application_id": applicationID, "department_id": constants.WeComRootDepartmentID, "fetch_child": true, + }, RequestID: requestID, CorrelationID: requestID, + }) + if err != nil { + return nil, err + } + startedAt := c.now() + response, err := c.httpClient.Do(request) + if err != nil { + return nil, c.completeFailed(ctx, attempt.IntegrationID, 0, "request_failed", "企业微信通讯录请求失败", startedAt) + } + defer response.Body.Close() + result, err := c.readResponse(response) + if err != nil { + return nil, c.completeFailed(ctx, attempt.IntegrationID, response.StatusCode, "invalid_response", "企业微信通讯录响应无效", startedAt) + } + if response.StatusCode < http.StatusOK || response.StatusCode >= http.StatusMultipleChoices || result.ErrCode != 0 { + providerCode := strconv.FormatInt(result.ErrCode, 10) + if result.ErrCode == 0 { + providerCode = strconv.Itoa(response.StatusCode) + } + return nil, c.completeFailed(ctx, attempt.IntegrationID, response.StatusCode, providerCode, result.ErrMsg, startedAt) + } + members := normalizeRemoteMembers(result.UserList) + if err := c.completeSuccess(ctx, attempt.IntegrationID, response.StatusCode, result, len(members), startedAt); err != nil { + return nil, err + } + return members, nil +} + +func (c *DirectoryClient) newListRequest(ctx context.Context, token string) (*http.Request, error) { + endpoint, err := url.Parse(c.baseURL + "/cgi-bin/user/simplelist") + if err != nil || endpoint.Scheme == "" || endpoint.Host == "" { + return nil, errors.New(errors.CodeWeComCredentialInvalid, "企业微信 API 地址配置无效") + } + query := endpoint.Query() + query.Set("access_token", token) + query.Set("department_id", strconv.FormatInt(constants.WeComRootDepartmentID, 10)) + query.Set("fetch_child", "1") + endpoint.RawQuery = query.Encode() + request, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint.String(), nil) + if err != nil { + return nil, errors.Wrap(errors.CodeInternalError, err, "创建企业微信通讯录请求失败") + } + return request, nil +} + +type directoryResponse struct { + ErrCode int64 `json:"errcode"` + ErrMsg string `json:"errmsg"` + UserList []directoryMember `json:"userlist"` +} + +type directoryMember struct { + UserID string `json:"userid"` + Name string `json:"name"` + Department []int64 `json:"department"` +} + +func (c *DirectoryClient) readResponse(response *http.Response) (directoryResponse, error) { + var result directoryResponse + body, err := io.ReadAll(io.LimitReader(response.Body, constants.WeComDirectoryMaxResponseBodyBytes+1)) + if err != nil { + return result, errors.Wrap(errors.CodeServiceUnavailable, err, "读取企业微信通讯录响应失败") + } + if int64(len(body)) > constants.WeComDirectoryMaxResponseBodyBytes { + return result, errors.New(errors.CodeServiceUnavailable, "企业微信通讯录响应过大") + } + if err := sonic.Unmarshal(body, &result); err != nil { + return result, errors.Wrap(errors.CodeServiceUnavailable, err, "解析企业微信通讯录响应失败") + } + return result, nil +} + +func normalizeRemoteMembers(source []directoryMember) []wecomapp.DirectoryMember { + seen := make(map[string]struct{}, len(source)) + result := make([]wecomapp.DirectoryMember, 0, len(source)) + for _, member := range source { + userID := strings.ToLower(strings.TrimSpace(member.UserID)) + if userID == "" { + continue + } + if _, exists := seen[userID]; exists { + continue + } + seen[userID] = struct{}{} + name := strings.TrimSpace(member.Name) + if name == "" { + name = userID + } + result = append(result, wecomapp.DirectoryMember{UserID: userID, Name: name, DepartmentIDs: member.Department}) + } + return result +} + +func (c *DirectoryClient) completeSuccess(ctx context.Context, integrationID string, status int, result directoryResponse, memberCount int, startedAt time.Time) error { + _, err := c.integration.Complete(ctx, integrationID, integrationlog.Completion{ + Result: constants.IntegrationResultSuccess, HTTPStatus: status, + ProviderCode: strconv.FormatInt(result.ErrCode, 10), ProviderMessage: result.ErrMsg, + ResponseSummary: map[string]any{"errcode": result.ErrCode, "member_count": memberCount}, + DurationMS: c.now().Sub(startedAt).Milliseconds(), + }) + return err +} + +func (c *DirectoryClient) completeFailed(ctx context.Context, integrationID string, status int, providerCode, providerMessage string, startedAt time.Time) error { + if providerMessage == "" { + providerMessage = "企业微信通讯录接口返回失败" + } + _, err := c.integration.Complete(ctx, integrationID, integrationlog.Completion{ + Result: constants.IntegrationResultFailed, HTTPStatus: status, ProviderCode: providerCode, + ProviderMessage: providerMessage, ResponseSummary: map[string]any{"success": false}, + DurationMS: c.now().Sub(startedAt).Milliseconds(), + }) + if err != nil { + return err + } + return errors.New(errors.CodeServiceUnavailable, "企业微信通讯录同步失败,请检查应用可见范围") +} + +var _ TokenIntegrationLog = (*integrationlog.Repository)(nil) diff --git a/internal/infrastructure/wecom/member_repository.go b/internal/infrastructure/wecom/member_repository.go new file mode 100644 index 0000000..3de6b89 --- /dev/null +++ b/internal/infrastructure/wecom/member_repository.go @@ -0,0 +1,89 @@ +package wecom + +import ( + "context" + "strings" + "time" + + "gorm.io/gorm" + "gorm.io/gorm/clause" + + "github.com/break/junhong_cmp_fiber/internal/model" + "github.com/break/junhong_cmp_fiber/pkg/constants" + "github.com/break/junhong_cmp_fiber/pkg/errors" +) + +// MemberRepository 持久化企业微信应用可见成员快照。 +type MemberRepository struct { + db *gorm.DB +} + +// NewMemberRepository 创建企业微信成员快照 Repository。 +func NewMemberRepository(db *gorm.DB) *MemberRepository { + return &MemberRepository{db: db} +} + +// ReplaceVisible 原子替换指定应用当前可见成员,历史不可见成员仅标记为不可见。 +func (r *MemberRepository) ReplaceVisible(ctx context.Context, applicationID uint, members []model.WeComMember, syncedAt time.Time) error { + if r == nil || r.db == nil { + return errors.New(errors.CodeDatabaseError, "企业微信成员存储未配置") + } + err := r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + if err := tx.Model(&model.WeComMember{}).Where("application_id = ?", applicationID). + Updates(map[string]any{"visible": false, "updated_at": syncedAt}).Error; err != nil { + return err + } + if len(members) == 0 { + return nil + } + return tx.Clauses(clause.OnConflict{ + Columns: []clause.Column{{Name: "application_id"}, {Name: "userid"}}, + DoUpdates: clause.Assignments(map[string]any{ + "corp_id": gorm.Expr("EXCLUDED.corp_id"), "name": gorm.Expr("EXCLUDED.name"), + "department_ids": gorm.Expr("EXCLUDED.department_ids"), "visible": true, + "synced_at": gorm.Expr("EXCLUDED.synced_at"), "updated_at": syncedAt, + }), + }).CreateInBatches(&members, constants.WeComMemberSyncBatchSize).Error + }) + if err != nil { + return errors.Wrap(errors.CodeDatabaseError, err, "同步企业微信可见成员失败") + } + return nil +} + +// ListVisible 分页查询指定应用当前可见成员。 +func (r *MemberRepository) ListVisible(ctx context.Context, applicationID uint, page, pageSize int, keyword string) ([]model.WeComMember, int64, error) { + query := r.db.WithContext(ctx).Model(&model.WeComMember{}). + Joins("JOIN tb_wecom_application ON tb_wecom_application.id = tb_wecom_member.application_id AND tb_wecom_application.deleted_at IS NULL AND tb_wecom_application.status = ?", constants.StatusEnabled). + Where("tb_wecom_member.application_id = ? AND tb_wecom_member.visible = ?", applicationID, true) + keyword = strings.TrimSpace(keyword) + if keyword != "" { + query = query.Where("tb_wecom_member.name ILIKE ? OR tb_wecom_member.userid ILIKE ?", "%"+keyword+"%", "%"+keyword+"%") + } + var total int64 + if err := query.Count(&total).Error; err != nil { + return nil, 0, errors.Wrap(errors.CodeDatabaseError, err, "统计企业微信可见成员失败") + } + var members []model.WeComMember + if err := query.Select("tb_wecom_member.*").Order("tb_wecom_member.name ASC, tb_wecom_member.userid ASC"). + Offset((page - 1) * pageSize).Limit(pageSize).Find(&members).Error; err != nil { + return nil, 0, errors.Wrap(errors.CodeDatabaseError, err, "查询企业微信可见成员失败") + } + return members, total, nil +} + +// GetVisible 查询可用于账号绑定的应用可见成员。 +func (r *MemberRepository) GetVisible(ctx context.Context, applicationID uint, userID string) (*model.WeComMember, error) { + var member model.WeComMember + if err := r.db.WithContext(ctx).Model(&model.WeComMember{}). + Select("tb_wecom_member.*"). + Joins("JOIN tb_wecom_application ON tb_wecom_application.id = tb_wecom_member.application_id AND tb_wecom_application.deleted_at IS NULL AND tb_wecom_application.status = ?", constants.StatusEnabled). + Where("tb_wecom_member.application_id = ? AND tb_wecom_member.userid = ? AND tb_wecom_member.visible = ?", applicationID, strings.ToLower(strings.TrimSpace(userID)), true). + First(&member).Error; err != nil { + if err == gorm.ErrRecordNotFound { + return nil, errors.New(errors.CodeInvalidParam, "所选成员不在企业微信应用可见范围内") + } + return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询企业微信可见成员失败") + } + return &member, nil +} diff --git a/internal/infrastructure/wecom/scene_repository.go b/internal/infrastructure/wecom/scene_repository.go new file mode 100644 index 0000000..73d6dd7 --- /dev/null +++ b/internal/infrastructure/wecom/scene_repository.go @@ -0,0 +1,83 @@ +package wecom + +import ( + "context" + + "gorm.io/gorm" + "gorm.io/gorm/clause" + + "github.com/break/junhong_cmp_fiber/internal/model" + "github.com/break/junhong_cmp_fiber/pkg/constants" + "github.com/break/junhong_cmp_fiber/pkg/errors" +) + +// SceneRepository 持久化企业微信审批业务场景当前配置。 +type SceneRepository struct { + db *gorm.DB +} + +// NewSceneRepository 创建企业微信审批场景 Repository。 +func NewSceneRepository(db *gorm.DB) *SceneRepository { + return &SceneRepository{db: db} +} + +// FindForUpdate 在事务内锁定指定业务场景。 +func (r *SceneRepository) FindForUpdate(ctx context.Context, tx *gorm.DB, businessType string) (*model.WeComApprovalScene, error) { + var scene model.WeComApprovalScene + err := tx.WithContext(ctx).Clauses(clause.Locking{Strength: "UPDATE"}).Where("business_type = ?", businessType).First(&scene).Error + if err == gorm.ErrRecordNotFound { + return nil, nil + } + if err != nil { + return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询企业微信审批场景失败") + } + return &scene, nil +} + +// Create 创建企业微信审批场景配置。 +func (r *SceneRepository) Create(ctx context.Context, tx *gorm.DB, scene *model.WeComApprovalScene) error { + if err := tx.WithContext(ctx).Create(scene).Error; err != nil { + return errors.Wrap(errors.CodeDatabaseError, err, "创建企业微信审批场景失败") + } + return nil +} + +// Update 更新企业微信审批场景当前配置。 +func (r *SceneRepository) Update(ctx context.Context, tx *gorm.DB, scene *model.WeComApprovalScene) error { + if err := tx.WithContext(ctx).Model(&model.WeComApprovalScene{}).Where("id = ?", scene.ID).Updates(map[string]any{ + "application_id": scene.ApplicationID, "template_id": scene.TemplateID, "template_name": scene.TemplateName, + "control_mapping": scene.ControlMapping, "template_snapshot": scene.TemplateSnapshot, + "template_fingerprint": scene.TemplateFingerprint, + "status": scene.Status, "last_verified_at": scene.LastVerifiedAt, + "updated_by": scene.UpdatedBy, "updated_at": scene.UpdatedAt, + }).Error; err != nil { + return errors.Wrap(errors.CodeDatabaseError, err, "更新企业微信审批场景失败") + } + return nil +} + +// List 分页查询企业微信审批场景配置。 +func (r *SceneRepository) List(ctx context.Context, page, pageSize int) ([]model.WeComApprovalScene, int64, error) { + query := r.db.WithContext(ctx).Model(&model.WeComApprovalScene{}) + var total int64 + if err := query.Count(&total).Error; err != nil { + return nil, 0, errors.Wrap(errors.CodeDatabaseError, err, "统计企业微信审批场景失败") + } + var scenes []model.WeComApprovalScene + if err := query.Order("business_type ASC").Offset((page - 1) * pageSize).Limit(pageSize).Find(&scenes).Error; err != nil { + return nil, 0, errors.Wrap(errors.CodeDatabaseError, err, "查询企业微信审批场景失败") + } + return scenes, total, nil +} + +// GetEnabled 查询指定业务类型当前启用的企微审批场景。 +func (r *SceneRepository) GetEnabled(ctx context.Context, businessType string) (*model.WeComApprovalScene, error) { + var scene model.WeComApprovalScene + if err := r.db.WithContext(ctx).Where("business_type = ? AND status = ?", businessType, constants.StatusEnabled).First(&scene).Error; err != nil { + if err == gorm.ErrRecordNotFound { + return nil, errors.New(errors.CodeServiceUnavailable, "企业微信审批场景未配置或已禁用") + } + return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询企业微信审批场景失败") + } + return &scene, nil +} diff --git a/internal/infrastructure/wecom/template_client.go b/internal/infrastructure/wecom/template_client.go new file mode 100644 index 0000000..6142085 --- /dev/null +++ b/internal/infrastructure/wecom/template_client.go @@ -0,0 +1,240 @@ +package wecom + +import ( + "bytes" + "context" + "io" + "net/http" + "net/url" + "strconv" + "strings" + "time" + + "github.com/bytedance/sonic" + + wecomapp "github.com/break/junhong_cmp_fiber/internal/application/wecom" + "github.com/break/junhong_cmp_fiber/internal/infrastructure/integrationlog" + "github.com/break/junhong_cmp_fiber/pkg/constants" + "github.com/break/junhong_cmp_fiber/pkg/errors" + "github.com/break/junhong_cmp_fiber/pkg/middleware" +) + +// TemplateClient 读取企微后台已有审批模板的控件结构。 +type TemplateClient struct { + tokens DirectoryTokenProvider + integration TokenIntegrationLog + httpClient *http.Client + baseURL string + now func() time.Time +} + +// NewTemplateClient 创建企业微信审批模板详情客户端。 +func NewTemplateClient(tokens DirectoryTokenProvider, integration TokenIntegrationLog, baseURL string, timeout time.Duration) *TemplateClient { + if timeout <= 0 { + timeout = constants.WeComDefaultHTTPTimeout + } + return &TemplateClient{ + tokens: tokens, integration: integration, httpClient: &http.Client{Timeout: timeout}, + baseURL: strings.TrimRight(baseURL, "/"), now: time.Now, + } +} + +// GetTemplateDetail 获取模板详情并只投影控件结构,不保存审批节点和审批人规则。 +func (c *TemplateClient) GetTemplateDetail(ctx context.Context, applicationID uint, templateID string) (wecomapp.TemplateDefinition, error) { + if c == nil || c.tokens == nil || c.integration == nil || c.httpClient == nil { + return wecomapp.TemplateDefinition{}, errors.New(errors.CodeServiceUnavailable, "企业微信模板服务未配置") + } + token, err := c.tokens.GetAccessToken(ctx, applicationID) + if err != nil { + return wecomapp.TemplateDefinition{}, err + } + request, err := c.newRequest(ctx, token, templateID) + if err != nil { + return wecomapp.TemplateDefinition{}, err + } + resourceID := strconv.FormatUint(uint64(applicationID), 10) + requestID := middleware.GetRequestIDFromContext(ctx) + attempt, err := c.integration.Start(ctx, integrationlog.Attempt{ + Provider: constants.IntegrationProviderWeCom, Direction: constants.IntegrationDirectionOutbound, + Operation: constants.IntegrationOperationWeComTemplateDetail, ResourceType: constants.WeComApprovalSceneResourceType, + ResourceID: &resourceID, RequestSummary: map[string]any{ + "application_id": applicationID, "template_id": templateID, + }, RequestID: requestID, CorrelationID: requestID, + }) + if err != nil { + return wecomapp.TemplateDefinition{}, err + } + startedAt := c.now() + response, err := c.httpClient.Do(request) + if err != nil { + return wecomapp.TemplateDefinition{}, c.completeTemplateFailure(ctx, attempt.IntegrationID, 0, "request_failed", "企业微信模板详情请求失败", startedAt) + } + defer response.Body.Close() + body, err := io.ReadAll(io.LimitReader(response.Body, constants.WeComDirectoryMaxResponseBodyBytes+1)) + if err != nil || int64(len(body)) > constants.WeComDirectoryMaxResponseBodyBytes { + return wecomapp.TemplateDefinition{}, c.completeTemplateFailure(ctx, attempt.IntegrationID, response.StatusCode, "invalid_response", "企业微信模板详情响应无效", startedAt) + } + definition, errCode, errMsg, err := parseTemplateDefinition(templateID, body) + if err != nil || response.StatusCode < http.StatusOK || response.StatusCode >= http.StatusMultipleChoices || errCode != 0 { + providerCode := strconv.FormatInt(errCode, 10) + if errCode == 0 { + providerCode = strconv.Itoa(response.StatusCode) + } + return wecomapp.TemplateDefinition{}, c.completeTemplateFailure(ctx, attempt.IntegrationID, response.StatusCode, providerCode, errMsg, startedAt) + } + _, err = c.integration.Complete(ctx, attempt.IntegrationID, integrationlog.Completion{ + Result: constants.IntegrationResultSuccess, HTTPStatus: response.StatusCode, + ProviderCode: strconv.FormatInt(errCode, 10), ProviderMessage: errMsg, + ResponseSummary: map[string]any{"errcode": errCode, "control_count": len(definition.Controls)}, + DurationMS: c.now().Sub(startedAt).Milliseconds(), + }) + if err != nil { + return wecomapp.TemplateDefinition{}, err + } + return definition, nil +} + +func (c *TemplateClient) newRequest(ctx context.Context, token, templateID string) (*http.Request, error) { + endpoint, err := url.Parse(c.baseURL + "/cgi-bin/oa/gettemplatedetail") + if err != nil || endpoint.Scheme == "" || endpoint.Host == "" { + return nil, errors.New(errors.CodeWeComCredentialInvalid, "企业微信 API 地址配置无效") + } + query := endpoint.Query() + query.Set("access_token", token) + endpoint.RawQuery = query.Encode() + body, err := sonic.Marshal(map[string]string{"template_id": templateID}) + if err != nil { + return nil, errors.Wrap(errors.CodeInternalError, err, "编码企业微信模板详情请求失败") + } + request, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint.String(), bytes.NewReader(body)) + if err != nil { + return nil, errors.Wrap(errors.CodeInternalError, err, "创建企业微信模板详情请求失败") + } + request.Header.Set("Content-Type", "application/json") + return request, nil +} + +func parseTemplateDefinition(templateID string, body []byte) (wecomapp.TemplateDefinition, int64, string, error) { + var payload map[string]any + if err := sonic.Unmarshal(body, &payload); err != nil { + return wecomapp.TemplateDefinition{}, 0, "", err + } + errCode := numberToInt64(payload["errcode"]) + errMsg, _ := payload["errmsg"].(string) + if errCode != 0 { + return wecomapp.TemplateDefinition{}, errCode, errMsg, nil + } + content, ok := payload["template_content"].(map[string]any) + if !ok { + return wecomapp.TemplateDefinition{}, errCode, errMsg, errors.New(errors.CodeServiceUnavailable, "企业微信模板详情缺少模板内容") + } + controls := make([]wecomapp.TemplateControl, 0) + seen := make(map[string]struct{}) + collectTemplateControls(content["controls"], &controls, seen) + if len(controls) == 0 { + return wecomapp.TemplateDefinition{}, errCode, errMsg, errors.New(errors.CodeInvalidParam, "企业微信模板没有可映射控件") + } + name := localizedText(content["template_name"]) + if name == "" { + name = localizedText(content["template_names"]) + } + return wecomapp.TemplateDefinition{ + TemplateID: templateID, Name: name, Controls: controls, + }, errCode, errMsg, nil +} + +func collectTemplateControls(value any, result *[]wecomapp.TemplateControl, seen map[string]struct{}) { + switch typed := value.(type) { + case []any: + for _, item := range typed { + collectTemplateControls(item, result, seen) + } + case map[string]any: + if property, ok := typed["property"].(map[string]any); ok { + id, _ := property["id"].(string) + controlType, _ := property["control"].(string) + if id != "" && controlType != "" { + if _, exists := seen[id]; !exists { + seen[id] = struct{}{} + *result = append(*result, wecomapp.TemplateControl{ + ID: id, Type: controlType, Title: localizedText(property["title"]), + Required: numberToInt64(property["require"]) == 1, OptionKeys: collectOptionKeys(typed), + }) + } + } + } + for _, item := range typed { + collectTemplateControls(item, result, seen) + } + } +} + +func collectOptionKeys(value any) []string { + keys := make([]string, 0) + seen := make(map[string]struct{}) + var walk func(any) + walk = func(current any) { + switch typed := current.(type) { + case []any: + for _, item := range typed { + walk(item) + } + case map[string]any: + if key, ok := typed["key"].(string); ok && key != "" { + if _, exists := seen[key]; !exists { + seen[key] = struct{}{} + keys = append(keys, key) + } + } + for _, item := range typed { + walk(item) + } + } + } + walk(value) + return keys +} + +func localizedText(value any) string { + if text, ok := value.(string); ok { + return text + } + if items, ok := value.([]any); ok { + for _, item := range items { + if translated, ok := item.(map[string]any); ok { + if text, ok := translated["text"].(string); ok && text != "" { + return text + } + } + } + } + return "" +} + +func numberToInt64(value any) int64 { + switch number := value.(type) { + case float64: + return int64(number) + case int64: + return number + case int: + return int64(number) + default: + return 0 + } +} + +func (c *TemplateClient) completeTemplateFailure(ctx context.Context, integrationID string, status int, providerCode, providerMessage string, startedAt time.Time) error { + if providerMessage == "" { + providerMessage = "企业微信模板详情接口返回失败" + } + _, err := c.integration.Complete(ctx, integrationID, integrationlog.Completion{ + Result: constants.IntegrationResultFailed, HTTPStatus: status, ProviderCode: providerCode, + ProviderMessage: providerMessage, ResponseSummary: map[string]any{"success": false}, + DurationMS: c.now().Sub(startedAt).Milliseconds(), + }) + if err != nil { + return err + } + return errors.New(errors.CodeInvalidParam, "企业微信审批模板无效或不可访问") +} diff --git a/internal/infrastructure/wecom/token_provider.go b/internal/infrastructure/wecom/token_provider.go new file mode 100644 index 0000000..b2a38a7 --- /dev/null +++ b/internal/infrastructure/wecom/token_provider.go @@ -0,0 +1,296 @@ +package wecom + +import ( + "context" + "io" + "net/http" + "net/url" + "strconv" + "strings" + "time" + + "github.com/bytedance/sonic" + "github.com/google/uuid" + "github.com/redis/go-redis/v9" + "go.uber.org/zap" + + "github.com/break/junhong_cmp_fiber/internal/infrastructure/integrationlog" + "github.com/break/junhong_cmp_fiber/internal/model" + "github.com/break/junhong_cmp_fiber/pkg/constants" + "github.com/break/junhong_cmp_fiber/pkg/errors" + "github.com/break/junhong_cmp_fiber/pkg/middleware" +) + +const releaseTokenLockScript = ` +if redis.call("GET", KEYS[1]) == ARGV[1] then + return redis.call("DEL", KEYS[1]) +end +return 0 +` + +// TokenApplicationRepository 定义 access_token Provider 所需的应用配置查询边界。 +type TokenApplicationRepository interface { + GetEnabled(ctx context.Context, applicationID uint) (*model.WeComApplication, error) + MarkConnected(ctx context.Context, applicationID uint, connectedAt time.Time) error +} + +// TokenCredentialCipher 定义 access_token Provider 所需的凭据解密边界。 +type TokenCredentialCipher interface { + Decrypt(ciphertext []byte) (string, error) +} + +// TokenIntegrationLog 定义企微外呼的 Integration Log 边界。 +type TokenIntegrationLog interface { + Start(ctx context.Context, input integrationlog.Attempt) (*model.IntegrationLog, error) + Complete(ctx context.Context, integrationID string, completion integrationlog.Completion) (*model.IntegrationLog, error) +} + +// TokenProvider 按应用缓存企业微信 access_token,并用 Redis 短锁抑制并发回源。 +type TokenProvider struct { + repo TokenApplicationRepository + cipher TokenCredentialCipher + redis *redis.Client + integration TokenIntegrationLog + httpClient *http.Client + baseURL string + logger *zap.Logger + now func() time.Time +} + +// NewTokenProvider 创建企业微信 access_token Provider。 +func NewTokenProvider( + repo TokenApplicationRepository, + cipher TokenCredentialCipher, + redisClient *redis.Client, + integration TokenIntegrationLog, + baseURL string, + timeout time.Duration, + logger *zap.Logger, +) *TokenProvider { + if timeout <= 0 { + timeout = constants.WeComDefaultHTTPTimeout + } + return &TokenProvider{ + repo: repo, cipher: cipher, redis: redisClient, integration: integration, + httpClient: &http.Client{Timeout: timeout}, baseURL: strings.TrimRight(baseURL, "/"), + logger: logger, now: time.Now, + } +} + +// GetAccessToken 优先读取应用缓存,未命中时串行调用企业微信 Token 接口。 +func (p *TokenProvider) GetAccessToken(ctx context.Context, applicationID uint) (string, error) { + if p == nil || p.repo == nil || p.cipher == nil || p.integration == nil || p.httpClient == nil || applicationID == 0 { + return "", errors.New(errors.CodeWeComCredentialInvalid) + } + cacheKey := constants.RedisWeComAccessTokenKey(applicationID) + if token, found, err := p.getCachedToken(ctx, cacheKey); err == nil && found { + return token, nil + } else if err != nil { + p.warnRedis("读取企业微信 access_token 缓存失败,改为受控直连", err, applicationID) + return p.fetchAndCache(ctx, applicationID, cacheKey) + } + if p.redis == nil { + return p.fetchAndCache(ctx, applicationID, cacheKey) + } + lockKey := constants.RedisWeComAccessTokenLockKey(applicationID) + lockValue := uuid.NewString() + acquired, err := p.redis.SetNX(ctx, lockKey, lockValue, constants.WeComTokenLockTTL).Result() + if err != nil { + p.warnRedis("获取企业微信 access_token 回源锁失败,改为受控直连", err, applicationID) + return p.fetchAndCache(ctx, applicationID, cacheKey) + } + if !acquired { + return p.waitForToken(ctx, applicationID, cacheKey) + } + defer p.releaseLock(ctx, lockKey, lockValue, applicationID) + if token, found, err := p.getCachedToken(ctx, cacheKey); err == nil && found { + return token, nil + } + return p.fetchAndCache(ctx, applicationID, cacheKey) +} + +// Invalidate 删除指定应用的 access_token 缓存。 +func (p *TokenProvider) Invalidate(ctx context.Context, applicationID uint) { + if p == nil || p.redis == nil || applicationID == 0 { + return + } + if err := p.redis.Del(ctx, constants.RedisWeComAccessTokenKey(applicationID)).Err(); err != nil { + p.warnRedis("删除企业微信 access_token 缓存失败", err, applicationID) + } +} + +func (p *TokenProvider) getCachedToken(ctx context.Context, cacheKey string) (string, bool, error) { + if p.redis == nil { + return "", false, nil + } + token, err := p.redis.Get(ctx, cacheKey).Result() + if err == redis.Nil { + return "", false, nil + } + if err != nil { + return "", false, err + } + return token, token != "", nil +} + +func (p *TokenProvider) waitForToken(ctx context.Context, applicationID uint, cacheKey string) (string, error) { + timer := time.NewTimer(constants.WeComTokenWaitTimeout) + defer timer.Stop() + ticker := time.NewTicker(constants.WeComTokenWaitPollInterval) + defer ticker.Stop() + for { + select { + case <-ctx.Done(): + return "", errors.Wrap(errors.CodeServiceUnavailable, ctx.Err(), "等待企业微信 access_token 已取消") + case <-timer.C: + return "", errors.New(errors.CodeServiceUnavailable, "企业微信 access_token 正在刷新,请稍后重试") + case <-ticker.C: + token, found, err := p.getCachedToken(ctx, cacheKey) + if err != nil { + p.warnRedis("等待企业微信 access_token 时 Redis 不可用,改为受控直连", err, applicationID) + return p.fetchAndCache(ctx, applicationID, cacheKey) + } + if found { + return token, nil + } + } + } +} + +func (p *TokenProvider) fetchAndCache(ctx context.Context, applicationID uint, cacheKey string) (string, error) { + application, err := p.repo.GetEnabled(ctx, applicationID) + if err != nil { + return "", err + } + secret, err := p.cipher.Decrypt(application.SecretCiphertext) + if err != nil { + return "", err + } + request, err := p.newTokenRequest(ctx, application.CorpID, secret) + if err != nil { + return "", err + } + resourceID := strconv.FormatUint(uint64(applicationID), 10) + requestID := middleware.GetRequestIDFromContext(ctx) + attempt, err := p.integration.Start(ctx, integrationlog.Attempt{ + Provider: constants.IntegrationProviderWeCom, Direction: constants.IntegrationDirectionOutbound, + Operation: constants.IntegrationOperationWeComAccessToken, ResourceType: constants.WeComApplicationResourceType, + ResourceID: &resourceID, RequestSummary: map[string]any{ + "application_id": applicationID, "corp_id": application.CorpID, "agent_id": application.AgentID, + }, RequestID: requestID, CorrelationID: requestID, + }) + if err != nil { + return "", err + } + startedAt := p.now() + response, err := p.httpClient.Do(request) + if err != nil { + return "", p.completeFailed(ctx, attempt.IntegrationID, 0, "request_failed", "企业微信 Token 请求失败", startedAt) + } + defer response.Body.Close() + tokenResponse, err := p.readTokenResponse(response) + if err != nil { + return "", p.completeFailed(ctx, attempt.IntegrationID, response.StatusCode, "invalid_response", "企业微信 Token 响应无效", startedAt) + } + if response.StatusCode < http.StatusOK || response.StatusCode >= http.StatusMultipleChoices || tokenResponse.ErrCode != 0 || tokenResponse.AccessToken == "" { + providerCode := strconv.FormatInt(tokenResponse.ErrCode, 10) + if tokenResponse.ErrCode == 0 { + providerCode = strconv.Itoa(response.StatusCode) + } + return "", p.completeFailed(ctx, attempt.IntegrationID, response.StatusCode, providerCode, tokenResponse.ErrMsg, startedAt) + } + ttl := time.Duration(tokenResponse.ExpiresIn)*time.Second - constants.WeComTokenRefreshAdvance + if ttl <= 0 { + return "", p.completeFailed(ctx, attempt.IntegrationID, response.StatusCode, "invalid_expires_in", "企业微信 access_token 有效期无效", startedAt) + } + if err := p.completeSuccess(ctx, attempt.IntegrationID, response.StatusCode, tokenResponse, startedAt); err != nil { + return "", err + } + if p.redis != nil { + if err := p.redis.Set(ctx, cacheKey, tokenResponse.AccessToken, ttl).Err(); err != nil { + p.warnRedis("缓存企业微信 access_token 失败,本次继续使用直连结果", err, applicationID) + } + } + if err := p.repo.MarkConnected(ctx, applicationID, p.now()); err != nil && p.logger != nil { + p.logger.Error("记录企业微信最近连接时间失败,本次 token 仍可使用", zap.Uint("application_id", applicationID), zap.Error(err)) + } + return tokenResponse.AccessToken, nil +} + +func (p *TokenProvider) newTokenRequest(ctx context.Context, corpID, secret string) (*http.Request, error) { + endpoint, err := url.Parse(p.baseURL + "/cgi-bin/gettoken") + if err != nil || endpoint.Scheme == "" || endpoint.Host == "" { + return nil, errors.New(errors.CodeWeComCredentialInvalid, "企业微信 API 地址配置无效") + } + query := endpoint.Query() + query.Set("corpid", corpID) + query.Set("corpsecret", secret) + endpoint.RawQuery = query.Encode() + request, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint.String(), nil) + if err != nil { + return nil, errors.Wrap(errors.CodeInternalError, err, "创建企业微信 Token 请求失败") + } + return request, nil +} + +type tokenResponse struct { + ErrCode int64 `json:"errcode"` + ErrMsg string `json:"errmsg"` + AccessToken string `json:"access_token"` + ExpiresIn int64 `json:"expires_in"` +} + +func (p *TokenProvider) readTokenResponse(response *http.Response) (tokenResponse, error) { + var result tokenResponse + body, err := io.ReadAll(io.LimitReader(response.Body, constants.WeComMaxResponseBodyBytes+1)) + if err != nil { + return result, errors.Wrap(errors.CodeServiceUnavailable, err, "读取企业微信 Token 响应失败") + } + if int64(len(body)) > constants.WeComMaxResponseBodyBytes { + return result, errors.New(errors.CodeServiceUnavailable, "企业微信 Token 响应过大") + } + if err := sonic.Unmarshal(body, &result); err != nil { + return result, errors.Wrap(errors.CodeServiceUnavailable, err, "解析企业微信 Token 响应失败") + } + return result, nil +} + +func (p *TokenProvider) completeSuccess(ctx context.Context, integrationID string, status int, result tokenResponse, startedAt time.Time) error { + _, err := p.integration.Complete(ctx, integrationID, integrationlog.Completion{ + Result: constants.IntegrationResultSuccess, HTTPStatus: status, + ProviderCode: strconv.FormatInt(result.ErrCode, 10), ProviderMessage: result.ErrMsg, + ResponseSummary: map[string]any{"errcode": result.ErrCode, "expires_in": result.ExpiresIn, "token_present": result.AccessToken != ""}, + DurationMS: p.now().Sub(startedAt).Milliseconds(), + }) + return err +} + +func (p *TokenProvider) completeFailed(ctx context.Context, integrationID string, status int, providerCode, providerMessage string, startedAt time.Time) error { + if providerMessage == "" { + providerMessage = "企业微信 Token 接口返回失败" + } + _, err := p.integration.Complete(ctx, integrationID, integrationlog.Completion{ + Result: constants.IntegrationResultFailed, HTTPStatus: status, ProviderCode: providerCode, + ProviderMessage: providerMessage, ResponseSummary: map[string]any{"success": false}, + DurationMS: p.now().Sub(startedAt).Milliseconds(), + }) + if err != nil { + return err + } + return errors.New(errors.CodeServiceUnavailable, "企业微信连接失败,请检查应用配置和可信 IP") +} + +func (p *TokenProvider) releaseLock(ctx context.Context, lockKey, lockValue string, applicationID uint) { + if p.redis == nil { + return + } + if err := p.redis.Eval(ctx, releaseTokenLockScript, []string{lockKey}, lockValue).Err(); err != nil { + p.warnRedis("释放企业微信 access_token 回源锁失败", err, applicationID) + } +} + +func (p *TokenProvider) warnRedis(message string, err error, applicationID uint) { + if p.logger != nil { + p.logger.Warn(message, zap.Uint("application_id", applicationID), zap.Error(err)) + } +} diff --git a/internal/model/account.go b/internal/model/account.go index 59dc971..841e42e 100644 --- a/internal/model/account.go +++ b/internal/model/account.go @@ -14,6 +14,9 @@ type Account struct { UserType int `gorm:"column:user_type;type:int;not null;index;comment:用户类型 1=超级管理员 2=平台用户 3=代理账号 4=企业账号" json:"user_type"` ShopID *uint `gorm:"column:shop_id;index;comment:店铺ID(代理账号必填)" json:"shop_id,omitempty"` EnterpriseID *uint `gorm:"column:enterprise_id;index;comment:企业ID(企业账号必填)" json:"enterprise_id,omitempty"` + WeComCorpID string `gorm:"column:wecom_corp_id;type:varchar(64);comment:绑定的企业微信企业 ID" json:"wecom_corp_id"` + WeComUserID string `gorm:"column:wecom_userid;type:varchar(64);comment:绑定的企业微信成员 userid" json:"wecom_userid"` + WeComName string `gorm:"column:wecom_name;type:varchar(100);comment:企业微信成员姓名快照" json:"wecom_name"` IsPrimary bool `gorm:"column:is_primary;type:boolean;default:false;comment:是否为店铺主账号(默认 false)" json:"is_primary"` Status int `gorm:"column:status;type:int;not null;default:1;comment:状态 0=禁用 1=启用" json:"status"` } diff --git a/internal/model/agent_wallet.go b/internal/model/agent_wallet.go index 3ed480c..35d8d9e 100644 --- a/internal/model/agent_wallet.go +++ b/internal/model/agent_wallet.go @@ -84,10 +84,11 @@ type AgentRechargeRecord struct { PaymentChannel *string `gorm:"column:payment_channel;type:varchar(50);comment:支付渠道" json:"payment_channel,omitempty"` PaymentTransactionID *string `gorm:"column:payment_transaction_id;type:varchar(100);comment:第三方支付交易号" json:"payment_transaction_id,omitempty"` PaymentConfigID *uint `gorm:"column:payment_config_id;index;comment:支付配置ID(关联tb_wechat_config.id)" json:"payment_config_id,omitempty"` - Status int `gorm:"column:status;type:int;not null;default:1;comment:充值状态(1-待支付 2-已支付 3-已完成 4-已关闭 5-已退款)" json:"status"` + Status int `gorm:"column:status;type:int;not null;default:1;comment:充值状态(1-待支付 2-已支付 3-已完成 4-已关闭 5-已退款 6-已驳回)" json:"status"` PaymentVoucherKey StringJSONBArray `gorm:"column:payment_voucher_key;type:jsonb;comment:支付凭证对象存储Key列表(线下支付时必填,最多5个,微信支付时为空)" json:"payment_voucher_key"` Remark string `gorm:"column:remark;type:text;comment:运营备注(创建时填写,不可修改)" json:"remark,omitempty"` RejectionReason *string `gorm:"column:rejection_reason;type:varchar(500);comment:驳回原因,仅驳回时写入" json:"rejection_reason,omitempty"` + ApprovalInstanceID *uint `gorm:"column:approval_instance_id;comment:员工线下代充值关联的唯一通用审批实例ID" json:"approval_instance_id,omitempty"` PaidAt *time.Time `gorm:"column:paid_at;comment:支付时间" json:"paid_at,omitempty"` CompletedAt *time.Time `gorm:"column:completed_at;comment:完成时间" json:"completed_at,omitempty"` ShopIDTag uint `gorm:"column:shop_id_tag;not null;index;comment:店铺ID标签(多租户过滤)" json:"shop_id_tag"` diff --git a/internal/model/asset_package_batch_order.go b/internal/model/asset_package_batch_order.go new file mode 100644 index 0000000..16674bc --- /dev/null +++ b/internal/model/asset_package_batch_order.go @@ -0,0 +1,78 @@ +package model + +import ( + "database/sql/driver" + "encoding/json" + "time" + + "gorm.io/gorm" +) + +// AssetPackageBatchOrderTask 资产套餐批量订购任务。 +type AssetPackageBatchOrderTask struct { + ID uint `gorm:"column:id;primaryKey" json:"id"` + TaskNo string `gorm:"column:task_no;type:varchar(50);not null;uniqueIndex" json:"task_no"` + PackageID uint `gorm:"column:package_id;not null;index" json:"package_id"` + PackageCode string `gorm:"column:package_code;type:varchar(100);not null;default:''" json:"package_code"` + PackageName string `gorm:"column:package_name;type:varchar(255);not null;default:''" json:"package_name"` + PaymentMethod string `gorm:"column:payment_method;type:varchar(20);not null" json:"payment_method"` + FileName string `gorm:"column:file_name;type:varchar(255);not null;default:''" json:"file_name"` + StorageKey string `gorm:"column:storage_key;type:varchar(500);not null" json:"storage_key"` + VoucherKeys StringJSONBArray `gorm:"column:voucher_keys;type:jsonb;not null;default:'[]'" json:"voucher_keys"` + Status int `gorm:"column:status;type:int;not null;default:1;index" json:"status"` + TotalCount int `gorm:"column:total_count;not null;default:0" json:"total_count"` + SuccessCount int `gorm:"column:success_count;not null;default:0" json:"success_count"` + FailCount int `gorm:"column:fail_count;not null;default:0" json:"fail_count"` + ResultItems AssetPackageBatchOrderResultItems `gorm:"column:result_items;type:jsonb;not null;default:'[]'" json:"result_items"` + ErrorMessage string `gorm:"column:error_message;type:text;not null;default:''" json:"error_message"` + CreatorUserType int `gorm:"column:creator_user_type;type:int;not null;default:0" json:"creator_user_type"` + CreatorShopID uint `gorm:"column:creator_shop_id;not null;default:0" json:"creator_shop_id"` + CreatorName string `gorm:"column:creator_name;type:varchar(100);not null;default:''" json:"creator_name"` + StartedAt *time.Time `gorm:"column:started_at" json:"started_at"` + CompletedAt *time.Time `gorm:"column:completed_at" json:"completed_at"` + Creator uint `gorm:"column:creator;not null;default:0" json:"creator"` + Updater uint `gorm:"column:updater;not null;default:0" json:"updater"` + CreatedAt time.Time `gorm:"column:created_at;not null;default:CURRENT_TIMESTAMP" json:"created_at"` + UpdatedAt time.Time `gorm:"column:updated_at;not null;default:CURRENT_TIMESTAMP" json:"updated_at"` + DeletedAt gorm.DeletedAt `gorm:"column:deleted_at;index" json:"deleted_at,omitempty"` +} + +// TableName 指定批量订购任务表名。 +func (AssetPackageBatchOrderTask) TableName() string { + return "tb_asset_package_batch_order_task" +} + +// AssetPackageBatchOrderResultItem 批量订购单行结果。 +type AssetPackageBatchOrderResultItem struct { + Line int `json:"line"` + AssetIdentifier string `json:"asset_identifier"` + Status int `json:"status"` + OrderID uint `json:"order_id,omitempty"` + OrderNo string `json:"order_no,omitempty"` + Amount int64 `json:"amount,omitempty"` + Reason string `json:"reason,omitempty"` +} + +// AssetPackageBatchOrderResultItems 批量订购单行结果集合。 +type AssetPackageBatchOrderResultItems []AssetPackageBatchOrderResultItem + +// Value 将批量订购单行结果序列化为 JSONB。 +func (items AssetPackageBatchOrderResultItems) Value() (driver.Value, error) { + if items == nil { + return "[]", nil + } + return json.Marshal(items) +} + +// Scan 从 JSONB 读取批量订购单行结果。 +func (items *AssetPackageBatchOrderResultItems) Scan(value any) error { + if value == nil { + *items = AssetPackageBatchOrderResultItems{} + return nil + } + data, ok := value.([]byte) + if !ok { + return nil + } + return json.Unmarshal(data, items) +} diff --git a/internal/model/device_import_task.go b/internal/model/device_import_task.go index 0774ee5..f7de5c5 100644 --- a/internal/model/device_import_task.go +++ b/internal/model/device_import_task.go @@ -13,6 +13,10 @@ type DeviceImportTask struct { gorm.Model BaseModel `gorm:"embedded"` TaskNo string `gorm:"column:task_no;type:varchar(50);uniqueIndex:idx_device_import_task_no,where:deleted_at IS NULL;not null;comment:任务编号(唯一)" json:"task_no"` + OperationType string `gorm:"column:operation_type;type:varchar(32);not null;default:'import';comment:任务业务类型(import/assign_shop/assign_series)" json:"operation_type"` + TargetID *uint `gorm:"column:target_id;comment:批量分配目标店铺或套餐系列ID" json:"target_id"` + OperatorType int `gorm:"column:operator_type;type:int;not null;default:0;comment:任务创建时操作者类型快照" json:"operator_type"` + OperatorShopID *uint `gorm:"column:operator_shop_id;comment:任务创建时操作者店铺ID快照" json:"operator_shop_id"` BatchNo string `gorm:"column:batch_no;type:varchar(100);comment:批次号" json:"batch_no"` StorageKey string `gorm:"column:storage_key;type:varchar(500);comment:对象存储文件路径" json:"storage_key"` FileName string `gorm:"column:file_name;type:varchar(255);comment:原始文件名" json:"file_name"` diff --git a/internal/model/dto/account_dto.go b/internal/model/dto/account_dto.go index ca911fc..3863ef5 100644 --- a/internal/model/dto/account_dto.go +++ b/internal/model/dto/account_dto.go @@ -40,6 +40,10 @@ type AccountResponse struct { ShopName string `json:"shop_name,omitempty" description:"店铺名称"` EnterpriseID *uint `json:"enterprise_id,omitempty" description:"关联企业ID"` EnterpriseName string `json:"enterprise_name,omitempty" description:"企业名称"` + WeComCorpID string `json:"wecom_corp_id" description:"已绑定的企业微信企业 ID"` + WeComUserID string `json:"wecom_userid" description:"已绑定的企业微信成员 userid"` + WeComName string `json:"wecom_name" description:"已绑定的企业微信成员姓名快照"` + WeComBound bool `json:"wecom_bound" description:"是否已绑定企业微信成员"` Status int `json:"status" description:"状态 (0:禁用, 1:启用)"` StatusName string `json:"status_name" description:"状态名称(中文)"` Creator uint `json:"creator" description:"创建人ID"` @@ -97,3 +101,15 @@ type UpdateStatusParams struct { IDReq UpdateStatusRequest } + +// BindAccountWeComRequest 绑定系统账号与企业微信可见成员请求。 +type BindAccountWeComRequest struct { + ApplicationID uint `json:"application_id" validate:"required,gt=0" minimum:"1" description:"企业微信应用配置 ID"` + UserID string `json:"userid" validate:"required,min=1,max=64" minLength:"1" maxLength:"64" description:"企业微信成员 userid"` +} + +// BindAccountWeComParams 绑定系统账号与企业微信成员参数。 +type BindAccountWeComParams struct { + IDReq + BindAccountWeComRequest +} diff --git a/internal/model/dto/agent_recharge_dto.go b/internal/model/dto/agent_recharge_dto.go index b868ebc..748f3ac 100644 --- a/internal/model/dto/agent_recharge_dto.go +++ b/internal/model/dto/agent_recharge_dto.go @@ -2,9 +2,9 @@ package dto // CreateAgentRechargeRequest 创建代理充值请求 type CreateAgentRechargeRequest struct { - ShopID uint `json:"shop_id" validate:"required" required:"true" description:"目标店铺ID,代理只能填自己店铺"` - Amount int64 `json:"amount" validate:"required,min=1,max=100000000" required:"true" minimum:"1" maximum:"100000000" description:"充值金额(分),范围1分~100万元"` - PaymentMethod string `json:"payment_method" validate:"required,oneof=wechat offline" required:"true" description:"支付方式 (wechat:微信在线支付, offline:线下转账仅平台可用)"` + ShopID uint `json:"shop_id" validate:"required" required:"true" description:"目标店铺ID,代理只能填自己店铺"` + Amount int64 `json:"amount" validate:"required,min=1,max=100000000" required:"true" minimum:"1" maximum:"100000000" description:"充值金额(分),范围1分~100万元"` + PaymentMethod string `json:"payment_method" validate:"required,oneof=wechat offline" required:"true" description:"支付方式 (wechat:微信在线支付, offline:线下转账仅平台可用)"` PaymentVoucherKey []string `json:"payment_voucher_key" validate:"omitempty,max=5,dive,max=500" maxItems:"5" description:"支付凭证对象存储Key列表(payment_method=offline 时至少1个,最多5个,微信支付时忽略)"` Remark string `json:"remark" validate:"omitempty,max=1000" maxLength:"1000" description:"运营备注(可选,创建后只读)"` } @@ -37,6 +37,12 @@ type AgentRechargeResponse struct { Status int `json:"status" description:"状态 (1:待支付, 2:已支付, 3:已完成, 4:已关闭, 5:已退款, 6:已驳回)"` StatusName string `json:"status_name" description:"状态名称(中文)"` RejectionReason *string `json:"rejection_reason,omitempty" description:"驳回原因,仅 status=6 时有值"` + SubmitterID uint `json:"submitter_id" description:"提交人账号ID"` + SubmitterName string `json:"submitter_name" description:"提交人账号名称"` + ApprovalInstanceID *uint `json:"approval_instance_id,omitempty" description:"通用审批实例ID,在线充值为null"` + ApprovalProvider string `json:"approval_provider,omitempty" description:"审批渠道,企微审批为wecom"` + ApprovalStatus *int `json:"approval_status,omitempty" description:"审批状态 (0:提交中, 1:审批中, 2:已通过, 3:已拒绝, 4:已撤销, 5:通过后撤销, 6:已删除, 7:提交失败, 8:提交结果未知)"` + ApprovalStatusName string `json:"approval_status_name,omitempty" description:"审批状态名称(中文)"` PaidAt *string `json:"paid_at" description:"支付时间"` CompletedAt *string `json:"completed_at" description:"完成时间"` CreatedAt string `json:"created_at" description:"创建时间"` diff --git a/internal/model/dto/asset_dto.go b/internal/model/dto/asset_dto.go index 5844d57..dae6c27 100644 --- a/internal/model/dto/asset_dto.go +++ b/internal/model/dto/asset_dto.go @@ -359,6 +359,18 @@ type UpdateAssetRealnamePolicyResponse struct { RealnamePolicy string `json:"realname_policy" description:"更新后的实名认证策略 (none:无需实名, before_order:先实名后充值/购买, after_order:先充值/购买后实名)"` } +// BatchUpdateAssetRealnamePolicyRequest 批量更新卡或设备实名认证策略请求。 +type BatchUpdateAssetRealnamePolicyRequest struct { + AssetIDs []uint `json:"asset_ids" validate:"required,min=1,max=500,dive,min=1" required:"true" minItems:"1" maxItems:"500" description:"资产ID列表(最多500条)"` + RealnamePolicy string `json:"realname_policy" validate:"required,oneof=none before_order after_order" required:"true" enum:"none,before_order,after_order" description:"实名认证策略 (none:无需实名, before_order:先实名后充值/购买, after_order:先充值/购买后实名)"` +} + +// BatchUpdateAssetRealnamePolicyResponse 批量更新卡或设备实名认证策略响应。 +type BatchUpdateAssetRealnamePolicyResponse struct { + SuccessCount int `json:"success_count" description:"成功更新数量"` + RealnamePolicy string `json:"realname_policy" description:"更新后的实名认证策略 (none:无需实名, before_order:先实名后充值/购买, after_order:先充值/购买后实名)"` +} + // UpdateAssetRealnameStatusRequest 手动更新资产实名状态请求 type UpdateAssetRealnameStatusRequest struct { Identifier string `path:"identifier" description:"资产标识符(ICCID 或 VirtualNo)" required:"true"` diff --git a/internal/model/dto/asset_package_batch_order_dto.go b/internal/model/dto/asset_package_batch_order_dto.go new file mode 100644 index 0000000..aa54b26 --- /dev/null +++ b/internal/model/dto/asset_package_batch_order_dto.go @@ -0,0 +1,70 @@ +package dto + +// CreateAssetPackageBatchOrderRequest 创建资产套餐批量订购任务请求。 +type CreateAssetPackageBatchOrderRequest struct { + FileKey string `json:"file_key" validate:"required,max=500" required:"true" maxLength:"500" description:"单列CSV对象存储Key(每行一个资产标识)"` + PackageID uint `json:"package_id" validate:"required,min=1" required:"true" minimum:"1" description:"整批统一套餐ID"` + PaymentMethod string `json:"payment_method" validate:"required,oneof=wallet offline" required:"true" description:"整批统一支付方式 (wallet:钱包支付, offline:线下支付)"` + VoucherKeys []string `json:"voucher_keys" validate:"omitempty,max=5,dive,max=500" maxItems:"5" description:"线下支付凭证对象存储Key列表(offline时1至5个)"` +} + +// AssetPackageBatchOrderTaskResponse 批量订购任务响应。 +type AssetPackageBatchOrderTaskResponse struct { + ID uint `json:"id" description:"任务ID"` + TaskNo string `json:"task_no" description:"任务编号"` + PackageID uint `json:"package_id" description:"套餐ID"` + PackageCode string `json:"package_code" description:"套餐编码快照"` + PackageName string `json:"package_name" description:"套餐名称快照"` + PaymentMethod string `json:"payment_method" description:"支付方式 (wallet:钱包支付, offline:线下支付)"` + FileName string `json:"file_name" description:"源CSV文件名"` + VoucherKeys []string `json:"voucher_keys" description:"线下支付凭证对象存储Key列表"` + Status int `json:"status" description:"任务状态 (1:待处理, 2:处理中, 3:已完成, 4:已失败, 5:已取消)"` + StatusName string `json:"status_name" description:"任务状态名称(中文)"` + TotalCount int `json:"total_count" description:"总行数"` + SuccessCount int `json:"success_count" description:"成功数"` + FailCount int `json:"fail_count" description:"失败数"` + ErrorMessage string `json:"error_message,omitempty" description:"任务级中文错误信息"` + CreatorName string `json:"creator_name" description:"操作人姓名快照"` + StartedAt *string `json:"started_at,omitempty" description:"开始处理时间"` + CompletedAt *string `json:"completed_at,omitempty" description:"完成时间"` + CreatedAt string `json:"created_at" description:"创建时间"` + UpdatedAt string `json:"updated_at" description:"更新时间"` +} + +// AssetPackageBatchOrderResultItemResponse 批量订购单行结果响应。 +type AssetPackageBatchOrderResultItemResponse struct { + Line int `json:"line" description:"CSV行号"` + AssetIdentifier string `json:"asset_identifier" description:"资产标识"` + Status int `json:"status" description:"单行状态 (3:成功, 4:失败)"` + StatusName string `json:"status_name" description:"单行状态名称(中文)"` + OrderID uint `json:"order_id,omitempty" description:"成功订单ID"` + OrderNo string `json:"order_no,omitempty" description:"成功订单号"` + Amount int64 `json:"amount,omitempty" description:"成功订单金额(分)"` + Reason string `json:"reason,omitempty" description:"中文失败原因"` +} + +// AssetPackageBatchOrderTaskDetailResponse 批量订购任务详情响应。 +type AssetPackageBatchOrderTaskDetailResponse struct { + AssetPackageBatchOrderTaskResponse + Items []AssetPackageBatchOrderResultItemResponse `json:"items" description:"逐行成功和失败明细"` +} + +// ListAssetPackageBatchOrderRequest 批量订购任务列表请求。 +type ListAssetPackageBatchOrderRequest struct { + Page int `json:"page" query:"page" validate:"omitempty,min=1" minimum:"1" description:"页码(默认1)"` + PageSize int `json:"page_size" query:"page_size" validate:"omitempty,min=1,max=100" minimum:"1" maximum:"100" description:"每页条数(默认20,最大100)"` + Status *int `json:"status" query:"status" validate:"omitempty,min=1,max=5" minimum:"1" maximum:"5" description:"任务状态 (1:待处理, 2:处理中, 3:已完成, 4:已失败, 5:已取消)"` +} + +// AssetPackageBatchOrderTaskListResponse 批量订购任务列表响应。 +type AssetPackageBatchOrderTaskListResponse struct { + Total int64 `json:"total" description:"总记录数"` + Page int `json:"page" description:"当前页码"` + PageSize int `json:"size" description:"每页条数"` + Items []*AssetPackageBatchOrderTaskResponse `json:"items" description:"任务列表"` +} + +// GetAssetPackageBatchOrderRequest 查询批量订购任务路径参数。 +type GetAssetPackageBatchOrderRequest struct { + ID uint `path:"id" required:"true" description:"任务ID"` +} diff --git a/internal/model/dto/client_asset_dto.go b/internal/model/dto/client_asset_dto.go index 68ded1d..fed7d96 100644 --- a/internal/model/dto/client_asset_dto.go +++ b/internal/model/dto/client_asset_dto.go @@ -19,24 +19,28 @@ type AssetInfoResponse struct { BoundPhone string `json:"bound_phone" description:"当前登录用户绑定的手机号"` // === 基础信息(通用) === - AssetType string `json:"asset_type" description:"资产类型(card:卡, device:设备)"` - AssetID uint `json:"asset_id" description:"资产ID"` - Identifier string `json:"identifier" description:"资产标识符"` - VirtualNo string `json:"virtual_no" description:"虚拟号"` - Status int `json:"status" description:"资产归属状态(1:在库, 2:已分销;卡仍沿用原卡状态枚举)"` - StatusName string `json:"status_name" description:"状态名称(中文)"` - ActivationStatus int `json:"activation_status" description:"激活状态(0:未激活, 1:已激活;设备需有生效中主套餐且任意绑定卡已实名)"` - ActivationStatusName string `json:"activation_status_name" description:"激活状态名称(中文)"` - RealNameStatus int `json:"real_name_status" description:"实名状态(0:未实名, 1:已实名)"` - RealNameStatusName string `json:"real_name_status_name" description:"实名状态名称(中文)"` - RealnamePolicy string `json:"realname_policy" description:"实名认证策略 (none:无需实名, before_order:先实名后充值/购买, after_order:先充值/购买后实名)"` - CarrierName string `json:"carrier_name" description:"运营商名称"` - Generation string `json:"generation" description:"世代"` - WalletBalance int64 `json:"wallet_balance" description:"钱包余额(分)"` - ActivatedAt *time.Time `json:"activated_at,omitempty" description:"激活时间"` + AssetType string `json:"asset_type" description:"资产类型(card:卡, device:设备)"` + AssetID uint `json:"asset_id" description:"资产ID"` + Identifier string `json:"identifier" description:"资产标识符"` + VirtualNo string `json:"virtual_no" description:"虚拟号"` + Status int `json:"status" description:"资产归属状态(1:在库, 2:已分销;卡仍沿用原卡状态枚举)"` + StatusName string `json:"status_name" description:"状态名称(中文)"` + ActivationStatus int `json:"activation_status" description:"激活状态(0:未激活, 1:已激活;设备需有生效中主套餐且任意绑定卡已实名)"` + ActivationStatusName string `json:"activation_status_name" description:"激活状态名称(中文)"` + RealNameStatus int `json:"real_name_status" description:"实名状态(0:未实名, 1:已实名)"` + RealNameStatusName string `json:"real_name_status_name" description:"实名状态名称(中文)"` + RealnamePolicy string `json:"realname_policy" description:"实名认证策略 (none:无需实名, before_order:先实名后充值/购买, after_order:先充值/购买后实名)"` + EffectiveRealnamePolicy string `json:"effective_realname_policy" description:"当前资产实际生效的实名认证策略 (none:无需实名, before_order:先实名后充值/购买, after_order:先充值/购买后实名)"` + RealnameRequired bool `json:"realname_required" description:"当前资产是否要求完成实名认证"` + CarrierName string `json:"carrier_name" description:"运营商名称"` + Generation string `json:"generation" description:"世代"` + WalletBalance int64 `json:"wallet_balance" description:"钱包余额(分)"` + AllowedPaymentMethods []string `json:"allowed_payment_methods" description:"当前资产允许的支付方式列表 (wallet:钱包, wechat:微信, alipay:支付宝)"` + ActivatedAt *time.Time `json:"activated_at,omitempty" description:"激活时间"` // === 套餐信息(通用) === CurrentPackage string `json:"current_package" description:"当前套餐名称(无套餐时为空)"` + CurrentPackageID uint `json:"current_package_id" description:"当前主套餐ID,无主套餐时为0,可用于发起续费"` CurrentPackageUsageID *uint `json:"current_package_usage_id" description:"当前主套餐的套餐使用记录ID,无主套餐时为 null"` CurrentPackageActivatedAt *time.Time `json:"current_package_activated_at,omitempty" description:"当前主套餐开始时间"` CurrentPackageExpiresAt *time.Time `json:"current_package_expires_at,omitempty" description:"当前主套餐过期时间"` diff --git a/internal/model/dto/client_order_dto.go b/internal/model/dto/client_order_dto.go index ea29722..135cb17 100644 --- a/internal/model/dto/client_order_dto.go +++ b/internal/model/dto/client_order_dto.go @@ -8,8 +8,8 @@ package dto type ClientCreateOrderRequest struct { Identifier string `json:"identifier" validate:"required,min=1,max=50" required:"true" minLength:"1" maxLength:"50" description:"资产标识符(SN/IMEI/虚拟号/ICCID/MSISDN)"` PackageIDs []uint `json:"package_ids" validate:"required,min=1,dive,gt=0" required:"true" description:"套餐ID列表"` - // PaymentMethod 指定支付方式;强充场景必传。wechat 时 app_type 也必传,alipay 时不需要 app_type - PaymentMethod string `json:"payment_method" validate:"omitempty,oneof=wechat alipay" description:"支付方式(强充必传)(wechat:微信, alipay:支付宝)"` + // PaymentMethod 指定新订单固化的支付方式;wechat 时 app_type 也必传,alipay 时不需要 app_type。 + PaymentMethod string `json:"payment_method" validate:"required,oneof=wallet wechat alipay" required:"true" description:"订单支付方式 (wallet:钱包, wechat:微信, alipay:支付宝)"` // AppType 仅强充 + 微信支付场景必传;支付宝强充无需传 AppType string `json:"app_type" validate:"omitempty,oneof=official_account miniapp" description:"应用类型(微信强充必传)(official_account:公众号, miniapp:小程序)"` } @@ -30,6 +30,7 @@ type ClientOrderInfo struct { OrderID uint `json:"order_id" description:"订单ID"` OrderNo string `json:"order_no" description:"订单号"` TotalAmount int64 `json:"total_amount" description:"订单总金额(分)"` + PaymentMethod string `json:"payment_method" description:"订单创建时选定的支付方式 (wallet:钱包, wechat:微信, alipay:支付宝)"` PaymentStatus int `json:"payment_status" description:"支付状态 (1:待支付, 2:已支付, 3:已取消, 4:已退款)"` PaymentStatusName string `json:"payment_status_name" description:"支付状态名称(中文)"` CreatedAt string `json:"created_at" description:"创建时间"` @@ -79,10 +80,15 @@ type ClientOrderListRequest struct { type ClientOrderListItem struct { OrderID uint `json:"order_id" description:"订单ID"` OrderNo string `json:"order_no" description:"订单号"` + AssetType string `json:"asset_type" description:"资产类型 (card:卡, device:设备)"` + AssetID uint `json:"asset_id" description:"资产ID"` + AssetIdentifier string `json:"asset_identifier" description:"下单时资产标识符快照(卡为ICCID,设备优先VirtualNo、其次IMEI)"` TotalAmount int64 `json:"total_amount" description:"订单总金额(分)"` + PaymentMethod string `json:"payment_method" description:"订单创建时选定的支付方式 (wallet:钱包, wechat:微信, alipay:支付宝)"` PaymentStatus int `json:"payment_status" description:"支付状态 (1:待支付, 2:已支付, 3:已取消, 4:已退款)"` PaymentStatusName string `json:"payment_status_name" description:"支付状态名称(中文)"` CreatedAt string `json:"created_at" description:"创建时间"` + PackageIDs []uint `json:"package_ids" description:"套餐ID列表,可用于发起续费"` PackageNames []string `json:"package_names" description:"套餐名称列表"` } @@ -102,6 +108,9 @@ type ClientOrderListResponse struct { type ClientOrderDetailResponse struct { OrderID uint `json:"order_id" description:"订单ID"` OrderNo string `json:"order_no" description:"订单号"` + AssetType string `json:"asset_type" description:"资产类型 (card:卡, device:设备)"` + AssetID uint `json:"asset_id" description:"资产ID"` + AssetIdentifier string `json:"asset_identifier" description:"下单时资产标识符快照(卡为ICCID,设备优先VirtualNo、其次IMEI)"` TotalAmount int64 `json:"total_amount" description:"订单总金额(分)"` PaymentStatus int `json:"payment_status" description:"支付状态 (1:待支付, 2:已支付, 3:已取消, 4:已退款)"` PaymentStatusName string `json:"payment_status_name" description:"支付状态名称(中文)"` @@ -135,7 +144,7 @@ type ClientPaymentLink struct { // ClientPayOrderRequest D4 订单支付请求 type ClientPayOrderRequest struct { - PaymentMethod string `json:"payment_method" validate:"required,oneof=wallet wechat alipay" required:"true" description:"支付方式 (wallet:钱包, wechat:微信, alipay:支付宝)"` + PaymentMethod string `json:"payment_method,omitempty" validate:"omitempty,oneof=wallet wechat alipay" description:"过渡兼容字段;为空时使用订单快照,非空时必须与订单快照一致"` // AppType 仅 payment_method=wechat 时必填,alipay 无需传 AppType string `json:"app_type" validate:"omitempty,oneof=official_account miniapp" description:"应用类型(微信支付必传)(official_account:公众号, miniapp:小程序)"` } diff --git a/internal/model/dto/client_wallet_dto.go b/internal/model/dto/client_wallet_dto.go index 974a22c..e28e71b 100644 --- a/internal/model/dto/client_wallet_dto.go +++ b/internal/model/dto/client_wallet_dto.go @@ -62,12 +62,13 @@ type ClientRechargeCheckRequest struct { // ClientRechargeCheckResponse C3 充值前校验响应 type ClientRechargeCheckResponse struct { - NeedForceRecharge bool `json:"need_force_recharge" description:"是否需要强制充值"` - ForceRechargeAmount int64 `json:"force_recharge_amount" description:"强制充值金额(分)"` - TriggerType string `json:"trigger_type" description:"触发类型"` - MinAmount int64 `json:"min_amount" description:"最小充值金额(分)"` - MaxAmount int64 `json:"max_amount" description:"最大充值金额(分)"` - Message string `json:"message" description:"提示信息"` + NeedForceRecharge bool `json:"need_force_recharge" description:"是否需要强制充值"` + ForceRechargeAmount int64 `json:"force_recharge_amount" description:"强制充值金额(分)"` + TriggerType string `json:"trigger_type" description:"触发类型"` + MinAmount int64 `json:"min_amount" description:"最小充值金额(分)"` + MaxAmount int64 `json:"max_amount" description:"最大充值金额(分)"` + Message string `json:"message" description:"提示信息"` + AllowedPaymentMethods []string `json:"allowed_payment_methods" description:"当前资产允许的支付方式列表 (wallet:钱包, wechat:微信, alipay:支付宝)"` } // ======================================== diff --git a/internal/model/dto/device_dto.go b/internal/model/dto/device_dto.go index 7d7e889..0f0ef49 100644 --- a/internal/model/dto/device_dto.go +++ b/internal/model/dto/device_dto.go @@ -3,25 +3,26 @@ package dto import "time" type ListDeviceRequest struct { - Page int `json:"page" query:"page" validate:"omitempty,min=1" minimum:"1" description:"页码"` - PageSize int `json:"page_size" query:"page_size" validate:"omitempty,min=1,max=100" minimum:"1" maximum:"100" description:"每页数量"` - VirtualNo string `json:"virtual_no" query:"virtual_no" validate:"omitempty,max=100" maxLength:"100" description:"虚拟号(模糊查询)"` - IMEI string `json:"imei" query:"imei" validate:"omitempty,max=20" maxLength:"20" description:"设备IMEI(模糊查询)"` - DeviceName string `json:"device_name" query:"device_name" validate:"omitempty,max=255" maxLength:"255" description:"设备名称(模糊查询)"` - Status *int `json:"status" query:"status" validate:"omitempty,min=1,max=2" minimum:"1" maximum:"2" description:"归属状态 (1:在库, 2:已分销)"` - ActivationStatus *int `json:"activation_status" query:"activation_status" validate:"omitempty,min=0,max=1" minimum:"0" maximum:"1" description:"激活状态 (0:未激活, 1:已激活;需有生效中主套餐且任意绑定卡已实名)"` - ShopID *uint `json:"shop_id" query:"shop_id" description:"店铺ID(兼容旧参数,单选;NULL表示平台库存)"` - ShopIDs []uint `json:"shop_ids" query:"shop_ids" validate:"omitempty,dive,min=1" description:"店铺ID列表(多选)"` - SeriesID *uint `json:"series_id" query:"series_id" description:"套餐系列ID"` - BatchNo string `json:"batch_no" query:"batch_no" validate:"omitempty,max=100" maxLength:"100" description:"批次号"` - DeviceType string `json:"device_type" query:"device_type" validate:"omitempty,max=50" maxLength:"50" description:"设备类型"` - Manufacturer string `json:"manufacturer" query:"manufacturer" validate:"omitempty,max=255" maxLength:"255" description:"制造商(模糊查询)"` - CreatedAtStart *time.Time `json:"created_at_start" query:"created_at_start" description:"创建时间起始"` - CreatedAtEnd *time.Time `json:"created_at_end" query:"created_at_end" description:"创建时间结束"` - HasActivePackage *bool `json:"has_active_package" query:"has_active_package" description:"是否有生效中的套餐(true:有生效中主套餐, false:无生效中主套餐)"` - Keyword string `json:"keyword" query:"keyword" validate:"omitempty,max=100" maxLength:"100" description:"关键字搜索,匹配虚拟号或IMEI(模糊查询,与virtual_no/imei独立)"` - AuthorizedEnterpriseID *uint `json:"authorized_enterprise_id" query:"authorized_enterprise_id" description:"按有效授权企业ID过滤(只返回当前授权给该企业的设备)"` - IsAuthorizedToEnterprise *bool `json:"is_authorized_to_enterprise" query:"is_authorized_to_enterprise" description:"企业授权状态过滤 (true:已授权给某企业, false:未授权任何企业)"` + Page int `json:"page" query:"page" validate:"omitempty,min=1" minimum:"1" description:"页码"` + PageSize int `json:"page_size" query:"page_size" validate:"omitempty,min=1,max=100" minimum:"1" maximum:"100" description:"每页数量"` + VirtualNo string `json:"virtual_no" query:"virtual_no" validate:"omitempty,max=100" maxLength:"100" description:"虚拟号(模糊查询)"` + IMEI string `json:"imei" query:"imei" validate:"omitempty,max=20" maxLength:"20" description:"设备IMEI(模糊查询)"` + DeviceName string `json:"device_name" query:"device_name" validate:"omitempty,max=255" maxLength:"255" description:"设备名称(模糊查询)"` + Status *int `json:"status" query:"status" validate:"omitempty,min=1,max=2" minimum:"1" maximum:"2" description:"归属状态 (1:在库, 2:已分销)"` + ActivationStatus *int `json:"activation_status" query:"activation_status" validate:"omitempty,min=0,max=1" minimum:"0" maximum:"1" description:"激活状态 (0:未激活, 1:已激活;需有生效中主套餐且任意绑定卡已实名)"` + RealNameStatus *int `json:"real_name_status" query:"real_name_status" validate:"omitempty,min=0,max=1" minimum:"0" maximum:"1" description:"实名状态 (0:未实名, 1:已实名;任意有效绑定卡已实名即视为设备已实名)"` + ShopID *uint `json:"shop_id" query:"shop_id" description:"店铺ID(兼容旧参数,单选;NULL表示平台库存)"` + ShopIDs []uint `json:"shop_ids" query:"shop_ids" validate:"omitempty,dive,min=1" description:"店铺ID列表(多选)"` + SeriesID *uint `json:"series_id" query:"series_id" description:"套餐系列ID"` + BatchNo string `json:"batch_no" query:"batch_no" validate:"omitempty,max=100" maxLength:"100" description:"批次号"` + DeviceType string `json:"device_type" query:"device_type" validate:"omitempty,max=50" maxLength:"50" description:"设备类型"` + Manufacturer string `json:"manufacturer" query:"manufacturer" validate:"omitempty,max=255" maxLength:"255" description:"制造商(模糊查询)"` + CreatedAtStart *time.Time `json:"created_at_start" query:"created_at_start" description:"创建时间起始"` + CreatedAtEnd *time.Time `json:"created_at_end" query:"created_at_end" description:"创建时间结束"` + HasActivePackage *bool `json:"has_active_package" query:"has_active_package" description:"是否有生效中的套餐(true:有生效中主套餐, false:无生效中主套餐)"` + Keyword string `json:"keyword" query:"keyword" validate:"omitempty,max=100" maxLength:"100" description:"关键字搜索,匹配虚拟号或IMEI(模糊查询,与virtual_no/imei独立)"` + AuthorizedEnterpriseID *uint `json:"authorized_enterprise_id" query:"authorized_enterprise_id" description:"按有效授权企业ID过滤(只返回当前授权给该企业的设备)"` + IsAuthorizedToEnterprise *bool `json:"is_authorized_to_enterprise" query:"is_authorized_to_enterprise" description:"企业授权状态过滤 (true:已授权给某企业, false:未授权任何企业)"` } type DeviceResponse struct { @@ -196,12 +197,6 @@ type BatchSetDeviceSeriesBindngResponse struct { FailedItems []DeviceSeriesBindngFailedItem `json:"failed_items" description:"失败详情列表"` } -// SetSpeedLimitRequest 设置设备限速请求 -type SetSpeedLimitRequest struct { - Identifier string `path:"identifier" description:"设备标识符(支持虚拟号/IMEI/SN)" required:"true"` - SpeedLimit int `json:"speed_limit" validate:"required,min=1" required:"true" minimum:"1" description:"限速值(KB/s)"` -} - // SetWiFiRequest 设置设备 WiFi 请求 type SetWiFiRequest struct { Identifier string `path:"identifier" description:"设备标识符(支持虚拟号/IMEI/SN)" required:"true"` diff --git a/internal/model/dto/device_import_dto.go b/internal/model/dto/device_import_dto.go index a3f4849..4c9d99e 100644 --- a/internal/model/dto/device_import_dto.go +++ b/internal/model/dto/device_import_dto.go @@ -14,19 +14,38 @@ type ImportDeviceResponse struct { Message string `json:"message" description:"提示信息"` } +// CreateDeviceBatchAllocationRequest 创建设备 CSV 批量分配任务请求。 +type CreateDeviceBatchAllocationRequest struct { + FileKey string `json:"file_key" validate:"required,min=1,max=500" required:"true" minLength:"1" maxLength:"500" description:"单列设备标识 CSV 对象存储 Key(每行支持 VirtualNo、IMEI 或 SN)"` + OperationType string `json:"operation_type" validate:"required,oneof=assign_shop assign_series" required:"true" enum:"assign_shop,assign_series" description:"操作类型 (assign_shop:分配目标代理, assign_series:设置套餐系列)"` + TargetID uint `json:"target_id" validate:"required,min=1" required:"true" minimum:"1" description:"目标代理店铺ID或套餐系列ID,由 operation_type 决定"` +} + +// CreateDeviceBatchAllocationResponse 创建设备 CSV 批量分配任务响应。 +type CreateDeviceBatchAllocationResponse struct { + TaskID uint `json:"task_id" description:"任务ID"` + TaskNo string `json:"task_no" description:"任务编号"` + Message string `json:"message" description:"提示信息"` +} + type ListDeviceImportTaskRequest struct { - Page int `json:"page" query:"page" validate:"omitempty,min=1" minimum:"1" description:"页码"` - PageSize int `json:"page_size" query:"page_size" validate:"omitempty,min=1,max=100" minimum:"1" maximum:"100" description:"每页数量"` - Status *int `json:"status" query:"status" validate:"omitempty,min=1,max=4" minimum:"1" maximum:"4" description:"任务状态 (1:待处理, 2:处理中, 3:已完成, 4:失败)"` - BatchNo string `json:"batch_no" query:"batch_no" validate:"omitempty,max=100" maxLength:"100" description:"批次号(模糊查询)"` - StartTime *time.Time `json:"start_time" query:"start_time" description:"创建时间起始"` - EndTime *time.Time `json:"end_time" query:"end_time" description:"创建时间结束"` + Page int `json:"page" query:"page" validate:"omitempty,min=1" minimum:"1" description:"页码"` + PageSize int `json:"page_size" query:"page_size" validate:"omitempty,min=1,max=100" minimum:"1" maximum:"100" description:"每页数量"` + Status *int `json:"status" query:"status" validate:"omitempty,min=1,max=4" minimum:"1" maximum:"4" description:"任务状态 (1:待处理, 2:处理中, 3:已完成, 4:失败)"` + OperationType string `json:"operation_type" query:"operation_type" validate:"omitempty,oneof=import assign_shop assign_series" enum:"import,assign_shop,assign_series" description:"任务业务类型 (import:导入设备, assign_shop:分配目标代理, assign_series:设置套餐系列)"` + BatchNo string `json:"batch_no" query:"batch_no" validate:"omitempty,max=100" maxLength:"100" description:"批次号(模糊查询)"` + StartTime *time.Time `json:"start_time" query:"start_time" description:"创建时间起始"` + EndTime *time.Time `json:"end_time" query:"end_time" description:"创建时间结束"` } type DeviceImportTaskResponse struct { ID uint `json:"id" description:"任务ID"` TaskNo string `json:"task_no" description:"任务编号"` + OperationType string `json:"operation_type" description:"任务业务类型 (import:导入设备, assign_shop:分配目标代理, assign_series:设置套餐系列)"` + OperationName string `json:"operation_name" description:"任务业务类型名称(中文)"` + TargetID *uint `json:"target_id,omitempty" description:"批量分配目标店铺或套餐系列ID"` Status int `json:"status" description:"任务状态 (1:待处理, 2:处理中, 3:已完成, 4:失败)"` + StatusName string `json:"status_name" description:"任务状态名称(中文)"` StatusText string `json:"status_text" description:"任务状态文本"` BatchNo string `json:"batch_no,omitempty" description:"批次号"` RealnamePolicy string `json:"realname_policy" description:"实名认证策略 (none:无需实名, before_order:先实名后充值/购买, after_order:先充值/购买后实名)"` @@ -51,9 +70,10 @@ type ListDeviceImportTaskResponse struct { } type DeviceImportResultItemDTO struct { - Line int `json:"line" description:"行号"` - VirtualNo string `json:"virtual_no" description:"设备虚拟号"` - Reason string `json:"reason" description:"原因"` + Line int `json:"line" description:"行号"` + VirtualNo string `json:"virtual_no" description:"设备虚拟号(兼容原设备导入任务字段)"` + DeviceIdentifier string `json:"device_identifier" description:"CSV原始设备标识(VirtualNo、IMEI或SN)"` + Reason string `json:"reason" description:"原因"` } type GetDeviceImportTaskRequest struct { diff --git a/internal/model/dto/exchange_dto.go b/internal/model/dto/exchange_dto.go index 2caf8f2..23fd5aa 100644 --- a/internal/model/dto/exchange_dto.go +++ b/internal/model/dto/exchange_dto.go @@ -97,6 +97,8 @@ type ExchangeOrderResponse struct { CreatedAt time.Time `json:"created_at" description:"创建时间"` UpdatedAt time.Time `json:"updated_at" description:"更新时间"` DeletedAt *time.Time `json:"deleted_at,omitempty" description:"删除时间"` + SubmitterID uint `json:"submitter_id" description:"提交人账号ID"` + SubmitterName string `json:"submitter_name" description:"提交人账号名称"` Creator uint `json:"creator" description:"创建人ID"` Updater uint `json:"updater" description:"更新人ID"` } diff --git a/internal/model/dto/export_task_dto.go b/internal/model/dto/export_task_dto.go index 97e45ae..e6f63f9 100644 --- a/internal/model/dto/export_task_dto.go +++ b/internal/model/dto/export_task_dto.go @@ -4,7 +4,7 @@ import "time" // CreateExportTaskRequest 创建导出任务请求。 type CreateExportTaskRequest struct { - Scene string `json:"scene" validate:"required,oneof=device iot_card order" required:"true" description:"导出场景 (device:设备, iot_card:IoT卡, order:订单)"` + Scene string `json:"scene" validate:"required,oneof=device iot_card order package agent_wallet_transaction agent_recharge refund exchange" required:"true" description:"导出场景 (device:设备, iot_card:IoT卡, order:订单, package:套餐, agent_wallet_transaction:代理主钱包流水, agent_recharge:代理充值, refund:退款, exchange:换货)"` Format string `json:"format" validate:"required,oneof=xlsx csv" required:"true" description:"导出格式 (xlsx:Excel, csv:CSV)"` Query map[string]interface{} `json:"query,omitempty" description:"导出筛选参数(JSON对象,可选)"` } @@ -22,7 +22,7 @@ type CreateExportTaskResponse struct { type ListExportTaskRequest struct { Page int `json:"page" query:"page" validate:"omitempty,min=1" minimum:"1" description:"页码"` PageSize int `json:"page_size" query:"page_size" validate:"omitempty,min=1,max=100" minimum:"1" maximum:"100" description:"每页数量"` - Scene string `json:"scene" query:"scene" validate:"omitempty,oneof=device iot_card order" description:"导出场景 (device:设备, iot_card:IoT卡, order:订单)"` + Scene string `json:"scene" query:"scene" validate:"omitempty,oneof=device iot_card order package agent_wallet_transaction agent_recharge refund exchange" description:"导出场景 (device:设备, iot_card:IoT卡, order:订单, package:套餐, agent_wallet_transaction:代理主钱包流水, agent_recharge:代理充值, refund:退款, exchange:换货)"` Status *int `json:"status" query:"status" validate:"omitempty,min=1,max=5" minimum:"1" maximum:"5" description:"任务状态 (1:待处理, 2:处理中, 3:已完成, 4:已失败, 5:已取消)"` StartTime *time.Time `json:"start_time" query:"start_time" description:"创建时间起始"` EndTime *time.Time `json:"end_time" query:"end_time" description:"创建时间结束"` @@ -33,7 +33,7 @@ type ExportTaskItem struct { ID uint `json:"id" description:"任务ID"` TaskID uint `json:"task_id" description:"任务ID"` TaskNo string `json:"task_no" description:"任务编号"` - Scene string `json:"scene" description:"导出场景 (device:设备, iot_card:IoT卡, order:订单)"` + Scene string `json:"scene" description:"导出场景 (device:设备, iot_card:IoT卡, order:订单, package:套餐, agent_wallet_transaction:代理主钱包流水, agent_recharge:代理充值, refund:退款, exchange:换货)"` Format string `json:"format" description:"导出格式 (xlsx:Excel, csv:CSV)"` Status int `json:"status" description:"任务状态 (1:待处理, 2:处理中, 3:已完成, 4:已失败, 5:已取消)"` StatusName string `json:"status_name" description:"任务状态名称(中文)"` diff --git a/internal/model/dto/iot_card_dto.go b/internal/model/dto/iot_card_dto.go index dfd2bb4..b5df894 100644 --- a/internal/model/dto/iot_card_dto.go +++ b/internal/model/dto/iot_card_dto.go @@ -3,29 +3,30 @@ package dto import "time" type ListStandaloneIotCardRequest struct { - Page int `json:"page" query:"page" validate:"omitempty,min=1" minimum:"1" description:"页码"` - PageSize int `json:"page_size" query:"page_size" validate:"omitempty,min=1,max=100" minimum:"1" maximum:"100" description:"每页数量"` - Status *int `json:"status" query:"status" validate:"omitempty,min=1,max=4" minimum:"1" maximum:"4" description:"状态 (1:在库, 2:已分销, 3:已激活, 4:已停用)"` - CarrierID *uint `json:"carrier_id" query:"carrier_id" description:"运营商ID"` - ShopID *uint `json:"shop_id" query:"shop_id" description:"分销商ID(兼容旧参数,单选)"` - ShopIDs []uint `json:"shop_ids" query:"shop_ids" validate:"omitempty,dive,min=1" description:"分销商ID列表(多选)"` - SeriesID *uint `json:"series_id" query:"series_id" description:"套餐系列ID"` - ICCID string `json:"iccid" query:"iccid" validate:"omitempty,max=20" maxLength:"20" description:"ICCID(模糊查询)"` - VirtualNo string `json:"virtual_no" query:"virtual_no" validate:"omitempty,max=100" maxLength:"100" description:"卡虚拟号(模糊查询)"` - MSISDN string `json:"msisdn" query:"msisdn" validate:"omitempty,max=20" maxLength:"20" description:"卡接入号(模糊查询)"` - IsStandalone *bool `json:"is_standalone" query:"is_standalone" description:"是否为独立卡(true:仅返回未绑定设备的卡, false:仅返回已绑定设备的卡, 不传:返回全部)"` - BatchNo string `json:"batch_no" query:"batch_no" validate:"omitempty,max=100" maxLength:"100" description:"批次号"` - PackageID *uint `json:"package_id" query:"package_id" description:"套餐ID"` - IsDistributed *bool `json:"is_distributed" query:"is_distributed" description:"是否已分销 (true:已分销, false:未分销)"` - IsReplaced *bool `json:"is_replaced" query:"is_replaced" description:"是否有换卡记录 (true:有换卡记录, false:无换卡记录)"` - HasActivePackage *bool `json:"has_active_package" query:"has_active_package" description:"是否有生效中的套餐 (true:有生效中套餐, false:无生效中套餐, 不传:不过滤)"` - ICCIDStart string `json:"iccid_start" query:"iccid_start" validate:"omitempty,max=20" maxLength:"20" description:"ICCID起始号"` - ICCIDEnd string `json:"iccid_end" query:"iccid_end" validate:"omitempty,max=20" maxLength:"20" description:"ICCID结束号"` - CarrierName string `json:"carrier_name" query:"carrier_name" validate:"omitempty,max=100" maxLength:"100" description:"运营商名称(模糊查询)"` - Keyword string `json:"keyword" query:"keyword" validate:"omitempty,max=100" maxLength:"100" description:"关键字搜索,匹配ICCID或卡虚拟号(模糊查询,与iccid/virtual_no独立)"` - NetworkStatus *int `json:"network_status" query:"network_status" validate:"omitempty,min=0,max=1" minimum:"0" maximum:"1" description:"网络状态 (0:停机, 1:开机)"` - AuthorizedEnterpriseID *uint `json:"authorized_enterprise_id" query:"authorized_enterprise_id" description:"按有效授权企业ID过滤(只返回当前授权给该企业的卡)"` - IsAuthorizedToEnterprise *bool `json:"is_authorized_to_enterprise" query:"is_authorized_to_enterprise" description:"企业授权状态过滤 (true:已授权给某企业, false:未授权任何企业)"` + Page int `json:"page" query:"page" validate:"omitempty,min=1" minimum:"1" description:"页码"` + PageSize int `json:"page_size" query:"page_size" validate:"omitempty,min=1,max=100" minimum:"1" maximum:"100" description:"每页数量"` + Status *int `json:"status" query:"status" validate:"omitempty,min=1,max=4" minimum:"1" maximum:"4" description:"状态 (1:在库, 2:已分销, 3:已激活, 4:已停用)"` + CarrierID *uint `json:"carrier_id" query:"carrier_id" description:"运营商ID"` + ShopID *uint `json:"shop_id" query:"shop_id" description:"分销商ID(兼容旧参数,单选)"` + ShopIDs []uint `json:"shop_ids" query:"shop_ids" validate:"omitempty,dive,min=1" description:"分销商ID列表(多选)"` + SeriesID *uint `json:"series_id" query:"series_id" description:"套餐系列ID"` + ICCID string `json:"iccid" query:"iccid" validate:"omitempty,max=20" maxLength:"20" description:"ICCID(模糊查询)"` + VirtualNo string `json:"virtual_no" query:"virtual_no" validate:"omitempty,max=100" maxLength:"100" description:"卡虚拟号(模糊查询)"` + MSISDN string `json:"msisdn" query:"msisdn" validate:"omitempty,max=20" maxLength:"20" description:"卡接入号(模糊查询)"` + IsStandalone *bool `json:"is_standalone" query:"is_standalone" description:"是否为独立卡(true:仅返回未绑定设备的卡, false:仅返回已绑定设备的卡, 不传:返回全部)"` + BatchNo string `json:"batch_no" query:"batch_no" validate:"omitempty,max=100" maxLength:"100" description:"批次号"` + PackageID *uint `json:"package_id" query:"package_id" description:"套餐ID"` + IsDistributed *bool `json:"is_distributed" query:"is_distributed" description:"是否已分销 (true:已分销, false:未分销)"` + IsReplaced *bool `json:"is_replaced" query:"is_replaced" description:"是否有换卡记录 (true:有换卡记录, false:无换卡记录)"` + HasActivePackage *bool `json:"has_active_package" query:"has_active_package" description:"是否有生效中的套餐 (true:有生效中套餐, false:无生效中套餐, 不传:不过滤)"` + ICCIDStart string `json:"iccid_start" query:"iccid_start" validate:"omitempty,max=20" maxLength:"20" description:"ICCID起始号"` + ICCIDEnd string `json:"iccid_end" query:"iccid_end" validate:"omitempty,max=20" maxLength:"20" description:"ICCID结束号"` + CarrierName string `json:"carrier_name" query:"carrier_name" validate:"omitempty,max=100" maxLength:"100" description:"运营商名称(模糊查询)"` + Keyword string `json:"keyword" query:"keyword" validate:"omitempty,max=100" maxLength:"100" description:"关键字搜索,匹配ICCID或卡虚拟号(模糊查询,与iccid/virtual_no独立)"` + NetworkStatus *int `json:"network_status" query:"network_status" validate:"omitempty,min=0,max=1" minimum:"0" maximum:"1" description:"网络状态 (0:停机, 1:开机)"` + RealNameStatus *int `json:"real_name_status" query:"real_name_status" validate:"omitempty,min=0,max=1" minimum:"0" maximum:"1" description:"实名状态 (0:未实名, 1:已实名)"` + AuthorizedEnterpriseID *uint `json:"authorized_enterprise_id" query:"authorized_enterprise_id" description:"按有效授权企业ID过滤(只返回当前授权给该企业的卡)"` + IsAuthorizedToEnterprise *bool `json:"is_authorized_to_enterprise" query:"is_authorized_to_enterprise" description:"企业授权状态过滤 (true:已授权给某企业, false:未授权任何企业)"` } type StandaloneIotCardResponse struct { @@ -159,6 +160,21 @@ type GetIotCardByICCIDRequest struct { ICCID string `path:"iccid" description:"ICCID" required:"true"` } +// SetIotCardSpeedTierRequest 设置 IoT 卡固定限速档位请求。 +type SetIotCardSpeedTierRequest struct { + ICCID string `path:"iccid" description:"IoT 卡 ICCID" required:"true"` + Code *int `json:"code" validate:"required,oneof=-1 0 1 2 3 4 5 6 7 8" required:"true" enum:"-1,0,1,2,3,4,5,6,7,8" description:"固定限速档位 (-1:恢复不限速, 0:0kbps, 1:128Kbps, 2:512Kbps, 3:1Mbps, 4:2Mbps, 5:10Mbps, 6:20Mbps, 7:50Mbps, 8:100Mbps)"` +} + +// SetIotCardSpeedTierResponse 设置 IoT 卡固定限速档位响应。 +type SetIotCardSpeedTierResponse struct { + IotCardID uint `json:"iot_card_id" description:"IoT 卡ID"` + ICCID string `json:"iccid" description:"实际调用 Gateway 的卡 ICCID"` + Code int `json:"code" description:"固定限速档位编码 (-1:恢复不限速, 0:0kbps, 1:128Kbps, 2:512Kbps, 3:1Mbps, 4:2Mbps, 5:10Mbps, 6:20Mbps, 7:50Mbps, 8:100Mbps)"` + SpeedTierName string `json:"speed_tier_name" description:"固定限速档位名称(中文)"` + IntegrationID string `json:"integration_id" description:"Gateway 外部交互记录ID"` +} + type IotCardDetailResponse struct { StandaloneIotCardResponse } diff --git a/internal/model/dto/notification_dto.go b/internal/model/dto/notification_dto.go index 4e657e5..4c9052e 100644 --- a/internal/model/dto/notification_dto.go +++ b/internal/model/dto/notification_dto.go @@ -26,9 +26,9 @@ type NotificationItem struct { Severity string `json:"severity" description:"通知级别 (info:提示, warning:警告, error:错误, critical:严重)"` Title string `json:"title" description:"纯文本标题"` Body string `json:"body" description:"纯文本正文"` - RefType string `json:"ref_type" description:"受控资源类型"` - RefID string `json:"ref_id" description:"受控资源ID"` - RefKey string `json:"ref_key" description:"受控资源Key"` + RefType string `json:"ref_type" description:"受控资源类型;可能为空。可选值及含义:system_config:系统配置, integration_log:外部集成日志, package:套餐, asset:C端资产, refund:退款, agent_recharge:代理充值, wecom_approval:企微审批, iot_card:物联网卡, device:设备, expiring_asset:临期资产列表, shop_fund:店铺资金概况, card_sync:卡同步记录。后台点击通知应调用目标解析接口,不得直接拼接路由"` + RefID string `json:"ref_id" description:"受控资源数字ID的十进制字符串;可能为空。refund、agent_recharge、wecom_approval、iot_card、device、expiring_asset、shop_fund、asset 等类型使用;仅用于资源定位,不是前端URL"` + RefKey string `json:"ref_key" description:"受控资源稳定Key或展示快照;可能为空。system_config 为配置Key,integration_log/card_sync 为集成标识,asset 为资产标识快照;仅用于定位或展示,不是前端URL"` IsRead bool `json:"is_read" description:"是否已读"` ReadAt *time.Time `json:"read_at,omitempty" description:"首次已读时间(ISO 8601)"` CreatedAt time.Time `json:"created_at" description:"创建时间(ISO 8601)"` @@ -54,10 +54,10 @@ type NotificationReadResponse struct { // NotificationTargetResponse 是通知受控目标解析结果,不包含任意 URL。 type NotificationTargetResponse struct { - TargetType string `json:"target_type" description:"前端白名单目标类型;空表示不支持跳转"` - TargetID *uint `json:"target_id,omitempty" description:"受控数值目标ID"` - TargetKey string `json:"target_key,omitempty" description:"受控稳定目标Key"` - Available bool `json:"available" description:"当前账号是否仍可访问目标"` + TargetType string `json:"target_type" description:"前端白名单目标类型;空表示不支持跳转。可选值:refund_detail、agent_recharge_detail、wecom_approval_detail、iot_card_detail、device_detail、expiring_asset_list、shop_fund_summary、integration_log、system_config"` + TargetID *uint `json:"target_id,omitempty" description:"ID型目标的业务主键;前端按 target_type 映射受控页面,不得自行拼接任意URL"` + TargetKey string `json:"target_key,omitempty" description:"Key型目标的稳定定位值;仅用于 integration_log 或 system_config 等白名单目标"` + Available bool `json:"available" description:"当前账号是否仍可访问目标;false 时只展示通知正文,不执行跳转"` } // NotificationUnreadSummaryResponse 是后台账号未读通知的固定分类汇总。 @@ -81,8 +81,9 @@ type NotificationReadAllResponse struct { // PersonalNotificationListRequest 是个人客户通知的简化分页参数。 type PersonalNotificationListRequest struct { - Page int `json:"page" query:"page" validate:"omitempty,min=1,max=10000" minimum:"1" maximum:"10000" description:"页码,默认 1,最大 10000"` - PageSize int `json:"page_size" query:"page_size" validate:"omitempty,min=1,max=50" minimum:"1" maximum:"50" description:"每页数量,默认 20,最大 50"` + IsRead *bool `json:"is_read" query:"is_read" description:"已读状态;false 仅查询未读,true 仅查询已读,不传时查询全部"` + Page int `json:"page" query:"page" validate:"omitempty,min=1,max=10000" minimum:"1" maximum:"10000" description:"页码,默认 1,最大 10000"` + PageSize int `json:"page_size" query:"page_size" validate:"omitempty,min=1,max=50" minimum:"1" maximum:"50" description:"每页数量,默认 20,最大 50"` } // PersonalNotificationListResponse 是个人客户通知的简化分页结果。 diff --git a/internal/model/dto/order_dto.go b/internal/model/dto/order_dto.go index 6744eff..929f902 100644 --- a/internal/model/dto/order_dto.go +++ b/internal/model/dto/order_dto.go @@ -12,9 +12,9 @@ type CreateOrderRequest struct { // CreateAdminOrderRequest 后台订单创建请求(仅允许 wallet/offline) type CreateAdminOrderRequest struct { - Identifier string `json:"identifier" validate:"required,min=1,max=100" required:"true" minLength:"1" maxLength:"100" description:"资产标识符(ICCID 或 VirtualNo)"` - PackageIDs []uint `json:"package_ids" validate:"required,min=1,max=10,dive,min=1" required:"true" minItems:"1" maxItems:"10" description:"套餐ID列表"` - PaymentMethod string `json:"payment_method" validate:"required,oneof=wallet offline" required:"true" description:"支付方式 (wallet:钱包支付, offline:线下支付)"` + Identifier string `json:"identifier" validate:"required,min=1,max=100" required:"true" minLength:"1" maxLength:"100" description:"资产标识符(卡支持 ICCID,设备支持 VirtualNo、IMEI 或 SN)"` + PackageIDs []uint `json:"package_ids" validate:"required,min=1,max=10,dive,min=1" required:"true" minItems:"1" maxItems:"10" description:"套餐ID列表"` + PaymentMethod string `json:"payment_method" validate:"required,oneof=wallet offline" required:"true" description:"支付方式 (wallet:钱包支付, offline:线下支付)"` PaymentVoucherKey []string `json:"payment_voucher_key" validate:"omitempty,max=5,dive,max=500" maxItems:"5" description:"线下支付凭证对象存储file_key列表(payment_method=offline时至少1个,最多5个,通过/storage/upload-url上传图片后获得)"` } @@ -93,7 +93,7 @@ type OrderResponse struct { IsExpired bool `json:"is_expired" description:"是否已过期"` // 资产标识符快照 - AssetIdentifier string `json:"asset_identifier,omitempty" description:"下单时资产的标识符快照(ICCID 或 VirtualNo)"` + AssetIdentifier string `json:"asset_identifier,omitempty" description:"下单时资产的标识符快照(卡为 ICCID,设备优先使用 VirtualNo,缺失时使用 IMEI)"` AssetType string `json:"asset_type,omitempty" description:"资产类型 (card:单卡, device:设备)"` } diff --git a/internal/model/dto/package_expiry_dto.go b/internal/model/dto/package_expiry_dto.go index d1b8e56..e20fa91 100644 --- a/internal/model/dto/package_expiry_dto.go +++ b/internal/model/dto/package_expiry_dto.go @@ -10,3 +10,50 @@ type PackageExpiryEstimate struct { ExpiryEstimateStatusName string `json:"expiry_estimate_status_name" description:"预计套餐到期推算状态名称(中文)"` IsExpiring bool `json:"is_expiring" description:"是否临期(仅精确推算且剩余 0 至 15 个上海自然日时为 true)"` } + +// ExpiringAssetListRequest 临期资产分页查询参数。 +type ExpiringAssetListRequest struct { + AssetType string `query:"asset_type" validate:"omitempty,oneof=iot_card device" enums:"iot_card,device" description:"资产类型 (iot_card:物联网卡, device:设备)"` + Keyword string `query:"keyword" validate:"omitempty,max=100" description:"资产标识关键词,匹配卡 ICCID/MSISDN/虚拟号或设备虚拟号/IMEI"` + ShopID *uint `query:"shop_id" validate:"omitempty,gt=0" description:"店铺 ID,只能缩小当前账号的数据权限范围"` + PackageID *uint `query:"package_id" validate:"omitempty,gt=0" description:"最终排队套餐 ID"` + DaysMin *int `query:"days_min" validate:"omitempty,min=0,max=15" description:"最小剩余上海自然日天数,范围 0 至 15"` + DaysMax *int `query:"days_max" validate:"omitempty,min=0,max=15" description:"最大剩余上海自然日天数,范围 0 至 15"` + ExpiresFrom string `query:"expires_from" validate:"omitempty,datetime=2006-01-02" description:"预计到期开始日期(上海自然日,格式 YYYY-MM-DD)"` + ExpiresTo string `query:"expires_to" validate:"omitempty,datetime=2006-01-02" description:"预计到期结束日期(上海自然日,格式 YYYY-MM-DD)"` + Page int `query:"page" validate:"omitempty,min=1" default:"1" description:"页码"` + PageSize int `query:"page_size" validate:"omitempty,min=1,max=100" default:"20" description:"每页数量,最大 100"` +} + +// ExpiringAssetItem 临期资产列表项。 +type ExpiringAssetItem struct { + AssetType string `json:"asset_type" description:"资产类型 (iot_card:物联网卡, device:设备)"` + AssetID uint `json:"asset_id" description:"资产 ID"` + Identifier string `json:"identifier" description:"稳定资产标识,卡返回 ICCID,设备优先返回虚拟号、为空时返回 IMEI"` + ShopID *uint `json:"shop_id" description:"所属店铺 ID,平台库存为 null"` + ShopName string `json:"shop_name" description:"所属店铺名称,平台库存为空字符串"` + PackageUsageID uint `json:"package_usage_id" description:"最终到期队列末条主套餐使用记录 ID"` + PackageID uint `json:"package_id" description:"最终到期队列末条套餐 ID"` + PackageName string `json:"package_name" description:"最终到期队列末条套餐名称"` + PackageExpiryEstimate + ExpiryLevel string `json:"expiry_level" description:"临期高亮等级 (pink:8至15天, purple:4至7天, red:0至3天)"` + ExpiryLevelName string `json:"expiry_level_name" description:"临期高亮等级名称(中文)"` + IsPriority bool `json:"is_priority" description:"是否优先展示,剩余 0 至 3 天时为 true"` +} + +// ExpiringAssetSummary 临期资产数量汇总。 +type ExpiringAssetSummary struct { + CardCount int64 `json:"card_count" description:"临期物联网卡数量"` + DeviceCount int64 `json:"device_count" description:"临期设备数量"` + TotalCount int64 `json:"total_count" description:"临期资产总数"` + WindowDays int `json:"window_days" description:"临期统计窗口天数,固定为 15"` +} + +// ExpiringAssetListResponse 临期资产分页响应。 +type ExpiringAssetListResponse struct { + Items []ExpiringAssetItem `json:"items" description:"临期资产列表"` + Total int64 `json:"total" description:"符合条件的总数量"` + Page int `json:"page" description:"当前页码"` + Size int `json:"size" description:"每页数量"` + Summary ExpiringAssetSummary `json:"summary" description:"同一筛选与权限范围内的卡、设备数量汇总"` +} diff --git a/internal/model/dto/refund_dto.go b/internal/model/dto/refund_dto.go index 5d13754..2b94de5 100644 --- a/internal/model/dto/refund_dto.go +++ b/internal/model/dto/refund_dto.go @@ -2,12 +2,12 @@ package dto // CreateRefundRequest 创建退款申请请求 type CreateRefundRequest struct { - OrderID uint `json:"order_id" validate:"required" required:"true" description:"关联订单ID"` - ActualReceivedAmount int64 `json:"actual_received_amount" validate:"required,min=1" required:"true" minimum:"1" description:"实收金额(分)"` - RequestedRefundAmount int64 `json:"requested_refund_amount" validate:"required,min=1" required:"true" minimum:"1" description:"申请退款金额(分)"` + OrderID uint `json:"order_id" validate:"required" required:"true" description:"关联订单ID"` + ActualReceivedAmount int64 `json:"actual_received_amount" validate:"required,min=1" required:"true" minimum:"1" description:"实收金额(分)"` + RequestedRefundAmount int64 `json:"requested_refund_amount" validate:"required,min=1" required:"true" minimum:"1" description:"申请退款金额(分)"` RefundVoucherKey []string `json:"refund_voucher_key" validate:"required,min=1,max=5,dive,max=500" required:"true" minItems:"1" maxItems:"5" description:"退款凭证对象存储file_key列表(至少1个,最多5个,通过/storage/upload-url上传图片后获得)"` - RefundReason string `json:"refund_reason" validate:"omitempty,max=1000" maxLength:"1000" description:"退款原因"` - PackageUsageID *uint `json:"package_usage_id" validate:"omitempty" description:"关联套餐使用记录ID(可选)"` + RefundReason string `json:"refund_reason" validate:"omitempty,max=1000" maxLength:"1000" description:"退款原因"` + PackageUsageID *uint `json:"package_usage_id" validate:"omitempty" description:"关联套餐使用记录ID(可选)"` } // RefundIDRequest 退款申请ID路径参数 @@ -24,11 +24,11 @@ type RejectRefundRequest struct { // ResubmitRefundRequest 重新提交退款申请请求 // 退款单被退回后,可修改部分字段后重新提交 type ResubmitRefundRequest struct { - ID uint `json:"-" params:"id" path:"id" validate:"required" description:"退款申请ID"` - ActualReceivedAmount *int64 `json:"actual_received_amount" validate:"omitempty,min=1" minimum:"1" description:"实收金额(分)"` - RequestedRefundAmount *int64 `json:"requested_refund_amount" validate:"omitempty,min=1" minimum:"1" description:"申请退款金额(分)"` + ID uint `json:"-" params:"id" path:"id" validate:"required" description:"退款申请ID"` + ActualReceivedAmount *int64 `json:"actual_received_amount" validate:"omitempty,min=1" minimum:"1" description:"实收金额(分)"` + RequestedRefundAmount *int64 `json:"requested_refund_amount" validate:"omitempty,min=1" minimum:"1" description:"申请退款金额(分)"` RefundVoucherKey *[]string `json:"refund_voucher_key" validate:"omitempty,max=5,dive,max=500" maxItems:"5" description:"退款凭证对象存储file_key列表(重新提交时可替换;历史记录缺失时必填,最多5个)"` - RefundReason *string `json:"refund_reason" validate:"omitempty,max=1000" maxLength:"1000" description:"退款原因"` + RefundReason *string `json:"refund_reason" validate:"omitempty,max=1000" maxLength:"1000" description:"退款原因"` } // ApproveRefundRequest 审批通过退款申请请求 @@ -56,34 +56,40 @@ type RefundListRequest struct { // RefundResponse 退款申请详情响应 type RefundResponse struct { - ID uint `json:"id" description:"退款申请ID"` - RefundNo string `json:"refund_no" description:"退款单号"` - OrderID uint `json:"order_id" description:"关联订单ID"` - OrderNo string `json:"order_no" description:"订单号"` - AssetIdentifier string `json:"asset_identifier,omitempty" description:"下单时资产的标识符快照(ICCID 或 VirtualNo)"` - AssetType string `json:"asset_type,omitempty" description:"资产类型 (card:单卡, device:设备)"` - IotCardID *uint `json:"iot_card_id,omitempty" description:"IoT卡ID"` - DeviceID *uint `json:"device_id,omitempty" description:"设备ID"` - PackageUsageID *uint `json:"package_usage_id,omitempty" description:"关联套餐使用记录ID"` - ShopID *uint `json:"shop_id,omitempty" description:"店铺ID"` - ShopName string `json:"shop_name,omitempty" description:"店铺名称"` - ActualReceivedAmount int64 `json:"actual_received_amount" description:"实收金额(分)"` - RequestedRefundAmount int64 `json:"requested_refund_amount" description:"申请退款金额(分)"` - ApprovedRefundAmount *int64 `json:"approved_refund_amount,omitempty" description:"审批实际退款金额(分)"` + ID uint `json:"id" description:"退款申请ID"` + RefundNo string `json:"refund_no" description:"退款单号"` + OrderID uint `json:"order_id" description:"关联订单ID"` + OrderNo string `json:"order_no" description:"订单号"` + AssetIdentifier string `json:"asset_identifier,omitempty" description:"下单时资产的标识符快照(卡为 ICCID,设备优先使用 VirtualNo,缺失时使用 IMEI)"` + AssetType string `json:"asset_type,omitempty" description:"资产类型 (card:单卡, device:设备)"` + IotCardID *uint `json:"iot_card_id,omitempty" description:"IoT卡ID"` + DeviceID *uint `json:"device_id,omitempty" description:"设备ID"` + PackageUsageID *uint `json:"package_usage_id,omitempty" description:"关联套餐使用记录ID"` + ShopID *uint `json:"shop_id,omitempty" description:"店铺ID"` + ShopName string `json:"shop_name,omitempty" description:"店铺名称"` + ActualReceivedAmount int64 `json:"actual_received_amount" description:"实收金额(分)"` + RequestedRefundAmount int64 `json:"requested_refund_amount" description:"申请退款金额(分)"` + ApprovedRefundAmount *int64 `json:"approved_refund_amount,omitempty" description:"审批实际退款金额(分)"` RefundVoucherKey []string `json:"refund_voucher_key" description:"退款凭证对象存储file_key列表(最多5个)"` - RefundReason string `json:"refund_reason" description:"退款原因"` - Status int `json:"status" description:"状态 (1:待审批, 2:已通过, 3:已拒绝, 4:已退回)"` - StatusName string `json:"status_name" description:"状态名称(中文)"` - ProcessorID *uint `json:"processor_id,omitempty" description:"审批人ID"` - ProcessedAt string `json:"processed_at,omitempty" description:"审批时间"` - RejectReason string `json:"reject_reason,omitempty" description:"拒绝原因"` - Remark string `json:"remark,omitempty" description:"审批备注"` - CommissionDeducted bool `json:"commission_deducted" description:"佣金是否已回扣"` - AssetReset bool `json:"asset_reset" description:"退款后资产处理是否完成"` - Creator uint `json:"creator" description:"创建人ID"` - Updater uint `json:"updater" description:"更新人ID"` - CreatedAt string `json:"created_at" description:"创建时间"` - UpdatedAt string `json:"updated_at" description:"更新时间"` + RefundReason string `json:"refund_reason" description:"退款原因"` + Status int `json:"status" description:"状态 (1:待审批, 2:已通过, 3:已拒绝, 4:已退回)"` + StatusName string `json:"status_name" description:"状态名称(中文)"` + ProcessorID *uint `json:"processor_id,omitempty" description:"审批人ID"` + ProcessedAt string `json:"processed_at,omitempty" description:"审批时间"` + RejectReason string `json:"reject_reason,omitempty" description:"拒绝原因"` + Remark string `json:"remark,omitempty" description:"审批备注"` + CommissionDeducted bool `json:"commission_deducted" description:"佣金是否已回扣"` + AssetReset bool `json:"asset_reset" description:"退款后资产处理是否完成"` + SubmitterID uint `json:"submitter_id" description:"提交人账号ID"` + SubmitterName string `json:"submitter_name" description:"提交人账号名称"` + ApprovalInstanceID *uint `json:"approval_instance_id,omitempty" description:"通用审批实例ID"` + ApprovalProvider string `json:"approval_provider,omitempty" description:"审批渠道,企业微信为wecom"` + ApprovalStatus *int `json:"approval_status,omitempty" description:"审批状态 (0:提交中, 1:审批中, 2:已通过, 3:已拒绝, 4:已撤销, 5:通过后撤销, 6:已删除, 7:提交失败, 8:提交结果未知)"` + ApprovalStatusName string `json:"approval_status_name,omitempty" description:"审批状态名称(中文)"` + Creator uint `json:"creator" description:"创建人ID"` + Updater uint `json:"updater" description:"更新人ID"` + CreatedAt string `json:"created_at" description:"创建时间"` + UpdatedAt string `json:"updated_at" description:"更新时间"` } // RefundListResponse 退款申请列表分页响应 diff --git a/internal/model/dto/shop_dto.go b/internal/model/dto/shop_dto.go index ca2a34d..caccfdf 100644 --- a/internal/model/dto/shop_dto.go +++ b/internal/model/dto/shop_dto.go @@ -62,6 +62,7 @@ type UpdateShopRequest struct { District string `json:"district" validate:"omitempty,max=50" maxLength:"50" description:"区县"` Address string `json:"address" validate:"omitempty,max=255" maxLength:"255" description:"详细地址"` Status int `json:"status" validate:"oneof=0 1" required:"true" description:"状态 (0:禁用, 1:启用)"` + ClientLoginDisabled *bool `json:"client_login_disabled" description:"是否禁止该店铺资产发起新的 C 端登录;不传保持不变"` } // UnmarshalJSON 解析更新店铺请求并保留业务员字段“缺失”和“显式 null”的差异。 @@ -103,6 +104,7 @@ type ShopResponse struct { Address string `json:"address" description:"详细地址"` Status int `json:"status" description:"状态 (0:禁用, 1:启用)"` StatusName string `json:"status_name" description:"状态名称(中文)"` + ClientLoginDisabled bool `json:"client_login_disabled" description:"是否禁止该店铺资产发起新的 C 端登录"` CreatedAt string `json:"created_at" description:"创建时间"` UpdatedAt string `json:"updated_at" description:"更新时间"` } diff --git a/internal/model/dto/storage_dto.go b/internal/model/dto/storage_dto.go index aacf1b7..46304e3 100644 --- a/internal/model/dto/storage_dto.go +++ b/internal/model/dto/storage_dto.go @@ -3,7 +3,7 @@ package dto type GetUploadURLRequest struct { FileName string `json:"file_name" validate:"required,min=1,max=255" required:"true" minLength:"1" maxLength:"255" description:"文件名(如:cards.csv)"` ContentType string `json:"content_type" validate:"omitempty,max=100" maxLength:"100" description:"文件 MIME 类型(如:text/csv),留空则自动推断"` - Purpose string `json:"purpose" validate:"required,oneof=iot_import export attachment" required:"true" description:"文件用途 (iot_import:ICCID导入, export:数据导出, attachment:附件)"` + Purpose string `json:"purpose" validate:"required,oneof=iot_import export attachment batch_purchase device_batch_allocation" required:"true" enum:"iot_import,export,attachment,batch_purchase,device_batch_allocation" description:"文件用途 (iot_import:ICCID导入, export:数据导出, attachment:附件, batch_purchase:资产套餐批量订购CSV, device_batch_allocation:设备批量分配CSV)"` } type GetUploadURLResponse struct { diff --git a/internal/model/dto/system_config_dto.go b/internal/model/dto/system_config_dto.go index 537c693..4578063 100644 --- a/internal/model/dto/system_config_dto.go +++ b/internal/model/dto/system_config_dto.go @@ -4,7 +4,7 @@ import "time" // SystemConfigListRequest 是系统配置分页查询参数。 type SystemConfigListRequest struct { - Module string `json:"module" query:"module" validate:"omitempty,max=100" description:"模块筛选"` + Module string `json:"module" query:"module" validate:"omitempty,oneof=carrier_callback c2b.payment" enum:"carrier_callback,c2b.payment" description:"模块筛选 (carrier_callback:运营商回调配置, c2b.payment:C端支付方式配置)"` Page int `json:"page" query:"page" validate:"omitempty,min=1" minimum:"1" description:"页码"` PageSize int `json:"page_size" query:"page_size" validate:"omitempty,min=1,max=100" minimum:"1" maximum:"100" description:"每页数量"` } @@ -14,7 +14,7 @@ type SystemConfigItem struct { ConfigKey string `json:"config_key" description:"稳定配置 Key"` Value string `json:"value" description:"配置值;敏感值按注册策略脱敏"` ValueType string `json:"value_type" description:"值类型 (string:字符串, int:整数, bool:布尔, json:JSON)"` - Module string `json:"module" description:"所属模块"` + Module string `json:"module" enum:"carrier_callback,c2b.payment" description:"所属模块 (carrier_callback:运营商回调配置, c2b.payment:C端支付方式配置)"` Description string `json:"description" description:"中文说明"` Readonly bool `json:"readonly" description:"是否只读"` Sensitive bool `json:"sensitive" description:"是否敏感"` diff --git a/internal/model/dto/wecom_dto.go b/internal/model/dto/wecom_dto.go new file mode 100644 index 0000000..e422eac --- /dev/null +++ b/internal/model/dto/wecom_dto.go @@ -0,0 +1,72 @@ +package dto + +import "time" + +// SaveWeComApplicationRequest 保存企业微信自建应用请求。 +type SaveWeComApplicationRequest struct { + CorpID string `json:"corp_id" validate:"required,min=1,max=64" description:"企业微信企业 ID"` + AgentID int64 `json:"agent_id" validate:"required,gt=0" description:"企业微信自建应用 AgentID"` + Name string `json:"name" validate:"required,min=1,max=100" description:"应用展示名称"` + Secret string `json:"secret" validate:"required,min=1,max=512" description:"应用 Secret,管理端使用明文传入,服务端加密保存"` + CallbackToken string `json:"callback_token" validate:"required,min=1,max=512" description:"回调 Token,管理端使用明文传入,服务端加密保存"` + EncodingAESKey string `json:"encoding_aes_key" validate:"required,len=43" description:"回调 EncodingAESKey,管理端使用明文传入,服务端加密保存"` + Status int `json:"status" validate:"oneof=0 1" enum:"0,1" description:"状态 (0:禁用, 1:启用)"` +} + +// WeComApplicationListRequest 查询企业微信应用配置列表请求。 +type WeComApplicationListRequest struct { + Page int `json:"page" query:"page" validate:"omitempty,min=1" minimum:"1" default:"1" description:"页码,默认 1"` + PageSize int `json:"page_size" query:"page_size" validate:"omitempty,min=1,max=100" minimum:"1" maximum:"100" default:"20" description:"每页数量,默认 20,最大 100"` +} + +// WeComApplicationResponse 企业微信应用配置响应,仅允许超级管理员读取明文凭据。 +type WeComApplicationResponse struct { + ID uint `json:"id" description:"应用配置 ID"` + CorpID string `json:"corp_id" description:"企业微信企业 ID"` + AgentID int64 `json:"agent_id" description:"企业微信自建应用 AgentID"` + Name string `json:"name" description:"应用展示名称"` + Secret string `json:"secret" description:"应用 Secret 明文,仅超级管理员可见"` + CallbackToken string `json:"callback_token" description:"回调 Token 明文,仅超级管理员可见"` + EncodingAESKey string `json:"encoding_aes_key" description:"回调 EncodingAESKey 明文,仅超级管理员可见"` + DefaultCreatorUserID string `json:"default_creator_userid" description:"代理等非企微账号发起审批时使用的默认企微成员 userid"` + DefaultCreatorName string `json:"default_creator_name" description:"默认企微审批发起人姓名快照"` + Status int `json:"status" description:"状态 (0:禁用, 1:启用)"` + StatusName string `json:"status_name" description:"状态名称(中文)"` + CredentialsSet bool `json:"credentials_set" description:"Secret、回调 Token 和 EncodingAESKey 是否均已配置"` + LastConnectedAt *time.Time `json:"last_connected_at" description:"最近一次成功取得 access_token 的时间"` + CreatedAt time.Time `json:"created_at" description:"创建时间"` + UpdatedAt time.Time `json:"updated_at" description:"更新时间"` +} + +// SaveWeComDefaultCreatorRequest 保存企业微信应用默认审批发起人请求。 +type SaveWeComDefaultCreatorRequest struct { + UserID string `json:"userid" validate:"required,min=1,max=64" description:"从应用当前可见成员中选择的默认发起人 userid"` +} + +// SaveWeComDefaultCreatorParams 保存默认审批发起人的路径与请求参数。 +type SaveWeComDefaultCreatorParams struct { + ID uint `path:"id" required:"true" description:"企业微信应用配置 ID"` + SaveWeComDefaultCreatorRequest +} + +// WeComApplicationListResponse 企业微信应用列表响应。 +type WeComApplicationListResponse struct { + Items []WeComApplicationResponse `json:"items" description:"企业微信应用配置列表"` + Total int64 `json:"total" description:"总数量"` + Page int `json:"page" description:"当前页码"` + PageSize int `json:"page_size" description:"每页数量"` +} + +// WeComConnectionTestResponse 企业微信连接测试响应。 +type WeComConnectionTestResponse struct { + Success bool `json:"success" description:"是否成功取得 access_token"` +} + +// WeComApprovalCallbackRequest 企业微信审批回调路径与签名参数。 +type WeComApprovalCallbackRequest struct { + ApplicationID uint `path:"application_id" required:"true" minimum:"1" description:"企业微信应用配置 ID"` + MsgSignature string `query:"msg_signature" required:"true" description:"企业微信回调消息签名"` + Timestamp string `query:"timestamp" required:"true" description:"企业微信回调时间戳"` + Nonce string `query:"nonce" required:"true" description:"企业微信回调随机串"` + EchoStr string `query:"echostr" description:"GET 回调地址验证使用的加密随机串"` +} diff --git a/internal/model/dto/wecom_member_dto.go b/internal/model/dto/wecom_member_dto.go new file mode 100644 index 0000000..0ab7dd3 --- /dev/null +++ b/internal/model/dto/wecom_member_dto.go @@ -0,0 +1,41 @@ +package dto + +import "time" + +// WeComMemberListRequest 查询企业微信应用可见成员请求。 +type WeComMemberListRequest struct { + Page int `json:"page" query:"page" validate:"omitempty,min=1" minimum:"1" default:"1" description:"页码,默认 1"` + PageSize int `json:"page_size" query:"page_size" validate:"omitempty,min=1,max=100" minimum:"1" maximum:"100" default:"20" description:"每页数量,默认 20,最大 100"` + Keyword string `json:"keyword" query:"keyword" validate:"omitempty,max=100" maxLength:"100" description:"按姓名或 userid 模糊搜索"` +} + +// WeComMemberListParams 企业微信应用可见成员分页路径与查询参数。 +type WeComMemberListParams struct { + IDReq + WeComMemberListRequest +} + +// WeComMemberResponse 企业微信应用可见成员响应。 +type WeComMemberResponse struct { + ApplicationID uint `json:"application_id" description:"企业微信应用配置 ID"` + CorpID string `json:"corp_id" description:"企业微信企业 ID"` + UserID string `json:"userid" description:"企业微信成员 userid"` + Name string `json:"name" description:"企业微信成员姓名"` + DepartmentIDs []int64 `json:"department_ids" description:"成员所属部门 ID 列表,仅作为选择辅助快照"` + SyncedAt time.Time `json:"synced_at" description:"最近同步时间"` +} + +// WeComMemberListResponse 企业微信应用可见成员分页响应。 +type WeComMemberListResponse struct { + Items []WeComMemberResponse `json:"items" description:"可见成员列表"` + Total int64 `json:"total" description:"总数量"` + Page int `json:"page" description:"当前页码"` + PageSize int `json:"page_size" description:"每页数量"` +} + +// WeComMemberSyncResponse 企业微信可见成员同步响应。 +type WeComMemberSyncResponse struct { + ApplicationID uint `json:"application_id" description:"企业微信应用配置 ID"` + SyncedCount int `json:"synced_count" description:"本次同步的可见成员数量"` + SyncedAt time.Time `json:"synced_at" description:"同步完成时间"` +} diff --git a/internal/model/dto/wecom_scene_dto.go b/internal/model/dto/wecom_scene_dto.go new file mode 100644 index 0000000..b0267ce --- /dev/null +++ b/internal/model/dto/wecom_scene_dto.go @@ -0,0 +1,55 @@ +package dto + +import "time" + +// WeComControlMappingItem 描述一个业务字段到企微模板控件的显式映射。 +type WeComControlMappingItem struct { + BusinessField string `json:"business_field" validate:"required,min=1,max=64" description:"稳定业务字段编码"` + ControlID string `json:"control_id" validate:"required,min=1,max=128" description:"企微模板控件 ID"` + ControlType string `json:"control_type" validate:"required,min=1,max=64" description:"企微模板控件类型,必须与模板详情一致"` + OptionMapping map[string]string `json:"option_mapping" description:"业务枚举值到企微选择项 key 的映射;非选择控件传空对象"` +} + +// SaveWeComApprovalSceneRequest 保存企业微信审批场景请求。 +type SaveWeComApprovalSceneRequest struct { + ApplicationID uint `json:"application_id" validate:"required,gt=0" description:"企业微信应用配置 ID"` + TemplateID string `json:"template_id" validate:"required,min=1,max=128" description:"企微后台已创建模板 ID"` + ControlMapping []WeComControlMappingItem `json:"control_mapping" validate:"required,min=1,dive" description:"业务字段与模板控件映射"` + Status int `json:"status" validate:"oneof=0 1" enum:"0,1" description:"状态 (0:禁用, 1:启用)"` +} + +// SaveWeComApprovalSceneParams 保存企业微信审批场景路径与请求参数。 +type SaveWeComApprovalSceneParams struct { + BusinessType string `path:"business_type" required:"true" description:"业务类型 (refund_approval:退款审批, offline_recharge_approval:员工线下代充值审批)"` + SaveWeComApprovalSceneRequest +} + +// WeComApprovalSceneListRequest 查询企业微信审批场景请求。 +type WeComApprovalSceneListRequest struct { + Page int `query:"page" validate:"omitempty,min=1" default:"1" description:"页码,默认 1"` + PageSize int `query:"page_size" validate:"omitempty,min=1,max=100" default:"20" description:"每页数量,默认 20,最大 100"` +} + +// WeComApprovalSceneResponse 企业微信审批场景响应。 +type WeComApprovalSceneResponse struct { + ID uint `json:"id" description:"场景配置 ID"` + BusinessType string `json:"business_type" description:"业务类型"` + BusinessTypeName string `json:"business_type_name" description:"业务类型名称(中文)"` + ApplicationID uint `json:"application_id" description:"企业微信应用配置 ID"` + TemplateID string `json:"template_id" description:"企微模板 ID"` + TemplateName string `json:"template_name" description:"企微模板名称"` + TemplateFingerprint string `json:"template_fingerprint" description:"模板控件最小快照 SHA-256 指纹"` + ControlMapping []WeComControlMappingItem `json:"control_mapping" description:"已验证控件映射"` + Status int `json:"status" description:"状态 (0:禁用, 1:启用)"` + StatusName string `json:"status_name" description:"状态名称(中文)"` + LastVerifiedAt time.Time `json:"last_verified_at" description:"最近模板校验时间"` + UpdatedAt time.Time `json:"updated_at" description:"更新时间"` +} + +// WeComApprovalSceneListResponse 企业微信审批场景分页响应。 +type WeComApprovalSceneListResponse struct { + Items []WeComApprovalSceneResponse `json:"items" description:"审批场景列表"` + Total int64 `json:"total" description:"总数量"` + Page int `json:"page" description:"当前页码"` + PageSize int `json:"page_size" description:"每页数量"` +} diff --git a/internal/model/order.go b/internal/model/order.go index e496ae7..6d1586f 100644 --- a/internal/model/order.go +++ b/internal/model/order.go @@ -28,7 +28,7 @@ type Order struct { // 关联资源 IotCardID *uint `gorm:"column:iot_card_id;index;comment:IoT卡ID(单卡购买时有值)" json:"iot_card_id,omitempty"` DeviceID *uint `gorm:"column:device_id;index;comment:设备ID(设备购买时有值)" json:"device_id,omitempty"` - AssetIdentifier string `gorm:"column:asset_identifier;type:varchar(100);comment:下单时资产的标识符快照(ICCID 或 VirtualNo)" json:"asset_identifier,omitempty"` + AssetIdentifier string `gorm:"column:asset_identifier;type:varchar(100);comment:下单时资产的标识符快照(卡为ICCID,设备优先VirtualNo、其次IMEI)" json:"asset_identifier,omitempty"` // 金额信息 TotalAmount int64 `gorm:"column:total_amount;type:bigint;not null;comment:订单总金额(分)" json:"total_amount"` diff --git a/internal/model/refund.go b/internal/model/refund.go index f8cb2cb..50153db 100644 --- a/internal/model/refund.go +++ b/internal/model/refund.go @@ -18,7 +18,7 @@ type RefundRequest struct { OrderID uint `gorm:"column:order_id;index;not null;comment:关联订单ID" json:"order_id"` OrderNo string `gorm:"column:order_no;type:varchar(30);not null;default:'';comment:关联订单号快照" json:"order_no"` OrderType string `gorm:"column:order_type;type:varchar(20);not null;default:'';comment:订单类型快照(single_card/device)" json:"order_type,omitempty"` - AssetIdentifier string `gorm:"column:asset_identifier;type:varchar(100);not null;default:'';comment:下单时资产标识符快照(ICCID 或 VirtualNo)" json:"asset_identifier,omitempty"` + AssetIdentifier string `gorm:"column:asset_identifier;type:varchar(100);not null;default:'';comment:下单时资产标识符快照(卡为ICCID,设备优先VirtualNo、其次IMEI)" json:"asset_identifier,omitempty"` IotCardID *uint `gorm:"column:iot_card_id;comment:下单时IoT卡ID快照" json:"iot_card_id,omitempty"` DeviceID *uint `gorm:"column:device_id;comment:下单时设备ID快照" json:"device_id,omitempty"` PackageUsageID *uint `gorm:"column:package_usage_id;comment:关联套餐使用记录ID(可选)" json:"package_usage_id,omitempty"` @@ -32,14 +32,15 @@ type RefundRequest struct { // 退款凭证、原因和状态 RefundVoucherKey StringJSONBArray `gorm:"column:refund_voucher_key;type:jsonb;not null;comment:退款凭证对象存储file_key列表(jsonb存储[]string)" json:"refund_voucher_key"` - RefundReason string `gorm:"column:refund_reason;type:text;comment:退款原因" json:"refund_reason"` - Status int `gorm:"column:status;type:int;not null;default:1;index;comment:状态 1-待审批 2-已通过 3-已拒绝 4-已退回" json:"status"` + RefundReason string `gorm:"column:refund_reason;type:text;comment:退款原因" json:"refund_reason"` + Status int `gorm:"column:status;type:int;not null;default:1;index;comment:状态 1-待审批 2-已通过 3-已拒绝 4-已退回" json:"status"` // 审批信息 - ProcessorID *uint `gorm:"column:processor_id;comment:审批人ID" json:"processor_id,omitempty"` - ProcessedAt *time.Time `gorm:"column:processed_at;comment:审批时间" json:"processed_at,omitempty"` - RejectReason string `gorm:"column:reject_reason;type:text;comment:拒绝原因" json:"reject_reason,omitempty"` - Remark string `gorm:"column:remark;type:text;comment:审批备注" json:"remark,omitempty"` + ApprovalInstanceID *uint `gorm:"column:approval_instance_id;comment:关联的唯一通用审批实例ID" json:"approval_instance_id,omitempty"` + ProcessorID *uint `gorm:"column:processor_id;comment:审批人ID" json:"processor_id,omitempty"` + ProcessedAt *time.Time `gorm:"column:processed_at;comment:审批时间" json:"processed_at,omitempty"` + RejectReason string `gorm:"column:reject_reason;type:text;comment:拒绝原因" json:"reject_reason,omitempty"` + Remark string `gorm:"column:remark;type:text;comment:审批备注" json:"remark,omitempty"` // 后处理标记 CommissionDeducted bool `gorm:"column:commission_deducted;not null;default:false;comment:佣金是否已回扣" json:"commission_deducted"` diff --git a/internal/model/shop.go b/internal/model/shop.go index 1280934..82a16db 100644 --- a/internal/model/shop.go +++ b/internal/model/shop.go @@ -20,6 +20,7 @@ type Shop struct { District string `gorm:"column:district;type:varchar(50);comment:区县" json:"district"` Address string `gorm:"column:address;type:varchar(255);comment:详细地址" json:"address"` Status int `gorm:"column:status;type:int;not null;default:1;comment:状态 0=禁用 1=启用" json:"status"` + ClientLoginDisabled bool `gorm:"column:client_login_disabled;type:boolean;not null;default:false;comment:是否禁止该店铺资产发起新的C端登录" json:"client_login_disabled"` } // TableName 指定表名 diff --git a/internal/model/wecom_application.go b/internal/model/wecom_application.go new file mode 100644 index 0000000..7a43b12 --- /dev/null +++ b/internal/model/wecom_application.go @@ -0,0 +1,29 @@ +package model + +import ( + "time" + + "gorm.io/gorm" +) + +// WeComApplication 企业微信自建应用及加密连接凭据。 +type WeComApplication struct { + gorm.Model + CorpID string `gorm:"column:corp_id;type:varchar(64);not null;uniqueIndex:uq_wecom_application_identity,priority:1,where:deleted_at IS NULL" json:"corp_id"` + AgentID int64 `gorm:"column:agent_id;type:bigint;not null;uniqueIndex:uq_wecom_application_identity,priority:2,where:deleted_at IS NULL" json:"agent_id"` + Name string `gorm:"column:name;type:varchar(100);not null" json:"name"` + SecretCiphertext []byte `gorm:"column:secret_ciphertext;type:bytea;not null" json:"-"` + CallbackTokenCiphertext []byte `gorm:"column:callback_token_ciphertext;type:bytea;not null" json:"-"` + EncodingAESKeyCiphertext []byte `gorm:"column:encoding_aes_key_ciphertext;type:bytea;not null" json:"-"` + DefaultCreatorUserID string `gorm:"column:default_creator_userid;type:varchar(64);not null;default:''" json:"default_creator_userid"` + DefaultCreatorName string `gorm:"column:default_creator_name;type:varchar(100);not null;default:''" json:"default_creator_name"` + Status int `gorm:"column:status;type:int;not null;default:1" json:"status"` + CreatedBy uint `gorm:"column:created_by;not null" json:"created_by"` + UpdatedBy uint `gorm:"column:updated_by;not null" json:"updated_by"` + LastConnectedAt *time.Time `gorm:"column:last_connected_at;type:timestamptz" json:"last_connected_at,omitempty"` +} + +// TableName 指定企业微信应用表名。 +func (WeComApplication) TableName() string { + return "tb_wecom_application" +} diff --git a/internal/model/wecom_approval_context.go b/internal/model/wecom_approval_context.go new file mode 100644 index 0000000..cc194ce --- /dev/null +++ b/internal/model/wecom_approval_context.go @@ -0,0 +1,37 @@ +package model + +import ( + "time" + + "gorm.io/datatypes" +) + +// WeComApprovalContext 保存通用审批实例对应的企微提交安全快照和提交结果。 +type WeComApprovalContext struct { + ID uint `gorm:"column:id;primaryKey;autoIncrement" json:"id"` + ApprovalInstanceID uint `gorm:"column:approval_instance_id;not null;uniqueIndex" json:"approval_instance_id"` + ApplicationID uint `gorm:"column:application_id;not null;index" json:"application_id"` + BusinessType string `gorm:"column:business_type;type:varchar(64);not null" json:"business_type"` + TemplateID string `gorm:"column:template_id;type:varchar(128);not null" json:"template_id"` + CreatorUserID string `gorm:"column:creator_userid;type:varchar(64);not null" json:"creator_userid"` + CreatorName string `gorm:"column:creator_name;type:varchar(100);not null" json:"creator_name"` + CreatorSource string `gorm:"column:creator_source;type:varchar(16);not null" json:"creator_source"` + ControlMapping datatypes.JSON `gorm:"column:control_mapping;type:jsonb;not null" json:"control_mapping"` + TemplateSnapshot datatypes.JSON `gorm:"column:template_snapshot;type:jsonb;not null" json:"template_snapshot"` + TemplateFingerprint string `gorm:"column:template_fingerprint;type:char(64);not null" json:"template_fingerprint"` + SubmissionStatus int `gorm:"column:submission_status;type:int;not null;default:0;index" json:"submission_status"` + SubmissionAttemptedAt *time.Time `gorm:"column:submission_attempted_at;type:timestamptz" json:"submission_attempted_at,omitempty"` + SPNo string `gorm:"column:sp_no;type:varchar(128);not null;default:'';uniqueIndex:uq_wecom_approval_context_sp_no,where:sp_no <> ''" json:"sp_no"` + LastError string `gorm:"column:last_error;type:varchar(500);not null;default:''" json:"last_error"` + LastRecoveryAt *time.Time `gorm:"column:last_recovery_at;type:timestamptz" json:"last_recovery_at,omitempty"` + LatestSPStatus int `gorm:"column:latest_sp_status;type:int;not null;default:0" json:"latest_sp_status"` + LatestDetailSnapshot datatypes.JSON `gorm:"column:latest_detail_snapshot;type:jsonb" json:"latest_detail_snapshot,omitempty"` + LastSyncedAt *time.Time `gorm:"column:last_synced_at;type:timestamptz" json:"last_synced_at,omitempty"` + CreatedAt time.Time `gorm:"column:created_at;type:timestamptz;not null;autoCreateTime" json:"created_at"` + UpdatedAt time.Time `gorm:"column:updated_at;type:timestamptz;not null;autoUpdateTime" json:"updated_at"` +} + +// TableName 指定企业微信审批渠道上下文表名。 +func (WeComApprovalContext) TableName() string { + return "tb_wecom_approval_context" +} diff --git a/internal/model/wecom_approval_scene.go b/internal/model/wecom_approval_scene.go new file mode 100644 index 0000000..be36f5e --- /dev/null +++ b/internal/model/wecom_approval_scene.go @@ -0,0 +1,30 @@ +package model + +import ( + "time" + + "gorm.io/datatypes" +) + +// WeComApprovalScene 保存稳定业务类型到企微后台模板及控件映射的当前配置。 +type WeComApprovalScene struct { + ID uint `gorm:"column:id;primaryKey;autoIncrement" json:"id"` + BusinessType string `gorm:"column:business_type;type:varchar(64);not null;uniqueIndex" json:"business_type"` + ApplicationID uint `gorm:"column:application_id;not null;index" json:"application_id"` + TemplateID string `gorm:"column:template_id;type:varchar(128);not null" json:"template_id"` + TemplateName string `gorm:"column:template_name;type:varchar(255);not null;default:''" json:"template_name"` + ControlMapping datatypes.JSON `gorm:"column:control_mapping;type:jsonb;not null" json:"control_mapping"` + TemplateSnapshot datatypes.JSON `gorm:"column:template_snapshot;type:jsonb;not null" json:"template_snapshot"` + TemplateFingerprint string `gorm:"column:template_fingerprint;type:char(64);not null" json:"template_fingerprint"` + Status int `gorm:"column:status;type:int;not null;default:1" json:"status"` + LastVerifiedAt time.Time `gorm:"column:last_verified_at;type:timestamptz;not null" json:"last_verified_at"` + CreatedBy uint `gorm:"column:created_by;not null" json:"created_by"` + UpdatedBy uint `gorm:"column:updated_by;not null" json:"updated_by"` + CreatedAt time.Time `gorm:"column:created_at;type:timestamptz;not null;autoCreateTime" json:"created_at"` + UpdatedAt time.Time `gorm:"column:updated_at;type:timestamptz;not null;autoUpdateTime" json:"updated_at"` +} + +// TableName 指定企业微信审批场景配置表名。 +func (WeComApprovalScene) TableName() string { + return "tb_wecom_approval_scene" +} diff --git a/internal/model/wecom_member.go b/internal/model/wecom_member.go new file mode 100644 index 0000000..852a6d0 --- /dev/null +++ b/internal/model/wecom_member.go @@ -0,0 +1,26 @@ +package model + +import ( + "time" + + "gorm.io/datatypes" +) + +// WeComMember 是企业微信应用可见成员的本地选择快照,不承担组织模型职责。 +type WeComMember struct { + ID uint `gorm:"column:id;primaryKey;autoIncrement" json:"id"` + ApplicationID uint `gorm:"column:application_id;not null;uniqueIndex:uq_wecom_member_identity,priority:1" json:"application_id"` + CorpID string `gorm:"column:corp_id;type:varchar(64);not null" json:"corp_id"` + UserID string `gorm:"column:userid;type:varchar(64);not null;uniqueIndex:uq_wecom_member_identity,priority:2" json:"userid"` + Name string `gorm:"column:name;type:varchar(100);not null" json:"name"` + DepartmentIDs datatypes.JSON `gorm:"column:department_ids;type:jsonb;not null;default:'[]'" json:"department_ids"` + Visible bool `gorm:"column:visible;not null;default:true;index" json:"visible"` + SyncedAt time.Time `gorm:"column:synced_at;type:timestamptz;not null" json:"synced_at"` + CreatedAt time.Time `gorm:"column:created_at;type:timestamptz;not null;autoCreateTime" json:"created_at"` + UpdatedAt time.Time `gorm:"column:updated_at;type:timestamptz;not null;autoUpdateTime" json:"updated_at"` +} + +// TableName 指定企业微信可见成员快照表名。 +func (WeComMember) TableName() string { + return "tb_wecom_member" +} diff --git a/internal/query/exchange/list.go b/internal/query/exchange/list.go index 66dd695..884759d 100644 --- a/internal/query/exchange/list.go +++ b/internal/query/exchange/list.go @@ -7,6 +7,7 @@ import ( "github.com/break/junhong_cmp_fiber/internal/model" "github.com/break/junhong_cmp_fiber/internal/model/dto" + "github.com/break/junhong_cmp_fiber/internal/store/postgres" "github.com/break/junhong_cmp_fiber/pkg/constants" "github.com/break/junhong_cmp_fiber/pkg/errors" "github.com/break/junhong_cmp_fiber/pkg/middleware" @@ -57,10 +58,26 @@ func (q *ListQuery) List(ctx context.Context, req *dto.ExchangeListRequest) (*dt if err := query.Order("created_at DESC").Offset(offset).Limit(pageSize).Find(&orders).Error; err != nil { return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询换货单列表失败") } + submitterIDs := make([]uint, 0, len(orders)) + for _, order := range orders { + if order.Creator > 0 { + submitterIDs = append(submitterIDs, order.Creator) + } + } + accounts, err := postgres.NewAccountStore(q.db, nil).GetDisplayAccountsByIDs(ctx, submitterIDs) + if err != nil { + return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询换货提交人失败") + } + submitterNames := make(map[uint]string, len(accounts)) + for _, account := range accounts { + submitterNames[account.ID] = account.Username + } items := make([]*dto.ExchangeOrderResponse, 0, len(orders)) for _, order := range orders { - items = append(items, projectExchangeOrder(order)) + item := projectExchangeOrder(order) + item.SubmitterName = submitterNames[order.Creator] + items = append(items, item) } return &dto.ExchangeListResponse{List: items, Total: total, Page: page, PageSize: pageSize}, nil } @@ -121,7 +138,7 @@ func projectExchangeOrder(order *model.ExchangeOrder) *dto.ExchangeOrderResponse ExchangeReason: order.ExchangeReason, Remark: order.Remark, Status: order.Status, StatusName: constants.GetExchangeStatusName(order.Status), StatusText: constants.GetExchangeStatusName(order.Status), ShopID: order.ShopID, CreatedAt: order.CreatedAt, UpdatedAt: order.UpdatedAt, DeletedAt: deletedAt, - Creator: order.Creator, Updater: order.Updater, + SubmitterID: order.Creator, Creator: order.Creator, Updater: order.Updater, } } diff --git a/internal/query/notification/query.go b/internal/query/notification/query.go index 2edcdf2..ec7ab98 100644 --- a/internal/query/notification/query.go +++ b/internal/query/notification/query.go @@ -115,6 +115,9 @@ func (q *Query) PersonalList(ctx context.Context, customerID uint, request dto.P return nil, err } base := personalNotificationScope(q.db.WithContext(ctx).Model(&model.Notification{}), customerID, q.now().UTC()) + if request.IsRead != nil { + base = base.Where("is_read = ?", *request.IsRead) + } var total int64 if err := base.Count(&total).Error; err != nil { return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询个人客户通知总数失败") @@ -194,8 +197,8 @@ func personalNotificationScope(db *gorm.DB, customerID uint, now time.Time) *gor AND category IN ? AND type IN ? AND (expires_at IS NULL OR expires_at > ?)`, constants.NotificationRecipientKindPersonalCustomer, customerID, - []string{constants.NotificationCategoryApproval, constants.NotificationCategoryExpiry}, - []string{constants.NotificationTypePackageExpiring}, + []string{constants.NotificationCategoryApproval, constants.NotificationCategoryExpiry, constants.NotificationCategorySystem}, + []string{constants.NotificationTypePackageExpiring, constants.NotificationTypeExchangeShippingCreated}, now, ) } diff --git a/internal/query/packageexpiry/list.go b/internal/query/packageexpiry/list.go new file mode 100644 index 0000000..5198fa7 --- /dev/null +++ b/internal/query/packageexpiry/list.go @@ -0,0 +1,372 @@ +package packageexpiry + +import ( + "context" + "sort" + "strings" + "time" + + "github.com/break/junhong_cmp_fiber/internal/model" + "github.com/break/junhong_cmp_fiber/internal/model/dto" + "github.com/break/junhong_cmp_fiber/pkg/constants" + "github.com/break/junhong_cmp_fiber/pkg/errors" + "github.com/break/junhong_cmp_fiber/pkg/middleware" + "gorm.io/gorm" +) + +const expiryWindowDays = 15 + +// ListResult 是临期资产 Query 的分页结果。 +type ListResult struct { + Items []dto.ExpiringAssetItem + Total int64 + Page int + Size int + Summary dto.ExpiringAssetSummary +} + +type assetCandidate struct { + AssetType string + AssetID uint + Identifier string + ShopID *uint +} + +// List 查询当前权限范围内的临期资产,并返回同口径数量汇总。 +func (q *Query) List(ctx context.Context, request dto.ExpiringAssetListRequest) (ListResult, error) { + if q == nil || q.db == nil { + return ListResult{}, errors.New(errors.CodeInternalError, "套餐临期查询未配置") + } + if middleware.GetUserTypeFromContext(ctx) == constants.UserTypeEnterprise { + return ListResult{}, errors.New(errors.CodeForbidden, "企业账号无权查看临期资产列表") + } + request = normalizeListRequest(request) + if err := validateListRequest(request); err != nil { + return ListResult{}, err + } + items, err := q.collect(ctx, request) + if err != nil { + return ListResult{}, err + } + summary := summarize(items) + total := int64(len(items)) + start := (request.Page - 1) * request.PageSize + if start > len(items) { + start = len(items) + } + end := start + request.PageSize + if end > len(items) { + end = len(items) + } + return ListResult{Items: items[start:end], Total: total, Page: request.Page, Size: request.PageSize, Summary: summary}, nil +} + +// ReminderCandidates 查询当天精确命中 15、7、3 天节点的全部资产,供每日通知任务复用。 +func (q *Query) ReminderCandidates(ctx context.Context) ([]dto.ExpiringAssetItem, error) { + items, err := q.collect(ctx, normalizeListRequest(dto.ExpiringAssetListRequest{})) + if err != nil { + return nil, err + } + result := make([]dto.ExpiringAssetItem, 0) + for _, item := range items { + if item.DaysUntilFinalExpiry == nil { + continue + } + switch *item.DaysUntilFinalExpiry { + case 15, 7, 3: + result = append(result, item) + } + } + return result, nil +} + +func (q *Query) collect(ctx context.Context, request dto.ExpiringAssetListRequest) ([]dto.ExpiringAssetItem, error) { + candidates := make([]assetCandidate, 0) + if request.AssetType == "" || request.AssetType == constants.AssetTypeIotCard { + cards, err := q.findCardCandidates(ctx, request) + if err != nil { + return nil, err + } + candidates = append(candidates, cards...) + } + if request.AssetType == "" || request.AssetType == constants.AssetTypeDevice { + devices, err := q.findDeviceCandidates(ctx, request) + if err != nil { + return nil, err + } + candidates = append(candidates, devices...) + } + items, err := q.resolveCandidates(ctx, candidates, request) + if err != nil { + return nil, err + } + if err := q.fillShopNames(ctx, items); err != nil { + return nil, err + } + sort.SliceStable(items, func(i, j int) bool { + if items[i].IsPriority != items[j].IsPriority { + return items[i].IsPriority + } + left, right := items[i].EstimatedFinalExpiresAt, items[j].EstimatedFinalExpiresAt + if left != nil && right != nil && !left.Equal(*right) { + return left.Before(*right) + } + if items[i].AssetType != items[j].AssetType { + return items[i].AssetType < items[j].AssetType + } + return items[i].AssetID < items[j].AssetID + }) + return items, nil +} + +func (q *Query) findCardCandidates(ctx context.Context, request dto.ExpiringAssetListRequest) ([]assetCandidate, error) { + var rows []model.IotCard + query := q.db.WithContext(ctx).Model(&model.IotCard{}). + Select("id, iccid, shop_id") + query = applyStrictShopScope(ctx, query) + if request.ShopID != nil { + query = query.Where("shop_id = ?", *request.ShopID) + } + if request.Keyword != "" { + keyword := "%" + strings.TrimSpace(request.Keyword) + "%" + query = query.Where("iccid ILIKE ? OR msisdn ILIKE ? OR virtual_no ILIKE ?", keyword, keyword, keyword) + } + query = query.Where(`EXISTS ( + SELECT 1 FROM tb_package_usage pu + WHERE pu.iot_card_id = tb_iot_card.id AND pu.deleted_at IS NULL + AND pu.master_usage_id IS NULL AND pu.refund_id IS NULL + AND pu.status IN ? AND pu.expires_at IS NOT NULL AND pu.expires_at < ? + )`, []int{constants.PackageUsageStatusActive, constants.PackageUsageStatusDepleted}, dateInShanghai(q.now()).AddDate(0, 0, expiryWindowDays+1)) + if err := query.Find(&rows).Error; err != nil { + return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询临期卡候选失败") + } + result := make([]assetCandidate, 0, len(rows)) + for _, row := range rows { + result = append(result, assetCandidate{AssetType: constants.AssetTypeIotCard, AssetID: row.ID, Identifier: row.ICCID, ShopID: row.ShopID}) + } + return result, nil +} + +func (q *Query) findDeviceCandidates(ctx context.Context, request dto.ExpiringAssetListRequest) ([]assetCandidate, error) { + var rows []model.Device + query := q.db.WithContext(ctx).Model(&model.Device{}). + Select("id, virtual_no, imei, shop_id") + query = applyStrictShopScope(ctx, query) + if request.ShopID != nil { + query = query.Where("shop_id = ?", *request.ShopID) + } + if request.Keyword != "" { + keyword := "%" + strings.TrimSpace(request.Keyword) + "%" + query = query.Where("virtual_no ILIKE ? OR imei ILIKE ?", keyword, keyword) + } + query = query.Where(`EXISTS ( + SELECT 1 FROM tb_package_usage pu + WHERE pu.device_id = tb_device.id AND pu.deleted_at IS NULL + AND pu.master_usage_id IS NULL AND pu.refund_id IS NULL + AND pu.status IN ? AND pu.expires_at IS NOT NULL AND pu.expires_at < ? + )`, []int{constants.PackageUsageStatusActive, constants.PackageUsageStatusDepleted}, dateInShanghai(q.now()).AddDate(0, 0, expiryWindowDays+1)) + if err := query.Find(&rows).Error; err != nil { + return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询临期设备候选失败") + } + result := make([]assetCandidate, 0, len(rows)) + for _, row := range rows { + identifier := row.VirtualNo + if identifier == "" { + identifier = row.IMEI + } + result = append(result, assetCandidate{AssetType: constants.AssetTypeDevice, AssetID: row.ID, Identifier: identifier, ShopID: row.ShopID}) + } + return result, nil +} + +func (q *Query) resolveCandidates(ctx context.Context, candidates []assetCandidate, request dto.ExpiringAssetListRequest) ([]dto.ExpiringAssetItem, error) { + groupedIDs := map[string][]uint{constants.AssetTypeIotCard: {}, constants.AssetTypeDevice: {}} + for _, candidate := range candidates { + groupedIDs[candidate.AssetType] = append(groupedIDs[candidate.AssetType], candidate.AssetID) + } + estimates := make(map[string]map[uint]dto.PackageExpiryEstimate, 2) + finalUsages := make(map[string]map[uint]*model.PackageUsage, 2) + for _, assetType := range []string{constants.AssetTypeIotCard, constants.AssetTypeDevice} { + var err error + estimates[assetType], err = q.ResolveBatch(ctx, assetType, groupedIDs[assetType]) + if err != nil { + return nil, err + } + finalUsages[assetType], err = q.loadFinalUsages(ctx, assetType, groupedIDs[assetType]) + if err != nil { + return nil, err + } + } + from, to, err := parseExpiryRange(request) + if err != nil { + return nil, err + } + items := make([]dto.ExpiringAssetItem, 0, len(candidates)) + for _, candidate := range candidates { + estimate := estimates[candidate.AssetType][candidate.AssetID] + usage := finalUsages[candidate.AssetType][candidate.AssetID] + if usage == nil || estimate.ExpiryEstimateStatus != constants.PackageExpiryEstimateStatusExact || estimate.DaysUntilFinalExpiry == nil || estimate.EstimatedFinalExpiresAt == nil { + continue + } + days := *estimate.DaysUntilFinalExpiry + if days < 0 || days > expiryWindowDays || request.DaysMin != nil && days < *request.DaysMin || request.DaysMax != nil && days > *request.DaysMax { + continue + } + expiryDate := dateInShanghai(*estimate.EstimatedFinalExpiresAt) + if from != nil && expiryDate.Before(*from) || to != nil && expiryDate.After(*to) { + continue + } + if request.PackageID != nil && usage.PackageID != *request.PackageID { + continue + } + level, levelName := expiryLevel(days) + items = append(items, dto.ExpiringAssetItem{ + AssetType: candidate.AssetType, AssetID: candidate.AssetID, Identifier: candidate.Identifier, ShopID: candidate.ShopID, + PackageUsageID: usage.ID, PackageID: usage.PackageID, PackageName: usage.PackageName, + PackageExpiryEstimate: estimate, ExpiryLevel: level, ExpiryLevelName: levelName, IsPriority: days <= 3, + }) + } + return items, nil +} + +func (q *Query) loadFinalUsages(ctx context.Context, assetType string, assetIDs []uint) (map[uint]*model.PackageUsage, error) { + result := make(map[uint]*model.PackageUsage, len(assetIDs)) + if len(assetIDs) == 0 { + return result, nil + } + column, ok := packageUsageAssetColumn(assetType) + if !ok { + return nil, errors.New(errors.CodeInvalidParam, "资产类型无效") + } + var usages []*model.PackageUsage + if err := q.db.WithContext(ctx).Where(column+" IN ?", assetIDs). + Where("master_usage_id IS NULL AND refund_id IS NULL"). + Where("status IN ?", []int{constants.PackageUsageStatusPending, constants.PackageUsageStatusActive, constants.PackageUsageStatusDepleted}). + Order("priority ASC, created_at ASC, id ASC").Find(&usages).Error; err != nil { + return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询临期资产最终套餐失败") + } + for _, usage := range usages { + assetID := usage.IotCardID + if assetType == constants.AssetTypeDevice { + assetID = usage.DeviceID + } + result[assetID] = usage + } + return result, nil +} + +func (q *Query) fillShopNames(ctx context.Context, items []dto.ExpiringAssetItem) error { + ids := make([]uint, 0) + seen := make(map[uint]struct{}) + for _, item := range items { + if item.ShopID == nil { + continue + } + if _, exists := seen[*item.ShopID]; !exists { + seen[*item.ShopID] = struct{}{} + ids = append(ids, *item.ShopID) + } + } + if len(ids) == 0 { + return nil + } + var shops []model.Shop + if err := q.db.WithContext(ctx).Select("id, shop_name").Where("id IN ?", ids).Find(&shops).Error; err != nil { + return errors.Wrap(errors.CodeDatabaseError, err, "查询临期资产店铺名称失败") + } + names := make(map[uint]string, len(shops)) + for _, shop := range shops { + names[shop.ID] = shop.ShopName + } + for index := range items { + if items[index].ShopID != nil { + items[index].ShopName = names[*items[index].ShopID] + } + } + return nil +} + +func applyStrictShopScope(ctx context.Context, query *gorm.DB) *gorm.DB { + if middleware.GetUserTypeFromContext(ctx) != constants.UserTypeAgent { + return query + } + shopIDs := middleware.GetSubordinateShopIDs(ctx) + if len(shopIDs) == 0 { + return query.Where("1 = 0") + } + return query.Where("shop_id IN ?", shopIDs) +} + +func normalizeListRequest(request dto.ExpiringAssetListRequest) dto.ExpiringAssetListRequest { + if request.Page <= 0 { + request.Page = 1 + } + if request.PageSize <= 0 { + request.PageSize = constants.DefaultPageSize + } + return request +} + +func validateListRequest(request dto.ExpiringAssetListRequest) error { + if request.PageSize > constants.MaxPageSize || request.Page < 1 { + return errors.New(errors.CodeInvalidParam) + } + if request.AssetType != "" && request.AssetType != constants.AssetTypeIotCard && request.AssetType != constants.AssetTypeDevice { + return errors.New(errors.CodeInvalidParam) + } + if request.DaysMin != nil && (*request.DaysMin < 0 || *request.DaysMin > expiryWindowDays) || request.DaysMax != nil && (*request.DaysMax < 0 || *request.DaysMax > expiryWindowDays) { + return errors.New(errors.CodeInvalidParam) + } + if request.DaysMin != nil && request.DaysMax != nil && *request.DaysMin > *request.DaysMax { + return errors.New(errors.CodeInvalidParam, "最小剩余天数不能大于最大剩余天数") + } + _, _, err := parseExpiryRange(request) + return err +} + +func parseExpiryRange(request dto.ExpiringAssetListRequest) (*time.Time, *time.Time, error) { + parse := func(value string) (*time.Time, error) { + if value == "" { + return nil, nil + } + result, err := time.ParseInLocation("2006-01-02", value, shanghaiLocation) + if err != nil { + return nil, errors.New(errors.CodeInvalidParam, "到期日期格式无效") + } + return &result, nil + } + from, err := parse(request.ExpiresFrom) + if err != nil { + return nil, nil, err + } + to, err := parse(request.ExpiresTo) + if err != nil { + return nil, nil, err + } + if from != nil && to != nil && from.After(*to) { + return nil, nil, errors.New(errors.CodeInvalidParam, "到期开始日期不能晚于结束日期") + } + return from, to, nil +} + +func expiryLevel(days int) (string, string) { + if days <= 3 { + return "red", "红色" + } + if days <= 7 { + return "purple", "紫色" + } + return "pink", "粉红色" +} + +func summarize(items []dto.ExpiringAssetItem) dto.ExpiringAssetSummary { + result := dto.ExpiringAssetSummary{WindowDays: expiryWindowDays, TotalCount: int64(len(items))} + for _, item := range items { + if item.AssetType == constants.AssetTypeIotCard { + result.CardCount++ + } else if item.AssetType == constants.AssetTypeDevice { + result.DeviceCount++ + } + } + return result +} diff --git a/internal/query/shop/business_owner.go b/internal/query/shop/business_owner.go index e6655ab..eeff825 100644 --- a/internal/query/shop/business_owner.go +++ b/internal/query/shop/business_owner.go @@ -139,7 +139,8 @@ func (q *BusinessOwnerQuery) project(ctx context.Context, shops []*model.Shop) ( BusinessOwnerAccountID: shop.BusinessOwnerAccountID, Level: shop.Level, ContactName: shop.ContactName, ContactPhone: shop.ContactPhone, Province: shop.Province, City: shop.City, District: shop.District, Address: shop.Address, Status: shop.Status, - StatusName: constants.GetStatusName(shop.Status), CreatedAt: shop.CreatedAt.Format("2006-01-02 15:04:05"), + ClientLoginDisabled: shop.ClientLoginDisabled, + StatusName: constants.GetStatusName(shop.Status), CreatedAt: shop.CreatedAt.Format("2006-01-02 15:04:05"), UpdatedAt: shop.UpdatedAt.Format("2006-01-02 15:04:05"), } if shop.ParentID != nil { diff --git a/internal/routes/account.go b/internal/routes/account.go index dafb705..27909ac 100644 --- a/internal/routes/account.go +++ b/internal/routes/account.go @@ -63,6 +63,14 @@ func registerAccountRoutes(api fiber.Router, h *admin.AccountHandler, doc *opena Auth: true, }) + Register(accounts, doc, accountsPath, "PUT", "/:id/wecom-binding", h.BindWeCom, RouteSpec{ + Summary: "绑定账号企业微信成员", + Tags: []string{"账号管理", "企业微信审批"}, + Input: new(dto.BindAccountWeComParams), + Output: new(dto.AccountResponse), + Auth: true, + }) + // 删除账号 Register(accounts, doc, accountsPath, "DELETE", "/:id", h.Delete, RouteSpec{ Summary: "删除账号", diff --git a/internal/routes/admin.go b/internal/routes/admin.go index 942516b..3cae80d 100644 --- a/internal/routes/admin.go +++ b/internal/routes/admin.go @@ -121,6 +121,7 @@ func RegisterAdminRoutes(router fiber.Router, handlers *bootstrap.Handlers, midd } if handlers.Asset != nil { registerAssetRoutes(authGroup, handlers.Asset, handlers.AssetWallet, doc, basePath) + registerPackageExpiryRoutes(authGroup, handlers.Asset, doc, basePath) } if handlers.WechatConfig != nil { registerWechatConfigRoutes(authGroup, handlers.WechatConfig, doc, basePath) @@ -134,10 +135,16 @@ func RegisterAdminRoutes(router fiber.Router, handlers *bootstrap.Handlers, midd if handlers.OrderPackageInvalidate != nil { registerOrderPackageInvalidateRoutes(authGroup, handlers.OrderPackageInvalidate, doc, basePath) } + if handlers.AssetPackageBatchOrder != nil { + registerAssetPackageBatchOrderRoutes(authGroup, handlers.AssetPackageBatchOrder, doc, basePath) + } if handlers.SuperAdmin != nil { registerSuperAdminRoutes(authGroup, handlers.SuperAdmin, doc, basePath) } if handlers.SystemConfig != nil { registerSystemConfigRoutes(authGroup, handlers.SystemConfig, doc, basePath) } + if handlers.WeCom != nil { + registerWeComRoutes(authGroup, handlers.WeCom, doc, basePath) + } } diff --git a/internal/routes/asset_package_batch_order.go b/internal/routes/asset_package_batch_order.go new file mode 100644 index 0000000..1460346 --- /dev/null +++ b/internal/routes/asset_package_batch_order.go @@ -0,0 +1,25 @@ +package routes + +import ( + "github.com/gofiber/fiber/v2" + + "github.com/break/junhong_cmp_fiber/internal/handler/admin" + "github.com/break/junhong_cmp_fiber/internal/model/dto" + "github.com/break/junhong_cmp_fiber/pkg/openapi" +) + +// registerAssetPackageBatchOrderRoutes 注册资产套餐批量订购任务路由。 +func registerAssetPackageBatchOrderRoutes(router fiber.Router, handler *admin.AssetPackageBatchOrderHandler, doc *openapi.Generator, basePath string) { + Register(router, doc, basePath, "POST", "/asset-package-batch-orders", handler.Create, RouteSpec{ + Summary: "创建资产套餐批量订购任务", Description: "上传单列CSV对象存储Key,并为整批选择一个套餐和支付方式;不选择代理。", + Tags: []string{"批量订购套餐"}, Input: new(dto.CreateAssetPackageBatchOrderRequest), Output: new(dto.AssetPackageBatchOrderTaskResponse), Auth: true, + }) + Register(router, doc, basePath, "GET", "/asset-package-batch-orders", handler.List, RouteSpec{ + Summary: "查询资产套餐批量订购任务列表", Tags: []string{"批量订购套餐"}, + Input: new(dto.ListAssetPackageBatchOrderRequest), Output: new(dto.AssetPackageBatchOrderTaskListResponse), Auth: true, + }) + Register(router, doc, basePath, "GET", "/asset-package-batch-orders/:id", handler.Get, RouteSpec{ + Summary: "查询资产套餐批量订购任务详情", Tags: []string{"批量订购套餐"}, + Input: new(dto.GetAssetPackageBatchOrderRequest), Output: new(dto.AssetPackageBatchOrderTaskDetailResponse), Auth: true, + }) +} diff --git a/internal/routes/device.go b/internal/routes/device.go index 236f914..8582775 100644 --- a/internal/routes/device.go +++ b/internal/routes/device.go @@ -21,6 +21,15 @@ func registerDeviceRoutes(router fiber.Router, handler *admin.DeviceHandler, imp Auth: true, }) + Register(devices, doc, groupPath, "POST", "/batch-update-realname-policy", handler.BatchUpdateRealnamePolicy, RouteSpec{ + Summary: "批量更新设备实名认证策略", + Description: "单次最多500条;先校验整批资产及数据权限,再在单事务内全成全败更新。设备下卡存在策略冲突时,C端以设备策略为准。", + Tags: []string{"设备管理"}, + Input: new(dto.BatchUpdateAssetRealnamePolicyRequest), + Output: new(dto.BatchUpdateAssetRealnamePolicyResponse), + Auth: true, + }) + Register(devices, doc, groupPath, "DELETE", "/:virtual_no", handler.Delete, RouteSpec{ Summary: "删除设备", Description: "仅平台用户可操作。删除设备时自动解绑所有卡(卡不会被删除)。", @@ -115,6 +124,15 @@ func registerDeviceRoutes(router fiber.Router, handler *admin.DeviceHandler, imp Auth: true, }) + Register(devices, doc, groupPath, "POST", "/import/allocations", importHandler.CreateAllocation, RouteSpec{ + Summary: "创建CSV设备批量分配任务", + Description: "复用设备导入任务。CSV只能包含一列设备标识,每行支持VirtualNo、IMEI或SN;整批选择分配目标代理或设置目标套餐系列。", + Tags: []string{"设备管理"}, + Input: new(dto.CreateDeviceBatchAllocationRequest), + Output: new(dto.CreateDeviceBatchAllocationResponse), + Auth: true, + }) + Register(devices, doc, groupPath, "GET", "/import/tasks/:id", importHandler.GetByID, RouteSpec{ Summary: "导入任务详情", Description: "仅平台用户可操作。包含跳过和失败记录的详细信息。", @@ -142,15 +160,6 @@ func registerDeviceRoutes(router fiber.Router, handler *admin.DeviceHandler, imp Auth: true, }) - Register(devices, doc, groupPath, "PUT", "/by-identifier/:identifier/speed-limit", handler.SetSpeedLimit, RouteSpec{ - Summary: "设置限速", - Description: "通过虚拟号/IMEI/SN 设置设备限速。设备必须已配置 IMEI。", - Tags: []string{"设备管理"}, - Input: new(dto.SetSpeedLimitRequest), - Output: new(dto.EmptyResponse), - Auth: true, - }) - Register(devices, doc, groupPath, "PUT", "/by-identifier/:identifier/wifi", handler.SetWiFi, RouteSpec{ Summary: "设置 WiFi", Description: "通过虚拟号/IMEI/SN 设置设备 WiFi。设备必须已配置 IMEI。", diff --git a/internal/routes/iot_card.go b/internal/routes/iot_card.go index 20eaca8..7cb147b 100644 --- a/internal/routes/iot_card.go +++ b/internal/routes/iot_card.go @@ -21,6 +21,15 @@ func registerIotCardRoutes(router fiber.Router, handler *admin.IotCardHandler, i Auth: true, }) + Register(iotCards, doc, groupPath, "POST", "/batch-update-realname-policy", handler.BatchUpdateRealnamePolicy, RouteSpec{ + Summary: "批量更新卡实名认证策略", + Description: "单次最多500条;先校验整批资产及数据权限,再在单事务内全成全败更新。", + Tags: []string{"IoT卡管理"}, + Input: new(dto.BatchUpdateAssetRealnamePolicyRequest), + Output: new(dto.BatchUpdateAssetRealnamePolicyResponse), + Auth: true, + }) + Register(iotCards, doc, groupPath, "POST", "/import", importHandler.Import, RouteSpec{ Summary: "批量导入IoT卡(ICCID+MSISDN)", Description: `仅平台用户可操作。 @@ -118,4 +127,13 @@ func registerIotCardRoutes(router fiber.Router, handler *admin.IotCardHandler, i Auth: true, }) + Register(iotCards, doc, groupPath, "PUT", "/:iccid/speed-tier", handler.SetSpeedTier, RouteSpec{ + Summary: "设置卡固定限速档位", + Description: "仅对有权限的 IoT 卡按 ICCID 设置固定 Gateway 限速档位;-1 表示恢复不限速。不支持设备限速,也不会通过设备绑定关系间接限速。", + Tags: []string{"IoT卡管理"}, + Input: new(dto.SetIotCardSpeedTierRequest), + Output: new(dto.SetIotCardSpeedTierResponse), + Auth: true, + }) + } diff --git a/internal/routes/notification.go b/internal/routes/notification.go index c8cf0df..221110a 100644 --- a/internal/routes/notification.go +++ b/internal/routes/notification.go @@ -32,7 +32,7 @@ func registerNotificationRoutes(router fiber.Router, handler *admin.Notification Register(notifications, doc, groupPath, "GET", "", handler.List, RouteSpec{ Summary: "查询通知列表", - Description: "仅返回当前认证后台账号的未过期通知,固定按创建时间和通知 ID 倒序。", + Description: "仅返回当前认证后台账号的未过期通知,固定按创建时间和通知 ID 倒序。ref_type/ref_id/ref_key 是受控资源引用,不是前端路由;点击通知时应调用 GET /api/admin/notifications/{id}/target,以返回的 target_type 和 available 决定是否跳转。", Tags: []string{"站内通知"}, Input: new(dto.NotificationListRequest), Output: new(dto.NotificationListResponse), @@ -50,7 +50,7 @@ func registerNotificationRoutes(router fiber.Router, handler *admin.Notification Register(notifications, doc, groupPath, "GET", "/:id/target", handler.Target, RouteSpec{ Summary: "解析通知受控目标", - Description: "先校验通知属于当前认证后台账号,再复核目标资源当前权限;只返回白名单结构化目标,不返回任意 URL。", + Description: "先校验通知属于当前认证后台账号,再复核目标资源当前权限;只返回白名单结构化目标,不返回任意 URL。前端维护 target_type 到页面的白名单映射;available=false 或 target_type 为空时只展示通知正文,不执行跳转。", Tags: []string{"站内通知"}, Input: new(dto.NotificationIDParams), Output: new(dto.NotificationTargetResponse), diff --git a/internal/routes/package_expiry.go b/internal/routes/package_expiry.go new file mode 100644 index 0000000..594375e --- /dev/null +++ b/internal/routes/package_expiry.go @@ -0,0 +1,21 @@ +package routes + +import ( + "github.com/gofiber/fiber/v2" + + "github.com/break/junhong_cmp_fiber/internal/handler/admin" + "github.com/break/junhong_cmp_fiber/internal/model/dto" + "github.com/break/junhong_cmp_fiber/pkg/openapi" +) + +// registerPackageExpiryRoutes 注册后台和代理共用的临期资产查询路由。 +func registerPackageExpiryRoutes(router fiber.Router, handler *admin.AssetHandler, doc *openapi.Generator, basePath string) { + Register(router, doc, basePath, "GET", "/expiring-assets", handler.ListExpiring, RouteSpec{ + Summary: "查询临期资产列表", + Description: "分页返回当前账号店铺权限范围内剩余 0 至 15 个上海自然日的卡和设备,并提供卡、设备数量、高亮等级及 0 至 3 天优先标识。企业账号禁止调用。", + Tags: []string{"资产管理"}, + Input: new(dto.ExpiringAssetListRequest), + Output: new(dto.ExpiringAssetListResponse), + Auth: true, + }) +} diff --git a/internal/routes/personal_notification.go b/internal/routes/personal_notification.go index 17df16c..7dd61b3 100644 --- a/internal/routes/personal_notification.go +++ b/internal/routes/personal_notification.go @@ -23,7 +23,7 @@ func registerPersonalNotificationRoutes(router fiber.Router, handler *apphandler Register(notifications, doc, groupPath, "GET", "", handler.List, RouteSpec{ Summary: "查询个人客户通知列表", - Description: "仅返回当前认证个人客户的未过期业务通知,固定按创建时间和通知 ID 倒序。", + Description: "仅返回当前认证个人客户的未过期业务通知,可按已读状态筛选,固定按创建时间和通知 ID 倒序。当前 C 端契约只承诺列表展示和未读弹窗;ref_type/ref_id/ref_key 是资源引用与展示快照,不是URL,也不承诺可直接拼接页面路由。", Tags: []string{"个人客户 - 站内通知"}, Input: new(dto.PersonalNotificationListRequest), Output: new(dto.PersonalNotificationListResponse), diff --git a/internal/routes/routes.go b/internal/routes/routes.go index 588b66c..a3c22f0 100644 --- a/internal/routes/routes.go +++ b/internal/routes/routes.go @@ -62,4 +62,8 @@ func RegisterRoutesWithDoc(app *fiber.App, handlers *bootstrap.Handlers, middlew callbackGroup := app.Group("/api/callback") registerCUCCCallbackRoutes(callbackGroup, handlers.CUCCRealnameRemovalCallback, doc, "/api/callback") } + if handlers.WeComApprovalCallback != nil { + callbackGroup := app.Group("/api/callback") + registerWeComApprovalCallbackRoutes(callbackGroup, handlers.WeComApprovalCallback, doc, "/api/callback") + } } diff --git a/internal/routes/storage.go b/internal/routes/storage.go index f4212fb..128aa00 100644 --- a/internal/routes/storage.go +++ b/internal/routes/storage.go @@ -134,6 +134,8 @@ await api.post('/iot-cards/import', { | 值 | 说明 | 生成路径格式 | |---|------|-------------| | iot_import | ICCID/设备导入 (Excel) | imports/YYYY/MM/DD/uuid.xlsx | +| batch_purchase | 资产套餐批量订购 (CSV) | batch-purchases/YYYY/MM/DD/uuid.csv | +| device_batch_allocation | 设备批量分配代理或套餐系列 (CSV) | device-batch-allocations/YYYY/MM/DD/uuid.csv | | export | 数据导出 | exports/YYYY/MM/DD/uuid.xlsx | | attachment | 附件上传 | attachments/YYYY/MM/DD/uuid.ext | diff --git a/internal/routes/wecom.go b/internal/routes/wecom.go new file mode 100644 index 0000000..6af184d --- /dev/null +++ b/internal/routes/wecom.go @@ -0,0 +1,80 @@ +package routes + +import ( + "github.com/gofiber/fiber/v2" + + "github.com/break/junhong_cmp_fiber/internal/handler/admin" + "github.com/break/junhong_cmp_fiber/internal/model/dto" + "github.com/break/junhong_cmp_fiber/pkg/constants" + "github.com/break/junhong_cmp_fiber/pkg/errors" + "github.com/break/junhong_cmp_fiber/pkg/middleware" + "github.com/break/junhong_cmp_fiber/pkg/openapi" +) + +func registerWeComRoutes(router fiber.Router, handler *admin.WeComHandler, doc *openapi.Generator, basePath string) { + group := router.Group("/wecom", func(c *fiber.Ctx) error { + userType := middleware.GetUserTypeFromContext(c.UserContext()) + if userType != constants.UserTypeSuperAdmin && userType != constants.UserTypePlatform { + return errors.New(errors.CodeForbidden, "无权限访问企业微信审批配置") + } + return c.Next() + }) + groupPath := basePath + "/wecom" + + Register(group, doc, groupPath, "POST", "/applications", handler.Save, RouteSpec{ + Summary: "创建或更新企业微信应用配置", + Tags: []string{"企业微信审批"}, + Input: new(dto.SaveWeComApplicationRequest), + Output: new(dto.WeComApplicationResponse), + Auth: true, + }) + Register(group, doc, groupPath, "GET", "/applications", handler.List, RouteSpec{ + Summary: "查询企业微信应用配置", + Tags: []string{"企业微信审批"}, + Input: new(dto.WeComApplicationListRequest), + Output: new(dto.WeComApplicationListResponse), + Auth: true, + }) + Register(group, doc, groupPath, "POST", "/applications/:id/test", handler.Test, RouteSpec{ + Summary: "测试企业微信应用连接", + Tags: []string{"企业微信审批"}, + Input: new(dto.IDReq), + Output: new(dto.WeComConnectionTestResponse), + Auth: true, + }) + Register(group, doc, groupPath, "PUT", "/applications/:id/default-creator", handler.SaveDefaultCreator, RouteSpec{ + Summary: "保存企业微信应用默认审批发起人", + Tags: []string{"企业微信审批"}, + Input: new(dto.SaveWeComDefaultCreatorParams), + Output: new(dto.WeComApplicationResponse), + Auth: true, + }) + Register(group, doc, groupPath, "POST", "/applications/:id/members/sync", handler.SyncMembers, RouteSpec{ + Summary: "同步企业微信应用可见成员", + Tags: []string{"企业微信审批"}, + Input: new(dto.IDReq), + Output: new(dto.WeComMemberSyncResponse), + Auth: true, + }) + Register(group, doc, groupPath, "GET", "/applications/:id/members", handler.ListMembers, RouteSpec{ + Summary: "分页查询企业微信应用可见成员", + Tags: []string{"企业微信审批"}, + Input: new(dto.WeComMemberListParams), + Output: new(dto.WeComMemberListResponse), + Auth: true, + }) + Register(group, doc, groupPath, "PUT", "/scenes/:business_type", handler.SaveScene, RouteSpec{ + Summary: "保存并校验企业微信审批场景模板映射", + Tags: []string{"企业微信审批"}, + Input: new(dto.SaveWeComApprovalSceneParams), + Output: new(dto.WeComApprovalSceneResponse), + Auth: true, + }) + Register(group, doc, groupPath, "GET", "/scenes", handler.ListScenes, RouteSpec{ + Summary: "分页查询企业微信审批场景配置", + Tags: []string{"企业微信审批"}, + Input: new(dto.WeComApprovalSceneListRequest), + Output: new(dto.WeComApprovalSceneListResponse), + Auth: true, + }) +} diff --git a/internal/routes/wecom_callback.go b/internal/routes/wecom_callback.go new file mode 100644 index 0000000..906ca31 --- /dev/null +++ b/internal/routes/wecom_callback.go @@ -0,0 +1,24 @@ +package routes + +import ( + "github.com/gofiber/fiber/v2" + + "github.com/break/junhong_cmp_fiber/internal/handler/callback" + "github.com/break/junhong_cmp_fiber/internal/model/dto" + "github.com/break/junhong_cmp_fiber/pkg/openapi" +) + +func registerWeComApprovalCallbackRoutes(router fiber.Router, handler *callback.WeComApprovalHandler, doc *openapi.Generator, basePath string) { + Register(router, doc, basePath, "GET", "/wecom/approval/:application_id", handler.Verify, RouteSpec{ + Summary: "验证企业微信审批回调地址", + Tags: []string{"企业微信审批回调"}, + Input: new(dto.WeComApprovalCallbackRequest), + Auth: false, + }) + Register(router, doc, basePath, "POST", "/wecom/approval/:application_id", handler.Receive, RouteSpec{ + Summary: "接收企业微信审批状态变化回调", + Tags: []string{"企业微信审批回调"}, + Input: new(dto.WeComApprovalCallbackRequest), + Auth: false, + }) +} diff --git a/internal/service/account/service.go b/internal/service/account/service.go index 7bf63f8..da7c29a 100644 --- a/internal/service/account/service.go +++ b/internal/service/account/service.go @@ -4,7 +4,9 @@ package account import ( "context" + stdErrors "errors" "fmt" + "strings" "github.com/break/junhong_cmp_fiber/internal/model" "github.com/break/junhong_cmp_fiber/internal/model/dto" @@ -13,6 +15,7 @@ import ( "github.com/break/junhong_cmp_fiber/pkg/constants" "github.com/break/junhong_cmp_fiber/pkg/errors" "github.com/break/junhong_cmp_fiber/pkg/middleware" + "github.com/jackc/pgx/v5/pgconn" "golang.org/x/crypto/bcrypt" "gorm.io/gorm" ) @@ -31,12 +34,18 @@ type Service struct { shopStore ShopStoreInterface enterpriseStore middleware.EnterpriseStoreInterface auditService AuditServiceInterface + wecomMembers WeComMemberFinder } type AuditServiceInterface interface { LogOperation(ctx context.Context, log *model.AccountOperationLog) } +// WeComMemberFinder 定义账号绑定时校验应用可见成员的边界。 +type WeComMemberFinder interface { + GetVisible(ctx context.Context, applicationID uint, userID string) (*model.WeComMember, error) +} + // New 创建账号服务 func New( accountStore *postgres.AccountStore, @@ -58,6 +67,11 @@ func New( } } +// SetWeComMemberFinder 注入企业微信应用可见成员查询边界。 +func (s *Service) SetWeComMemberFinder(finder WeComMemberFinder) { + s.wecomMembers = finder +} + // Create 创建账号 func (s *Service) Create(ctx context.Context, req *dto.CreateAccountRequest) (*model.Account, error) { currentUserID := middleware.GetUserIDFromContext(ctx) @@ -180,7 +194,7 @@ func (s *Service) Create(ctx context.Context, req *dto.CreateAccountRequest) (*m } // Get 获取账号 -func (s *Service) Get(ctx context.Context, id uint) (*model.Account, error) { +func (s *Service) Get(ctx context.Context, id uint) (*dto.AccountResponse, error) { account, err := s.accountStore.GetByID(ctx, id) if err != nil { if err == gorm.ErrRecordNotFound { @@ -188,7 +202,66 @@ func (s *Service) Get(ctx context.Context, id uint) (*model.Account, error) { } return nil, errors.Wrap(errors.CodeInternalError, err, "获取账号失败") } - return account, nil + accounts := []*model.Account{account} + return s.toAccountResponse(account, s.loadShopNames(ctx, accounts), s.loadEnterpriseNames(ctx, accounts)), nil +} + +// BindWeCom 将系统账号绑定到管理员明确选择的企微应用可见成员。 +func (s *Service) BindWeCom(ctx context.Context, accountID uint, request dto.BindAccountWeComRequest) (*dto.AccountResponse, error) { + operatorID := middleware.GetUserIDFromContext(ctx) + operatorType := middleware.GetUserTypeFromContext(ctx) + if operatorID == 0 { + return nil, errors.New(errors.CodeUnauthorized) + } + if operatorType != constants.UserTypeSuperAdmin && operatorType != constants.UserTypePlatform { + return nil, errors.New(errors.CodeForbidden) + } + if s.wecomMembers == nil || request.ApplicationID == 0 || strings.TrimSpace(request.UserID) == "" { + return nil, errors.New(errors.CodeInvalidParam) + } + account, err := s.accountStore.GetByID(ctx, accountID) + if err != nil { + if err == gorm.ErrRecordNotFound { + return nil, errors.New(errors.CodeForbidden, "无权限操作该资源或资源不存在") + } + return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询待绑定账号失败") + } + member, err := s.wecomMembers.GetVisible(ctx, request.ApplicationID, request.UserID) + if err != nil { + return nil, err + } + beforeData := model.JSONB{ + "wecom_corp_id": account.WeComCorpID, "wecom_userid": account.WeComUserID, + "wecom_name": account.WeComName, + } + if err := s.accountStore.BindWeCom(ctx, accountID, member.CorpID, member.UserID, member.Name, operatorID); err != nil { + var pgErr *pgconn.PgError + if stdErrors.As(err, &pgErr) && pgErr.Code == "23505" { + return nil, errors.New(errors.CodeConflict, "该企业微信成员已绑定其他系统账号") + } + return nil, errors.Wrap(errors.CodeDatabaseError, err, "绑定企业微信成员失败") + } + account.WeComCorpID = member.CorpID + account.WeComUserID = member.UserID + account.WeComName = member.Name + account.Updater = operatorID + if s.auditService != nil { + operatorName := "" + if operator, getErr := s.accountStore.GetByID(ctx, operatorID); getErr == nil { + operatorName = operator.Username + } + s.auditService.LogOperation(ctx, &model.AccountOperationLog{ + OperatorID: operatorID, OperatorType: operatorType, OperatorName: operatorName, + TargetAccountID: &account.ID, TargetUsername: &account.Username, TargetUserType: &account.UserType, + OperationType: "bind_wecom", OperationDesc: fmt.Sprintf("绑定账号企业微信成员: %s", account.Username), + BeforeData: beforeData, AfterData: model.JSONB{ + "wecom_corp_id": member.CorpID, "wecom_userid": member.UserID, "wecom_name": member.Name, + }, RequestID: middleware.GetRequestIDFromContext(ctx), IPAddress: middleware.GetIPFromContext(ctx), + UserAgent: middleware.GetUserAgentFromContext(ctx), + }) + } + accounts := []*model.Account{account} + return s.toAccountResponse(account, s.loadShopNames(ctx, accounts), s.loadEnterpriseNames(ctx, accounts)), nil } // Update 更新账号 @@ -796,6 +869,10 @@ func (s *Service) toAccountResponse(acc *model.Account, shopMap map[uint]string, UserType: acc.UserType, ShopID: acc.ShopID, EnterpriseID: acc.EnterpriseID, + WeComCorpID: acc.WeComCorpID, + WeComUserID: acc.WeComUserID, + WeComName: acc.WeComName, + WeComBound: acc.WeComCorpID != "" && acc.WeComUserID != "", Status: acc.Status, StatusName: constants.GetStatusName(acc.Status), Creator: acc.Creator, diff --git a/internal/service/agent_recharge/service.go b/internal/service/agent_recharge/service.go index 0f63357..81cc82e 100644 --- a/internal/service/agent_recharge/service.go +++ b/internal/service/agent_recharge/service.go @@ -13,10 +13,12 @@ import ( "go.uber.org/zap" "gorm.io/gorm" + agentrechargeapp "github.com/break/junhong_cmp_fiber/internal/application/agentrecharge" walletapp "github.com/break/junhong_cmp_fiber/internal/application/wallet" "github.com/break/junhong_cmp_fiber/internal/model" "github.com/break/junhong_cmp_fiber/internal/model/dto" "github.com/break/junhong_cmp_fiber/internal/store/postgres" + "github.com/break/junhong_cmp_fiber/pkg/config" "github.com/break/junhong_cmp_fiber/pkg/constants" "github.com/break/junhong_cmp_fiber/pkg/errors" "github.com/break/junhong_cmp_fiber/pkg/middleware" @@ -44,6 +46,7 @@ type Service struct { agentRechargeStore *postgres.AgentRechargeStore agentWalletStore *postgres.AgentWalletStore agentWalletPosting *walletapp.PostingService + offlineCreation *agentrechargeapp.OfflineCreationService shopStore *postgres.ShopStore wechatConfigService WechatConfigServiceInterface auditService AuditServiceInterface @@ -82,6 +85,11 @@ func (s *Service) SetAgentWalletPostingService(service *walletapp.PostingService s.agentWalletPosting = service } +// SetOfflineCreationService 注入员工线下代充值审批申请用例。 +func (s *Service) SetOfflineCreationService(service *agentrechargeapp.OfflineCreationService) { + s.offlineCreation = service +} + // Create 创建代理充值订单 // POST /api/admin/agent-recharges func (s *Service) Create(ctx context.Context, req *dto.CreateAgentRechargeRequest) (*dto.AgentRechargeResponse, error) { @@ -95,12 +103,12 @@ func (s *Service) Create(ctx context.Context, req *dto.CreateAgentRechargeReques } // 线下充值仅平台可用 - if req.PaymentMethod == "offline" && userType != constants.UserTypePlatform && userType != constants.UserTypeSuperAdmin { + if req.PaymentMethod == constants.RechargeMethodOffline && userType != constants.UserTypePlatform && userType != constants.UserTypeSuperAdmin { return nil, errors.New(errors.CodeForbidden, "线下充值仅平台管理员可操作") } // 线下充值必须上传支付凭证 - if req.PaymentMethod == "offline" && len(req.PaymentVoucherKey) == 0 { + if req.PaymentMethod == constants.RechargeMethodOffline && len(req.PaymentVoucherKey) == 0 { return nil, errors.New(errors.CodeInvalidParam, "线下充值必须上传支付凭证") } @@ -108,6 +116,11 @@ func (s *Service) Create(ctx context.Context, req *dto.CreateAgentRechargeReques return nil, errors.New(errors.CodeInvalidParam, "充值金额超出允许范围") } + rechargeNo := s.generateRechargeNo() + if req.PaymentMethod == constants.RechargeMethodOffline { + return s.createOffline(ctx, req, userID, userType, rechargeNo) + } + // 查找目标店铺的主钱包 wallet, err := s.agentWalletStore.GetMainWallet(ctx, req.ShopID) if err != nil { @@ -140,8 +153,6 @@ func (s *Service) Create(ctx context.Context, req *dto.CreateAgentRechargeReques paymentChannel = "offline" } - rechargeNo := s.generateRechargeNo() - record := &model.AgentRechargeRecord{ UserID: userID, AgentWalletID: wallet.ID, @@ -170,12 +181,55 @@ func (s *Service) Create(ctx context.Context, req *dto.CreateAgentRechargeReques zap.Uint("user_id", userID), ) - return toResponse(record, shop.ShopName), nil + resp := toResponse(record, shop.ShopName) + resp.SubmitterName = s.loadSubmitterNameBestEffort(ctx, record.UserID) + return resp, nil +} + +func (s *Service) createOffline( + ctx context.Context, + req *dto.CreateAgentRechargeRequest, + userID uint, + userType int, + rechargeNo string, +) (*dto.AgentRechargeResponse, error) { + if s.offlineCreation == nil { + return nil, errors.New(errors.CodeServiceUnavailable, "员工线下代充值审批能力未配置") + } + result, err := s.offlineCreation.Execute(ctx, agentrechargeapp.CreateOfflineCommand{ + SubmitterAccountID: userID, + SubmitterUserType: userType, + ShopID: req.ShopID, + RechargeNo: rechargeNo, + Amount: req.Amount, + PaymentVoucherKeys: req.PaymentVoucherKey, + Remark: req.Remark, + }) + if err != nil { + return nil, err + } + resp := toResponse(result.Record, result.ShopName) + resp.SubmitterName = result.SubmitterName + resp.ApprovalProvider = constants.IntegrationProviderWeCom + resp.ApprovalStatus = &result.ApprovalStatus + resp.ApprovalStatusName = constants.GetApprovalStatusName(result.ApprovalStatus) + s.logger.Info("创建员工线下代充值审批申请成功", + zap.Uint("recharge_id", result.Record.ID), + zap.String("recharge_no", result.Record.RechargeNo), + zap.Uint("approval_instance_id", *result.Record.ApprovalInstanceID), + zap.Int64("amount", result.Record.Amount), + zap.Uint("shop_id", result.Record.ShopID), + zap.Uint("submitter_id", result.Record.UserID), + ) + return resp, nil } // OfflinePay 线下充值确认 // POST /api/admin/agent-recharges/:id/offline-pay func (s *Service) OfflinePay(ctx context.Context, id uint, req *dto.AgentOfflinePayRequest) (*dto.AgentRechargeResponse, error) { + if !legacyOfflineRechargePayEnabled() { + return nil, errors.New(errors.CodeInvalidStatus, "线下充值人工确认入口已停用,请查看企业微信审批状态") + } userID := middleware.GetUserIDFromContext(ctx) userType := middleware.GetUserTypeFromContext(ctx) @@ -197,9 +251,12 @@ func (s *Service) OfflinePay(ctx context.Context, id uint, req *dto.AgentOffline return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询充值记录失败") } - if record.PaymentMethod != "offline" { + if record.PaymentMethod != constants.RechargeMethodOffline { return nil, errors.New(errors.CodeInvalidParam, "该订单非线下充值,不支持此操作") } + if record.ApprovalInstanceID != nil { + return nil, errors.New(errors.CodeInvalidStatus, "该线下充值申请由企业微信审批决定,不能人工确认") + } if record.Status != constants.RechargeStatusPending { return nil, errors.New(errors.CodeInvalidParam, "该订单状态不允许确认支付") } @@ -266,7 +323,19 @@ func (s *Service) OfflinePay(ctx context.Context, id uint, req *dto.AgentOffline record.PaidAt = &now record.CompletedAt = &now - return toResponse(record, shopName), nil + resp := toResponse(record, shopName) + resp.SubmitterName = s.loadSubmitterNameBestEffort(ctx, record.UserID) + approvalSummaries, err := s.loadApprovalSummaries(ctx, []*model.AgentRechargeRecord{record}) + if err != nil { + return nil, err + } + applyApprovalSummary(resp, approvalSummaries, record.ApprovalInstanceID) + return resp, nil +} + +func legacyOfflineRechargePayEnabled() bool { + cfg := config.Get() + return cfg == nil || cfg.Approval.LegacyOfflineRechargePayEnabled } // HandlePaymentCallback 处理支付回调 @@ -388,6 +457,9 @@ func (s *Service) Reject(ctx context.Context, id uint, rejectionReason string) e if record.Status != constants.RechargeStatusPending { return errors.New(errors.CodeInvalidStatus, "仅待支付订单可驳回") } + if record.ApprovalInstanceID != nil { + return errors.New(errors.CodeInvalidStatus, "该线下充值申请由企业微信审批决定,不能人工驳回") + } if err := s.agentRechargeStore.UpdateStatusWithRejection(ctx, id, rejectionReason); err != nil { if err == gorm.ErrRecordNotFound { @@ -420,7 +492,14 @@ func (s *Service) GetByID(ctx context.Context, id uint) (*dto.AgentRechargeRespo shopName = shop.ShopName } - return toResponse(record, shopName), nil + resp := toResponse(record, shopName) + resp.SubmitterName = s.loadSubmitterNameBestEffort(ctx, record.UserID) + approvalSummaries, err := s.loadApprovalSummaries(ctx, []*model.AgentRechargeRecord{record}) + if err != nil { + return nil, err + } + applyApprovalSummary(resp, approvalSummaries, record.ApprovalInstanceID) + return resp, nil } // List 分页查询充值订单列表 @@ -475,10 +554,27 @@ func (s *Service) List(ctx context.Context, req *dto.AgentRechargeListRequest) ( } } } + submitterIDs := make([]uint, 0, len(records)) + for _, record := range records { + if record.UserID > 0 { + submitterIDs = append(submitterIDs, record.UserID) + } + } + submitterNames, err := s.loadSubmitterNames(ctx, submitterIDs) + if err != nil { + return nil, 0, errors.Wrap(errors.CodeDatabaseError, err, "查询充值提交人失败") + } + approvalSummaries, err := s.loadApprovalSummaries(ctx, records) + if err != nil { + return nil, 0, err + } list := make([]*dto.AgentRechargeResponse, 0, len(records)) for _, r := range records { - list = append(list, toResponse(r, shopMap[r.ShopID])) + item := toResponse(r, shopMap[r.ShopID]) + item.SubmitterName = submitterNames[r.UserID] + applyApprovalSummary(item, approvalSummaries, r.ApprovalInstanceID) + list = append(list, item) } return list, total, nil @@ -495,20 +591,22 @@ func (s *Service) generateRechargeNo() string { // toResponse 将模型转换为响应 DTO func toResponse(record *model.AgentRechargeRecord, shopName string) *dto.AgentRechargeResponse { resp := &dto.AgentRechargeResponse{ - ID: record.ID, - RechargeNo: record.RechargeNo, - ShopID: record.ShopID, - ShopName: shopName, - AgentWalletID: record.AgentWalletID, - Amount: record.Amount, - PaymentMethod: record.PaymentMethod, - PaymentVoucherKey: []string(record.PaymentVoucherKey), - Remark: record.Remark, - Status: record.Status, - StatusName: constants.GetRechargeStatusName(record.Status), - RejectionReason: record.RejectionReason, - CreatedAt: record.CreatedAt.Format("2006-01-02 15:04:05"), - UpdatedAt: record.UpdatedAt.Format("2006-01-02 15:04:05"), + ID: record.ID, + RechargeNo: record.RechargeNo, + ShopID: record.ShopID, + ShopName: shopName, + AgentWalletID: record.AgentWalletID, + Amount: record.Amount, + PaymentMethod: record.PaymentMethod, + PaymentVoucherKey: []string(record.PaymentVoucherKey), + Remark: record.Remark, + Status: record.Status, + StatusName: constants.GetRechargeStatusName(record.Status), + RejectionReason: record.RejectionReason, + SubmitterID: record.UserID, + ApprovalInstanceID: record.ApprovalInstanceID, + CreatedAt: record.CreatedAt.Format("2006-01-02 15:04:05"), + UpdatedAt: record.UpdatedAt.Format("2006-01-02 15:04:05"), } if record.PaymentChannel != nil { @@ -531,3 +629,74 @@ func toResponse(record *model.AgentRechargeRecord, shopName string) *dto.AgentRe return resp } + +type approvalSummary struct { + Provider string + Status int +} + +func (s *Service) loadApprovalSummaries( + ctx context.Context, + records []*model.AgentRechargeRecord, +) (map[uint]approvalSummary, error) { + ids := make([]uint, 0, len(records)) + seen := make(map[uint]struct{}, len(records)) + for _, record := range records { + if record == nil || record.ApprovalInstanceID == nil || *record.ApprovalInstanceID == 0 { + continue + } + id := *record.ApprovalInstanceID + if _, exists := seen[id]; exists { + continue + } + seen[id] = struct{}{} + ids = append(ids, id) + } + summaries := make(map[uint]approvalSummary, len(ids)) + if len(ids) == 0 { + return summaries, nil + } + var instances []model.ApprovalInstance + if err := s.db.WithContext(ctx).Select("id", "provider", "status").Where("id IN ?", ids).Find(&instances).Error; err != nil { + return nil, errors.Wrap(errors.CodeDatabaseError, err, "批量查询充值审批状态失败") + } + for _, instance := range instances { + summaries[instance.ID] = approvalSummary{Provider: instance.Provider, Status: instance.Status} + } + return summaries, nil +} + +func applyApprovalSummary(response *dto.AgentRechargeResponse, summaries map[uint]approvalSummary, instanceID *uint) { + if response == nil || instanceID == nil { + return + } + summary, exists := summaries[*instanceID] + if !exists { + return + } + status := summary.Status + response.ApprovalProvider = summary.Provider + response.ApprovalStatus = &status + response.ApprovalStatusName = constants.GetApprovalStatusName(status) +} + +func (s *Service) loadSubmitterNames(ctx context.Context, ids []uint) (map[uint]string, error) { + accounts, err := postgres.NewAccountStore(s.db, nil).GetDisplayAccountsByIDs(ctx, ids) + if err != nil { + return nil, err + } + names := make(map[uint]string, len(accounts)) + for _, account := range accounts { + names[account.ID] = account.Username + } + return names, nil +} + +func (s *Service) loadSubmitterNameBestEffort(ctx context.Context, id uint) string { + names, err := s.loadSubmitterNames(ctx, []uint{id}) + if err != nil { + s.logger.Warn("查询充值提交人失败", zap.Uint("submitter_id", id), zap.Error(err)) + return "" + } + return names[id] +} diff --git a/internal/service/asset_audit/operation_content.go b/internal/service/asset_audit/operation_content.go index 4a25cc7..d16973a 100644 --- a/internal/service/asset_audit/operation_content.go +++ b/internal/service/asset_audit/operation_content.go @@ -300,9 +300,11 @@ func getOperationFieldDesc(operationType string) map[string]string { "skip_count": "跳过数量", "failed_items": "失败明细(含ICCID与原因)", }) - case "device_speed_limit": + case "card_speed_tier": return map[string]string{ - "speed_limit": "限速值(KB/s)", + "tier_code": "固定限速档位编码", + "tier_name": "固定限速档位", + "integration_id": "Gateway 外部交互记录ID", } case "device_set_wifi": return map[string]string{ diff --git a/internal/service/asset_package_batch_order/service.go b/internal/service/asset_package_batch_order/service.go new file mode 100644 index 0000000..5b6d715 --- /dev/null +++ b/internal/service/asset_package_batch_order/service.go @@ -0,0 +1,163 @@ +// Package asset_package_batch_order 提供单列 CSV 资产套餐批量订购任务能力。 +package asset_package_batch_order + +import ( + "context" + "path/filepath" + "strings" + "time" + + "github.com/hibiken/asynq" + "gorm.io/gorm" + + "github.com/break/junhong_cmp_fiber/internal/model" + "github.com/break/junhong_cmp_fiber/internal/model/dto" + "github.com/break/junhong_cmp_fiber/internal/store" + "github.com/break/junhong_cmp_fiber/internal/store/postgres" + "github.com/break/junhong_cmp_fiber/pkg/asynctask" + "github.com/break/junhong_cmp_fiber/pkg/constants" + "github.com/break/junhong_cmp_fiber/pkg/errors" + "github.com/break/junhong_cmp_fiber/pkg/middleware" + "github.com/break/junhong_cmp_fiber/pkg/queue" +) + +// Service 资产套餐批量订购任务服务。 +type Service struct { + taskStore *postgres.AssetPackageBatchOrderTaskStore + packageStore *postgres.PackageStore + queueClient *queue.Client +} + +// New 创建资产套餐批量订购任务服务。 +func New(taskStore *postgres.AssetPackageBatchOrderTaskStore, packageStore *postgres.PackageStore, queueClient *queue.Client) *Service { + return &Service{taskStore: taskStore, packageStore: packageStore, queueClient: queueClient} +} + +// TaskPayload 批量订购 Worker 结构化载荷。 +type TaskPayload struct { + TaskID uint `json:"task_id"` +} + +// Create 创建批量订购任务。 +func (s *Service) Create(ctx context.Context, req *dto.CreateAssetPackageBatchOrderRequest) (*dto.AssetPackageBatchOrderTaskResponse, error) { + if !strings.EqualFold(filepath.Ext(req.FileKey), ".csv") { + return nil, errors.New(errors.CodeInvalidParam, "批量订购文件必须为CSV格式") + } + if !strings.HasPrefix(req.FileKey, constants.AssetPackageBatchOrderStoragePrefix+"/") { + return nil, errors.New(errors.CodeInvalidParam, "批量订购文件Key不属于指定上传目录") + } + if err := validatePaymentParameters(req); err != nil { + return nil, err + } + userID := middleware.GetUserIDFromContext(ctx) + if userID == 0 { + return nil, errors.New(errors.CodeUnauthorized) + } + pkg, err := s.packageStore.GetByID(ctx, req.PackageID) + if err != nil { + if err == gorm.ErrRecordNotFound { + return nil, errors.New(errors.CodeNotFound, "套餐不存在") + } + return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询套餐失败") + } + task := &model.AssetPackageBatchOrderTask{ + TaskNo: s.taskStore.GenerateTaskNo(), PackageID: pkg.ID, + PackageCode: pkg.PackageCode, PackageName: pkg.PackageName, + PaymentMethod: req.PaymentMethod, FileName: filepath.Base(req.FileKey), StorageKey: req.FileKey, + VoucherKeys: model.StringJSONBArray(req.VoucherKeys), Status: asynctask.StatusPending, + ResultItems: model.AssetPackageBatchOrderResultItems{}, Creator: userID, Updater: userID, + CreatorUserType: middleware.GetUserTypeFromContext(ctx), CreatorShopID: middleware.GetShopIDFromContext(ctx), + CreatorName: middleware.GetUsernameFromContext(ctx), + } + if err := s.taskStore.Create(ctx, task); err != nil { + return nil, errors.Wrap(errors.CodeDatabaseError, err, "创建批量订购任务失败") + } + var enqueueErr error + if s.queueClient == nil { + enqueueErr = errors.New(errors.CodeTaskQueueError, "批量订购任务队列未配置") + } else { + enqueueErr = s.queueClient.EnqueueTask(ctx, constants.TaskTypeAssetPackageBatchOrder, TaskPayload{TaskID: task.ID}, + asynq.Queue(constants.QueueForTaskType(constants.TaskTypeAssetPackageBatchOrder)), + asynq.Timeout(constants.AssetPackageBatchOrderTaskTimeout)) + } + if enqueueErr != nil { + message := "批量订购任务入队失败" + _ = s.taskStore.MarkFailed(ctx, task.ID, message) + task.Status, task.ErrorMessage = asynctask.StatusFailed, message + now := time.Now() + task.CompletedAt = &now + } + return toResponse(task), nil +} + +// List 分页查询批量订购任务。 +func (s *Service) List(ctx context.Context, req *dto.ListAssetPackageBatchOrderRequest) (*dto.AssetPackageBatchOrderTaskListResponse, error) { + if req.Page <= 0 { + req.Page = constants.DefaultPage + } + if req.PageSize <= 0 { + req.PageSize = constants.DefaultPageSize + } + tasks, total, err := s.taskStore.List(ctx, &store.QueryOptions{Page: req.Page, PageSize: req.PageSize}, req.Status) + if err != nil { + return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询批量订购任务失败") + } + items := make([]*dto.AssetPackageBatchOrderTaskResponse, 0, len(tasks)) + for _, task := range tasks { + items = append(items, toResponse(task)) + } + return &dto.AssetPackageBatchOrderTaskListResponse{Total: total, Page: req.Page, PageSize: req.PageSize, Items: items}, nil +} + +// GetByID 查询批量订购任务及逐行结果。 +func (s *Service) GetByID(ctx context.Context, id uint) (*dto.AssetPackageBatchOrderTaskDetailResponse, error) { + task, err := s.taskStore.GetByID(ctx, id) + if err != nil { + if err == gorm.ErrRecordNotFound { + return nil, errors.New(errors.CodeNotFound, "批量订购任务不存在") + } + return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询批量订购任务失败") + } + items := make([]dto.AssetPackageBatchOrderResultItemResponse, 0, len(task.ResultItems)) + for _, item := range task.ResultItems { + items = append(items, dto.AssetPackageBatchOrderResultItemResponse{ + Line: item.Line, AssetIdentifier: item.AssetIdentifier, Status: item.Status, + StatusName: constants.GetAssetPackageBatchOrderItemStatusName(item.Status), + OrderID: item.OrderID, OrderNo: item.OrderNo, Amount: item.Amount, Reason: item.Reason, + }) + } + return &dto.AssetPackageBatchOrderTaskDetailResponse{AssetPackageBatchOrderTaskResponse: *toResponse(task), Items: items}, nil +} + +func validatePaymentParameters(req *dto.CreateAssetPackageBatchOrderRequest) error { + if req.PaymentMethod == model.PaymentMethodWallet && len(req.VoucherKeys) > 0 { + return errors.New(errors.CodeInvalidParam, "钱包支付不能提交线下凭证") + } + if req.PaymentMethod == model.PaymentMethodOffline && len(req.VoucherKeys) == 0 { + return errors.New(errors.CodeInvalidParam, "线下支付必须提交至少一个凭证") + } + return nil +} + +func toResponse(task *model.AssetPackageBatchOrderTask) *dto.AssetPackageBatchOrderTaskResponse { + response := &dto.AssetPackageBatchOrderTaskResponse{ + ID: task.ID, TaskNo: task.TaskNo, PackageID: task.PackageID, PackageCode: task.PackageCode, + PackageName: task.PackageName, PaymentMethod: task.PaymentMethod, FileName: task.FileName, + VoucherKeys: []string(task.VoucherKeys), Status: task.Status, StatusName: asynctask.StatusName(task.Status), + TotalCount: task.TotalCount, SuccessCount: task.SuccessCount, FailCount: task.FailCount, + ErrorMessage: task.ErrorMessage, CreatorName: task.CreatorName, + CreatedAt: task.CreatedAt.Format(time.RFC3339), UpdatedAt: task.UpdatedAt.Format(time.RFC3339), + } + if response.VoucherKeys == nil { + response.VoucherKeys = []string{} + } + if task.StartedAt != nil { + value := task.StartedAt.Format(time.RFC3339) + response.StartedAt = &value + } + if task.CompletedAt != nil { + value := task.CompletedAt.Format(time.RFC3339) + response.CompletedAt = &value + } + return response +} diff --git a/internal/service/client_auth/service.go b/internal/service/client_auth/service.go index 1996ebb..a816697 100644 --- a/internal/service/client_auth/service.go +++ b/internal/service/client_auth/service.go @@ -102,7 +102,7 @@ func (s *Service) VerifyAsset(ctx context.Context, req *dto.VerifyAssetRequest, return nil, err } - assetType, assetID, err := s.resolveAsset(ctx, req.Identifier) + assetType, assetID, shopID, err := s.resolveAsset(ctx, req.Identifier) if err != nil { return nil, err } @@ -116,6 +116,9 @@ func (s *Service) VerifyAsset(ctx context.Context, req *dto.VerifyAssetRequest, return nil, errors.New(errors.CodeForbidden, "该卡绑定了设备,请使用设备的虚拟号/设备号/IMEI/SN登录") } } + if err := s.ensureShopClientLoginAllowed(ctx, shopID); err != nil { + return nil, err + } assetToken, err := s.signAssetToken(assetType, assetID) if err != nil { @@ -407,26 +410,45 @@ func (s *Service) checkAssetVerifyRateLimit(ctx context.Context, clientIP string return nil } -func (s *Service) resolveAsset(ctx context.Context, identifier string) (string, uint, error) { +func (s *Service) resolveAsset(ctx context.Context, identifier string) (string, uint, *uint, error) { var card model.IotCard if err := s.db.WithContext(ctx). Where("iccid = ? OR virtual_no = ? OR msisdn = ?", identifier, identifier, identifier). First(&card).Error; err == nil { - return assetTypeIotCard, card.ID, nil + return assetTypeIotCard, card.ID, card.ShopID, nil } else if err != gorm.ErrRecordNotFound { - return "", 0, errors.Wrap(errors.CodeInternalError, err, "查询卡资产失败") + return "", 0, nil, errors.Wrap(errors.CodeInternalError, err, "查询卡资产失败") } var device model.Device if err := s.db.WithContext(ctx). Where("virtual_no = ? OR imei = ? OR sn = ?", identifier, identifier, identifier). First(&device).Error; err == nil { - return assetTypeDevice, device.ID, nil + return assetTypeDevice, device.ID, device.ShopID, nil } else if err != gorm.ErrRecordNotFound { - return "", 0, errors.Wrap(errors.CodeInternalError, err, "查询设备资产失败") + return "", 0, nil, errors.Wrap(errors.CodeInternalError, err, "查询设备资产失败") } - return "", 0, errors.New(errors.CodeAssetNotFound) + return "", 0, nil, errors.New(errors.CodeAssetNotFound) +} + +// ensureShopClientLoginAllowed 在签发资产令牌前检查店铺的新登录限制。 +func (s *Service) ensureShopClientLoginAllowed(ctx context.Context, shopID *uint) error { + if shopID == nil { + return nil + } + var shop model.Shop + err := s.db.WithContext(ctx).Select("client_login_disabled").First(&shop, *shopID).Error + if err == gorm.ErrRecordNotFound { + return errors.New(errors.CodeForbidden, "资产所属店铺不可用") + } + if err != nil { + return errors.Wrap(errors.CodeDatabaseError, err, "查询店铺C端登录限制失败") + } + if shop.ClientLoginDisabled { + return errors.New(errors.CodeForbidden, "该店铺暂不允许C端登录") + } + return nil } func (s *Service) signAssetToken(assetType string, assetID uint) (string, error) { @@ -681,7 +703,7 @@ func (s *Service) issueLoginToken(ctx context.Context, customerID uint, assetTyp // 根据资产标识符查找或创建测试客户并直接签发 JWT,无需微信 OAuth // ⚠️ 仅限 logging.development=true 时由路由层暴露,严禁生产环境调用 func (s *Service) DevLogin(ctx context.Context, identifier string) (string, uint, bool, error) { - assetType, assetID, err := s.resolveAsset(ctx, identifier) + assetType, assetID, _, err := s.resolveAsset(ctx, identifier) if err != nil { return "", 0, false, err } diff --git a/internal/service/client_order/service.go b/internal/service/client_order/service.go index c846084..0669751 100644 --- a/internal/service/client_order/service.go +++ b/internal/service/client_order/service.go @@ -42,6 +42,12 @@ type OrderWalletPayServiceInterface interface { WalletPay(ctx context.Context, orderID uint, buyerType string, buyerID uint) error } +// PaymentMethodPolicy 提供按资产类型校验支付方式的能力。 +type PaymentMethodPolicy interface { + AllowedMethods(ctx context.Context, assetType string) ([]string, error) + EnsureAllowed(ctx context.Context, assetType, paymentMethod string) error +} + // ForceRechargeRequirement 强充要求。 type ForceRechargeRequirement struct { NeedForceRecharge bool @@ -69,6 +75,12 @@ type Service struct { db *gorm.DB redis *redis.Client logger *zap.Logger + paymentMethodPolicy PaymentMethodPolicy +} + +// SetPaymentMethodPolicy 注入 C 端支付方式策略。 +func (s *Service) SetPaymentMethodPolicy(policy PaymentMethodPolicy) { + s.paymentMethodPolicy = policy } // New 创建客户端订单服务。 @@ -132,12 +144,20 @@ func (s *Service) CreateOrder(ctx context.Context, customerID uint, req *dto.Cli if err != nil { return nil, err } - if owned, err := s.customerBinding.OwnsAsset(skipCtx, customerID, assetInfo.AssetType, assetInfo.AssetID); err != nil { return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询资产归属失败") } else if !owned { return nil, errors.New(errors.CodeForbidden, "无权限操作该资产或资源不存在") } + if s.paymentMethodPolicy == nil { + return nil, errors.New(errors.CodeNoPaymentConfig) + } + if req.PaymentMethod == "" { + return nil, errors.New(errors.CodeInvalidParam, "请选择支付方式") + } + if err := s.paymentMethodPolicy.EnsureAllowed(skipCtx, assetInfo.AssetType, req.PaymentMethod); err != nil { + return nil, err + } validationResult, err := s.validatePurchase(skipCtx, assetInfo, req.PackageIDs) if err != nil { @@ -152,7 +172,7 @@ func (s *Service) CreateOrder(ctx context.Context, customerID uint, req *dto.Cli // 先判断是否需要强充,避免普通下单时不必要地校验微信配置 forceRecharge := s.checkForceRechargeRequirement(skipCtx, validationResult) - businessKey := buildClientPurchaseBusinessKey(customerID, assetInfo, req.PackageIDs) + businessKey := buildClientPurchaseBusinessKey(customerID, assetInfo, req.PackageIDs, req.PaymentMethod) redisKey := constants.RedisClientPurchaseIdempotencyKey(businessKey) lockKey := constants.RedisClientPurchaseLockKey(assetInfo.AssetType, assetInfo.AssetID) @@ -229,6 +249,15 @@ func (s *Service) CreateOrder(ctx context.Context, customerID uint, req *dto.Cli }() if forceRecharge.NeedForceRecharge { + if s.paymentMethodPolicy == nil { + return nil, errors.New(errors.CodeNoPaymentConfig) + } + if err := s.paymentMethodPolicy.EnsureAllowed(skipCtx, assetInfo.AssetType, req.PaymentMethod); err != nil { + return nil, err + } + if req.PaymentMethod == model.PaymentMethodWallet { + return nil, errors.New(errors.CodePaymentMethodUnavailable, "该套餐需要先充值,请选择当前资产允许的微信或支付宝方式") + } // 强充第三方支付方式与资产类型必须匹配:单卡只允许支付宝,设备只允许微信(已暂时注释) // switch assetInfo.AssetType { // case "card": @@ -268,7 +297,7 @@ func (s *Service) CreateOrder(ctx context.Context, customerID uint, req *dto.Cli return s.createForceRechargeOrder(skipCtx, customerID, openID, assetInfo, validationResult, activeConfig, forceRecharge, redisKey, paymentProvider, &created) } - return s.createPackageOrder(skipCtx, customerID, validationResult, redisKey, &created) + return s.createPackageOrder(skipCtx, customerID, validationResult, req.PaymentMethod, redisKey, &created) } // getOrderAssetInfo 从订单中提取资产类型和 ID,无需额外 DB 查询 @@ -295,9 +324,9 @@ func getOrderAssetInfo(order *model.Order) (string, uint, error) { func (s *Service) validatePurchase(ctx context.Context, assetInfo *dto.AssetResolveResponse, packageIDs []uint) (*purchase_validation.PurchaseValidationResult, error) { switch assetInfo.AssetType { case "card": - return s.purchaseValidationService.ValidateCardPurchase(ctx, assetInfo.AssetID, packageIDs) + return s.purchaseValidationService.ValidatePersonalCardPurchase(ctx, assetInfo.AssetID, packageIDs) case constants.ResourceTypeDevice: - return s.purchaseValidationService.ValidateDevicePurchase(ctx, assetInfo.AssetID, packageIDs) + return s.purchaseValidationService.ValidatePersonalDevicePurchase(ctx, assetInfo.AssetID, packageIDs) default: return nil, errors.New(errors.CodeInvalidParam) } @@ -350,6 +379,7 @@ func (s *Service) createPackageOrder( ctx context.Context, customerID uint, validationResult *purchase_validation.PurchaseValidationResult, + paymentMethod string, redisKey string, created *bool, ) (*dto.ClientCreateOrderResponse, error) { @@ -365,7 +395,7 @@ func (s *Service) createPackageOrder( sellerCostPrice = costPrice } - order, err := s.buildPendingOrder(ctx, customerID, validationResult, sellerCostPrice) + order, err := s.buildPendingOrder(ctx, customerID, validationResult, sellerCostPrice, paymentMethod) if err != nil { return nil, err } @@ -388,6 +418,7 @@ func (s *Service) createPackageOrder( OrderID: order.ID, OrderNo: order.OrderNo, TotalAmount: order.TotalAmount, + PaymentMethod: order.PaymentMethod, PaymentStatus: order.PaymentStatus, PaymentStatusName: constants.GetOrderPaymentStatusName(order.PaymentStatus), CreatedAt: formatClientServiceTime(order.CreatedAt), @@ -538,7 +569,7 @@ func (s *Service) buildPersonalCustomerOperatorSnapshot(ctx context.Context, cus return &id, model.OperatorAccountTypePersonalCustomer, name } -func (s *Service) buildPendingOrder(ctx context.Context, customerID uint, result *purchase_validation.PurchaseValidationResult, sellerCostPrice int64) (*model.Order, error) { +func (s *Service) buildPendingOrder(ctx context.Context, customerID uint, result *purchase_validation.PurchaseValidationResult, sellerCostPrice int64, paymentMethod string) (*model.Order, error) { orderType := resolveOrderType(result) if orderType == "" { return nil, errors.New(errors.CodeInvalidParam) @@ -556,10 +587,12 @@ func (s *Service) buildPendingOrder(ctx context.Context, customerID uint, result BuyerType: model.BuyerTypePersonal, BuyerID: customerID, TotalAmount: result.TotalPrice, + PaymentMethod: paymentMethod, PaymentStatus: model.PaymentStatusPending, CommissionStatus: model.CommissionStatusPending, CommissionConfigVersion: 0, Source: constants.OrderSourceClient, + PurchaseRole: model.PurchaseRoleSelfPurchase, Generation: resolveGeneration(result), ExpiresAt: &expiresAt, SellerCostPrice: sellerCostPrice, @@ -569,10 +602,12 @@ func (s *Service) buildPendingOrder(ctx context.Context, customerID uint, result order.IotCardID = &result.Card.ID order.SeriesID = result.Card.SeriesID order.SellerShopID = result.Card.ShopID + order.AssetIdentifier = result.Card.ICCID } else if result.Device != nil { order.DeviceID = &result.Device.ID order.SeriesID = result.Device.SeriesID order.SellerShopID = result.Device.ShopID + order.AssetIdentifier = firstNonEmptyClientOrderIdentifier(result.Device.VirtualNo, result.Device.IMEI) } order.BuyerPhone, order.BuyerNickname = s.fetchBuyerSnapshot(ctx, customerID) @@ -581,6 +616,16 @@ func (s *Service) buildPendingOrder(ctx context.Context, customerID uint, result return order, nil } +// firstNonEmptyClientOrderIdentifier 返回 C 端订单可用的第一个资产标识快照。 +func firstNonEmptyClientOrderIdentifier(values ...string) string { + for _, value := range values { + if value != "" { + return value + } + } + return "" +} + func (s *Service) buildOrderItems(ctx context.Context, customerID uint, result *purchase_validation.PurchaseValidationResult) ([]*model.OrderItem, error) { sellerShopID := resolveSellerShopID(result) items := make([]*model.OrderItem, 0, len(result.Packages)) @@ -617,13 +662,20 @@ func (s *Service) checkForceRechargeRequirement(ctx context.Context, result *pur var seriesID *uint var sellerShopID uint + var firstCommissionTriggered bool if result.Card != nil { seriesID = result.Card.SeriesID + if seriesID != nil { + firstCommissionTriggered = result.Card.IsFirstRechargeTriggeredBySeries(*seriesID) + } if result.Card.ShopID != nil { sellerShopID = *result.Card.ShopID } } else if result.Device != nil { seriesID = result.Device.SeriesID + if seriesID != nil { + firstCommissionTriggered = result.Device.IsFirstRechargeTriggeredBySeries(*seriesID) + } if result.Device.ShopID != nil { sellerShopID = *result.Device.ShopID } @@ -643,6 +695,9 @@ func (s *Service) checkForceRechargeRequirement(ctx context.Context, result *pur if err != nil || config == nil || !config.Enable { return defaultResult } + if firstCommissionTriggered { + return defaultResult + } if config.TriggerType == model.OneTimeCommissionTriggerFirstRecharge { return &ForceRechargeRequirement{ @@ -796,7 +851,7 @@ func extractPackageIDs(packages []*model.Package) []uint { return ids } -func buildClientPurchaseBusinessKey(customerID uint, assetInfo *dto.AssetResolveResponse, packageIDs []uint) string { +func buildClientPurchaseBusinessKey(customerID uint, assetInfo *dto.AssetResolveResponse, packageIDs []uint, paymentMethod string) string { sorted := make([]uint, len(packageIDs)) copy(sorted, packageIDs) slices.Sort(sorted) @@ -806,7 +861,7 @@ func buildClientPurchaseBusinessKey(customerID uint, assetInfo *dto.AssetResolve parts = append(parts, strconv.FormatUint(uint64(id), 10)) } - return fmt.Sprintf("%d:%s:%d:%s", customerID, assetInfo.AssetType, assetInfo.AssetID, strings.Join(parts, ",")) + return fmt.Sprintf("%d:%s:%d:%s:%s", customerID, assetInfo.AssetType, assetInfo.AssetID, strings.Join(parts, ","), paymentMethod) } func rechargeStatusToClientStatus(status int) int { @@ -1090,7 +1145,7 @@ func (s *Service) ListOrders(ctx context.Context, customerID uint, req *dto.Clie query := s.db.WithContext(skipCtx). Model(&model.Order{}). - Where("generation = ?", generation) + Where("generation = ? AND buyer_type = ? AND buyer_id = ?", generation, model.BuyerTypePersonal, customerID) if assetInfo.AssetType == constants.ResourceTypeDevice { query = query.Where("order_type = ? AND device_id = ?", model.OrderTypeDevice, assetInfo.AssetID) @@ -1132,19 +1187,29 @@ func (s *Service) ListOrders(ctx context.Context, customerID uint, req *dto.Clie } packageNames := make([]string, 0) + packageIDs := make([]uint, 0) for _, item := range itemMap[order.ID] { - if item != nil && item.PackageName != "" { - packageNames = append(packageNames, item.PackageName) + if item != nil { + packageIDs = append(packageIDs, item.PackageID) + if item.PackageName != "" { + packageNames = append(packageNames, item.PackageName) + } } } + assetType, assetID := clientOrderAssetReference(order) list = append(list, dto.ClientOrderListItem{ OrderID: order.ID, OrderNo: order.OrderNo, + AssetType: assetType, + AssetID: assetID, + AssetIdentifier: order.AssetIdentifier, TotalAmount: order.TotalAmount, + PaymentMethod: order.PaymentMethod, PaymentStatus: order.PaymentStatus, PaymentStatusName: constants.GetOrderPaymentStatusName(order.PaymentStatus), CreatedAt: formatClientServiceTime(order.CreatedAt), + PackageIDs: packageIDs, PackageNames: packageNames, }) } @@ -1161,6 +1226,9 @@ func (s *Service) GetOrderDetail(ctx context.Context, customerID uint, orderID u } return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询订单详情失败") } + if order.BuyerType != model.BuyerTypePersonal || order.BuyerID != customerID { + return nil, errors.New(errors.CodeForbidden, "无权查看此订单") + } skipCtx := context.WithValue(ctx, constants.ContextKeySubordinateShopIDs, []uint{}) orderAssetType, orderAssetID, err := getOrderAssetInfo(order) @@ -1185,10 +1253,14 @@ func (s *Service) GetOrderDetail(ctx context.Context, customerID uint, orderID u Quantity: item.Quantity, }) } + assetType, assetID := clientOrderAssetReference(order) return &dto.ClientOrderDetailResponse{ OrderID: order.ID, OrderNo: order.OrderNo, + AssetType: assetType, + AssetID: assetID, + AssetIdentifier: order.AssetIdentifier, TotalAmount: order.TotalAmount, PaymentStatus: order.PaymentStatus, PaymentStatusName: constants.GetOrderPaymentStatusName(order.PaymentStatus), @@ -1200,6 +1272,19 @@ func (s *Service) GetOrderDetail(ctx context.Context, customerID uint, orderID u }, nil } +func clientOrderAssetReference(order *model.Order) (string, uint) { + if order == nil { + return "", 0 + } + if order.OrderType == model.OrderTypeDevice && order.DeviceID != nil { + return constants.ResourceTypeDevice, *order.DeviceID + } + if order.OrderType == model.OrderTypeSingleCard && order.IotCardID != nil { + return "card", *order.IotCardID + } + return "", 0 +} + // PayOrder D4 对待支付的 C 端订单发起支付。 // 支持 wallet(钱包扣款)和 wechat(微信 JSAPI)两种支付方式。 // 先校验订单归属和状态,再按 payment_method 路由到对应支付逻辑: @@ -1228,6 +1313,19 @@ func (s *Service) PayOrder(ctx context.Context, customerID uint, orderID uint, r if order.PaymentStatus != model.PaymentStatusPending { return nil, errors.New(errors.CodeInvalidStatus, "订单状态不允许支付") } + paymentMethod := order.PaymentMethod + if paymentMethod == "" { + return nil, errors.New(errors.CodePaymentMethodUnavailable, "历史订单缺少支付方式,请重新下单") + } + if req.PaymentMethod != "" && req.PaymentMethod != paymentMethod { + return nil, errors.New(errors.CodeConflict, "支付方式与订单创建时选择不一致") + } + if s.paymentMethodPolicy == nil { + return nil, errors.New(errors.CodeNoPaymentConfig) + } + if err := s.paymentMethodPolicy.EnsureAllowed(skipCtx, order.OrderType, paymentMethod); err != nil { + return nil, err + } // before_order 策略:必须先实名才能支付订单 var assetRealnamePolicy string @@ -1267,12 +1365,12 @@ func (s *Service) PayOrder(ctx context.Context, customerID uint, orderID uint, r // } // } - switch req.PaymentMethod { + switch paymentMethod { case model.PaymentMethodWallet: if err := s.orderPaymentService.WalletPay(skipCtx, orderID, model.BuyerTypePersonal, customerID); err != nil { return nil, err } - return &dto.ClientPayOrderResponse{PaymentMethod: model.PaymentMethodWallet}, nil + return &dto.ClientPayOrderResponse{PaymentMethod: paymentMethod}, nil case model.PaymentMethodWechat: activeConfig, appID, err := s.resolveWechatConfig(skipCtx, req.AppType) @@ -1326,7 +1424,7 @@ func (s *Service) PayOrder(ctx context.Context, customerID uint, orderID uint, r return nil, err } return &dto.ClientPayOrderResponse{ - PaymentMethod: model.PaymentMethodWechat, + PaymentMethod: paymentMethod, PayConfig: buildClientPayConfigFromResult(paymentResult), }, nil @@ -1356,7 +1454,7 @@ func (s *Service) PayOrder(ctx context.Context, customerID uint, orderID uint, r ) } return &dto.ClientPayOrderResponse{ - PaymentMethod: model.PaymentMethodAlipay, + PaymentMethod: paymentMethod, PaymentLink: paymentLink, }, nil diff --git a/internal/service/customer_binding/service.go b/internal/service/customer_binding/service.go index 90537d3..faa55b5 100644 --- a/internal/service/customer_binding/service.go +++ b/internal/service/customer_binding/service.go @@ -4,12 +4,14 @@ package customer_binding import ( "context" + "sort" "time" "gorm.io/gorm" "github.com/break/junhong_cmp_fiber/internal/model" "github.com/break/junhong_cmp_fiber/internal/store/postgres" + "github.com/break/junhong_cmp_fiber/pkg/constants" "github.com/break/junhong_cmp_fiber/pkg/errors" ) @@ -144,6 +146,72 @@ func (s *Service) OwnsAsset(ctx context.Context, customerID uint, assetType stri return false, errors.New(errors.CodeInvalidParam) } +// ActiveCustomerIDsByAsset 返回当前启用且关联指定资产的个人客户 ID,结果去重并稳定排序。 +func (s *Service) ActiveCustomerIDsByAsset(ctx context.Context, tx *gorm.DB, assetType string, assetID uint) ([]uint, error) { + if tx == nil { + tx = s.db + } + customerIDs := make(map[uint]struct{}) + switch normalizeAssetType(assetType) { + case assetTypeIotCard: + card, err := s.readCard(ctx, tx, assetID) + if err != nil { + return nil, err + } + if card.VirtualNo != "" { + records, err := s.makePCD(tx).GetByDeviceNo(ctx, card.VirtualNo) + if err != nil { + return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询卡关联客户失败") + } + collectActiveDeviceCustomerIDs(customerIDs, records) + } else { + records, err := s.makePCI(tx).GetByICCID(ctx, card.ICCID) + if err != nil { + return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询卡关联客户失败") + } + collectActiveCardCustomerIDs(customerIDs, records) + } + case assetTypeDevice: + device, err := s.readDevice(ctx, tx, assetID) + if err != nil { + return nil, err + } + identifier := device.VirtualNo + if identifier == "" { + identifier = device.IMEI + } + records, err := s.makePCD(tx).GetByDeviceNo(ctx, identifier) + if err != nil { + return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询设备关联客户失败") + } + collectActiveDeviceCustomerIDs(customerIDs, records) + default: + return nil, errors.New(errors.CodeInvalidParam, "无效的资产类型") + } + result := make([]uint, 0, len(customerIDs)) + for customerID := range customerIDs { + result = append(result, customerID) + } + sort.Slice(result, func(i, j int) bool { return result[i] < result[j] }) + return result, nil +} + +func collectActiveDeviceCustomerIDs(target map[uint]struct{}, records []*model.PersonalCustomerDevice) { + for _, record := range records { + if record != nil && record.Status == constants.StatusEnabled && record.CustomerID > 0 { + target[record.CustomerID] = struct{}{} + } + } +} + +func collectActiveCardCustomerIDs(target map[uint]struct{}, records []*model.PersonalCustomerICCID) { + for _, record := range records { + if record != nil && record.Status == constants.StatusEnabled && record.CustomerID > 0 { + target[record.CustomerID] = struct{}{} + } + } +} + // Bind 在事务中创建客户与资产的绑定关系 // 有虚拟号的 IoT 卡 / 设备 → tb_personal_customer_device // 无虚拟号的 IoT 卡 → tb_personal_customer_iccid(Issue 02) diff --git a/internal/service/device/gateway_service.go b/internal/service/device/gateway_service.go index f50bf62..d75adac 100644 --- a/internal/service/device/gateway_service.go +++ b/internal/service/device/gateway_service.go @@ -47,64 +47,6 @@ func (s *Service) GatewayGetSlotInfo(ctx context.Context, identifier string) (*g }) } -// GatewaySetSpeedLimit 通过标识符设置设备限速 -func (s *Service) GatewaySetSpeedLimit(ctx context.Context, identifier string, req *dto.SetSpeedLimitRequest) error { - device, imei, err := s.getGatewayDevice(ctx, identifier) - if err != nil { - s.logDeviceOperation( - ctx, - constants.AssetAuditOpDeviceSpeedLimit, - "设备限速失败", - constants.AssetAuditResultFailed, - nil, - nil, - map[string]any{ - "identifier": identifier, - "speed_limit": req.SpeedLimit, - }, - 0, - 0, - 0, - err, - ) - return err - } - if err = s.gatewayClient.SetSpeedLimit(ctx, &gateway.SpeedLimitReq{ - DeviceID: imei, - SpeedLimit: req.SpeedLimit, - }); err != nil { - s.logDeviceOperation( - ctx, - constants.AssetAuditOpDeviceSpeedLimit, - "设备限速失败", - constants.AssetAuditResultFailed, - device, - map[string]any{"device": deviceSnapshot(device)}, - map[string]any{"speed_limit": req.SpeedLimit}, - 0, - 0, - 0, - err, - ) - return err - } - - s.logDeviceOperation( - ctx, - constants.AssetAuditOpDeviceSpeedLimit, - "设备限速", - constants.AssetAuditResultSuccess, - device, - map[string]any{"device": deviceSnapshot(device)}, - map[string]any{"speed_limit": req.SpeedLimit}, - 0, - 0, - 0, - nil, - ) - return nil -} - // GatewaySetWiFi 通过标识符设置设备 WiFi func (s *Service) GatewaySetWiFi(ctx context.Context, identifier string, req *dto.SetWiFiRequest) error { device, imei, err := s.getGatewayDevice(ctx, identifier) diff --git a/internal/service/device/realname_policy_batch.go b/internal/service/device/realname_policy_batch.go new file mode 100644 index 0000000..8b05468 --- /dev/null +++ b/internal/service/device/realname_policy_batch.go @@ -0,0 +1,84 @@ +package device + +import ( + "context" + + "github.com/break/junhong_cmp_fiber/internal/model" + "github.com/break/junhong_cmp_fiber/internal/model/dto" + "github.com/break/junhong_cmp_fiber/pkg/constants" + "github.com/break/junhong_cmp_fiber/pkg/errors" + "github.com/break/junhong_cmp_fiber/pkg/middleware" + "gorm.io/gorm" + "gorm.io/gorm/clause" +) + +// BatchUpdateRealnamePolicy 批量更新设备实名认证策略,整批校验后在单事务内全成全败。 +func (s *Service) BatchUpdateRealnamePolicy(ctx context.Context, req *dto.BatchUpdateAssetRealnamePolicyRequest) (*dto.BatchUpdateAssetRealnamePolicyResponse, error) { + if middleware.GetUserTypeFromContext(ctx) == constants.UserTypeEnterprise { + return nil, errors.New(errors.CodeForbidden, "企业账号无权修改设备实名认证策略") + } + ids, err := validateBatchRealnamePolicyRequest(req) + if err != nil { + return nil, err + } + err = s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + var devices []model.Device + query := middleware.ApplyShopFilter(ctx, tx.Model(&model.Device{})).Clauses(clause.Locking{Strength: "UPDATE"}) + if err := query.Where("id IN ?", ids).Find(&devices).Error; err != nil { + return errors.Wrap(errors.CodeDatabaseError, err, "查询批量设备资产失败") + } + if len(devices) != len(ids) { + return errors.New(errors.CodeForbidden, "无权限操作该资源或资源不存在") + } + result := tx.Model(&model.Device{}).Where("id IN ?", ids).Update("realname_policy", req.RealnamePolicy) + if result.Error != nil { + return errors.Wrap(errors.CodeDatabaseError, result.Error, "批量更新设备实名认证策略失败") + } + if result.RowsAffected != int64(len(ids)) { + return errors.New(errors.CodeConflict, "设备资产状态已变化,请刷新后重试") + } + return nil + }) + if err != nil { + return nil, err + } + s.logDeviceOperation( + ctx, + constants.AssetAuditOpAssetRealnamePolicy, + "批量更新设备实名认证策略", + constants.AssetAuditResultSuccess, + nil, + nil, + map[string]any{"asset_ids": ids, "realname_policy": req.RealnamePolicy}, + len(ids), + len(ids), + 0, + nil, + ) + return &dto.BatchUpdateAssetRealnamePolicyResponse{SuccessCount: len(ids), RealnamePolicy: req.RealnamePolicy}, nil +} + +func validateBatchRealnamePolicyRequest(req *dto.BatchUpdateAssetRealnamePolicyRequest) ([]uint, error) { + if req == nil || len(req.AssetIDs) == 0 || len(req.AssetIDs) > 500 || !isValidRealnamePolicy(req.RealnamePolicy) { + return nil, errors.New(errors.CodeInvalidParam) + } + seen := make(map[uint]struct{}, len(req.AssetIDs)) + ids := make([]uint, 0, len(req.AssetIDs)) + for _, id := range req.AssetIDs { + if id == 0 { + return nil, errors.New(errors.CodeInvalidParam) + } + if _, exists := seen[id]; exists { + return nil, errors.New(errors.CodeInvalidParam, "资产ID不能重复") + } + seen[id] = struct{}{} + ids = append(ids, id) + } + return ids, nil +} + +func isValidRealnamePolicy(policy string) bool { + return policy == constants.RealnamePolicyNone || + policy == constants.RealnamePolicyBeforeOrder || + policy == constants.RealnamePolicyAfterOrder +} diff --git a/internal/service/device/service.go b/internal/service/device/service.go index fa181e5..8043dec 100644 --- a/internal/service/device/service.go +++ b/internal/service/device/service.go @@ -206,6 +206,9 @@ func (s *Service) List(ctx context.Context, req *dto.ListDeviceRequest) (*dto.Li if req.ActivationStatus != nil { filters["activation_status"] = *req.ActivationStatus } + if req.RealNameStatus != nil { + filters["real_name_status"] = *req.RealNameStatus + } shopIDs, hasShopIDs := normalizeShopIDs(req.ShopIDs) if hasShopIDs { filters["shop_ids"] = shopIDs diff --git a/internal/service/device_import/audit.go b/internal/service/device_import/audit.go index 813279a..ffb3186 100644 --- a/internal/service/device_import/audit.go +++ b/internal/service/device_import/audit.go @@ -55,3 +55,19 @@ func newDeviceImportAuditParams( AfterData: afterData, } } + +func newDeviceBatchAllocationAuditParams(taskID uint, taskNo string, req *dto.CreateDeviceBatchAllocationRequest, resultStatus string, err error) assetAuditSvc.BuildLogParams { + afterData := map[string]any{} + if req != nil { + afterData["file_key"] = req.FileKey + afterData["operation_type"] = req.OperationType + afterData["target_id"] = req.TargetID + } + errorCode, errorMsg := assetAuditSvc.BuildErrorInfo(err) + return assetAuditSvc.BuildLogParams{ + AssetID: taskID, AssetIdentifier: taskNo, + OperationType: constants.AssetAuditOpDeviceBatchTaskCreate, + OperationDesc: "创建设备CSV批量分配任务", ResultStatus: resultStatus, + ErrorCode: errorCode, ErrorMsg: errorMsg, AfterData: afterData, + } +} diff --git a/internal/service/device_import/service.go b/internal/service/device_import/service.go index 1c362a8..3e164cc 100644 --- a/internal/service/device_import/service.go +++ b/internal/service/device_import/service.go @@ -3,6 +3,7 @@ package device_import import ( "context" "path/filepath" + "strings" "time" "github.com/break/junhong_cmp_fiber/internal/model" @@ -55,6 +56,7 @@ func (s *Service) CreateImportTask(ctx context.Context, req *dto.ImportDeviceReq task := &model.DeviceImportTask{ TaskNo: taskNo, + OperationType: constants.DeviceImportOperationCreate, Status: model.ImportTaskStatusPending, BatchNo: req.BatchNo, FileName: fileName, @@ -94,6 +96,58 @@ func (s *Service) CreateImportTask(ctx context.Context, req *dto.ImportDeviceReq }, nil } +// CreateBatchAllocationTask 复用设备导入任务创建单列 CSV 批量分配任务。 +func (s *Service) CreateBatchAllocationTask(ctx context.Context, req *dto.CreateDeviceBatchAllocationRequest) (*dto.CreateDeviceBatchAllocationResponse, error) { + userID := middleware.GetUserIDFromContext(ctx) + userType := middleware.GetUserTypeFromContext(ctx) + if userID == 0 || (userType != constants.UserTypeSuperAdmin && userType != constants.UserTypePlatform && userType != constants.UserTypeAgent) { + appErr := errors.New(errors.CodeForbidden, "仅平台和代理后台账号可创建设备批量分配任务") + s.logDeviceImportAudit(ctx, newDeviceBatchAllocationAuditParams(0, "", req, constants.AssetAuditResultDenied, appErr)) + return nil, appErr + } + if req == nil || !constants.IsDeviceImportOperation(req.OperationType) || req.OperationType == constants.DeviceImportOperationCreate || req.TargetID == 0 { + return nil, errors.New(errors.CodeInvalidParam, "设备批量分配参数不合法") + } + if !strings.HasPrefix(req.FileKey, constants.DeviceBatchAllocationStoragePrefix+"/") || !strings.EqualFold(filepath.Ext(req.FileKey), ".csv") { + return nil, errors.New(errors.CodeInvalidParam, "设备批量分配文件必须是指定目录下的CSV文件") + } + + taskNo := s.importTaskStore.GenerateTaskNo(ctx) + var operatorShopID *uint + if userType == constants.UserTypeAgent { + shopID := middleware.GetShopIDFromContext(ctx) + if shopID == 0 { + return nil, errors.New(errors.CodeForbidden, "代理账号缺少店铺归属") + } + operatorShopID = &shopID + } + targetID := req.TargetID + task := &model.DeviceImportTask{ + TaskNo: taskNo, OperationType: req.OperationType, TargetID: &targetID, + OperatorType: userType, OperatorShopID: operatorShopID, + Status: model.ImportTaskStatusPending, StorageKey: req.FileKey, FileName: filepath.Base(req.FileKey), + CreatorName: middleware.GetUsernameFromContext(ctx), + } + task.Creator, task.Updater = userID, userID + if err := s.importTaskStore.Create(ctx, task); err != nil { + appErr := errors.Wrap(errors.CodeDatabaseError, err, "创建设备批量分配任务失败") + s.logDeviceImportAudit(ctx, newDeviceBatchAllocationAuditParams(0, taskNo, req, constants.AssetAuditResultFailed, appErr)) + return nil, appErr + } + if err := s.queueClient.EnqueueTask(ctx, constants.TaskTypeDeviceImport, DeviceImportPayload{TaskID: task.ID}, + asynq.Queue(constants.QueueForTaskType(constants.TaskTypeDeviceImport)), + asynq.Timeout(constants.DeviceBatchAllocationTaskTimeout)); err != nil { + _ = s.importTaskStore.UpdateStatus(ctx, task.ID, model.ImportTaskStatusFailed, "任务入队失败") + appErr := errors.Wrap(errors.CodeInternalError, err, "设备批量分配任务入队失败") + s.logDeviceImportAudit(ctx, newDeviceBatchAllocationAuditParams(task.ID, taskNo, req, constants.AssetAuditResultFailed, appErr)) + return nil, appErr + } + s.logDeviceImportAudit(ctx, newDeviceBatchAllocationAuditParams(task.ID, taskNo, req, constants.AssetAuditResultSuccess, nil)) + return &dto.CreateDeviceBatchAllocationResponse{ + TaskID: task.ID, TaskNo: task.TaskNo, Message: "设备批量分配任务已创建,Worker 将异步处理CSV文件", + }, nil +} + func (s *Service) List(ctx context.Context, req *dto.ListDeviceImportTaskRequest) (*dto.ListDeviceImportTaskResponse, error) { page := req.Page pageSize := req.PageSize @@ -113,6 +167,9 @@ func (s *Service) List(ctx context.Context, req *dto.ListDeviceImportTaskRequest if req.Status != nil { filters["status"] = *req.Status } + if req.OperationType != "" { + filters["operation_type"] = req.OperationType + } if req.BatchNo != "" { filters["batch_no"] = req.BatchNo } @@ -156,25 +213,19 @@ func (s *Service) GetByID(ctx context.Context, id uint) (*dto.DeviceImportTaskDe for _, item := range task.SkippedItems { resp.SkippedItems = append(resp.SkippedItems, &dto.DeviceImportResultItemDTO{ - Line: item.Line, - VirtualNo: item.ICCID, - Reason: item.Reason, + Line: item.Line, VirtualNo: item.ICCID, DeviceIdentifier: item.ICCID, Reason: item.Reason, }) } for _, item := range task.FailedItems { resp.FailedItems = append(resp.FailedItems, &dto.DeviceImportResultItemDTO{ - Line: item.Line, - VirtualNo: item.ICCID, - Reason: item.Reason, + Line: item.Line, VirtualNo: item.ICCID, DeviceIdentifier: item.ICCID, Reason: item.Reason, }) } for _, item := range task.WarningItems { resp.WarningItems = append(resp.WarningItems, &dto.DeviceImportResultItemDTO{ - Line: item.Line, - VirtualNo: item.ICCID, - Reason: item.Reason, + Line: item.Line, VirtualNo: item.ICCID, DeviceIdentifier: item.ICCID, Reason: item.Reason, }) } @@ -193,7 +244,11 @@ func (s *Service) toTaskResponse(task *model.DeviceImportTask) *dto.DeviceImport return &dto.DeviceImportTaskResponse{ ID: task.ID, TaskNo: task.TaskNo, + OperationType: task.OperationType, + OperationName: constants.GetDeviceImportOperationName(task.OperationType), + TargetID: task.TargetID, Status: task.Status, + StatusName: getStatusText(task.Status), StatusText: getStatusText(task.Status), BatchNo: task.BatchNo, RealnamePolicy: task.RealnamePolicy, diff --git a/internal/service/exchange/service.go b/internal/service/exchange/service.go index 37ec5f0..1a9d5de 100644 --- a/internal/service/exchange/service.go +++ b/internal/service/exchange/service.go @@ -5,6 +5,7 @@ import ( "strings" "time" + exchangeapp "github.com/break/junhong_cmp_fiber/internal/application/exchange" "github.com/break/junhong_cmp_fiber/internal/model" "github.com/break/junhong_cmp_fiber/internal/model/dto" customerBindingSvc "github.com/break/junhong_cmp_fiber/internal/service/customer_binding" @@ -20,6 +21,7 @@ import ( type Service struct { db *gorm.DB exchangeStore *postgres.ExchangeOrderStore + refundStore *postgres.RefundStore iotCardStore *postgres.IotCardStore deviceStore *postgres.DeviceStore assetWalletStore *postgres.AssetWalletStore @@ -28,6 +30,7 @@ type Service struct { packageUsageDailyRecordStore *postgres.PackageUsageDailyRecordStore resourceTagStore *postgres.ResourceTagStore customerBinding *customerBindingSvc.Service + shippingCreatedNotifier *exchangeapp.ShippingCreatedNotifier logger *zap.Logger } @@ -47,6 +50,7 @@ func New( return &Service{ db: db, exchangeStore: exchangeStore, + refundStore: postgres.NewRefundStore(db), iotCardStore: iotCardStore, deviceStore: deviceStore, assetWalletStore: assetWalletStore, @@ -59,6 +63,11 @@ func New( } } +// SetShippingCreatedNotifier 注入物流换货创建后的可靠通知用例。 +func (s *Service) SetShippingCreatedNotifier(notifier *exchangeapp.ShippingCreatedNotifier) { + s.shippingCreatedNotifier = notifier +} + func (s *Service) Create(ctx context.Context, req *dto.CreateExchangeRequest) (*dto.ExchangeOrderResponse, error) { flowType := normalizeExchangeFlowType(req.FlowType) if !isValidExchangeFlowType(flowType) { @@ -75,6 +84,13 @@ func (s *Service) Create(ctx context.Context, req *dto.CreateExchangeRequest) (* if !isExchangeableAssetStatus(asset.AssetStatus) { return nil, oldAssetStatusError(asset.AssetStatus) } + hasUnfinishedRefund, err := s.refundStore.HasUnfinishedByAsset(ctx, asset.AssetType, asset.AssetID) + if err != nil { + return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询资产退款申请失败") + } + if hasUnfinishedRefund { + return nil, errors.New(errors.CodeExchangeActiveRefund) + } if _, err = s.exchangeStore.FindActiveByOldAsset(ctx, asset.AssetType, asset.AssetID); err == nil { return nil, errors.New(errors.CodeExchangeInProgress) @@ -105,11 +121,39 @@ func (s *Service) Create(ctx context.Context, req *dto.CreateExchangeRequest) (* order.ShopID = asset.ShopID } - if err = s.exchangeStore.Create(ctx, order); err != nil { - return nil, errors.Wrap(errors.CodeDatabaseError, err, "创建换货单失败") + if s.shippingCreatedNotifier == nil { + return nil, errors.New(errors.CodeInternalError, "物流换货通知服务未配置") + } + requestID := "" + if value := middleware.GetRequestIDFromContext(ctx); value != nil { + requestID = *value + } + err = s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + if createErr := tx.WithContext(ctx).Create(order).Error; createErr != nil { + return errors.Wrap(errors.CodeDatabaseError, createErr, "创建换货单失败") + } + customerIDs, queryErr := s.customerBinding.ActiveCustomerIDsByAsset(ctx, tx, asset.AssetType, asset.AssetID) + if queryErr != nil { + return queryErr + } + for _, customerID := range customerIDs { + if notifyErr := s.shippingCreatedNotifier.Notify(ctx, tx, exchangeapp.ShippingCreatedEvent{ + ExchangeID: order.ID, ExchangeNo: order.ExchangeNo, CustomerID: customerID, + AssetType: asset.AssetType, AssetID: asset.AssetID, AssetIdentifier: asset.Identifier, + RequestID: requestID, CorrelationID: requestID, + }); notifyErr != nil { + return notifyErr + } + } + return nil + }) + if err != nil { + return nil, err } - return s.toExchangeOrderResponse(order), nil + resp := s.toExchangeOrderResponse(order) + resp.SubmitterName = s.loadExchangeSubmitterNameBestEffort(ctx, order.Creator) + return resp, nil } func (s *Service) Get(ctx context.Context, id uint) (*dto.ExchangeOrderResponse, error) { @@ -120,7 +164,9 @@ func (s *Service) Get(ctx context.Context, id uint) (*dto.ExchangeOrderResponse, } return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询换货单详情失败") } - return s.toExchangeOrderResponse(order), nil + resp := s.toExchangeOrderResponse(order) + resp.SubmitterName = s.loadExchangeSubmitterNameBestEffort(ctx, order.Creator) + return resp, nil } func (s *Service) Ship(ctx context.Context, id uint, req *dto.ExchangeShipRequest) (*dto.ExchangeOrderResponse, error) { @@ -1006,7 +1052,20 @@ func (s *Service) toExchangeOrderResponse(order *model.ExchangeOrder) *dto.Excha CreatedAt: order.CreatedAt, UpdatedAt: order.UpdatedAt, DeletedAt: deletedAt, + SubmitterID: order.Creator, Creator: order.Creator, Updater: order.Updater, } } + +func (s *Service) loadExchangeSubmitterNameBestEffort(ctx context.Context, id uint) string { + accounts, err := postgres.NewAccountStore(s.db, nil).GetDisplayAccountsByIDs(ctx, []uint{id}) + if err != nil { + s.logger.Warn("查询换货提交人失败", zap.Uint("submitter_id", id), zap.Error(err)) + return "" + } + if len(accounts) == 0 { + return "" + } + return accounts[0].Username +} diff --git a/internal/service/iot_card/realname_policy_batch.go b/internal/service/iot_card/realname_policy_batch.go new file mode 100644 index 0000000..62bca37 --- /dev/null +++ b/internal/service/iot_card/realname_policy_batch.go @@ -0,0 +1,83 @@ +package iot_card + +import ( + "context" + + "github.com/break/junhong_cmp_fiber/internal/model" + "github.com/break/junhong_cmp_fiber/internal/model/dto" + assetAuditSvc "github.com/break/junhong_cmp_fiber/internal/service/asset_audit" + "github.com/break/junhong_cmp_fiber/pkg/constants" + "github.com/break/junhong_cmp_fiber/pkg/errors" + "github.com/break/junhong_cmp_fiber/pkg/middleware" + "gorm.io/gorm" + "gorm.io/gorm/clause" +) + +// BatchUpdateRealnamePolicy 批量更新卡实名认证策略,整批校验后在单事务内全成全败。 +func (s *Service) BatchUpdateRealnamePolicy(ctx context.Context, req *dto.BatchUpdateAssetRealnamePolicyRequest) (*dto.BatchUpdateAssetRealnamePolicyResponse, error) { + if middleware.GetUserTypeFromContext(ctx) == constants.UserTypeEnterprise { + return nil, errors.New(errors.CodeForbidden, "企业账号无权修改卡实名认证策略") + } + ids, err := validateBatchRealnamePolicyRequest(req) + if err != nil { + return nil, err + } + err = s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + var cards []model.IotCard + query := middleware.ApplyShopFilter(ctx, tx.Model(&model.IotCard{})).Clauses(clause.Locking{Strength: "UPDATE"}) + if err := query.Where("id IN ?", ids).Find(&cards).Error; err != nil { + return errors.Wrap(errors.CodeDatabaseError, err, "查询批量卡资产失败") + } + if len(cards) != len(ids) { + return errors.New(errors.CodeForbidden, "无权限操作该资源或资源不存在") + } + result := tx.Model(&model.IotCard{}).Where("id IN ?", ids).Update("realname_policy", req.RealnamePolicy) + if result.Error != nil { + return errors.Wrap(errors.CodeDatabaseError, result.Error, "批量更新卡实名认证策略失败") + } + if result.RowsAffected != int64(len(ids)) { + return errors.New(errors.CodeConflict, "卡资产状态已变化,请刷新后重试") + } + return nil + }) + if err != nil { + return nil, err + } + s.logCardAudit(ctx, assetAuditSvc.BuildLogParams{ + OperationType: constants.AssetAuditOpCardRealnamePolicy, + OperationDesc: "批量更新卡实名认证策略", + ResultStatus: constants.AssetAuditResultSuccess, + BatchTotal: len(ids), + SuccessCount: len(ids), + AfterData: map[string]any{ + "asset_ids": ids, + "realname_policy": req.RealnamePolicy, + }, + }) + return &dto.BatchUpdateAssetRealnamePolicyResponse{SuccessCount: len(ids), RealnamePolicy: req.RealnamePolicy}, nil +} + +func validateBatchRealnamePolicyRequest(req *dto.BatchUpdateAssetRealnamePolicyRequest) ([]uint, error) { + if req == nil || len(req.AssetIDs) == 0 || len(req.AssetIDs) > 500 || !isValidRealnamePolicy(req.RealnamePolicy) { + return nil, errors.New(errors.CodeInvalidParam) + } + seen := make(map[uint]struct{}, len(req.AssetIDs)) + ids := make([]uint, 0, len(req.AssetIDs)) + for _, id := range req.AssetIDs { + if id == 0 { + return nil, errors.New(errors.CodeInvalidParam) + } + if _, exists := seen[id]; exists { + return nil, errors.New(errors.CodeInvalidParam, "资产ID不能重复") + } + seen[id] = struct{}{} + ids = append(ids, id) + } + return ids, nil +} + +func isValidRealnamePolicy(policy string) bool { + return policy == constants.RealnamePolicyNone || + policy == constants.RealnamePolicyBeforeOrder || + policy == constants.RealnamePolicyAfterOrder +} diff --git a/internal/service/iot_card/service.go b/internal/service/iot_card/service.go index 5552e02..a7bf56c 100644 --- a/internal/service/iot_card/service.go +++ b/internal/service/iot_card/service.go @@ -72,6 +72,7 @@ type Service struct { cardObservation *cardapp.Service observationSeries cardapp.BestEffortSeriesDispatcher trafficLock *cardtrafficlock.Lock + speedTierIntegration speedTierIntegrationLog } // SetObservationSeriesDispatcher 注入获取实名链接后的后台观测端口。 @@ -84,6 +85,11 @@ func (s *Service) SetCardObservationService(service *cardapp.Service) { s.cardObservation = service } +// SetSpeedTierIntegrationLog 注入卡固定限速的 Integration Log 接缝。 +func (s *Service) SetSpeedTierIntegrationLog(integration speedTierIntegrationLog) { + s.speedTierIntegration = integration +} + // SetPackageExpiryQuery 注入套餐最终到期查询,供列表使用批量投影。 func (s *Service) SetPackageExpiryQuery(query *packageexpiry.Query) { s.packageExpiryQuery = query @@ -250,6 +256,9 @@ func (s *Service) ListStandalone(ctx context.Context, req *dto.ListStandaloneIot if req.NetworkStatus != nil { filters["network_status"] = *req.NetworkStatus } + if req.RealNameStatus != nil { + filters["real_name_status"] = *req.RealNameStatus + } if req.AuthorizedEnterpriseID != nil { filters["authorized_enterprise_id"] = *req.AuthorizedEnterpriseID } diff --git a/internal/service/iot_card/speed_tier.go b/internal/service/iot_card/speed_tier.go new file mode 100644 index 0000000..ad36e59 --- /dev/null +++ b/internal/service/iot_card/speed_tier.go @@ -0,0 +1,158 @@ +package iot_card + +import ( + "context" + stderrors "errors" + "strconv" + "time" + + "github.com/break/junhong_cmp_fiber/internal/gateway" + "github.com/break/junhong_cmp_fiber/internal/infrastructure/integrationlog" + "github.com/break/junhong_cmp_fiber/internal/model" + "github.com/break/junhong_cmp_fiber/internal/model/dto" + assetAuditSvc "github.com/break/junhong_cmp_fiber/internal/service/asset_audit" + "github.com/break/junhong_cmp_fiber/pkg/constants" + pkgerrors "github.com/break/junhong_cmp_fiber/pkg/errors" + "github.com/break/junhong_cmp_fiber/pkg/middleware" + "go.uber.org/zap" +) + +type speedTierIntegrationLog interface { + Start(ctx context.Context, input integrationlog.Attempt) (*model.IntegrationLog, error) + Complete(ctx context.Context, integrationID string, completion integrationlog.Completion) (*model.IntegrationLog, error) +} + +// SetSpeedTier 为有权限的 IoT 卡设置或恢复固定限速档位。 +func (s *Service) SetSpeedTier(ctx context.Context, iccid string, code *int) (*dto.SetIotCardSpeedTierResponse, error) { + if !canManageCardSpeedTier(ctx) { + return nil, pkgerrors.New(pkgerrors.CodeForbidden, "仅平台和代理后台账号可设置卡限速档位") + } + if code == nil || !constants.IsGatewaySpeedTier(*code) { + return nil, pkgerrors.New(pkgerrors.CodeInvalidParam, "固定限速档位不合法") + } + if s == nil || s.iotCardStore == nil || s.gatewayClient == nil || s.speedTierIntegration == nil { + return nil, pkgerrors.New(pkgerrors.CodeServiceUnavailable, "卡限速服务未完整配置") + } + + card, err := s.iotCardStore.GetByICCID(ctx, iccid) + if err != nil || card == nil || card.ICCID == "" { + return nil, pkgerrors.New(pkgerrors.CodeForbidden, "无权限操作该资源或资源不存在") + } + + tierName := constants.GetGatewaySpeedTierName(*code) + resourceID := strconv.FormatUint(uint64(card.ID), 10) + requestID := middleware.GetRequestIDFromContext(ctx) + attempt, err := s.speedTierIntegration.Start(ctx, integrationlog.Attempt{ + Provider: constants.IntegrationProviderGateway, + Direction: constants.IntegrationDirectionOutbound, + Operation: constants.IntegrationOperationGatewaySpeedTier, + ExternalID: &card.ICCID, + ResourceType: constants.AssetTypeIotCard, + ResourceID: &resourceID, + ResourceKey: &card.ICCID, + RequestID: requestID, + CorrelationID: requestID, + RequestSummary: map[string]any{ + "iot_card_id": card.ID, + "iccid": card.ICCID, + "tier_code": *code, + "tier_name": tierName, + "operator_id": middleware.GetUserIDFromContext(ctx), + }, + }) + if err != nil { + s.logSpeedTierAudit(ctx, card, *code, "", constants.AssetAuditResultFailed, err) + return nil, err + } + + startedAt := time.Now() + gatewayErr := s.gatewayClient.SetCardSpeedTier(ctx, &gateway.CardSpeedTierReq{ + CardNo: card.ICCID, + Code: strconv.Itoa(*code), + }) + completion := speedTierCompletion(gatewayErr, time.Since(startedAt)) + if _, completeErr := s.speedTierIntegration.Complete(ctx, attempt.IntegrationID, completion); completeErr != nil { + if s.logger != nil { + s.logger.Error("终结卡限速 Integration Log 失败", + zap.Uint("iot_card_id", card.ID), + zap.String("integration_id", attempt.IntegrationID), + zap.Error(completeErr), + ) + } + s.logSpeedTierAudit(ctx, card, *code, attempt.IntegrationID, constants.AssetAuditResultFailed, completeErr) + return nil, pkgerrors.Wrap(pkgerrors.CodeDatabaseError, completeErr, "终结卡限速外部交互记录失败") + } + if gatewayErr != nil { + s.logSpeedTierAudit(ctx, card, *code, attempt.IntegrationID, constants.AssetAuditResultFailed, gatewayErr) + if isGatewayTimeout(gatewayErr) { + return nil, pkgerrors.New(pkgerrors.CodeGatewayTimeout, "Gateway 卡限速请求结果未知,请核对实际档位后再操作") + } + return nil, gatewayErr + } + + s.logSpeedTierAudit(ctx, card, *code, attempt.IntegrationID, constants.AssetAuditResultSuccess, nil) + return &dto.SetIotCardSpeedTierResponse{ + IotCardID: card.ID, ICCID: card.ICCID, Code: *code, + SpeedTierName: tierName, IntegrationID: attempt.IntegrationID, + }, nil +} + +func canManageCardSpeedTier(ctx context.Context) bool { + switch middleware.GetUserTypeFromContext(ctx) { + case constants.UserTypeSuperAdmin, constants.UserTypePlatform, constants.UserTypeAgent: + return middleware.GetUserIDFromContext(ctx) > 0 + default: + return false + } +} + +func speedTierCompletion(err error, duration time.Duration) integrationlog.Completion { + completion := integrationlog.Completion{ + Result: constants.IntegrationResultSuccess, + DurationMS: duration.Milliseconds(), + StateChanged: true, + ResponseSummary: map[string]any{ + "result": "success", + }, + } + if err == nil { + return completion + } + completion.Result = constants.IntegrationResultFailed + completion.StateChanged = false + completion.ProviderMessage = err.Error() + completion.ResponseSummary = map[string]any{"result": "failed"} + if isGatewayTimeout(err) { + completion.Result = constants.IntegrationResultUnknown + completion.ResponseSummary = map[string]any{"result": "unknown"} + completion.RecoveryStrategy = constants.GatewaySpeedTierUnknownRecoveryStrategy + } + return completion +} + +func isGatewayTimeout(err error) bool { + var appErr *pkgerrors.AppError + return stderrors.As(err, &appErr) && appErr != nil && appErr.Code == pkgerrors.CodeGatewayTimeout +} + +func (s *Service) logSpeedTierAudit(ctx context.Context, card *model.IotCard, code int, integrationID, result string, err error) { + errorCode, errorMsg := assetAuditSvc.BuildErrorInfo(err) + s.logCardAudit(ctx, assetAuditSvc.BuildLogParams{ + AssetType: constants.AssetTypeIotCard, + AssetID: card.ID, + AssetIdentifier: card.ICCID, + OperationType: constants.AssetAuditOpCardSpeedTier, + OperationDesc: "设置 IoT 卡固定限速档位", + BeforeData: map[string]any{"card": cardSnapshot(card)}, + AfterData: map[string]any{ + "tier_code": code, + "tier_name": constants.GetGatewaySpeedTierName(code), + "integration_id": integrationID, + }, + ResultStatus: result, + ErrorCode: errorCode, + ErrorMsg: errorMsg, + }) +} + +var _ speedTierIntegrationLog = (*integrationlog.Repository)(nil) diff --git a/internal/service/order/service.go b/internal/service/order/service.go index 014596f..942accb 100644 --- a/internal/service/order/service.go +++ b/internal/service/order/service.go @@ -1093,7 +1093,7 @@ func buildAgentWalletTransactionAssetSnapshot(order *model.Order) (string, uint, } // buildOrderAssetIdentifier 生成订单资产标识快照。 -// 后台按标识下单时保留请求标识;按ID下单时用卡ICCID或设备IMEI/虚拟号兜底。 +// 后台按标识下单时保留请求标识;按ID下单时用卡ICCID或设备虚拟号/IMEI兜底。 func buildOrderAssetIdentifier(result *purchase_validation.PurchaseValidationResult) string { if result == nil { return "" @@ -1102,7 +1102,7 @@ func buildOrderAssetIdentifier(result *purchase_validation.PurchaseValidationRes return result.Card.ICCID } if result.Device != nil { - return firstNonEmpty(result.Device.IMEI, result.Device.VirtualNo) + return firstNonEmpty(result.Device.VirtualNo, result.Device.IMEI) } return "" } @@ -2134,7 +2134,7 @@ func resolveAdminCarrierInfoFromVars(orderType string, iotCardID *uint, deviceID return "", 0 } -// resolveAssetByIdentifier 通过标识符(ICCID 或 VirtualNo)解析资产 +// resolveAssetByIdentifier 通过卡 ICCID 或设备 VirtualNo、IMEI、SN 解析资产。 // 优先查注册表,注册表命中则直接返回对应卡或设备 func (s *Service) resolveAssetByIdentifier(ctx context.Context, identifier string) (*model.IotCard, *model.Device, error) { if s.assetIdentifierStore != nil { diff --git a/internal/service/package/activation_service.go b/internal/service/package/activation_service.go index 9b721d2..02dbf24 100644 --- a/internal/service/package/activation_service.go +++ b/internal/service/package/activation_service.go @@ -701,6 +701,9 @@ func (s *ActivationService) InvalidatePackagesForRefund(ctx context.Context, ass if orderID == 0 { return errors.New(errors.CodeInvalidParam, "无效的订单ID") } + if assetType != constants.AssetTypeIotCard && assetType != constants.AssetTypeDevice { + return errors.New(errors.CodeInvalidParam, "无效的资产类型") + } validStatuses := []int{ constants.PackageUsageStatusPending, @@ -708,21 +711,9 @@ func (s *ActivationService) InvalidatePackagesForRefund(ctx context.Context, ass constants.PackageUsageStatusDepleted, } - newAssetQuery := func() (*gorm.DB, error) { - query := s.db.WithContext(ctx).Model(&model.PackageUsage{}) - switch assetType { - case "iot_card": - return query.Where("iot_card_id = ?", assetID), nil - case "device": - return query.Where("device_id = ?", assetID), nil - default: - return nil, errors.New(errors.CodeInvalidParam, "无效的资产类型") - } - } - baseQuery, err := newAssetQuery() - if err != nil { - return err - } + // 换货会把套餐使用记录迁移到新资产,但原订单与套餐使用记录的关联保持不变。 + // 因此退款必须以订单和套餐使用记录为权威定位键,不能再用旧资产 ID 缩小查询范围。 + baseQuery := s.db.WithContext(ctx).Model(&model.PackageUsage{}) var targets []model.PackageUsage if packageUsageID != nil && *packageUsageID > 0 { @@ -772,11 +763,7 @@ func (s *ActivationService) InvalidatePackagesForRefund(ctx context.Context, ass if len(mainUsageIDs) > 0 { var addons []model.PackageUsage - addonQuery, queryErr := newAssetQuery() - if queryErr != nil { - return queryErr - } - if err := addonQuery. + if err := s.db.WithContext(ctx).Model(&model.PackageUsage{}). Where("master_usage_id IN ?", mainUsageIDs). Where("status IN ?", validStatuses). Find(&addons).Error; err != nil { diff --git a/internal/service/paymentmethod/policy.go b/internal/service/paymentmethod/policy.go new file mode 100644 index 0000000..5724365 --- /dev/null +++ b/internal/service/paymentmethod/policy.go @@ -0,0 +1,134 @@ +// Package paymentmethod 提供按资产类型读取和校验 C 端支付方式的能力。 +package paymentmethod + +import ( + "context" + stderrors "errors" + "slices" + + "github.com/break/junhong_cmp_fiber/internal/model" + "github.com/break/junhong_cmp_fiber/pkg/constants" + "github.com/break/junhong_cmp_fiber/pkg/errors" + "github.com/bytedance/sonic" +) + +// ConfigReader 提供受控系统配置读取能力。 +type ConfigReader interface { + GetStrict(ctx context.Context, key string) (string, error) +} + +// Policy 负责返回资产允许的支付方式并执行后端强校验。 +type Policy struct { + reader ConfigReader +} + +// NewPolicy 创建支付方式策略。 +func NewPolicy(reader ConfigReader) *Policy { + return &Policy{reader: reader} +} + +// AllowedMethods 返回指定资产类型允许的支付方式;配置异常时失败关闭。 +func (p *Policy) AllowedMethods(ctx context.Context, assetType string) ([]string, error) { + if p == nil || p.reader == nil { + return nil, errors.New(errors.CodeNoPaymentConfig) + } + key, err := configKey(assetType) + if err != nil { + return nil, err + } + raw, err := p.reader.GetStrict(ctx, key) + if err != nil { + return nil, errors.Wrap(errors.CodeNoPaymentConfig, err, "读取支付方式配置失败") + } + var methods []string + if err := sonic.Unmarshal([]byte(raw), &methods); err != nil || !validMethods(methods) { + return nil, errors.New(errors.CodeNoPaymentConfig, "支付方式配置无效") + } + return stableMethods(methods), nil +} + +// EnsureAllowed 校验指定支付方式是否允许。 +func (p *Policy) EnsureAllowed(ctx context.Context, assetType, paymentMethod string) error { + methods, err := p.AllowedMethods(ctx, assetType) + if err != nil { + return err + } + if !slices.Contains(methods, paymentMethod) { + return errors.New(errors.CodePaymentMethodUnavailable) + } + return nil +} + +// AllowedRechargeMethods 返回资产允许且钱包充值接口支持的第三方支付方式。 +func (p *Policy) AllowedRechargeMethods(ctx context.Context, assetType string) ([]string, error) { + methods, err := p.AllowedMethods(ctx, assetType) + if err != nil { + return nil, err + } + result := make([]string, 0, 2) + for _, method := range methods { + if method == model.PaymentMethodWechat || method == model.PaymentMethodAlipay { + result = append(result, method) + } + } + return result, nil +} + +// EnsureRechargeAllowed 校验普通充值只使用资产配置允许的第三方支付方式。 +func (p *Policy) EnsureRechargeAllowed(ctx context.Context, assetType, paymentMethod string) error { + methods, err := p.AllowedRechargeMethods(ctx, assetType) + if err != nil { + return err + } + if !slices.Contains(methods, paymentMethod) { + return errors.New(errors.CodePaymentMethodUnavailable) + } + return nil +} + +func configKey(assetType string) (string, error) { + switch assetType { + case "card", constants.AssetTypeIotCard, model.OrderTypeSingleCard: + return constants.SystemConfigPaymentAllowedCard, nil + case constants.AssetTypeDevice: + return constants.SystemConfigPaymentAllowedDevice, nil + default: + return "", errors.New(errors.CodeInvalidParam, "无效的资产类型") + } +} + +func validMethods(methods []string) bool { + if len(methods) == 0 { + return false + } + seen := make(map[string]struct{}, len(methods)) + for _, method := range methods { + if method != model.PaymentMethodWallet && method != model.PaymentMethodWechat && method != model.PaymentMethodAlipay { + return false + } + if _, exists := seen[method]; exists { + return false + } + seen[method] = struct{}{} + } + return true +} + +// ValidateConfigValue 校验支付方式系统配置的 JSON 集合。 +func ValidateConfigValue(value string) error { + var methods []string + if err := sonic.Unmarshal([]byte(value), &methods); err != nil || !validMethods(methods) { + return stderrors.New("支付方式配置必须是非空且不重复的合法数组") + } + return nil +} + +func stableMethods(methods []string) []string { + result := make([]string, 0, len(methods)) + for _, method := range []string{model.PaymentMethodWallet, model.PaymentMethodWechat, model.PaymentMethodAlipay} { + if slices.Contains(methods, method) { + result = append(result, method) + } + } + return result +} diff --git a/internal/service/purchase_validation/service.go b/internal/service/purchase_validation/service.go index 691d2c3..fe61e13 100644 --- a/internal/service/purchase_validation/service.go +++ b/internal/service/purchase_validation/service.go @@ -57,6 +57,15 @@ type PurchaseValidationResult struct { } func (s *Service) ValidateCardPurchase(ctx context.Context, cardID uint, packageIDs []uint) (*PurchaseValidationResult, error) { + return s.validateCardPurchase(ctx, cardID, packageIDs, false) +} + +// ValidatePersonalCardPurchase 校验个人客户卡套餐购买,并允许当前世代生效中套餐续费。 +func (s *Service) ValidatePersonalCardPurchase(ctx context.Context, cardID uint, packageIDs []uint) (*PurchaseValidationResult, error) { + return s.validateCardPurchase(ctx, cardID, packageIDs, true) +} + +func (s *Service) validateCardPurchase(ctx context.Context, cardID uint, packageIDs []uint, allowRenewal bool) (*PurchaseValidationResult, error) { card, err := s.iotCardStore.GetByID(ctx, cardID) if err != nil { if err == gorm.ErrRecordNotFound { @@ -79,7 +88,11 @@ func (s *Service) ValidateCardPurchase(ctx context.Context, cardID uint, package sellerShopID = *card.ShopID } - packages, totalPrice, err := s.validatePackages(ctx, packageIDs, *card.SeriesID, sellerShopID) + renewablePackageIDs, err := s.loadRenewablePackageIDs(ctx, constants.AssetWalletResourceTypeIotCard, card.ID, card.Generation, packageIDs, allowRenewal) + if err != nil { + return nil, err + } + packages, totalPrice, err := s.validatePackages(ctx, packageIDs, *card.SeriesID, sellerShopID, renewablePackageIDs) if err != nil { return nil, err } @@ -96,6 +109,15 @@ func (s *Service) ValidateCardPurchase(ctx context.Context, cardID uint, package } func (s *Service) ValidateDevicePurchase(ctx context.Context, deviceID uint, packageIDs []uint) (*PurchaseValidationResult, error) { + return s.validateDevicePurchase(ctx, deviceID, packageIDs, false) +} + +// ValidatePersonalDevicePurchase 校验个人客户设备套餐购买,并允许当前世代生效中套餐续费。 +func (s *Service) ValidatePersonalDevicePurchase(ctx context.Context, deviceID uint, packageIDs []uint) (*PurchaseValidationResult, error) { + return s.validateDevicePurchase(ctx, deviceID, packageIDs, true) +} + +func (s *Service) validateDevicePurchase(ctx context.Context, deviceID uint, packageIDs []uint, allowRenewal bool) (*PurchaseValidationResult, error) { device, err := s.deviceStore.GetByID(ctx, deviceID) if err != nil { if err == gorm.ErrRecordNotFound { @@ -114,7 +136,11 @@ func (s *Service) ValidateDevicePurchase(ctx context.Context, deviceID uint, pac sellerShopID = *device.ShopID } - packages, totalPrice, err := s.validatePackages(ctx, packageIDs, *device.SeriesID, sellerShopID) + renewablePackageIDs, err := s.loadRenewablePackageIDs(ctx, constants.AssetWalletResourceTypeDevice, device.ID, device.Generation, packageIDs, allowRenewal) + if err != nil { + return nil, err + } + packages, totalPrice, err := s.validatePackages(ctx, packageIDs, *device.SeriesID, sellerShopID, renewablePackageIDs) if err != nil { return nil, err } @@ -133,7 +159,7 @@ func (s *Service) ValidateDevicePurchase(ctx context.Context, deviceID uint, pac // validatePackages 验证套餐列表是否可购买 // sellerShopID > 0 表示代理渠道,校验该代理的 allocation.shelf_status; // sellerShopID == 0 表示平台自营渠道,校验 package.shelf_status -func (s *Service) validatePackages(ctx context.Context, packageIDs []uint, seriesID uint, sellerShopID uint) ([]*model.Package, int64, error) { +func (s *Service) validatePackages(ctx context.Context, packageIDs []uint, seriesID uint, sellerShopID uint, renewablePackageIDs map[uint]struct{}) ([]*model.Package, int64, error) { if len(packageIDs) == 0 { return nil, 0, errors.New(errors.CodeInvalidParam, "请选择至少一个套餐") } @@ -164,9 +190,10 @@ func (s *Service) validatePackages(ctx context.Context, packageIDs []uint, serie return nil, 0, errors.New(errors.CodeInvalidParam, "套餐已禁用") } + _, canRenewOffShelf := renewablePackageIDs[pkgID] if sellerShopID > 0 { // 代理渠道:检查上架状态并获取分配记录,使用零售价 - allocation, allocErr := s.validateAgentAllocation(ctx, sellerShopID, pkgID) + allocation, allocErr := s.validateAgentAllocation(ctx, sellerShopID, pkgID, canRenewOffShelf) if allocErr != nil { return nil, 0, allocErr } @@ -177,7 +204,7 @@ func (s *Service) validatePackages(ctx context.Context, packageIDs []uint, serie } totalPrice += effectiveRetailPrice } else { - if pkg.ShelfStatus != constants.ShelfStatusOn { + if pkg.ShelfStatus != constants.ShelfStatusOn && !canRenewOffShelf { return nil, 0, errors.New(errors.CodeInvalidParam, "套餐已下架") } totalPrice += packageprice.PackageEffectiveRetailPrice(pkg) @@ -230,7 +257,7 @@ func (s *Service) validatePackageUsageRules(ctx context.Context, carrierType str } // validateAgentAllocation 校验卖家代理的分配记录上架状态,并返回分配记录 -func (s *Service) validateAgentAllocation(ctx context.Context, sellerShopID, packageID uint) (*model.ShopPackageAllocation, error) { +func (s *Service) validateAgentAllocation(ctx context.Context, sellerShopID, packageID uint, allowOffShelf bool) (*model.ShopPackageAllocation, error) { allocation, err := s.packageAllocationStore.GetByShopAndPackageForSystem(ctx, sellerShopID, packageID) if err != nil { if err == gorm.ErrRecordNotFound { @@ -239,13 +266,44 @@ func (s *Service) validateAgentAllocation(ctx context.Context, sellerShopID, pac return nil, errors.Wrap(errors.CodeInternalError, err, "查询套餐分配记录失败") } - if allocation.ShelfStatus != constants.ShelfStatusOn { + if allocation.Status != constants.StatusEnabled { + return nil, errors.New(errors.CodeInvalidParam, "套餐已禁用") + } + if allocation.ShelfStatus != constants.ShelfStatusOn && !allowOffShelf { return nil, errors.New(errors.CodeInvalidParam, "套餐已下架") } return allocation, nil } +func (s *Service) loadRenewablePackageIDs(ctx context.Context, assetType string, assetID uint, generation int, packageIDs []uint, allowRenewal bool) (map[uint]struct{}, error) { + result := make(map[uint]struct{}) + if !allowRenewal || len(packageIDs) == 0 { + return result, nil + } + + query := s.db.WithContext(ctx).Model(&model.PackageUsage{}). + Distinct("package_id"). + Where("generation = ? AND status = ? AND refund_id IS NULL AND package_id IN ?", generation, constants.PackageUsageStatusActive, packageIDs) + switch assetType { + case constants.AssetWalletResourceTypeIotCard: + query = query.Where("usage_type = ? AND iot_card_id = ?", constants.PackageUsageTypeSingleCard, assetID) + case constants.AssetWalletResourceTypeDevice: + query = query.Where("usage_type = ? AND device_id = ?", constants.AssetWalletResourceTypeDevice, assetID) + default: + return nil, errors.New(errors.CodeInvalidParam) + } + + var ids []uint + if err := query.Pluck("package_id", &ids).Error; err != nil { + return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询套餐续费资格失败") + } + for _, id := range ids { + result[id] = struct{}{} + } + return result, nil +} + // GetPurchasePrice 获取购买价格 // 代理渠道(sellerShopID > 0)返回 allocation.RetailPrice,平台渠道返回 Package.SuggestedRetailPrice func (s *Service) GetPurchasePrice(ctx context.Context, pkg *model.Package, sellerShopID uint) (int64, error) { @@ -297,7 +355,7 @@ func (s *Service) ValidateAdminOfflineCardPurchase(ctx context.Context, cardID u return nil, errors.New(errors.CodeInvalidParam, "该卡未关联套餐系列,无法购买套餐") } - packages, totalPrice, err := s.validatePackages(ctx, packageIDs, *card.SeriesID, 0) + packages, totalPrice, err := s.validatePackages(ctx, packageIDs, *card.SeriesID, 0, nil) if err != nil { return nil, err } @@ -328,7 +386,7 @@ func (s *Service) ValidateAdminOfflineDevicePurchase(ctx context.Context, device return nil, errors.New(errors.CodeInvalidParam, "该设备未关联套餐系列,无法购买套餐") } - packages, totalPrice, err := s.validatePackages(ctx, packageIDs, *device.SeriesID, 0) + packages, totalPrice, err := s.validatePackages(ctx, packageIDs, *device.SeriesID, 0, nil) if err != nil { return nil, err } diff --git a/internal/service/refund/approval_decision.go b/internal/service/refund/approval_decision.go new file mode 100644 index 0000000..8146f42 --- /dev/null +++ b/internal/service/refund/approval_decision.go @@ -0,0 +1,145 @@ +package refund + +import ( + "context" + "strings" + + "gorm.io/gorm" + "gorm.io/gorm/clause" + + approvalapp "github.com/break/junhong_cmp_fiber/internal/application/approval" + "github.com/break/junhong_cmp_fiber/internal/model" + "github.com/break/junhong_cmp_fiber/pkg/constants" + "github.com/break/junhong_cmp_fiber/pkg/errors" +) + +// Handle 幂等处理退款的渠道无关审批终态。 +func (s *Service) Handle(ctx context.Context, event approvalapp.TerminalDecisionEvent) error { + if s == nil || s.db == nil || event.BusinessType != constants.ApprovalBusinessTypeRefund || + event.BusinessID == 0 || event.InstanceID == 0 { + return errors.New(errors.CodeInvalidParam, "退款审批终态参数无效") + } + switch event.Decision { + case constants.ApprovalDecisionApproved: + return s.applyApprovedDecision(ctx, event) + case constants.ApprovalDecisionRejected, constants.ApprovalDecisionCancelled, constants.ApprovalDecisionDeleted: + return s.applyClosedDecision(ctx, event) + case constants.ApprovalDecisionRevokedAfterApproved: + return nil + default: + return errors.New(errors.CodeInvalidParam, "不支持的退款审批终态") + } +} + +func (s *Service) applyApprovedDecision(ctx context.Context, event approvalapp.TerminalDecisionEvent) error { + err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + var refund model.RefundRequest + if err := tx.WithContext(ctx).Clauses(clause.Locking{Strength: "UPDATE"}).Where("id = ?", event.BusinessID).First(&refund).Error; err != nil { + if err == gorm.ErrRecordNotFound { + return errors.New(errors.CodeForbidden, "无权限操作该资源或资源不存在") + } + return errors.Wrap(errors.CodeDatabaseError, err, "锁定退款申请失败") + } + if refund.ApprovalInstanceID == nil || *refund.ApprovalInstanceID != event.InstanceID { + return errors.New(errors.CodeConflict, "退款申请关联的审批实例不一致") + } + if refund.Status != model.RefundStatusPending && refund.Status != model.RefundStatusApproved { + return errors.New(errors.CodeInvalidStatus, "退款申请状态不允许审批通过") + } + var order model.Order + if err := tx.WithContext(ctx).Clauses(clause.Locking{Strength: "UPDATE"}).Where("id = ?", refund.OrderID).First(&order).Error; err != nil { + return errors.Wrap(errors.CodeDatabaseError, err, "锁定退款关联订单失败") + } + approvedAmount := refund.RequestedRefundAmount + if err := validateApprovedRefundAmount(approvedAmount, refund.RequestedRefundAmount, &order); err != nil { + return err + } + if refund.Status == model.RefundStatusPending { + result := tx.WithContext(ctx).Model(&model.RefundRequest{}). + Where("id = ? AND status = ?", refund.ID, model.RefundStatusPending). + Updates(map[string]any{ + "status": model.RefundStatusApproved, "processed_at": event.OccurredAt, + "approved_refund_amount": approvedAmount, "remark": "企业微信审批通过", + "updated_at": event.OccurredAt, + }) + if result.Error != nil { + return errors.Wrap(errors.CodeDatabaseError, result.Error, "完成退款审批申请失败") + } + if result.RowsAffected != 1 { + return errors.New(errors.CodeConflict, "退款申请状态已变化") + } + } + switch order.PaymentStatus { + case model.PaymentStatusPaid: + result := tx.WithContext(ctx).Model(&model.Order{}). + Where("id = ? AND payment_status = ?", order.ID, model.PaymentStatusPaid). + Updates(map[string]any{"payment_status": model.PaymentStatusRefunded, "updated_at": event.OccurredAt}) + if result.Error != nil { + return errors.Wrap(errors.CodeDatabaseError, result.Error, "更新订单退款状态失败") + } + if result.RowsAffected != 1 { + return errors.New(errors.CodeConflict, "订单退款状态已变化") + } + order.PaymentStatus = model.PaymentStatusRefunded + case model.PaymentStatusRefunded: + default: + return errors.New(errors.CodeInvalidStatus, "订单状态不允许完成退款") + } + return s.refundWalletPayment(ctx, tx, &refund, &order, approvedAmount, event.SubmitterAccountID) + }) + if err != nil { + return err + } + return s.ensureApprovedPostProcessing(ctx, event.BusinessID) +} + +func (s *Service) applyClosedDecision(ctx context.Context, event approvalapp.TerminalDecisionEvent) error { + reason := map[string]string{ + constants.ApprovalDecisionRejected: "企业微信审批已拒绝", + constants.ApprovalDecisionCancelled: "企业微信审批已撤销", + constants.ApprovalDecisionDeleted: "企业微信审批已删除", + }[event.Decision] + return s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + var refund model.RefundRequest + if err := tx.WithContext(ctx).Clauses(clause.Locking{Strength: "UPDATE"}).Where("id = ?", event.BusinessID).First(&refund).Error; err != nil { + return errors.Wrap(errors.CodeDatabaseError, err, "锁定退款申请失败") + } + if refund.ApprovalInstanceID == nil || *refund.ApprovalInstanceID != event.InstanceID { + return errors.New(errors.CodeConflict, "退款申请关联的审批实例不一致") + } + if refund.Status == model.RefundStatusRejected { + return nil + } + if refund.Status != model.RefundStatusPending { + return errors.New(errors.CodeInvalidStatus, "退款申请状态不允许结束审批") + } + result := tx.WithContext(ctx).Model(&model.RefundRequest{}). + Where("id = ? AND status = ?", refund.ID, model.RefundStatusPending). + Updates(map[string]any{ + "status": model.RefundStatusRejected, "processed_at": event.OccurredAt, + "reject_reason": strings.TrimSpace(reason), "updated_at": event.OccurredAt, + }) + if result.Error != nil { + return errors.Wrap(errors.CodeDatabaseError, result.Error, "结束退款审批申请失败") + } + if result.RowsAffected != 1 { + return errors.New(errors.CodeConflict, "退款申请状态已变化") + } + return nil + }) +} + +func (s *Service) ensureApprovedPostProcessing(ctx context.Context, refundID uint) error { + s.deductAllCommission(ctx, refundID) + s.handleRefundAssetProcessing(ctx, refundID) + var refund model.RefundRequest + if err := s.db.WithContext(ctx).Select("commission_deducted", "asset_reset").Where("id = ?", refundID).First(&refund).Error; err != nil { + return errors.Wrap(errors.CodeDatabaseError, err, "复核退款后处理状态失败") + } + if !refund.CommissionDeducted || !refund.AssetReset { + return errors.New(errors.CodeServiceUnavailable, "退款后处理尚未全部完成,将自动重试") + } + return nil +} + +var _ approvalapp.BusinessDecisionHandler = (*Service)(nil) diff --git a/internal/service/refund/service.go b/internal/service/refund/service.go index ac2232f..0e92af9 100644 --- a/internal/service/refund/service.go +++ b/internal/service/refund/service.go @@ -7,17 +7,20 @@ import ( "context" "fmt" "math/rand" + "strings" "time" "go.uber.org/zap" "gorm.io/gorm" "gorm.io/gorm/clause" + refundapprovalapp "github.com/break/junhong_cmp_fiber/internal/application/refundapproval" walletapp "github.com/break/junhong_cmp_fiber/internal/application/wallet" "github.com/break/junhong_cmp_fiber/internal/model" "github.com/break/junhong_cmp_fiber/internal/model/dto" "github.com/break/junhong_cmp_fiber/internal/store" "github.com/break/junhong_cmp_fiber/internal/store/postgres" + "github.com/break/junhong_cmp_fiber/pkg/config" "github.com/break/junhong_cmp_fiber/pkg/constants" "github.com/break/junhong_cmp_fiber/pkg/errors" "github.com/break/junhong_cmp_fiber/pkg/middleware" @@ -43,6 +46,7 @@ type Service struct { deviceStore *postgres.DeviceStore assetWalletStore *postgres.AssetWalletStore agentWalletRefundService *walletapp.RefundService + refundApprovalCreation *refundapprovalapp.CreationService logger *zap.Logger } @@ -84,6 +88,11 @@ func (s *Service) SetAgentWalletRefundService(service *walletapp.RefundService) s.agentWalletRefundService = service } +// SetRefundApprovalCreationService 注入退款企微审批申请用例。 +func (s *Service) SetRefundApprovalCreationService(service *refundapprovalapp.CreationService) { + s.refundApprovalCreation = service +} + // Create 创建退款申请 // 校验订单存在且已支付,检查是否存在活跃退款申请,生成退款单号并创建记录 func (s *Service) Create(ctx context.Context, req *dto.CreateRefundRequest) (*dto.RefundResponse, error) { @@ -111,12 +120,6 @@ func (s *Service) Create(ctx context.Context, req *dto.CreateRefundRequest) (*dt return nil, err } - // 检查是否存在活跃退款申请(待审批、已通过、已退回状态) - existing, err := s.refundStore.FindActiveByOrderID(ctx, req.OrderID) - if err == nil && existing != nil { - return nil, errors.New(errors.CodeConflict, "该订单已存在退款申请") - } - // 从订单获取 shop_id:优先使用 SellerShopID,代理商买家使用 BuyerID var shopID *uint if order.SellerShopID != nil { @@ -144,11 +147,22 @@ func (s *Service) Create(ctx context.Context, req *dto.CreateRefundRequest) (*dt refund.Creator = userID refund.Updater = userID - if err := s.refundStore.Create(ctx, refund); err != nil { - return nil, errors.Wrap(errors.CodeInternalError, err, "创建退款申请失败") + if s.refundApprovalCreation == nil { + return nil, errors.New(errors.CodeServiceUnavailable, "退款审批能力未配置") + } + result, err := s.refundApprovalCreation.Execute(ctx, refundapprovalapp.CreateCommand{ + Refund: refund, SubmitterAccountID: userID, + }) + if err != nil { + return nil, err } - return buildRefundResponse(refund), nil + resp := buildRefundResponse(result.Refund) + resp.SubmitterName = result.SubmitterName + resp.ApprovalProvider = constants.IntegrationProviderWeCom + resp.ApprovalStatus = &result.ApprovalStatus + resp.ApprovalStatusName = constants.GetApprovalStatusName(result.ApprovalStatus) + return resp, nil } // List 分页查询退款申请列表 @@ -176,10 +190,21 @@ func (s *Service) List(ctx context.Context, req *dto.RefundListRequest) (*dto.Re if err != nil { return nil, errors.Wrap(errors.CodeInternalError, err, "查询退款申请列表失败") } + submitterNames, err := s.loadSubmitterNames(ctx, refundSubmitterIDs(requests)) + if err != nil { + return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询退款提交人失败") + } + approvalSummaries, err := s.loadApprovalSummaries(ctx, requests) + if err != nil { + return nil, err + } items := make([]dto.RefundResponse, 0, len(requests)) for _, r := range requests { - items = append(items, *buildRefundResponse(r)) + item := buildRefundResponse(r) + item.SubmitterName = submitterNames[r.Creator] + applyApprovalSummary(item, approvalSummaries, r.ApprovalInstanceID) + items = append(items, *item) } return &dto.RefundListResponse{ @@ -196,13 +221,23 @@ func (s *Service) GetByID(ctx context.Context, id uint) (*dto.RefundResponse, er if err != nil { return nil, errors.New(errors.CodeNotFound, "退款申请不存在") } - return buildRefundResponse(refund), nil + resp := buildRefundResponse(refund) + resp.SubmitterName = s.loadSubmitterNameBestEffort(ctx, refund.Creator) + approvalSummaries, err := s.loadApprovalSummaries(ctx, []*model.RefundRequest{refund}) + if err != nil { + return nil, err + } + applyApprovalSummary(resp, approvalSummaries, refund.ApprovalInstanceID) + return resp, nil } // Approve 审批通过退款申请 // 条件更新 WHERE status=1,设置审批信息 // 事务提交成功后异步执行佣金回扣和退款后资产处理 func (s *Service) Approve(ctx context.Context, id uint, req *dto.ApproveRefundRequest) error { + if !legacyRefundManualEnabled() { + return errors.New(errors.CodeInvalidStatus, "退款人工审批入口已停用,请查看企业微信审批状态") + } userID := middleware.GetUserIDFromContext(ctx) if userID == 0 { return errors.New(errors.CodeUnauthorized, "未授权访问") @@ -218,6 +253,9 @@ func (s *Service) Approve(ctx context.Context, id uint, req *dto.ApproveRefundRe if refund.Status != model.RefundStatusPending { return errors.New(errors.CodeInvalidStatus, "仅待审批状态可审批通过") } + if refund.ApprovalInstanceID != nil { + return errors.New(errors.CodeInvalidStatus, "该退款申请由企业微信审批决定,不能人工审批") + } now := time.Now() approvedAmount := refund.RequestedRefundAmount @@ -356,6 +394,20 @@ func resolveAgentWalletRefundShopID(order *model.Order) (uint, *uint, error) { // refundAssetWalletPayment 将个人资产钱包支付的订单退款退回原资产钱包。 func (s *Service) refundAssetWalletPayment(ctx context.Context, tx *gorm.DB, refund *model.RefundRequest, order *model.Order, amount int64, operatorID uint) error { + var existing model.AssetWalletTransaction + err := tx.WithContext(ctx). + Where("reference_type = ? AND reference_no = ? AND transaction_type = ? AND status = ?", + constants.ReferenceTypeRefund, refund.RefundNo, constants.AssetTransactionTypeRefund, constants.TransactionStatusSuccess). + First(&existing).Error + if err == nil { + if existing.Amount != amount { + return errors.New(errors.CodeConflict, "退款申请已存在不一致的资产钱包回款流水") + } + return nil + } + if err != gorm.ErrRecordNotFound { + return errors.Wrap(errors.CodeDatabaseError, err, "复核资产钱包退款流水失败") + } wallet, err := s.resolveAssetRefundWallet(tx, order) if err != nil { return err @@ -463,6 +515,9 @@ func lockAssetWalletByResource(tx *gorm.DB, resourceType string, resourceID uint // Reject 审批拒绝退款申请 // 条件更新 WHERE status=1 func (s *Service) Reject(ctx context.Context, id uint, req *dto.RejectRefundRequest) error { + if !legacyRefundManualEnabled() { + return errors.New(errors.CodeInvalidStatus, "退款人工审批入口已停用,请查看企业微信审批状态") + } userID := middleware.GetUserIDFromContext(ctx) if userID == 0 { return errors.New(errors.CodeUnauthorized, "未授权访问") @@ -474,7 +529,7 @@ func (s *Service) Reject(ctx context.Context, id uint, req *dto.RejectRefundRequ now := time.Now() result := s.db.WithContext(ctx). Model(&model.RefundRequest{}). - Where("id = ? AND status = ?", id, model.RefundStatusPending). + Where("id = ? AND status = ? AND approval_instance_id IS NULL", id, model.RefundStatusPending). Updates(map[string]any{ "status": model.RefundStatusRejected, "processor_id": userID, @@ -492,6 +547,11 @@ func (s *Service) Reject(ctx context.Context, id uint, req *dto.RejectRefundRequ return nil } +func legacyRefundManualEnabled() bool { + cfg := config.Get() + return cfg == nil || cfg.Approval.LegacyRefundManualEnabled +} + // Return 退回退款申请 // 条件更新 WHERE status=1,退回后可重新提交 func (s *Service) Return(ctx context.Context, id uint, req *dto.ReturnRefundRequest) error { @@ -506,7 +566,7 @@ func (s *Service) Return(ctx context.Context, id uint, req *dto.ReturnRefundRequ now := time.Now() result := s.db.WithContext(ctx). Model(&model.RefundRequest{}). - Where("id = ? AND status = ?", id, model.RefundStatusPending). + Where("id = ? AND status = ? AND approval_instance_id IS NULL", id, model.RefundStatusPending). Updates(map[string]any{ "status": model.RefundStatusReturned, "processor_id": userID, @@ -602,9 +662,8 @@ func (s *Service) Resubmit(ctx context.Context, id uint, req *dto.ResubmitRefund return nil } -// deductAllCommission 异步回扣该订单所有已入账佣金 -// 查询退款单关联订单的所有已入账佣金记录,逐条从代理佣金钱包扣减 -// 失败仅记录日志,不影响审批结果 +// deductAllCommission 幂等回扣该订单所有已入账佣金。 +// 每条佣金在独立事务中锁定并失效;全部完成后才设置退款单完成标记。 func (s *Service) deductAllCommission(ctx context.Context, refundID uint) { logger := s.logger @@ -614,6 +673,9 @@ func (s *Service) deductAllCommission(ctx context.Context, refundID uint) { logger.Error("佣金回扣:查询退款单失败", zap.Uint("refund_id", refundID), zap.Error(err)) return } + if refund.CommissionDeducted { + return + } // 查询该订单所有已入账佣金记录 var commissions []model.CommissionRecord @@ -629,9 +691,11 @@ func (s *Service) deductAllCommission(ctx context.Context, refundID uint) { return } + allSucceeded := true // 对每条佣金记录执行扣减 for _, commission := range commissions { if err := s.deductSingleCommission(ctx, &refund, &commission); err != nil { + allSucceeded = false logger.Error("佣金回扣:单条佣金扣减失败", zap.Uint("refund_id", refundID), zap.Uint("commission_id", commission.ID), @@ -642,6 +706,9 @@ func (s *Service) deductAllCommission(ctx context.Context, refundID uint) { // 继续处理下一条,不中断 } } + if !allSucceeded { + return + } // 全部完成后标记退款单佣金已回扣 if err := s.db.Model(&model.RefundRequest{}).Where("id = ?", refundID).Update("commission_deducted", true).Error; err != nil { @@ -652,54 +719,72 @@ func (s *Service) deductAllCommission(ctx context.Context, refundID uint) { // deductSingleCommission 扣减单条佣金记录对应的代理钱包余额 // 使用乐观锁扣减(允许余额为负数),并创建交易流水 func (s *Service) deductSingleCommission(ctx context.Context, refund *model.RefundRequest, commission *model.CommissionRecord) error { - // 获取对应代理的佣金钱包 - wallet, err := s.agentWalletStore.GetCommissionWallet(ctx, commission.ShopID) - if err != nil { - return fmt.Errorf("获取佣金钱包失败: shop_id=%d, %w", commission.ShopID, err) - } - - // 乐观锁扣减余额(允许负数) - result := s.db.Model(&model.AgentWallet{}). - Where("id = ? AND version = ?", wallet.ID, wallet.Version). - Updates(map[string]any{ - "balance": gorm.Expr("balance - ?", commission.Amount), - "version": gorm.Expr("version + 1"), - }) - if result.Error != nil { - return fmt.Errorf("扣减钱包余额失败: %w", result.Error) - } - if result.RowsAffected == 0 { - return fmt.Errorf("乐观锁冲突: wallet_id=%d, version=%d", wallet.ID, wallet.Version) - } - - // 创建交易流水 - refType := constants.ReferenceTypeRefund - refID := refund.ID - transaction := &model.AgentWalletTransaction{ - AgentWalletID: wallet.ID, - ShopID: commission.ShopID, - UserID: refund.Creator, - TransactionType: constants.AgentTransactionTypeCommissionDeduct, - Amount: -commission.Amount, - BalanceBefore: wallet.Balance, - BalanceAfter: wallet.Balance - commission.Amount, - Status: constants.TransactionStatusSuccess, - ReferenceType: &refType, - ReferenceID: &refID, - Creator: refund.Creator, - ShopIDTag: commission.ShopID, - } - - if err := s.agentWalletTransactionStore.CreateWithTx(ctx, s.db, transaction); err != nil { - return fmt.Errorf("创建交易流水失败: %w", err) - } - - return nil + return s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + var current model.CommissionRecord + if err := tx.WithContext(ctx).Clauses(clause.Locking{Strength: "UPDATE"}).Where("id = ?", commission.ID).First(¤t).Error; err != nil { + return errors.Wrap(errors.CodeDatabaseError, err, "锁定退款佣金记录失败") + } + if current.Status == constants.CommissionStatusInvalid { + return nil + } + if current.Status != constants.CommissionStatusReleased { + return errors.New(errors.CodeInvalidStatus, "退款佣金状态不允许回扣") + } + var existingCount int64 + if err := tx.WithContext(ctx).Model(&model.AgentWalletTransaction{}). + Where("reference_type = ? AND reference_id = ? AND transaction_type = ? AND status = ?", + constants.ReferenceTypeCommission, current.ID, constants.AgentTransactionTypeCommissionDeduct, constants.TransactionStatusSuccess). + Count(&existingCount).Error; err != nil { + return errors.Wrap(errors.CodeDatabaseError, err, "复核退款佣金回扣流水失败") + } + if existingCount > 0 { + if err := tx.WithContext(ctx).Model(&model.CommissionRecord{}).Where("id = ?", current.ID). + Update("status", constants.CommissionStatusInvalid).Error; err != nil { + return errors.Wrap(errors.CodeDatabaseError, err, "复核后失效退款佣金记录失败") + } + return nil + } + var wallet model.AgentWallet + if err := tx.WithContext(ctx).Clauses(clause.Locking{Strength: "UPDATE"}). + Where("shop_id = ? AND wallet_type = ?", current.ShopID, constants.AgentWalletTypeCommission). + First(&wallet).Error; err != nil { + return errors.Wrap(errors.CodeDatabaseError, err, "锁定退款佣金钱包失败") + } + result := tx.WithContext(ctx).Model(&model.AgentWallet{}). + Where("id = ? AND version = ?", wallet.ID, wallet.Version). + Updates(map[string]any{"balance": gorm.Expr("balance - ?", current.Amount), "version": gorm.Expr("version + 1")}) + if result.Error != nil { + return errors.Wrap(errors.CodeDatabaseError, result.Error, "扣减退款佣金钱包失败") + } + if result.RowsAffected != 1 { + return errors.New(errors.CodeConflict, "退款佣金钱包版本已变化") + } + refType, refID := constants.ReferenceTypeCommission, current.ID + transaction := &model.AgentWalletTransaction{ + AgentWalletID: wallet.ID, ShopID: current.ShopID, UserID: refund.Creator, + TransactionType: constants.AgentTransactionTypeCommissionDeduct, Amount: -current.Amount, + BalanceBefore: wallet.Balance, BalanceAfter: wallet.Balance - current.Amount, + Status: constants.TransactionStatusSuccess, ReferenceType: &refType, ReferenceID: &refID, + Creator: refund.Creator, ShopIDTag: current.ShopID, + } + if err := tx.WithContext(ctx).Create(transaction).Error; err != nil { + return errors.Wrap(errors.CodeDatabaseError, err, "创建退款佣金回扣流水失败") + } + updated := tx.WithContext(ctx).Model(&model.CommissionRecord{}). + Where("id = ? AND status = ?", current.ID, constants.CommissionStatusReleased). + Update("status", constants.CommissionStatusInvalid) + if updated.Error != nil { + return errors.Wrap(errors.CodeDatabaseError, updated.Error, "失效退款佣金记录失败") + } + if updated.RowsAffected != 1 { + return errors.New(errors.CodeConflict, "退款佣金状态已变化") + } + return nil + }) } -// handleRefundAssetProcessing 异步处理退款后的资产状态 -// 包括:退款套餐精准失效、尝试接续待生效主套餐、必要时停机 -// 失败仅记录日志,不影响审批结果 +// handleRefundAssetProcessing 幂等处理退款后的资产状态。 +// 包括退款套餐精准失效、尝试接续待生效主套餐和必要时停机;全部完成后才设置完成标记。 func (s *Service) handleRefundAssetProcessing(ctx context.Context, refundID uint) { logger := s.logger @@ -709,6 +794,9 @@ func (s *Service) handleRefundAssetProcessing(ctx context.Context, refundID uint logger.Error("退款资产处理:查询退款单失败", zap.Uint("refund_id", refundID), zap.Error(err)) return } + if refund.AssetReset { + return + } // 查询关联订单 var order model.Order @@ -784,7 +872,9 @@ func (s *Service) handleRefundAssetProcessing(ctx context.Context, refundID uint } if !hasActiveMain { // 3. 无可用主套餐时才停机;退款不再重置世代或重建钱包。 - s.stopAsset(ctx, assetType, assetID) + if !s.stopAsset(ctx, assetType, assetID) { + return + } } // 4. 标记退款后资产处理已完成 @@ -794,7 +884,7 @@ func (s *Service) handleRefundAssetProcessing(ctx context.Context, refundID uint } // stopAsset 根据资产类型执行停机操作 -func (s *Service) stopAsset(ctx context.Context, assetType string, assetID uint) { +func (s *Service) stopAsset(ctx context.Context, assetType string, assetID uint) bool { logger := s.logger switch assetType { @@ -803,21 +893,38 @@ func (s *Service) stopAsset(ctx context.Context, assetType string, assetID uint) card, err := s.iotCardStore.GetByID(ctx, assetID) if err != nil { logger.Error("退款资产处理:查询卡信息失败", zap.Uint("card_id", assetID), zap.Error(err)) - return + return false } - if s.stopResumeService != nil { - if err := s.stopResumeService.ManualStopCard(ctx, card.ICCID); err != nil { - logger.Error("退款资产处理:单卡停机失败", zap.String("iccid", card.ICCID), zap.Error(err)) - } + if s.stopResumeService == nil { + logger.Error("退款资产处理:单卡停机服务未注入", zap.Uint("card_id", assetID)) + return false + } + if err := s.stopResumeService.ManualStopCard(ctx, card.ICCID); err != nil { + logger.Error("退款资产处理:单卡停机失败", zap.String("iccid", card.ICCID), zap.Error(err)) + return false } case "device": - // 设备停机 - if s.deviceService != nil { - if _, err := s.deviceService.StopDevice(ctx, assetID); err != nil { - logger.Error("退款资产处理:设备停机失败", zap.Uint("device_id", assetID), zap.Error(err)) + if s.stopResumeService == nil { + logger.Error("退款资产处理:设备停机服务未注入", zap.Uint("device_id", assetID)) + return false + } + var cards []model.IotCard + if err := s.db.WithContext(ctx).Model(&model.IotCard{}). + Joins("JOIN tb_device_sim_binding binding ON binding.iot_card_id = tb_iot_card.id AND binding.deleted_at IS NULL"). + Where("binding.device_id = ? AND binding.bind_status = ?", assetID, constants.BindStatusBound). + Find(&cards).Error; err != nil { + logger.Error("退款资产处理:查询设备绑定卡失败", zap.Uint("device_id", assetID), zap.Error(err)) + return false + } + for _, card := range cards { + if err := s.stopResumeService.ManualStopCard(ctx, card.ICCID); err != nil { + logger.Error("退款资产处理:设备绑定卡停机失败", + zap.Uint("device_id", assetID), zap.Uint("card_id", card.ID), zap.Error(err)) + return false } } } + return true } // generateRefundNo 生成退款单号 @@ -841,7 +948,7 @@ func validateRequestedRefundAmountByOrder(requestedRefundAmount int64, order *mo // validateApprovedRefundAmount 校验审批退款金额不能超过申请金额和订单实收金额。 func validateApprovedRefundAmount(approvedAmount int64, requestedRefundAmount int64, order *model.Order) error { - if approvedAmount < 0 { + if approvedAmount <= 0 { return errors.New(errors.CodeInvalidParam, "审批退款金额必须大于0") } if approvedAmount > requestedRefundAmount { @@ -857,7 +964,15 @@ func normalizeRefundVoucherKey(keys []string) (model.StringJSONBArray, error) { if len(keys) > 5 { return nil, errors.New(errors.CodeInvalidParam, "退款凭证最多上传5个") } - return model.StringJSONBArray(keys), nil + normalized := make(model.StringJSONBArray, 0, len(keys)) + for _, key := range keys { + key = strings.TrimSpace(key) + if key == "" { + return nil, errors.New(errors.CodeInvalidParam, "退款凭证对象存储 Key 不能为空") + } + normalized = append(normalized, key) + } + return normalized, nil } // buildRefundResponse 将退款 Model 转换为 DTO 响应 @@ -893,6 +1008,8 @@ func buildRefundResponse(r *model.RefundRequest) *dto.RefundResponse { Remark: r.Remark, CommissionDeducted: r.CommissionDeducted, AssetReset: r.AssetReset, + SubmitterID: r.Creator, + ApprovalInstanceID: r.ApprovalInstanceID, Creator: r.Creator, Updater: r.Updater, CreatedAt: r.CreatedAt.Format("2006-01-02 15:04:05"), @@ -906,6 +1023,84 @@ func buildRefundResponse(r *model.RefundRequest) *dto.RefundResponse { return resp } +type approvalSummary struct { + Provider string + Status int +} + +func (s *Service) loadApprovalSummaries(ctx context.Context, refunds []*model.RefundRequest) (map[uint]approvalSummary, error) { + ids := make([]uint, 0, len(refunds)) + seen := make(map[uint]struct{}, len(refunds)) + for _, refund := range refunds { + if refund == nil || refund.ApprovalInstanceID == nil || *refund.ApprovalInstanceID == 0 { + continue + } + id := *refund.ApprovalInstanceID + if _, exists := seen[id]; exists { + continue + } + seen[id] = struct{}{} + ids = append(ids, id) + } + summaries := make(map[uint]approvalSummary, len(ids)) + if len(ids) == 0 { + return summaries, nil + } + var instances []model.ApprovalInstance + if err := s.db.WithContext(ctx).Select("id", "provider", "status").Where("id IN ?", ids).Find(&instances).Error; err != nil { + return nil, errors.Wrap(errors.CodeDatabaseError, err, "批量查询退款审批状态失败") + } + for _, instance := range instances { + summaries[instance.ID] = approvalSummary{Provider: instance.Provider, Status: instance.Status} + } + return summaries, nil +} + +func applyApprovalSummary(response *dto.RefundResponse, summaries map[uint]approvalSummary, instanceID *uint) { + if response == nil || instanceID == nil { + return + } + summary, exists := summaries[*instanceID] + if !exists { + return + } + status := summary.Status + response.ApprovalProvider = summary.Provider + response.ApprovalStatus = &status + response.ApprovalStatusName = constants.GetApprovalStatusName(status) +} + +func refundSubmitterIDs(requests []*model.RefundRequest) []uint { + ids := make([]uint, 0, len(requests)) + for _, request := range requests { + if request != nil && request.Creator > 0 { + ids = append(ids, request.Creator) + } + } + return ids +} + +func (s *Service) loadSubmitterNames(ctx context.Context, ids []uint) (map[uint]string, error) { + accounts, err := postgres.NewAccountStore(s.db, nil).GetDisplayAccountsByIDs(ctx, ids) + if err != nil { + return nil, err + } + names := make(map[uint]string, len(accounts)) + for _, account := range accounts { + names[account.ID] = account.Username + } + return names, nil +} + +func (s *Service) loadSubmitterNameBestEffort(ctx context.Context, id uint) string { + names, err := s.loadSubmitterNames(ctx, []uint{id}) + if err != nil { + s.logger.Warn("查询退款提交人失败", zap.Uint("submitter_id", id), zap.Error(err)) + return "" + } + return names[id] +} + // buildRefundWalletTransactionAssetSnapshot 从订单中生成退款流水的资产快照。 func buildRefundWalletTransactionAssetSnapshot(order *model.Order) (string, uint, string) { if order == nil { diff --git a/internal/store/postgres/account_store.go b/internal/store/postgres/account_store.go index 731b982..30e34a5 100644 --- a/internal/store/postgres/account_store.go +++ b/internal/store/postgres/account_store.go @@ -99,6 +99,13 @@ func (s *AccountStore) Update(ctx context.Context, account *model.Account) error return s.db.WithContext(ctx).Save(account).Error } +// BindWeCom 更新账号的企业微信成员绑定快照。 +func (s *AccountStore) BindWeCom(ctx context.Context, accountID uint, corpID, userID, name string, updater uint) error { + return s.db.WithContext(ctx).Model(&model.Account{}).Where("id = ?", accountID).Updates(map[string]any{ + "wecom_corp_id": corpID, "wecom_userid": userID, "wecom_name": name, "updater": updater, + }).Error +} + // Delete 软删除账号 func (s *AccountStore) Delete(ctx context.Context, id uint) error { return s.db.WithContext(ctx).Delete(&model.Account{}, id).Error @@ -254,6 +261,22 @@ func (s *AccountStore) GetByIDs(ctx context.Context, ids []uint) ([]*model.Accou return accounts, nil } +// GetDisplayAccountsByIDs 批量读取历史业务记录的账号展示信息。 +// 该查询只返回 ID 和用户名,包含已软删除账号,不用于授权判定。 +func (s *AccountStore) GetDisplayAccountsByIDs(ctx context.Context, ids []uint) ([]*model.Account, error) { + if len(ids) == 0 { + return []*model.Account{}, nil + } + var accounts []*model.Account + if err := s.db.WithContext(ctx).Unscoped(). + Select("id", "username"). + Where("id IN ?", ids). + Find(&accounts).Error; err != nil { + return nil, err + } + return accounts, nil +} + func (s *AccountStore) GetPrimaryAccountsByShopIDs(ctx context.Context, shopIDs []uint) ([]*model.Account, error) { if len(shopIDs) == 0 { return []*model.Account{}, nil diff --git a/internal/store/postgres/asset_package_batch_order_task_store.go b/internal/store/postgres/asset_package_batch_order_task_store.go new file mode 100644 index 0000000..d8f8dcf --- /dev/null +++ b/internal/store/postgres/asset_package_batch_order_task_store.go @@ -0,0 +1,106 @@ +package postgres + +import ( + "context" + "fmt" + "time" + + "gorm.io/gorm" + + "github.com/break/junhong_cmp_fiber/internal/model" + "github.com/break/junhong_cmp_fiber/internal/store" + "github.com/break/junhong_cmp_fiber/pkg/asynctask" + "github.com/break/junhong_cmp_fiber/pkg/constants" + "github.com/break/junhong_cmp_fiber/pkg/middleware" +) + +// AssetPackageBatchOrderTaskStore 批量订购任务数据访问层。 +type AssetPackageBatchOrderTaskStore struct { + db *gorm.DB +} + +// NewAssetPackageBatchOrderTaskStore 创建批量订购任务 Store。 +func NewAssetPackageBatchOrderTaskStore(db *gorm.DB) *AssetPackageBatchOrderTaskStore { + return &AssetPackageBatchOrderTaskStore{db: db} +} + +// Create 创建批量订购任务。 +func (s *AssetPackageBatchOrderTaskStore) Create(ctx context.Context, task *model.AssetPackageBatchOrderTask) error { + return s.db.WithContext(ctx).Create(task).Error +} + +// GetByID 按 ID 查询批量订购任务。 +func (s *AssetPackageBatchOrderTaskStore) GetByID(ctx context.Context, id uint) (*model.AssetPackageBatchOrderTask, error) { + var task model.AssetPackageBatchOrderTask + query := s.applyVisibleScope(ctx, s.db.WithContext(ctx).Where("id = ?", id)) + if err := query.First(&task).Error; err != nil { + return nil, err + } + return &task, nil +} + +// List 分页查询批量订购任务。 +func (s *AssetPackageBatchOrderTaskStore) List(ctx context.Context, opts *store.QueryOptions, status *int) ([]*model.AssetPackageBatchOrderTask, int64, error) { + query := s.applyVisibleScope(ctx, s.db.WithContext(ctx).Model(&model.AssetPackageBatchOrderTask{})) + if status != nil { + query = query.Where("status = ?", *status) + } + var total int64 + if err := query.Count(&total).Error; err != nil { + return nil, 0, err + } + if opts == nil { + opts = &store.QueryOptions{Page: 1, PageSize: constants.DefaultPageSize} + } + var tasks []*model.AssetPackageBatchOrderTask + if err := query.Order("created_at DESC").Offset((opts.Page - 1) * opts.PageSize).Limit(opts.PageSize).Find(&tasks).Error; err != nil { + return nil, 0, err + } + return tasks, total, nil +} + +// Claim 领取待处理或已超时的处理中任务,避免正常执行期间被重复消费。 +func (s *AssetPackageBatchOrderTaskStore) Claim(ctx context.Context, id uint) (bool, error) { + now := time.Now() + staleBefore := now.Add(-constants.AssetPackageBatchOrderTaskTimeout) + result := s.db.WithContext(ctx).Model(&model.AssetPackageBatchOrderTask{}). + Where("id = ? AND (status = ? OR (status = ? AND started_at < ?))", id, asynctask.StatusPending, asynctask.StatusProcessing, staleBefore). + Updates(map[string]any{"status": asynctask.StatusProcessing, "started_at": now, "updated_at": now}) + return result.RowsAffected == 1, result.Error +} + +func (s *AssetPackageBatchOrderTaskStore) applyVisibleScope(ctx context.Context, query *gorm.DB) *gorm.DB { + if middleware.GetUserTypeFromContext(ctx) != constants.UserTypeAgent { + return query + } + shopIDs := middleware.GetSubordinateShopIDs(ctx) + if len(shopIDs) == 0 { + return query.Where("1 = 0") + } + return query.Where("creator_shop_id IN ?", shopIDs) +} + +// MarkFailed 将任务标记为整体失败。 +func (s *AssetPackageBatchOrderTaskStore) MarkFailed(ctx context.Context, id uint, message string) error { + now := time.Now() + return s.db.WithContext(ctx).Model(&model.AssetPackageBatchOrderTask{}).Where("id = ?", id). + Updates(map[string]any{"status": asynctask.StatusFailed, "error_message": message, "completed_at": now, "updated_at": now}).Error +} + +// Complete 保存逐行结果并完成任务。 +func (s *AssetPackageBatchOrderTaskStore) Complete(ctx context.Context, id uint, items model.AssetPackageBatchOrderResultItems, successCount, failCount int) error { + now := time.Now() + return s.db.WithContext(ctx).Model(&model.AssetPackageBatchOrderTask{}). + Where("id = ? AND status = ?", id, asynctask.StatusProcessing). + Updates(map[string]any{ + "status": asynctask.StatusCompleted, "total_count": len(items), + "success_count": successCount, "fail_count": failCount, + "result_items": items, "completed_at": now, "updated_at": now, + }).Error +} + +// GenerateTaskNo 生成批量订购任务编号。 +func (s *AssetPackageBatchOrderTaskStore) GenerateTaskNo() string { + now := time.Now() + return fmt.Sprintf("BPO-%s-%06d", now.Format("20060102"), now.UnixNano()%1000000) +} diff --git a/internal/store/postgres/device_import_task_store.go b/internal/store/postgres/device_import_task_store.go index 274bf78..9785415 100644 --- a/internal/store/postgres/device_import_task_store.go +++ b/internal/store/postgres/device_import_task_store.go @@ -101,6 +101,9 @@ func (s *DeviceImportTaskStore) List(ctx context.Context, opts *store.QueryOptio if status, ok := filters["status"].(int); ok && status > 0 { query = query.Where("status = ?", status) } + if operationType, ok := filters["operation_type"].(string); ok && operationType != "" { + query = query.Where("operation_type = ?", operationType) + } if batchNo, ok := filters["batch_no"].(string); ok && batchNo != "" { query = query.Where("batch_no LIKE ?", "%"+batchNo+"%") } diff --git a/internal/store/postgres/device_store.go b/internal/store/postgres/device_store.go index 94c72bf..1d9b6e2 100644 --- a/internal/store/postgres/device_store.go +++ b/internal/store/postgres/device_store.go @@ -70,6 +70,21 @@ func (s *DeviceStore) GetByIdentifier(ctx context.Context, identifier string) (* return &device, nil } +// GetByIdentifiers 批量按 VirtualNo、IMEI 或 SN 查询设备,并应用当前数据权限。 +func (s *DeviceStore) GetByIdentifiers(ctx context.Context, identifiers []string) ([]*model.Device, error) { + devices := make([]*model.Device, 0) + if len(identifiers) == 0 { + return devices, nil + } + query := s.db.WithContext(ctx). + Where("virtual_no IN ? OR imei IN ? OR sn IN ?", identifiers, identifiers, identifiers) + query = middleware.ApplyShopFilter(ctx, query) + if err := query.Find(&devices).Error; err != nil { + return nil, err + } + return devices, nil +} + func (s *DeviceStore) GetByIDs(ctx context.Context, ids []uint) ([]*model.Device, error) { var devices []*model.Device if len(ids) == 0 { @@ -152,6 +167,9 @@ func (s *DeviceStore) applyDeviceFilters(ctx context.Context, query *gorm.DB, fi if activationStatus, ok := filters["activation_status"].(int); ok { query = s.applyActivationStatusFilter(query, activationStatus) } + if realNameStatus, ok := filters["real_name_status"].(int); ok { + query = s.applyRealNameStatusFilter(query, realNameStatus) + } if shopIDs, ok := filters["shop_ids"].([]uint); ok { if len(shopIDs) == 0 { query = query.Where("1 = 0") @@ -206,6 +224,27 @@ func (s *DeviceStore) applyDeviceFilters(ctx context.Context, query *gorm.DB, fi return query } +// applyRealNameStatusFilter 按设备当前有效绑定卡的实名状态过滤。 +// 任意有效绑定卡已实名时设备视为已实名,否则视为未实名。 +func (s *DeviceStore) applyRealNameStatusFilter(query *gorm.DB, realNameStatus int) *gorm.DB { + verifiedBindingCondition := `EXISTS ( + SELECT 1 + FROM tb_device_sim_binding AS b + JOIN tb_iot_card AS c + ON c.id = b.iot_card_id + AND c.real_name_status = ? + AND c.deleted_at IS NULL + WHERE b.device_id = tb_device.id + AND b.bind_status = ? + AND b.deleted_at IS NULL + )` + args := []any{constants.RealNameStatusVerified, constants.BindStatusBound} + if realNameStatus == constants.RealNameStatusVerified { + return query.Where(verifiedBindingCondition, args...) + } + return query.Where("NOT "+verifiedBindingCondition, args...) +} + // applyHasActivePackageFilter 按"是否有生效中主套餐"过滤设备。 // true: 设备在 tb_package_usage 中存在生效中(status=PackageUsageStatusActive)的主套餐记录; // false: 不存在上述记录。 diff --git a/internal/store/postgres/iot_card_store.go b/internal/store/postgres/iot_card_store.go index 6250200..2f20290 100644 --- a/internal/store/postgres/iot_card_store.go +++ b/internal/store/postgres/iot_card_store.go @@ -902,6 +902,9 @@ func (s *IotCardStore) applyStandaloneFilters(ctx context.Context, query *gorm.D if networkStatus, ok := filters["network_status"].(int); ok { query = query.Where("network_status = ?", networkStatus) } + if realNameStatus, ok := filters["real_name_status"].(int); ok { + query = query.Where("real_name_status = ?", realNameStatus) + } if enterpriseID, ok := filters["authorized_enterprise_id"].(uint); ok && enterpriseID > 0 { // 只返回有效授权给该企业的卡 query = query.Where("id IN (?)", diff --git a/internal/store/postgres/refund_store.go b/internal/store/postgres/refund_store.go index 153b227..8d2105c 100644 --- a/internal/store/postgres/refund_store.go +++ b/internal/store/postgres/refund_store.go @@ -144,3 +144,29 @@ func (s *RefundStore) FindActiveByOrderID(ctx context.Context, orderID uint) (*m } return &req, nil } + +// HasUnfinishedByAsset 检查资产是否存在仍会影响资产状态的退款申请。 +// 待审批、已退回,以及已通过但退款后资产处理尚未完成的申请均视为未终结。 +func (s *RefundStore) HasUnfinishedByAsset(ctx context.Context, assetType string, assetID uint) (bool, error) { + var assetColumn string + switch assetType { + case constants.ExchangeAssetTypeIotCard: + assetColumn = "iot_card_id" + case constants.ExchangeAssetTypeDevice: + assetColumn = "device_id" + default: + return false, gorm.ErrInvalidData + } + + var count int64 + err := s.db.WithContext(ctx). + Model(&model.RefundRequest{}). + Where(assetColumn+" = ?", assetID). + Where("status IN ? OR (status = ? AND asset_reset = ?)", + []int{model.RefundStatusPending, model.RefundStatusReturned}, + model.RefundStatusApproved, + false, + ). + Count(&count).Error + return count > 0, err +} diff --git a/internal/task/asset_package_batch_order.go b/internal/task/asset_package_batch_order.go new file mode 100644 index 0000000..62dffd9 --- /dev/null +++ b/internal/task/asset_package_batch_order.go @@ -0,0 +1,242 @@ +package task + +import ( + "bytes" + "context" + "encoding/csv" + stderrors "errors" + "io" + "os" + "strconv" + "strings" + "unicode/utf8" + + "github.com/bytedance/sonic" + "github.com/hibiken/asynq" + "go.uber.org/zap" + + "github.com/break/junhong_cmp_fiber/internal/model" + "github.com/break/junhong_cmp_fiber/internal/model/dto" + "github.com/break/junhong_cmp_fiber/internal/store/postgres" + "github.com/break/junhong_cmp_fiber/pkg/constants" + apperrors "github.com/break/junhong_cmp_fiber/pkg/errors" + "github.com/break/junhong_cmp_fiber/pkg/middleware" + "github.com/break/junhong_cmp_fiber/pkg/storage" +) + +// AssetPackageBatchOrderCreator 定义 Worker 复用现有后台订单规则的最小接口。 +type AssetPackageBatchOrderCreator interface { + CreateAdminOrder(ctx context.Context, req *dto.CreateAdminOrderRequest, buyerType string, buyerID uint) (*dto.OrderResponse, error) +} + +// AssetPackageBatchOrderPayload 资产套餐批量订购任务载荷。 +type AssetPackageBatchOrderPayload struct { + TaskID uint `json:"task_id"` +} + +// AssetPackageBatchOrderHandler 资产套餐批量订购任务处理器。 +type AssetPackageBatchOrderHandler struct { + taskStore *postgres.AssetPackageBatchOrderTaskStore + shopStore *postgres.ShopStore + orderCreator AssetPackageBatchOrderCreator + storageService *storage.Service + logger *zap.Logger +} + +// NewAssetPackageBatchOrderHandler 创建资产套餐批量订购任务处理器。 +func NewAssetPackageBatchOrderHandler(taskStore *postgres.AssetPackageBatchOrderTaskStore, shopStore *postgres.ShopStore, orderCreator AssetPackageBatchOrderCreator, storageService *storage.Service, logger *zap.Logger) *AssetPackageBatchOrderHandler { + return &AssetPackageBatchOrderHandler{taskStore: taskStore, shopStore: shopStore, orderCreator: orderCreator, storageService: storageService, logger: logger} +} + +// Handle 处理资产套餐批量订购任务。 +func (h *AssetPackageBatchOrderHandler) Handle(ctx context.Context, taskMessage *asynq.Task) error { + var payload AssetPackageBatchOrderPayload + if err := sonic.Unmarshal(taskMessage.Payload(), &payload); err != nil { + h.logger.Error("解析资产套餐批量订购任务载荷失败", zap.Error(err)) + return asynq.SkipRetry + } + taskRecord, err := h.taskStore.GetByID(ctx, payload.TaskID) + if err != nil { + h.logger.Error("查询资产套餐批量订购任务失败", zap.Uint("task_id", payload.TaskID), zap.Error(err)) + return asynq.SkipRetry + } + claimed, err := h.taskStore.Claim(ctx, taskRecord.ID) + if err != nil { + return err + } + if !claimed { + h.logger.Info("资产套餐批量订购任务已被领取或终结,跳过重复消费", zap.Uint("task_id", taskRecord.ID)) + return nil + } + rows, err := h.downloadAndParse(ctx, taskRecord.StorageKey) + if err != nil { + h.logger.Error("下载或解析批量订购CSV失败", zap.Uint("task_id", taskRecord.ID), zap.Error(err)) + _ = h.taskStore.MarkFailed(ctx, taskRecord.ID, err.Error()) + return asynq.SkipRetry + } + items, successCount, failCount := h.processRows(ctx, taskRecord, rows) + if err := h.taskStore.Complete(ctx, taskRecord.ID, items, successCount, failCount); err != nil { + return err + } + h.logger.Info("资产套餐批量订购任务完成", zap.Uint("task_id", taskRecord.ID), zap.Int("success", successCount), zap.Int("fail", failCount)) + return nil +} + +type assetPackageBatchOrderRow struct { + Line int + Identifier string +} + +func (h *AssetPackageBatchOrderHandler) downloadAndParse(ctx context.Context, key string) ([]assetPackageBatchOrderRow, error) { + if h.storageService == nil { + return nil, assetPackageBatchOrderError("对象存储服务未配置") + } + localPath, cleanup, err := h.storageService.DownloadToTemp(ctx, key) + if err != nil { + return nil, assetPackageBatchOrderError("下载批量订购CSV失败") + } + defer cleanup() + info, err := os.Stat(localPath) + if err != nil || info.Size() > constants.AssetPackageBatchOrderMaxFileSize { + return nil, assetPackageBatchOrderError("批量订购CSV不存在或超过10MB") + } + data, err := os.ReadFile(localPath) + if err != nil { + return nil, assetPackageBatchOrderError("读取批量订购CSV失败") + } + return parseAssetPackageBatchOrderCSV(data) +} + +func parseAssetPackageBatchOrderCSV(data []byte) ([]assetPackageBatchOrderRow, error) { + data = bytes.TrimPrefix(data, []byte{0xEF, 0xBB, 0xBF}) + if !utf8.Valid(data) { + return nil, assetPackageBatchOrderError("批量订购CSV必须使用UTF-8编码") + } + reader := csv.NewReader(bytes.NewReader(data)) + reader.TrimLeadingSpace = true + rows := make([]assetPackageBatchOrderRow, 0) + line := 0 + for { + record, err := reader.Read() + if err == io.EOF { + break + } + line++ + if err != nil { + return nil, assetPackageBatchOrderError("批量订购CSV格式错误") + } + if len(record) != 1 { + return nil, assetPackageBatchOrderError("批量订购CSV必须只有一列资产标识") + } + identifier := strings.TrimSpace(record[0]) + if line == 1 && isAssetIdentifierHeader(identifier) { + continue + } + rows = append(rows, assetPackageBatchOrderRow{Line: line, Identifier: identifier}) + if len(rows) > constants.AssetPackageBatchOrderMaxRows { + return nil, assetPackageBatchOrderError("批量订购CSV最多包含1000行资产") + } + } + if len(rows) == 0 { + return nil, assetPackageBatchOrderError("批量订购CSV没有有效数据行") + } + return rows, nil +} + +func (h *AssetPackageBatchOrderHandler) processRows(ctx context.Context, taskRecord *model.AssetPackageBatchOrderTask, rows []assetPackageBatchOrderRow) (model.AssetPackageBatchOrderResultItems, int, int) { + subordinateShopIDs := h.resolveSubordinateShopIDs(ctx, taskRecord) + workerCtx := middleware.SetUserContext(ctx, &middleware.UserContextInfo{ + UserID: taskRecord.Creator, UserType: taskRecord.CreatorUserType, + Username: taskRecord.CreatorName, ShopID: taskRecord.CreatorShopID, + SubordinateShopIDs: subordinateShopIDs, + }) + buyerType, buyerID := "", uint(0) + if taskRecord.CreatorUserType == constants.UserTypeAgent { + buyerType, buyerID = model.BuyerTypeAgent, taskRecord.CreatorShopID + } + items := make(model.AssetPackageBatchOrderResultItems, 0, len(rows)) + seenInputs, seenAssets := make(map[string]int), make(map[string]int) + successCount := 0 + for _, row := range rows { + item := h.processOne(workerCtx, taskRecord, row, buyerType, buyerID, seenInputs, seenAssets) + items = append(items, item) + if item.Status == constants.AssetPackageBatchOrderItemStatusSuccess { + successCount++ + } + } + return items, successCount, len(items) - successCount +} + +func (h *AssetPackageBatchOrderHandler) resolveSubordinateShopIDs(ctx context.Context, taskRecord *model.AssetPackageBatchOrderTask) []uint { + if taskRecord.CreatorUserType != constants.UserTypeAgent { + return nil + } + fallback := []uint{taskRecord.CreatorShopID} + if h.shopStore == nil { + h.logger.Warn("批量订购任务未配置店铺存储,代理数据权限降级为仅自己店铺", zap.Uint("task_id", taskRecord.ID), zap.Uint("shop_id", taskRecord.CreatorShopID)) + return fallback + } + shopIDs, err := h.shopStore.GetSubordinateShopIDs(ctx, taskRecord.CreatorShopID) + if err != nil || len(shopIDs) == 0 { + h.logger.Warn("查询批量订购任务代理数据权限失败,降级为仅自己店铺", zap.Uint("task_id", taskRecord.ID), zap.Uint("shop_id", taskRecord.CreatorShopID), zap.Error(err)) + return fallback + } + return shopIDs +} + +func (h *AssetPackageBatchOrderHandler) processOne(ctx context.Context, taskRecord *model.AssetPackageBatchOrderTask, row assetPackageBatchOrderRow, buyerType string, buyerID uint, seenInputs, seenAssets map[string]int) model.AssetPackageBatchOrderResultItem { + item := model.AssetPackageBatchOrderResultItem{Line: row.Line, AssetIdentifier: row.Identifier} + if row.Identifier == "" { + return failedBatchOrderItem(item, "资产标识不能为空") + } + normalized := strings.ToLower(row.Identifier) + if firstLine, exists := seenInputs[normalized]; exists { + return failedBatchOrderItem(item, "资产标识与第"+strconv.Itoa(firstLine)+"行重复") + } + seenInputs[normalized] = row.Line + order, err := h.orderCreator.CreateAdminOrder(ctx, &dto.CreateAdminOrderRequest{ + Identifier: row.Identifier, PackageIDs: []uint{taskRecord.PackageID}, + PaymentMethod: taskRecord.PaymentMethod, PaymentVoucherKey: []string(taskRecord.VoucherKeys), + }, buyerType, buyerID) + if err != nil { + return failedBatchOrderItem(item, publicBatchOrderError(err)) + } + assetKey := order.AssetType + ":" + if order.IotCardID != nil { + assetKey += strconv.FormatUint(uint64(*order.IotCardID), 10) + } else if order.DeviceID != nil { + assetKey += strconv.FormatUint(uint64(*order.DeviceID), 10) + } + if firstLine, exists := seenAssets[assetKey]; assetKey != ":" && exists { + return failedBatchOrderItem(item, "资产与第"+strconv.Itoa(firstLine)+"行解析为同一资产") + } + seenAssets[assetKey] = row.Line + item.Status, item.OrderID, item.OrderNo, item.Amount = constants.AssetPackageBatchOrderItemStatusSuccess, order.ID, order.OrderNo, order.TotalAmount + return item +} + +func failedBatchOrderItem(item model.AssetPackageBatchOrderResultItem, reason string) model.AssetPackageBatchOrderResultItem { + item.Status, item.Reason = constants.AssetPackageBatchOrderItemStatusFailed, reason + return item +} + +func publicBatchOrderError(err error) string { + var appErr *apperrors.AppError + if stderrors.As(err, &appErr) && appErr.Message != "" { + return appErr.Message + } + return "创建订单失败" +} + +func isAssetIdentifierHeader(value string) bool { + switch strings.ToLower(strings.TrimSpace(value)) { + case "资产标识", "identifier", "iccid", "iccid/虚拟号": + return true + default: + return false + } +} + +type assetPackageBatchOrderError string + +func (e assetPackageBatchOrderError) Error() string { return string(e) } diff --git a/internal/task/device_batch_allocation.go b/internal/task/device_batch_allocation.go new file mode 100644 index 0000000..dfa4102 --- /dev/null +++ b/internal/task/device_batch_allocation.go @@ -0,0 +1,211 @@ +package task + +import ( + "bytes" + "context" + "encoding/csv" + "io" + "os" + "strings" + + "github.com/hibiken/asynq" + + "github.com/break/junhong_cmp_fiber/internal/model" + "github.com/break/junhong_cmp_fiber/internal/model/dto" + "github.com/break/junhong_cmp_fiber/pkg/constants" + "github.com/break/junhong_cmp_fiber/pkg/errors" + "github.com/break/junhong_cmp_fiber/pkg/middleware" +) + +type deviceBatchAllocationRow struct { + line int + identifier string +} + +func (h *DeviceImportHandler) handleDeviceBatchAllocation(ctx context.Context, task *model.DeviceImportTask) error { + if h.allocationExecutor == nil || task.TargetID == nil || *task.TargetID == 0 { + _ = h.importTaskStore.UpdateStatus(ctx, task.ID, model.ImportTaskStatusFailed, "设备批量分配执行器或目标未配置") + return asynq.SkipRetry + } + rows, err := h.downloadDeviceBatchAllocationCSV(ctx, task) + if err != nil { + _ = h.importTaskStore.UpdateStatus(ctx, task.ID, model.ImportTaskStatusFailed, err.Error()) + return asynq.SkipRetry + } + workerCtx := middleware.SetUserContext(ctx, &middleware.UserContextInfo{ + UserID: task.Creator, UserType: task.OperatorType, Username: task.CreatorName, + ShopID: valueOrZero(task.OperatorShopID), SubordinateShopIDs: operatorDeviceShopScope(task.OperatorShopID), + }) + result, err := h.executeDeviceBatchAllocation(workerCtx, task, rows) + if err != nil { + _ = h.importTaskStore.UpdateStatus(ctx, task.ID, model.ImportTaskStatusFailed, err.Error()) + return asynq.SkipRetry + } + _ = h.importTaskStore.UpdateResult(ctx, task.ID, len(rows), result.successCount, result.skipCount, result.failCount, 0, result.skippedItems, result.failedItems, nil) + if result.successCount == 0 && result.failCount > 0 { + _ = h.importTaskStore.UpdateStatus(ctx, task.ID, model.ImportTaskStatusFailed, "所有设备分配均失败") + } else { + _ = h.importTaskStore.UpdateStatus(ctx, task.ID, model.ImportTaskStatusCompleted, "") + } + return nil +} + +func (h *DeviceImportHandler) downloadDeviceBatchAllocationCSV(ctx context.Context, task *model.DeviceImportTask) ([]deviceBatchAllocationRow, error) { + if h.storageService == nil || task.StorageKey == "" { + return nil, errors.New(errors.CodeServiceUnavailable, "设备批量分配对象存储未配置") + } + path, cleanup, err := h.storageService.DownloadToTemp(ctx, task.StorageKey) + if err != nil { + return nil, errors.Wrap(errors.CodeInternalError, err, "下载设备批量分配CSV失败") + } + defer cleanup() + info, err := os.Stat(path) + if err != nil || info.Size() > constants.DeviceBatchAllocationMaxFileSize { + return nil, errors.New(errors.CodeInvalidParam, "设备批量分配CSV不存在或超过10MB") + } + data, err := os.ReadFile(path) + if err != nil { + return nil, errors.Wrap(errors.CodeInternalError, err, "读取设备批量分配CSV失败") + } + return parseDeviceBatchAllocationCSV(data) +} + +func parseDeviceBatchAllocationCSV(data []byte) ([]deviceBatchAllocationRow, error) { + data = bytes.TrimPrefix(data, []byte{0xEF, 0xBB, 0xBF}) + reader := csv.NewReader(bytes.NewReader(data)) + reader.FieldsPerRecord = -1 + rows := make([]deviceBatchAllocationRow, 0) + for line := 1; ; line++ { + record, err := reader.Read() + if err == io.EOF { + break + } + if err != nil || len(record) != 1 { + return nil, errors.New(errors.CodeInvalidParam, "设备批量分配CSV必须只有一列设备标识") + } + identifier := strings.TrimSpace(record[0]) + if line == 1 && (identifier == "device_identifier" || identifier == "设备标识") { + continue + } + if identifier == "" { + return nil, errors.New(errors.CodeInvalidParam, "设备批量分配CSV存在空设备标识") + } + rows = append(rows, deviceBatchAllocationRow{line: line, identifier: identifier}) + if len(rows) > constants.DeviceBatchAllocationMaxRows { + return nil, errors.New(errors.CodeInvalidParam, "设备批量分配CSV最多包含1000行设备") + } + } + if len(rows) == 0 { + return nil, errors.New(errors.CodeInvalidParam, "设备批量分配CSV没有有效数据行") + } + return rows, nil +} + +func (h *DeviceImportHandler) executeDeviceBatchAllocation(ctx context.Context, task *model.DeviceImportTask, rows []deviceBatchAllocationRow) (*deviceImportResult, error) { + result := &deviceImportResult{skippedItems: model.ImportResultItems{}, failedItems: model.ImportResultItems{}} + identifiers := make([]string, 0, len(rows)) + for _, row := range rows { + identifiers = append(identifiers, row.identifier) + } + devices, err := h.deviceStore.GetByIdentifiers(ctx, identifiers) + if err != nil { + return nil, errors.Wrap(errors.CodeDatabaseError, err, "批量查询设备失败") + } + lookup := make(map[string]*model.Device, len(devices)*3) + for _, device := range devices { + for _, key := range []string{device.VirtualNo, device.IMEI, device.SN} { + if key != "" { + lookup[key] = device + } + } + } + seen := make(map[uint]struct{}) + pending := make([]uint, 0, len(rows)) + lineByID := make(map[uint]deviceBatchAllocationRow) + for _, row := range rows { + device := lookup[row.identifier] + if device == nil { + result.failedItems = append(result.failedItems, deviceAllocationResultItem(row, "设备不存在或无权限")) + result.failCount++ + continue + } + if _, exists := seen[device.ID]; exists { + result.skippedItems = append(result.skippedItems, deviceAllocationResultItem(row, "同一文件重复设备")) + result.skipCount++ + continue + } + seen[device.ID] = struct{}{} + if deviceAlreadyAtAllocationTarget(device, task.OperationType, *task.TargetID) { + result.successCount++ + continue + } + pending = append(pending, device.ID) + lineByID[device.ID] = row + } + if len(pending) == 0 { + return result, nil + } + failed, err := h.applyDeviceBatchAllocation(ctx, task, pending) + if err != nil { + return nil, err + } + for _, id := range pending { + if reason, exists := failed[id]; exists { + result.failedItems = append(result.failedItems, deviceAllocationResultItem(lineByID[id], reason)) + result.failCount++ + } else { + result.successCount++ + } + } + return result, nil +} + +func (h *DeviceImportHandler) applyDeviceBatchAllocation(ctx context.Context, task *model.DeviceImportTask, deviceIDs []uint) (map[uint]string, error) { + failed := make(map[uint]string) + switch task.OperationType { + case constants.DeviceImportOperationAssignShop: + response, err := h.allocationExecutor.AllocateDevices(ctx, &dto.AllocateDevicesRequest{TargetShopID: *task.TargetID, DeviceIDs: deviceIDs, Remark: "CSV批量分配任务 " + task.TaskNo}, task.Creator, task.OperatorShopID) + if err != nil { + return nil, err + } + for _, item := range response.FailedItems { + failed[item.DeviceID] = item.Reason + } + case constants.DeviceImportOperationAssignSeries: + response, err := h.allocationExecutor.BatchSetSeriesBinding(ctx, &dto.BatchSetDeviceSeriesBindngRequest{SelectionType: dto.SelectionTypeList, DeviceIDs: deviceIDs, SeriesID: *task.TargetID}, task.OperatorShopID) + if err != nil { + return nil, err + } + for _, item := range response.FailedItems { + failed[item.DeviceID] = item.Reason + } + default: + return nil, errors.New(errors.CodeInvalidParam, "设备批量分配任务类型不支持") + } + return failed, nil +} + +func deviceAlreadyAtAllocationTarget(device *model.Device, operation string, targetID uint) bool { + if operation == constants.DeviceImportOperationAssignShop { + return device.ShopID != nil && *device.ShopID == targetID + } + return operation == constants.DeviceImportOperationAssignSeries && device.SeriesID != nil && *device.SeriesID == targetID +} + +func deviceAllocationResultItem(row deviceBatchAllocationRow, reason string) model.ImportResultItem { + return model.ImportResultItem{Line: row.line, ICCID: row.identifier, Reason: reason} +} + +func valueOrZero(value *uint) uint { + if value == nil { + return 0 + } + return *value +} + +func operatorDeviceShopScope(shopID *uint) []uint { + if shopID == nil { + return nil + } + return []uint{*shopID} +} diff --git a/internal/task/device_import.go b/internal/task/device_import.go index 0448e52..a70816f 100644 --- a/internal/task/device_import.go +++ b/internal/task/device_import.go @@ -15,6 +15,7 @@ import ( "gorm.io/gorm" "github.com/break/junhong_cmp_fiber/internal/model" + "github.com/break/junhong_cmp_fiber/internal/model/dto" "github.com/break/junhong_cmp_fiber/internal/store/postgres" "github.com/break/junhong_cmp_fiber/pkg/constants" "github.com/break/junhong_cmp_fiber/pkg/storage" @@ -27,6 +28,12 @@ type DeviceImportPayload struct { TaskID uint `json:"task_id"` } +// DeviceBatchAllocationExecutor 定义 Worker 复用现有设备分配与系列绑定规则的最小接口。 +type DeviceBatchAllocationExecutor interface { + AllocateDevices(ctx context.Context, req *dto.AllocateDevicesRequest, operatorID uint, operatorShopID *uint) (*dto.AllocateDevicesResponse, error) + BatchSetSeriesBinding(ctx context.Context, req *dto.BatchSetDeviceSeriesBindngRequest, operatorShopID *uint) (*dto.BatchSetDeviceSeriesBindngResponse, error) +} + type DeviceImportHandler struct { db *gorm.DB redis *redis.Client @@ -38,6 +45,7 @@ type DeviceImportHandler struct { assetIdentifierStore *postgres.AssetIdentifierStore storageService *storage.Service logger *zap.Logger + allocationExecutor DeviceBatchAllocationExecutor } func NewDeviceImportHandler( @@ -51,6 +59,7 @@ func NewDeviceImportHandler( assetIdentifierStore *postgres.AssetIdentifierStore, storageSvc *storage.Service, logger *zap.Logger, + allocationExecutor DeviceBatchAllocationExecutor, ) *DeviceImportHandler { return &DeviceImportHandler{ db: db, @@ -63,6 +72,7 @@ func NewDeviceImportHandler( assetIdentifierStore: assetIdentifierStore, storageService: storageSvc, logger: logger, + allocationExecutor: allocationExecutor, } } @@ -111,6 +121,9 @@ func (h *DeviceImportHandler) HandleDeviceImport(ctx context.Context, task *asyn zap.String("task_no", importTask.TaskNo), zap.String("storage_key", importTask.StorageKey), ) + if importTask.OperationType != "" && importTask.OperationType != constants.DeviceImportOperationCreate { + return h.handleDeviceBatchAllocation(ctx, importTask) + } parseResult, err := h.downloadAndParse(ctx, importTask) if err != nil { diff --git a/internal/task/package_expiry_reminder.go b/internal/task/package_expiry_reminder.go new file mode 100644 index 0000000..c8e4162 --- /dev/null +++ b/internal/task/package_expiry_reminder.go @@ -0,0 +1,31 @@ +package task + +import ( + "context" + + "github.com/hibiken/asynq" + "go.uber.org/zap" + + packageexpiryapp "github.com/break/junhong_cmp_fiber/internal/application/packageexpiry" +) + +// PackageExpiryReminderHandler 处理每日套餐临期节点提醒任务。 +type PackageExpiryReminderHandler struct { + service *packageexpiryapp.ReminderService + logger *zap.Logger +} + +// NewPackageExpiryReminderHandler 创建套餐临期提醒任务处理器。 +func NewPackageExpiryReminderHandler(service *packageexpiryapp.ReminderService, logger *zap.Logger) *PackageExpiryReminderHandler { + return &PackageExpiryReminderHandler{service: service, logger: logger} +} + +// Handle 扫描 15、7、3 天节点并发布个人客户站内通知。 +func (h *PackageExpiryReminderHandler) Handle(ctx context.Context, _ *asynq.Task) error { + h.logger.Info("开始执行套餐临期节点提醒") + if err := h.service.Run(ctx); err != nil { + h.logger.Error("套餐临期节点提醒失败", zap.Error(err)) + return err + } + return nil +} diff --git a/migrations/000182_add_shop_client_login_disabled.down.sql b/migrations/000182_add_shop_client_login_disabled.down.sql new file mode 100644 index 0000000..d018f35 --- /dev/null +++ b/migrations/000182_add_shop_client_login_disabled.down.sql @@ -0,0 +1,26 @@ +BEGIN; + +-- 已启用限制的店铺属于有效配置事实,存在时禁止删列回滚。 +DO $$ +BEGIN + IF to_regclass('tb_shop') IS NULL + OR NOT EXISTS ( + SELECT 1 + FROM pg_attribute + WHERE attrelid = to_regclass('tb_shop') + AND attname = 'client_login_disabled' + AND NOT attisdropped + ) THEN + RETURN; + END IF; + + LOCK TABLE tb_shop IN ACCESS EXCLUSIVE MODE; + IF EXISTS (SELECT 1 FROM tb_shop WHERE client_login_disabled = true LIMIT 1) THEN + RAISE EXCEPTION 'tb_shop 已存在C端登录限制配置,禁止删列回滚,请保留字段并向前修复'; + END IF; + + ALTER TABLE tb_shop DROP COLUMN client_login_disabled; +END +$$; + +COMMIT; diff --git a/migrations/000182_add_shop_client_login_disabled.up.sql b/migrations/000182_add_shop_client_login_disabled.up.sql new file mode 100644 index 0000000..69344b9 --- /dev/null +++ b/migrations/000182_add_shop_client_login_disabled.up.sql @@ -0,0 +1,5 @@ +-- 店铺可独立禁止名下资产发起新的 C 端登录,不影响已有登录令牌。 +ALTER TABLE tb_shop + ADD COLUMN client_login_disabled boolean NOT NULL DEFAULT false; + +COMMENT ON COLUMN tb_shop.client_login_disabled IS '是否禁止该店铺资产发起新的C端登录'; diff --git a/migrations/000183_add_package_expiry_scan_indexes.down.sql b/migrations/000183_add_package_expiry_scan_indexes.down.sql new file mode 100644 index 0000000..4c8bb93 --- /dev/null +++ b/migrations/000183_add_package_expiry_scan_indexes.down.sql @@ -0,0 +1,3 @@ +-- 回滚每日临期节点扫描索引。 +DROP INDEX IF EXISTS idx_package_usage_device_expiry_scan; +DROP INDEX IF EXISTS idx_package_usage_card_expiry_scan; diff --git a/migrations/000183_add_package_expiry_scan_indexes.up.sql b/migrations/000183_add_package_expiry_scan_indexes.up.sql new file mode 100644 index 0000000..134424f --- /dev/null +++ b/migrations/000183_add_package_expiry_scan_indexes.up.sql @@ -0,0 +1,16 @@ +-- 为每日临期节点扫描增加按到期时间预筛的主套餐索引。 +CREATE INDEX IF NOT EXISTS idx_package_usage_card_expiry_scan + ON tb_package_usage (expires_at, iot_card_id) + WHERE deleted_at IS NULL + AND master_usage_id IS NULL + AND refund_id IS NULL + AND status IN (1, 2) + AND expires_at IS NOT NULL; + +CREATE INDEX IF NOT EXISTS idx_package_usage_device_expiry_scan + ON tb_package_usage (expires_at, device_id) + WHERE deleted_at IS NULL + AND master_usage_id IS NULL + AND refund_id IS NULL + AND status IN (1, 2) + AND expires_at IS NOT NULL; diff --git a/migrations/000184_create_wecom_application.down.sql b/migrations/000184_create_wecom_application.down.sql new file mode 100644 index 0000000..2142c14 --- /dev/null +++ b/migrations/000184_create_wecom_application.down.sql @@ -0,0 +1,2 @@ +-- 回滚企业微信自建应用安全配置表。 +DROP TABLE IF EXISTS tb_wecom_application; diff --git a/migrations/000184_create_wecom_application.up.sql b/migrations/000184_create_wecom_application.up.sql new file mode 100644 index 0000000..38e619b --- /dev/null +++ b/migrations/000184_create_wecom_application.up.sql @@ -0,0 +1,32 @@ +-- 创建企业微信自建应用安全配置表;凭据仅保存 AES-256-GCM 密文。 +CREATE TABLE IF NOT EXISTS tb_wecom_application ( + id bigserial PRIMARY KEY, + corp_id varchar(64) NOT NULL, + agent_id bigint NOT NULL, + name varchar(100) NOT NULL, + secret_ciphertext bytea NOT NULL, + callback_token_ciphertext bytea NOT NULL, + encoding_aes_key_ciphertext bytea NOT NULL, + status integer NOT NULL DEFAULT 1, + created_by bigint NOT NULL, + updated_by bigint NOT NULL, + last_connected_at timestamptz, + created_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP, + deleted_at timestamptz, + CONSTRAINT chk_wecom_application_status CHECK (status IN (0, 1)), + CONSTRAINT chk_wecom_application_agent_id CHECK (agent_id > 0) +); + +CREATE UNIQUE INDEX IF NOT EXISTS uq_wecom_application_identity + ON tb_wecom_application (corp_id, agent_id) + WHERE deleted_at IS NULL; + +CREATE INDEX IF NOT EXISTS idx_wecom_application_status + ON tb_wecom_application (status, id) + WHERE deleted_at IS NULL; + +COMMENT ON TABLE tb_wecom_application IS '企业微信自建应用安全连接配置'; +COMMENT ON COLUMN tb_wecom_application.secret_ciphertext IS '应用 Secret 的 AES-256-GCM 密文'; +COMMENT ON COLUMN tb_wecom_application.callback_token_ciphertext IS '回调 Token 的 AES-256-GCM 密文'; +COMMENT ON COLUMN tb_wecom_application.encoding_aes_key_ciphertext IS '回调 EncodingAESKey 的 AES-256-GCM 密文'; diff --git a/migrations/000185_add_wecom_member_binding.down.sql b/migrations/000185_add_wecom_member_binding.down.sql new file mode 100644 index 0000000..ec1a4ad --- /dev/null +++ b/migrations/000185_add_wecom_member_binding.down.sql @@ -0,0 +1,8 @@ +-- 回滚企业微信可见成员快照及账号绑定字段。 +DROP TABLE IF EXISTS tb_wecom_member; +DROP INDEX IF EXISTS uq_account_wecom_identity; + +ALTER TABLE tb_account + DROP COLUMN IF EXISTS wecom_name, + DROP COLUMN IF EXISTS wecom_userid, + DROP COLUMN IF EXISTS wecom_corp_id; diff --git a/migrations/000185_add_wecom_member_binding.up.sql b/migrations/000185_add_wecom_member_binding.up.sql new file mode 100644 index 0000000..e07c123 --- /dev/null +++ b/migrations/000185_add_wecom_member_binding.up.sql @@ -0,0 +1,36 @@ +-- 增加系统账号企微身份绑定,并保存应用可见成员选择快照。 +ALTER TABLE tb_account + ADD COLUMN IF NOT EXISTS wecom_corp_id varchar(64) NOT NULL DEFAULT '', + ADD COLUMN IF NOT EXISTS wecom_userid varchar(64) NOT NULL DEFAULT '', + ADD COLUMN IF NOT EXISTS wecom_name varchar(100) NOT NULL DEFAULT ''; + +CREATE UNIQUE INDEX IF NOT EXISTS uq_account_wecom_identity + ON tb_account (wecom_corp_id, wecom_userid) + WHERE deleted_at IS NULL AND wecom_corp_id <> '' AND wecom_userid <> ''; + +CREATE TABLE IF NOT EXISTS tb_wecom_member ( + id bigserial PRIMARY KEY, + application_id bigint NOT NULL, + corp_id varchar(64) NOT NULL, + userid varchar(64) NOT NULL, + name varchar(100) NOT NULL, + department_ids jsonb NOT NULL DEFAULT '[]'::jsonb, + visible boolean NOT NULL DEFAULT true, + synced_at timestamptz NOT NULL, + created_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT chk_wecom_member_application_id CHECK (application_id > 0), + CONSTRAINT chk_wecom_member_userid CHECK (length(btrim(userid)) > 0) +); + +CREATE UNIQUE INDEX IF NOT EXISTS uq_wecom_member_identity + ON tb_wecom_member (application_id, userid); + +CREATE INDEX IF NOT EXISTS idx_wecom_member_visible_name + ON tb_wecom_member (application_id, visible, name, userid); + +COMMENT ON TABLE tb_wecom_member IS '企业微信应用可见成员的本地选择快照,不作为组织模型'; +COMMENT ON COLUMN tb_wecom_member.department_ids IS '企微部门 ID 列表,仅用于管理员辨认成员'; +COMMENT ON COLUMN tb_account.wecom_corp_id IS '绑定的企业微信企业 ID'; +COMMENT ON COLUMN tb_account.wecom_userid IS '绑定的企业微信成员 userid,入库前统一转为小写'; +COMMENT ON COLUMN tb_account.wecom_name IS '绑定时的企业微信成员姓名快照'; diff --git a/migrations/000186_create_wecom_approval_scene.down.sql b/migrations/000186_create_wecom_approval_scene.down.sql new file mode 100644 index 0000000..b354b9c --- /dev/null +++ b/migrations/000186_create_wecom_approval_scene.down.sql @@ -0,0 +1,2 @@ +-- 回滚企业微信审批场景配置表。 +DROP TABLE IF EXISTS tb_wecom_approval_scene; diff --git a/migrations/000186_create_wecom_approval_scene.up.sql b/migrations/000186_create_wecom_approval_scene.up.sql new file mode 100644 index 0000000..3b58bc3 --- /dev/null +++ b/migrations/000186_create_wecom_approval_scene.up.sql @@ -0,0 +1,29 @@ +-- 创建企业微信审批业务场景与后台模板当前映射配置。 +CREATE TABLE IF NOT EXISTS tb_wecom_approval_scene ( + id bigserial PRIMARY KEY, + business_type varchar(64) NOT NULL, + application_id bigint NOT NULL, + template_id varchar(128) NOT NULL, + template_name varchar(255) NOT NULL DEFAULT '', + control_mapping jsonb NOT NULL, + template_snapshot jsonb NOT NULL, + template_fingerprint char(64) NOT NULL, + status integer NOT NULL DEFAULT 1, + last_verified_at timestamptz NOT NULL, + created_by bigint NOT NULL, + updated_by bigint NOT NULL, + created_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT uq_wecom_approval_scene_business UNIQUE (business_type), + CONSTRAINT chk_wecom_approval_scene_business CHECK (business_type IN ('refund_approval', 'offline_recharge_approval')), + CONSTRAINT chk_wecom_approval_scene_application CHECK (application_id > 0), + CONSTRAINT chk_wecom_approval_scene_status CHECK (status IN (0, 1)) +); + +CREATE INDEX IF NOT EXISTS idx_wecom_approval_scene_application + ON tb_wecom_approval_scene (application_id, status); + +COMMENT ON TABLE tb_wecom_approval_scene IS '稳定审批业务类型到企微后台模板和控件的当前映射'; +COMMENT ON COLUMN tb_wecom_approval_scene.control_mapping IS '已通过模板详情校验的业务字段、控件 ID、类型及选项 key 映射'; +COMMENT ON COLUMN tb_wecom_approval_scene.template_snapshot IS '校验时读取的控件最小快照,不包含审批节点或审批人规则'; +COMMENT ON COLUMN tb_wecom_approval_scene.template_fingerprint IS '模板控件最小快照 SHA-256 指纹'; diff --git a/migrations/000187_add_wecom_approval_submission.down.sql b/migrations/000187_add_wecom_approval_submission.down.sql new file mode 100644 index 0000000..ce68eec --- /dev/null +++ b/migrations/000187_add_wecom_approval_submission.down.sql @@ -0,0 +1,5 @@ +DROP TABLE IF EXISTS tb_wecom_approval_context; + +ALTER TABLE tb_wecom_application + DROP COLUMN IF EXISTS default_creator_name, + DROP COLUMN IF EXISTS default_creator_userid; diff --git a/migrations/000187_add_wecom_approval_submission.up.sql b/migrations/000187_add_wecom_approval_submission.up.sql new file mode 100644 index 0000000..709b037 --- /dev/null +++ b/migrations/000187_add_wecom_approval_submission.up.sql @@ -0,0 +1,43 @@ +-- 增加应用默认审批发起人,并创建企微审批渠道上下文。 +ALTER TABLE tb_wecom_application + ADD COLUMN IF NOT EXISTS default_creator_userid varchar(64) NOT NULL DEFAULT '', + ADD COLUMN IF NOT EXISTS default_creator_name varchar(100) NOT NULL DEFAULT ''; + +CREATE TABLE IF NOT EXISTS tb_wecom_approval_context ( + id bigserial PRIMARY KEY, + approval_instance_id bigint NOT NULL, + application_id bigint NOT NULL, + business_type varchar(64) NOT NULL, + template_id varchar(128) NOT NULL, + creator_userid varchar(64) NOT NULL, + creator_name varchar(100) NOT NULL, + creator_source varchar(16) NOT NULL, + control_mapping jsonb NOT NULL, + template_snapshot jsonb NOT NULL, + template_fingerprint char(64) NOT NULL, + submission_status integer NOT NULL DEFAULT 0, + sp_no varchar(128) NOT NULL DEFAULT '', + last_error varchar(500) NOT NULL DEFAULT '', + created_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT uq_wecom_approval_context_instance UNIQUE (approval_instance_id), + CONSTRAINT chk_wecom_approval_context_instance CHECK (approval_instance_id > 0), + CONSTRAINT chk_wecom_approval_context_application CHECK (application_id > 0), + CONSTRAINT chk_wecom_approval_context_creator CHECK (length(btrim(creator_userid)) > 0), + CONSTRAINT chk_wecom_approval_context_creator_source CHECK (creator_source IN ('bound', 'default')), + CONSTRAINT chk_wecom_approval_context_submission_status CHECK (submission_status IN (0, 1, 2, 3, 4)), + CONSTRAINT chk_wecom_approval_context_mapping CHECK (jsonb_typeof(control_mapping) = 'array'), + CONSTRAINT chk_wecom_approval_context_template CHECK (jsonb_typeof(template_snapshot) = 'object') +); + +CREATE UNIQUE INDEX IF NOT EXISTS uq_wecom_approval_context_sp_no + ON tb_wecom_approval_context (sp_no) WHERE sp_no <> ''; + +CREATE INDEX IF NOT EXISTS idx_wecom_approval_context_recovery + ON tb_wecom_approval_context (submission_status, updated_at, id); + +COMMENT ON COLUMN tb_wecom_application.default_creator_userid IS '代理等非企微账号提交业务时使用的默认企微审批发起人 userid'; +COMMENT ON COLUMN tb_wecom_application.default_creator_name IS '默认企微审批发起人姓名快照'; +COMMENT ON TABLE tb_wecom_approval_context IS '通用审批实例对应的企业微信提交安全快照与提交结果'; +COMMENT ON COLUMN tb_wecom_approval_context.creator_source IS '企微发起人来源:bound 系统账号绑定,default 应用默认发起人'; +COMMENT ON COLUMN tb_wecom_approval_context.submission_status IS '提交状态:0待提交,1请求处理中,2已提交,3明确失败,4结果未知'; diff --git a/migrations/000188_add_wecom_approval_detail_snapshot.down.sql b/migrations/000188_add_wecom_approval_detail_snapshot.down.sql new file mode 100644 index 0000000..e555ba1 --- /dev/null +++ b/migrations/000188_add_wecom_approval_detail_snapshot.down.sql @@ -0,0 +1,5 @@ +ALTER TABLE tb_wecom_approval_context + DROP CONSTRAINT IF EXISTS chk_wecom_approval_context_detail_snapshot, + DROP COLUMN IF EXISTS last_synced_at, + DROP COLUMN IF EXISTS latest_detail_snapshot, + DROP COLUMN IF EXISTS latest_sp_status; diff --git a/migrations/000188_add_wecom_approval_detail_snapshot.up.sql b/migrations/000188_add_wecom_approval_detail_snapshot.up.sql new file mode 100644 index 0000000..2c4004a --- /dev/null +++ b/migrations/000188_add_wecom_approval_detail_snapshot.up.sql @@ -0,0 +1,20 @@ +-- 增加企业微信审批详情当前快照,供回调同步和后续读取投影复用。 +ALTER TABLE tb_wecom_approval_context + ADD COLUMN IF NOT EXISTS latest_sp_status integer NOT NULL DEFAULT 0, + ADD COLUMN IF NOT EXISTS latest_detail_snapshot jsonb, + ADD COLUMN IF NOT EXISTS last_synced_at timestamptz; + +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM pg_constraint WHERE conname = 'chk_wecom_approval_context_detail_snapshot' + ) THEN + ALTER TABLE tb_wecom_approval_context + ADD CONSTRAINT chk_wecom_approval_context_detail_snapshot + CHECK (latest_detail_snapshot IS NULL OR jsonb_typeof(latest_detail_snapshot) = 'object'); + END IF; +END $$; + +COMMENT ON COLUMN tb_wecom_approval_context.latest_sp_status IS '最近一次企微审批详情状态码'; +COMMENT ON COLUMN tb_wecom_approval_context.latest_detail_snapshot IS '最近一次权威企微审批详情快照,不含 access_token'; +COMMENT ON COLUMN tb_wecom_approval_context.last_synced_at IS '最近一次成功获取企微审批详情的时间'; diff --git a/migrations/000189_add_wecom_approval_recovery_timestamps.down.sql b/migrations/000189_add_wecom_approval_recovery_timestamps.down.sql new file mode 100644 index 0000000..0c9b65b --- /dev/null +++ b/migrations/000189_add_wecom_approval_recovery_timestamps.down.sql @@ -0,0 +1,7 @@ +-- 回滚企微审批恢复时间字段与索引。 +DROP INDEX IF EXISTS idx_wecom_approval_context_pending_sync; +DROP INDEX IF EXISTS idx_wecom_approval_context_unknown_recovery; + +ALTER TABLE tb_wecom_approval_context + DROP COLUMN IF EXISTS last_recovery_at, + DROP COLUMN IF EXISTS submission_attempted_at; diff --git a/migrations/000189_add_wecom_approval_recovery_timestamps.up.sql b/migrations/000189_add_wecom_approval_recovery_timestamps.up.sql new file mode 100644 index 0000000..6f7bc8e --- /dev/null +++ b/migrations/000189_add_wecom_approval_recovery_timestamps.up.sql @@ -0,0 +1,20 @@ +-- 增加企微审批提交与恢复时间,避免周期恢复覆盖原始提交时间窗。 +ALTER TABLE tb_wecom_approval_context + ADD COLUMN IF NOT EXISTS submission_attempted_at timestamptz, + ADD COLUMN IF NOT EXISTS last_recovery_at timestamptz; + +UPDATE tb_wecom_approval_context +SET submission_attempted_at = updated_at +WHERE submission_attempted_at IS NULL + AND submission_status IN (1, 4); + +CREATE INDEX IF NOT EXISTS idx_wecom_approval_context_unknown_recovery + ON tb_wecom_approval_context (submission_status, last_recovery_at, id) + WHERE submission_status = 4 AND sp_no = ''; + +CREATE INDEX IF NOT EXISTS idx_wecom_approval_context_pending_sync + ON tb_wecom_approval_context (last_synced_at, id) + WHERE submission_status = 2 AND sp_no <> ''; + +COMMENT ON COLUMN tb_wecom_approval_context.submission_attempted_at IS '最近一次实际领取并准备调用企微提交接口的时间'; +COMMENT ON COLUMN tb_wecom_approval_context.last_recovery_at IS '最近一次结果未知主动恢复尝试时间'; diff --git a/migrations/000190_add_agent_recharge_approval_instance.down.sql b/migrations/000190_add_agent_recharge_approval_instance.down.sql new file mode 100644 index 0000000..4e76663 --- /dev/null +++ b/migrations/000190_add_agent_recharge_approval_instance.down.sql @@ -0,0 +1,6 @@ +-- 回滚员工线下代充值通用审批实例关联。 +DROP INDEX IF EXISTS uq_agent_recharge_approval_instance; + +ALTER TABLE tb_agent_recharge_record + DROP CONSTRAINT IF EXISTS chk_agent_recharge_approval_instance, + DROP COLUMN IF EXISTS approval_instance_id; diff --git a/migrations/000190_add_agent_recharge_approval_instance.up.sql b/migrations/000190_add_agent_recharge_approval_instance.up.sql new file mode 100644 index 0000000..ede0d49 --- /dev/null +++ b/migrations/000190_add_agent_recharge_approval_instance.up.sql @@ -0,0 +1,20 @@ +-- 为员工线下代充值关联唯一通用审批实例。 +ALTER TABLE tb_agent_recharge_record + ADD COLUMN IF NOT EXISTS approval_instance_id bigint; + +CREATE UNIQUE INDEX IF NOT EXISTS uq_agent_recharge_approval_instance + ON tb_agent_recharge_record (approval_instance_id) + WHERE approval_instance_id IS NOT NULL; + +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM pg_constraint WHERE conname = 'chk_agent_recharge_approval_instance' + ) THEN + ALTER TABLE tb_agent_recharge_record + ADD CONSTRAINT chk_agent_recharge_approval_instance + CHECK (approval_instance_id IS NULL OR approval_instance_id > 0); + END IF; +END $$; + +COMMENT ON COLUMN tb_agent_recharge_record.approval_instance_id IS '员工线下代充值关联的唯一通用审批实例 ID,在线充值为空'; diff --git a/migrations/000191_add_refund_approval_instance.down.sql b/migrations/000191_add_refund_approval_instance.down.sql new file mode 100644 index 0000000..7f4be5d --- /dev/null +++ b/migrations/000191_add_refund_approval_instance.down.sql @@ -0,0 +1,6 @@ +-- 回滚退款申请通用审批实例关联。 +DROP INDEX IF EXISTS uq_refund_request_approval_instance; + +ALTER TABLE tb_refund_request + DROP CONSTRAINT IF EXISTS chk_refund_request_approval_instance, + DROP COLUMN IF EXISTS approval_instance_id; diff --git a/migrations/000191_add_refund_approval_instance.up.sql b/migrations/000191_add_refund_approval_instance.up.sql new file mode 100644 index 0000000..c3a7e80 --- /dev/null +++ b/migrations/000191_add_refund_approval_instance.up.sql @@ -0,0 +1,20 @@ +-- 为退款申请关联唯一通用审批实例。 +ALTER TABLE tb_refund_request + ADD COLUMN IF NOT EXISTS approval_instance_id bigint; + +CREATE UNIQUE INDEX IF NOT EXISTS uq_refund_request_approval_instance + ON tb_refund_request (approval_instance_id) + WHERE approval_instance_id IS NOT NULL; + +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM pg_constraint WHERE conname = 'chk_refund_request_approval_instance' + ) THEN + ALTER TABLE tb_refund_request + ADD CONSTRAINT chk_refund_request_approval_instance + CHECK (approval_instance_id IS NULL OR approval_instance_id > 0); + END IF; +END $$; + +COMMENT ON COLUMN tb_refund_request.approval_instance_id IS '退款申请关联的唯一通用审批实例 ID,存量旧审批记录为空'; diff --git a/migrations/000192_create_asset_package_batch_order_task.down.sql b/migrations/000192_create_asset_package_batch_order_task.down.sql new file mode 100644 index 0000000..062a55f --- /dev/null +++ b/migrations/000192_create_asset_package_batch_order_task.down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS tb_asset_package_batch_order_task; diff --git a/migrations/000192_create_asset_package_batch_order_task.up.sql b/migrations/000192_create_asset_package_batch_order_task.up.sql new file mode 100644 index 0000000..14dad23 --- /dev/null +++ b/migrations/000192_create_asset_package_batch_order_task.up.sql @@ -0,0 +1,49 @@ +CREATE TABLE IF NOT EXISTS tb_asset_package_batch_order_task ( + id bigserial PRIMARY KEY, + task_no varchar(50) NOT NULL, + package_id bigint NOT NULL, + package_code varchar(100) NOT NULL DEFAULT '', + package_name varchar(255) NOT NULL DEFAULT '', + payment_method varchar(20) NOT NULL, + file_name varchar(255) NOT NULL DEFAULT '', + storage_key varchar(500) NOT NULL, + voucher_keys jsonb NOT NULL DEFAULT '[]'::jsonb, + status integer NOT NULL DEFAULT 1, + total_count integer NOT NULL DEFAULT 0, + success_count integer NOT NULL DEFAULT 0, + fail_count integer NOT NULL DEFAULT 0, + result_items jsonb NOT NULL DEFAULT '[]'::jsonb, + error_message text NOT NULL DEFAULT '', + creator_user_type integer NOT NULL DEFAULT 0, + creator_shop_id bigint NOT NULL DEFAULT 0, + creator_name varchar(100) NOT NULL DEFAULT '', + started_at timestamptz, + completed_at timestamptz, + creator bigint NOT NULL DEFAULT 0, + updater bigint NOT NULL DEFAULT 0, + created_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP, + deleted_at timestamptz, + CONSTRAINT chk_asset_package_batch_order_status CHECK (status IN (1, 2, 3, 4, 5)), + CONSTRAINT chk_asset_package_batch_order_payment_method CHECK (payment_method IN ('wallet', 'offline')), + CONSTRAINT chk_asset_package_batch_order_counts CHECK ( + total_count >= 0 AND success_count >= 0 AND fail_count >= 0 + AND success_count + fail_count <= total_count + ) +); + +CREATE UNIQUE INDEX IF NOT EXISTS uq_asset_package_batch_order_task_no + ON tb_asset_package_batch_order_task (task_no) + WHERE deleted_at IS NULL; + +CREATE INDEX IF NOT EXISTS idx_asset_package_batch_order_status_created + ON tb_asset_package_batch_order_task (status, created_at DESC) + WHERE deleted_at IS NULL; + +COMMENT ON TABLE tb_asset_package_batch_order_task IS '单列CSV资产套餐批量订购任务'; +COMMENT ON COLUMN tb_asset_package_batch_order_task.package_id IS '整批统一套餐ID'; +COMMENT ON COLUMN tb_asset_package_batch_order_task.payment_method IS '整批统一支付方式(wallet/offline)'; +COMMENT ON COLUMN tb_asset_package_batch_order_task.storage_key IS '源CSV对象存储Key'; +COMMENT ON COLUMN tb_asset_package_batch_order_task.voucher_keys IS '线下支付凭证对象存储Key列表'; +COMMENT ON COLUMN tb_asset_package_batch_order_task.status IS '任务状态(1待处理,2处理中,3已完成,4已失败,5已取消)'; +COMMENT ON COLUMN tb_asset_package_batch_order_task.result_items IS '逐行成功或失败结果明细'; diff --git a/migrations/000193_extend_device_import_task_for_allocation.down.sql b/migrations/000193_extend_device_import_task_for_allocation.down.sql new file mode 100644 index 0000000..cd24541 --- /dev/null +++ b/migrations/000193_extend_device_import_task_for_allocation.down.sql @@ -0,0 +1,7 @@ +DROP INDEX IF EXISTS idx_device_import_task_operation_type; + +ALTER TABLE tb_device_import_task + DROP COLUMN IF EXISTS operator_shop_id, + DROP COLUMN IF EXISTS operator_type, + DROP COLUMN IF EXISTS target_id, + DROP COLUMN IF EXISTS operation_type; diff --git a/migrations/000193_extend_device_import_task_for_allocation.up.sql b/migrations/000193_extend_device_import_task_for_allocation.up.sql new file mode 100644 index 0000000..1b57a62 --- /dev/null +++ b/migrations/000193_extend_device_import_task_for_allocation.up.sql @@ -0,0 +1,15 @@ +-- 扩展既有设备导入任务,承载单列 CSV 批量分配代理或套餐系列。 +ALTER TABLE tb_device_import_task + ADD COLUMN IF NOT EXISTS operation_type VARCHAR(32) NOT NULL DEFAULT 'import', + ADD COLUMN IF NOT EXISTS target_id BIGINT, + ADD COLUMN IF NOT EXISTS operator_type INT NOT NULL DEFAULT 0, + ADD COLUMN IF NOT EXISTS operator_shop_id BIGINT; + +CREATE INDEX IF NOT EXISTS idx_device_import_task_operation_type + ON tb_device_import_task(operation_type, created_at DESC) + WHERE deleted_at IS NULL; + +COMMENT ON COLUMN tb_device_import_task.operation_type IS '任务业务类型(import=导入设备,assign_shop=分配目标代理,assign_series=设置套餐系列)'; +COMMENT ON COLUMN tb_device_import_task.target_id IS '批量分配目标店铺或套餐系列ID'; +COMMENT ON COLUMN tb_device_import_task.operator_type IS '任务创建时操作者类型快照'; +COMMENT ON COLUMN tb_device_import_task.operator_shop_id IS '任务创建时操作者店铺ID快照'; diff --git a/migrations/000194_backfill_july_schema_comments.down.sql b/migrations/000194_backfill_july_schema_comments.down.sql new file mode 100644 index 0000000..1cbf79b --- /dev/null +++ b/migrations/000194_backfill_july_schema_comments.down.sql @@ -0,0 +1,77 @@ +-- 回滚七月迭代字段注释补丁,不修改任何表结构或业务数据。 + +COMMENT ON COLUMN tb_card_observation_effect.id IS NULL; +COMMENT ON COLUMN tb_card_observation_effect.created_at IS NULL; +COMMENT ON COLUMN tb_card_observation_effect.updated_at IS NULL; +COMMENT ON COLUMN tb_card_observation_effect.deleted_at IS NULL; +COMMENT ON COLUMN tb_card_observation_effect.creator IS NULL; +COMMENT ON COLUMN tb_card_observation_effect.updater IS NULL; + +COMMENT ON COLUMN tb_wecom_application.id IS NULL; +COMMENT ON COLUMN tb_wecom_application.corp_id IS NULL; +COMMENT ON COLUMN tb_wecom_application.agent_id IS NULL; +COMMENT ON COLUMN tb_wecom_application.name IS NULL; +COMMENT ON COLUMN tb_wecom_application.status IS NULL; +COMMENT ON COLUMN tb_wecom_application.created_by IS NULL; +COMMENT ON COLUMN tb_wecom_application.updated_by IS NULL; +COMMENT ON COLUMN tb_wecom_application.last_connected_at IS NULL; +COMMENT ON COLUMN tb_wecom_application.created_at IS NULL; +COMMENT ON COLUMN tb_wecom_application.updated_at IS NULL; +COMMENT ON COLUMN tb_wecom_application.deleted_at IS NULL; + +COMMENT ON COLUMN tb_wecom_member.id IS NULL; +COMMENT ON COLUMN tb_wecom_member.application_id IS NULL; +COMMENT ON COLUMN tb_wecom_member.corp_id IS NULL; +COMMENT ON COLUMN tb_wecom_member.userid IS NULL; +COMMENT ON COLUMN tb_wecom_member.name IS NULL; +COMMENT ON COLUMN tb_wecom_member.visible IS NULL; +COMMENT ON COLUMN tb_wecom_member.synced_at IS NULL; +COMMENT ON COLUMN tb_wecom_member.created_at IS NULL; +COMMENT ON COLUMN tb_wecom_member.updated_at IS NULL; + +COMMENT ON COLUMN tb_wecom_approval_scene.id IS NULL; +COMMENT ON COLUMN tb_wecom_approval_scene.business_type IS NULL; +COMMENT ON COLUMN tb_wecom_approval_scene.application_id IS NULL; +COMMENT ON COLUMN tb_wecom_approval_scene.template_id IS NULL; +COMMENT ON COLUMN tb_wecom_approval_scene.template_name IS NULL; +COMMENT ON COLUMN tb_wecom_approval_scene.status IS NULL; +COMMENT ON COLUMN tb_wecom_approval_scene.last_verified_at IS NULL; +COMMENT ON COLUMN tb_wecom_approval_scene.created_by IS NULL; +COMMENT ON COLUMN tb_wecom_approval_scene.updated_by IS NULL; +COMMENT ON COLUMN tb_wecom_approval_scene.created_at IS NULL; +COMMENT ON COLUMN tb_wecom_approval_scene.updated_at IS NULL; + +COMMENT ON COLUMN tb_wecom_approval_context.id IS NULL; +COMMENT ON COLUMN tb_wecom_approval_context.approval_instance_id IS NULL; +COMMENT ON COLUMN tb_wecom_approval_context.application_id IS NULL; +COMMENT ON COLUMN tb_wecom_approval_context.business_type IS NULL; +COMMENT ON COLUMN tb_wecom_approval_context.template_id IS NULL; +COMMENT ON COLUMN tb_wecom_approval_context.creator_userid IS NULL; +COMMENT ON COLUMN tb_wecom_approval_context.creator_name IS NULL; +COMMENT ON COLUMN tb_wecom_approval_context.control_mapping IS NULL; +COMMENT ON COLUMN tb_wecom_approval_context.template_snapshot IS NULL; +COMMENT ON COLUMN tb_wecom_approval_context.template_fingerprint IS NULL; +COMMENT ON COLUMN tb_wecom_approval_context.sp_no IS NULL; +COMMENT ON COLUMN tb_wecom_approval_context.last_error IS NULL; +COMMENT ON COLUMN tb_wecom_approval_context.created_at IS NULL; +COMMENT ON COLUMN tb_wecom_approval_context.updated_at IS NULL; + +COMMENT ON COLUMN tb_asset_package_batch_order_task.id IS NULL; +COMMENT ON COLUMN tb_asset_package_batch_order_task.task_no IS NULL; +COMMENT ON COLUMN tb_asset_package_batch_order_task.package_code IS NULL; +COMMENT ON COLUMN tb_asset_package_batch_order_task.package_name IS NULL; +COMMENT ON COLUMN tb_asset_package_batch_order_task.file_name IS NULL; +COMMENT ON COLUMN tb_asset_package_batch_order_task.total_count IS NULL; +COMMENT ON COLUMN tb_asset_package_batch_order_task.success_count IS NULL; +COMMENT ON COLUMN tb_asset_package_batch_order_task.fail_count IS NULL; +COMMENT ON COLUMN tb_asset_package_batch_order_task.error_message IS NULL; +COMMENT ON COLUMN tb_asset_package_batch_order_task.creator_user_type IS NULL; +COMMENT ON COLUMN tb_asset_package_batch_order_task.creator_shop_id IS NULL; +COMMENT ON COLUMN tb_asset_package_batch_order_task.creator_name IS NULL; +COMMENT ON COLUMN tb_asset_package_batch_order_task.started_at IS NULL; +COMMENT ON COLUMN tb_asset_package_batch_order_task.completed_at IS NULL; +COMMENT ON COLUMN tb_asset_package_batch_order_task.creator IS NULL; +COMMENT ON COLUMN tb_asset_package_batch_order_task.updater IS NULL; +COMMENT ON COLUMN tb_asset_package_batch_order_task.created_at IS NULL; +COMMENT ON COLUMN tb_asset_package_batch_order_task.updated_at IS NULL; +COMMENT ON COLUMN tb_asset_package_batch_order_task.deleted_at IS NULL; diff --git a/migrations/000194_backfill_july_schema_comments.up.sql b/migrations/000194_backfill_july_schema_comments.up.sql new file mode 100644 index 0000000..d9fe069 --- /dev/null +++ b/migrations/000194_backfill_july_schema_comments.up.sql @@ -0,0 +1,77 @@ +-- 补齐七月迭代新增表的字段注释,确保数据库结构可直接识别字段业务含义。 + +COMMENT ON COLUMN tb_card_observation_effect.id IS '卡观测副作用处理记录ID'; +COMMENT ON COLUMN tb_card_observation_effect.created_at IS '创建时间'; +COMMENT ON COLUMN tb_card_observation_effect.updated_at IS '更新时间'; +COMMENT ON COLUMN tb_card_observation_effect.deleted_at IS '软删除时间'; +COMMENT ON COLUMN tb_card_observation_effect.creator IS '创建人账号ID'; +COMMENT ON COLUMN tb_card_observation_effect.updater IS '更新人账号ID'; + +COMMENT ON COLUMN tb_wecom_application.id IS '企业微信应用配置ID'; +COMMENT ON COLUMN tb_wecom_application.corp_id IS '企业微信企业ID'; +COMMENT ON COLUMN tb_wecom_application.agent_id IS '企业微信自建应用AgentId'; +COMMENT ON COLUMN tb_wecom_application.name IS '企业微信应用配置名称'; +COMMENT ON COLUMN tb_wecom_application.status IS '启用状态:0禁用,1启用'; +COMMENT ON COLUMN tb_wecom_application.created_by IS '创建人账号ID'; +COMMENT ON COLUMN tb_wecom_application.updated_by IS '更新人账号ID'; +COMMENT ON COLUMN tb_wecom_application.last_connected_at IS '最近一次连接成功时间'; +COMMENT ON COLUMN tb_wecom_application.created_at IS '创建时间'; +COMMENT ON COLUMN tb_wecom_application.updated_at IS '更新时间'; +COMMENT ON COLUMN tb_wecom_application.deleted_at IS '软删除时间'; + +COMMENT ON COLUMN tb_wecom_member.id IS '企业微信成员快照ID'; +COMMENT ON COLUMN tb_wecom_member.application_id IS '所属企业微信应用配置ID'; +COMMENT ON COLUMN tb_wecom_member.corp_id IS '企业微信企业ID'; +COMMENT ON COLUMN tb_wecom_member.userid IS '企业微信成员userid'; +COMMENT ON COLUMN tb_wecom_member.name IS '企业微信成员姓名'; +COMMENT ON COLUMN tb_wecom_member.visible IS '成员当前是否在应用可见范围内'; +COMMENT ON COLUMN tb_wecom_member.synced_at IS '最近一次从企业微信同步时间'; +COMMENT ON COLUMN tb_wecom_member.created_at IS '创建时间'; +COMMENT ON COLUMN tb_wecom_member.updated_at IS '更新时间'; + +COMMENT ON COLUMN tb_wecom_approval_scene.id IS '企业微信审批场景配置ID'; +COMMENT ON COLUMN tb_wecom_approval_scene.business_type IS '稳定审批业务类型编码'; +COMMENT ON COLUMN tb_wecom_approval_scene.application_id IS '发起审批使用的企业微信应用配置ID'; +COMMENT ON COLUMN tb_wecom_approval_scene.template_id IS '企业微信后台审批模板ID'; +COMMENT ON COLUMN tb_wecom_approval_scene.template_name IS '企业微信审批模板名称快照'; +COMMENT ON COLUMN tb_wecom_approval_scene.status IS '启用状态:0禁用,1启用'; +COMMENT ON COLUMN tb_wecom_approval_scene.last_verified_at IS '最近一次模板结构校验通过时间'; +COMMENT ON COLUMN tb_wecom_approval_scene.created_by IS '创建人账号ID'; +COMMENT ON COLUMN tb_wecom_approval_scene.updated_by IS '更新人账号ID'; +COMMENT ON COLUMN tb_wecom_approval_scene.created_at IS '创建时间'; +COMMENT ON COLUMN tb_wecom_approval_scene.updated_at IS '更新时间'; + +COMMENT ON COLUMN tb_wecom_approval_context.id IS '企业微信审批渠道上下文ID'; +COMMENT ON COLUMN tb_wecom_approval_context.approval_instance_id IS '关联的通用审批实例ID'; +COMMENT ON COLUMN tb_wecom_approval_context.application_id IS '提交审批使用的企业微信应用配置ID'; +COMMENT ON COLUMN tb_wecom_approval_context.business_type IS '提交时冻结的稳定审批业务类型'; +COMMENT ON COLUMN tb_wecom_approval_context.template_id IS '提交时冻结的企业微信审批模板ID'; +COMMENT ON COLUMN tb_wecom_approval_context.creator_userid IS '实际企业微信审批发起人userid'; +COMMENT ON COLUMN tb_wecom_approval_context.creator_name IS '实际企业微信审批发起人姓名快照'; +COMMENT ON COLUMN tb_wecom_approval_context.control_mapping IS '提交时冻结的业务字段与企微控件映射'; +COMMENT ON COLUMN tb_wecom_approval_context.template_snapshot IS '提交时冻结的企微模板控件最小快照'; +COMMENT ON COLUMN tb_wecom_approval_context.template_fingerprint IS '提交时冻结的模板控件快照SHA-256指纹'; +COMMENT ON COLUMN tb_wecom_approval_context.sp_no IS '企业微信审批单号'; +COMMENT ON COLUMN tb_wecom_approval_context.last_error IS '最近一次提交或恢复失败的安全摘要'; +COMMENT ON COLUMN tb_wecom_approval_context.created_at IS '创建时间'; +COMMENT ON COLUMN tb_wecom_approval_context.updated_at IS '更新时间'; + +COMMENT ON COLUMN tb_asset_package_batch_order_task.id IS '资产套餐批量订购任务ID'; +COMMENT ON COLUMN tb_asset_package_batch_order_task.task_no IS '批量订购任务编号'; +COMMENT ON COLUMN tb_asset_package_batch_order_task.package_code IS '整批统一套餐编码快照'; +COMMENT ON COLUMN tb_asset_package_batch_order_task.package_name IS '整批统一套餐名称快照'; +COMMENT ON COLUMN tb_asset_package_batch_order_task.file_name IS '上传的源CSV文件名'; +COMMENT ON COLUMN tb_asset_package_batch_order_task.total_count IS '任务总行数'; +COMMENT ON COLUMN tb_asset_package_batch_order_task.success_count IS '处理成功行数'; +COMMENT ON COLUMN tb_asset_package_batch_order_task.fail_count IS '处理失败行数'; +COMMENT ON COLUMN tb_asset_package_batch_order_task.error_message IS '任务级失败原因'; +COMMENT ON COLUMN tb_asset_package_batch_order_task.creator_user_type IS '任务创建人账号类型快照'; +COMMENT ON COLUMN tb_asset_package_batch_order_task.creator_shop_id IS '任务创建人所属店铺ID快照'; +COMMENT ON COLUMN tb_asset_package_batch_order_task.creator_name IS '任务创建人名称快照'; +COMMENT ON COLUMN tb_asset_package_batch_order_task.started_at IS '任务开始处理时间'; +COMMENT ON COLUMN tb_asset_package_batch_order_task.completed_at IS '任务处理完成时间'; +COMMENT ON COLUMN tb_asset_package_batch_order_task.creator IS '创建人账号ID'; +COMMENT ON COLUMN tb_asset_package_batch_order_task.updater IS '更新人账号ID'; +COMMENT ON COLUMN tb_asset_package_batch_order_task.created_at IS '创建时间'; +COMMENT ON COLUMN tb_asset_package_batch_order_task.updated_at IS '更新时间'; +COMMENT ON COLUMN tb_asset_package_batch_order_task.deleted_at IS '软删除时间'; diff --git a/openspec/changes/deliver-july-iteration-confirmed-scope/.openspec.yaml b/openspec/changes/deliver-july-iteration-confirmed-scope/.openspec.yaml new file mode 100644 index 0000000..5e6d53a --- /dev/null +++ b/openspec/changes/deliver-july-iteration-confirmed-scope/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-07-24 diff --git a/openspec/changes/deliver-july-iteration-confirmed-scope/design.md b/openspec/changes/deliver-july-iteration-confirmed-scope/design.md new file mode 100644 index 0000000..c8a4b4d --- /dev/null +++ b/openspec/changes/deliver-july-iteration-confirmed-scope/design.md @@ -0,0 +1,163 @@ +## Context + +本 Change 以 `docs/7月迭代/七月迭代需求范围确认表.md` 为业务范围,以 `docs/7月迭代/物联网卡管系统-需求.csv` 为原始来源,以 `docs/7月迭代/企业微信审批官方接口调研.md` 为企微能力依据。旧 Change 已完成部分公共能力,但其剩余任务存在漏项、错误口径和过度 DDD 迁移,不能继续作为实施顺序。 + +当前仓库已经具备可复用的通用审批实例、标准决策同步、公共 Outbox/Relay、异步任务、对象存储、站内通知、`system_config`、钱包流水和 Integration Log。本设计只补业务接线和确实缺失的企微 Adapter,不重建这些基础设施。 + +涉及方包括后端、前端、测试、部署人员,以及企业微信和 Gateway 配置负责人。当前仓库没有前端源码,因此本 Change 交付后端接口、OpenAPI 契约和联调门禁。 + +## Goals / Non-Goals + +**Goals:** + +- 按已确认口径覆盖所有“本期做”需求,并核验所有“已完成,只联调/验收”需求。 +- 让简单字段、筛选、配置和 Bug 保持简单,能够独立开发、独立验证、独立上线。 +- 让退款和员工线下代充值真正通过企微模板审批闭环,同时保留金额、幂等和终态处理的现有安全边界。 +- 冻结前后端需要的字段、接口、状态和错误语义,避免实现阶段再次扩张范围。 + +**Non-Goals:** + +- 不继续执行旧 Change 中未被本 Change重新列出的任务。 +- 不做代理在线扫码充值、原路退款、聚水潭、跨品类换货、分销码/佣金提现、自动限速、通用营销/ERP、本地审批流程设计器、未来钉钉适配或全局 Audit Event 专项。 +- 不迁移未触碰的旧模块,不因为新增字段或筛选建立聚合、Repository 接口、事件消费者或历史投影。 +- 不强制吊销已登录 C 端 Token;店铺限制只阻止新的登录流程。 +- 本次执行不编写或补齐测试代码,不运行测试、构建、LSP、迁移、OpenAPI 生成或真实外部环境验收;实现收口到生产代码可联调状态。 + +## Decisions + +### 1. 新 Change 是后续实施的唯一契约 + +本 Change 校验通过后,`complete-july-iteration-test-release` 只作为历史完成证据归档,不再从其中领取任务。已完成代码不会回滚或重复实现;本 Change 的 tasks 会先做一次证据核验,再只实现真实缺口。 + +**理由**:旧 Change 的 task 勾选、proposal/spec 和仓库状态已经漂移,继续补丁式修订容易再次执行错误任务。 + +**否决方案**:在旧 Change 上继续增删 191 个任务。该方案无法可靠区分已完成公共能力、错误口径和本期真实需求。 + +### 2. 按纵向切片选择最小架构通道 + +| 需求切片 | 主通道 | 完整业务边界 | 明确不迁移范围 | +| --- | --- | --- | --- | +| #189、#181、#57 | 旧 `Handler → Service → Store → Model` | 对现有换货、退款、订单路径做局部修复和拦截 | 不迁移换货、退款或订单模块 | +| #182、#44、#53 | 既有 Query 或旧 Store 查询 | 列表/详情字段、实名筛选、批量账号名称解析 | 不建聚合、历史投影或事件消费者 | +| #41、#62、#48 | 简单写 + 既有读取 | 店铺开关、实名策略、系统配置的更新和查询 | 不建领域模型,不重构认证/支付模块 | +| #188、#97、#33 | Application/既有通知 Adapter | 产生明确业务事件后向既有站内通知接线 | 不建营销平台或新通知中心 | +| #36、#42、#49 | 既有异步导入/导出 Infrastructure + 业务 Service | 文件校验、异步处理、结果文件和业务写入 | 不再建第二套任务、对象存储或进度平台 | +| #47 | 旧 IoT 卡 Service + Gateway Adapter | 固定档位校验、卡 ICCID 权限解析、一次 Gateway 调用 | 不提供设备限速,不通过设备绑定关系间接限速,不自动计算运营商策略,不迁移资产模块 | +| #34、#35 | 复杂写 Application/Domain | 申请快照、审批终态、充值入账或退款终结 | 不把企微 DTO/状态码写入资金领域 | +| #37 | Infrastructure Adapter + Application Port | 通讯录、模板、提交、回调、轮询和状态翻译 | 不建设本地审批流引擎或继续抽象未来渠道 | +| #40 | 旧 Service/Query | 下架套餐续费资格与可购列表过滤 | 不迁移套餐或订单全模块 | + +依赖通过构造函数结构体字段注入。旧 Service 保持现有注入方式;企微 Adapter 实现现有 `internal/application/approval` Port,由 bootstrap 同时注入 API、回调和 Worker。新增 Handler 必须同步两个文档生成器。 + +### 3. API 只表达当前业务,不暴露通用平台 + +所有接口继续使用 Fiber、Validator、`pkg/errors` 和 `{code,msg,data,timestamp}` 统一响应;既有项目字段名为 `msg`,不引入另一套 `message` 字段。列表默认 20、最大 100,平台/代理数据权限沿用现有中间件与 GORM Callback。 + +关键契约如下: + +| 场景 | 接口决定 | +| --- | --- | +| 店铺 C 端登录限制 | 店铺模型和管理接口增加 `client_login_disabled`,默认 `false`;复用店铺更新接口或增加单一语义 PATCH。C 端 `verify-asset` 在签发 asset token 前按资产 `shop_id` 拦截 | +| 企微账号绑定 | 账号列表/详情返回 `wecom_corp_id`、`wecom_userid`、`wecom_name`;管理员更新账号时选择通讯录成员,不提供个人扫码接口 | +| 企微通讯录 | 提供分页成员选择接口,支持姓名或 userid 搜索;只展示应用可见成员,不依赖手机号/邮箱 | +| 企微场景配置 | 管理员配置 `business_type → template_id` 及业务字段到控件 ID/类型/选项 key 的映射;模板必须先在企微后台创建 | +| 实名顺序 | 保持已约定的单资产 PATCH、卡批量 POST、设备批量 POST;枚举仅为 `none/before_order/after_order`,批量上限 500、全成全败 | +| 列表人员字段 | 退款、充值、换货返回提交人 ID/名称;审批人仅从已同步企微详情中的 userid 批量映射,映射不到返回空,不实时调用企微 | +| 批量订购 | CSV 只有资产标识列;请求额外选择一个套餐和一个支付方式,不选择代理;整批使用相同参数 | +| 导出 | 复用现有导出任务入口,通过六个 datasource code 区分;每类只输出本业务已有且已确认的字段 | +| 支付方式 | `system_config` 分别保存卡和设备允许的支付方式;两类资产默认均启用 `wallet/wechat/alipay`,三项可独立取消但至少保留一项;C 端返回当前购包场景有效集合,创建订单时后端再次校验 | +| 设备批量分配 | 复用现有导入任务 API 和状态模型,CSV 单列设备标识;业务参数选择“代理”或“套餐系列”及目标 ID | + +### 4. 店铺登录限制在资产令牌前拦截 + +`client_auth.Service.VerifyAsset` 解析出卡或设备后取得资产 `shop_id`,再读取店铺开关。无店铺/平台库存保持当前行为;开关关闭时返回统一禁止错误,不签发短期资产令牌,因此不会进入微信登录、客户创建或资产绑定。 + +不在每个 C 端业务接口重复查询店铺开关,也不吊销已有 Token。这严格对应“不能登录”而不是“立即踢下线”,并避免在本期引入 Token 版本和全局会话撤销。 + +### 5. 企微只做 Adapter,审批流程仍由企微模板拥有 + +系统按应用保存加密 Secret、回调 Token、EncodingAESKey、`corp_id/agent_id` 和一个从当前可见成员中选择的默认审批发起人;`access_token` 按应用缓存并预留提前刷新。管理员从应用可见通讯录选择成员,账号绑定键为 `(corp_id, userid)`,姓名和部门仅为展示快照。内部员工已绑定且仍可见时优先以本人发起,代理等非企微账号始终以应用默认成员发起;本地申请仍保留真实业务提交人,不用默认成员冒充业务操作者。 + +场景配置保存已知 `template_id`。保存或发布时调用 `oa/gettemplatedetail` 校验控件 ID、类型、必填项和选择项 key。系统不创建模板、不保存审批节点、不计算部门领导或财务人员;`oa/applyevent` 使用 `use_template_approver=1`,审批人由企微后台模板决定。 + +提交过程: + +1. 业务用例校验内部提交人的企微绑定或应用默认发起人仍可见、场景已启用且模板结构有效。 +2. 退款或线下充值在同一 GORM 事务保存业务申请、通用审批实例和提交 Outbox。 +3. Adapter 消费提交事件,上传必要附件并调用 `oa/applyevent`,保存返回的 `sp_no` 到通用实例 `external_ref`。 +4. 外部调用记录 Integration Log;结果未知时不盲目创建第二张审批单,转人工/查询恢复。 +5. 回调端点在 5 秒内完成验签解密、必要幂等记录和快速响应,再异步调用 `getapprovaldetail`。 +6. Adapter 将企微状态翻译为现有标准决策;现有决策 Outbox 分别驱动退款终结或线下代充入账。 +7. 定时任务扫描未终态实例,并用批量拉取审批单号与详情查询补偿回调遗漏。 + +企业微信审批详情的审批节点 userid 可以用于页面展示,但只做读取投影;不能映射系统账号时返回空名称,不影响业务终态。 + +### 6. 通知只复用现有站内能力 + +- 创建物流换货单成功后,向关联个人客户写一次站内通知;C 端按既有未读弹窗机制展示。 +- 店铺主钱包余额从不低于 100 元变为低于 100 元时,向 `business_owner_account_id` 对应后台账号发送一次通知;余额恢复到阈值以上后允许下一次下降再次提醒。 +- 每日扫描预计套餐到期日在 15、7、3 天节点的资产,生成后台/代理临期列表所需标识,并向关联个人客户发送节点通知。相同资产、节点、到期日只通知一次。 + +通知属于可靠副作用时使用现有 Outbox 和幂等键;不新增短信、企微业务员提醒或自定义营销内容。 + +### 7. 批量与导出复用已完成基础设施 + +批量订购和设备分配只新增业务解析器/执行器,继续使用现有对象存储、任务五态、Asynq 重试、失败明细和下载能力。文件级校验失败不写业务数据;涉及扣款/订购时按现有订单幂等键和钱包流水保证不重复扣款。 + +六类导出分别实现 datasource,查询直接投影 DTO,不串联多个业务迁移。不存在的字段不得临时创造含义;先从既有数据组合,确认确实缺失后再增加最小字段和迁移。 + +### 8. 数据、事务、常量与缓存 + +- 新表/字段通过 PostgreSQL 增量迁移添加,不建外键,不写 GORM 关联标签。 +- 简单列表字段使用批量 `WHERE id IN (...)` 或 JOIN 投影,禁止逐行查询账号。 +- 批量实名策略在单事务内校验全部资产后统一更新;任何冲突则全部回滚。 +- 退款和充值金额继续使用现有 Domain Ledger/钱包流水与乐观锁;审批 Adapter 不直接修改余额或退款金额。 +- 所有新枚举、任务类型、场景编码、导出 datasource code 和 Redis key 在 `pkg/constants` 定义并写中文注释。 +- 企微 token 使用 Redis 缓存和短锁;Redis 不可用时允许受控回源,但不得把 token 返回前端。 + +### 9. Audit Event、Domain Ledger、Integration Log 与 Outbox 决策 + +| 切片 | Audit Event | Domain Ledger | Integration Log | Outbox | +| --- | --- | --- | --- | --- | +| 字段、筛选、Bug、只读导出 | N/A:无敏感状态变更;实现时登记覆盖基线理由 | N/A 或沿用既有订单/套餐事实 | N/A | N/A | +| 店铺登录开关、企微账号绑定、模板场景、支付方式配置 | 使用现有统一审计接缝记录操作者和前后值;不建设新的审计中心 | N/A | 模板校验调用记录 | 缓存失效仅沿用现有配置机制 | +| 退款、线下代充 | 审批申请人和配置操作进入既有审计/申请快照;企微自动终态不伪造人工操作者 | 沿用退款、订单、钱包流水 | 所有企微请求、响应摘要、回调和结果未知 | 申请提交、标准终态和资金后续动作使用现有 Outbox | +| 通知 | N/A:通知记录本身是投递事实 | N/A | N/A | 必须,按业务键和接收人防重 | +| Gateway 限速 | 记录后台操作者、资产和档位 | N/A | 必须记录请求、响应及结果未知 | 无后续可靠副作用时 N/A | +| 批量订购/分配 | 沿用任务提交审计;资金动作沿用既有记录 | 订购扣款沿用订单/钱包流水 | 涉及外部支付或 Gateway 时沿用 | 仅业务已有可靠副作用继续使用 | + +实现前必须增量更新 `.scratch/tech-global-audit/审计覆盖基线.md`,不得因本 Change 排除全局 Audit Event 专项而跳过上述已有审计接缝。 + +### 10. 续费、待支付订单与强充支付方式边界 + +- 历史已支付订单和资产当前套餐只提供稳定的资产、套餐引用。用户点击续费时继续调用现有 C 端创建订单接口并生成一张新订单,不新增续费接口,也不在旧订单上修改支付方式。 +- 续费新订单创建前按当前 `system_config`、当前套餐价格/流量和当前强充状态重新选择支付方式;订单创建成功后,和普通订单一样固化所选方式。既有待支付订单再次支付时只能执行其已保存方式,不允许切换。 +- 卡和设备分别维护非空支付方式集合,合法选项均为 `wallet`、`wechat`、`alipay`,三项可独立启停;首次初始化两类资产均全选。配置非法或读取失败时失败关闭,不推断或放开支付渠道。 +- C 端购包接口返回“配置集合与当前业务场景的交集”。当前资产仍命中既有强充规则时必须剔除 `wallet`;普通钱包充值场景固定剔除 `wallet`。后端在创建订单、创建充值单和支付准备时执行相同校验,不能信任前端展示结果。 +- 强充继续沿用现有流程:第三方支付充值成功后先入资产钱包,再通过既有异步自动购包任务扣钱包并购买所选套餐。支付方式配置不得把强充改成提示后由用户手工充值。 +- 强充资格必须复用既有一次性佣金状态语义:`first_recharge` 与 `accumulated_recharge` 是同一强充/自动购包流程的不同触发配置;该系列一次性佣金已经触发后不再强充。不得在 C 端订单服务复制一套遗漏已触发状态的判断。 + +## Risks / Trade-offs + +- **[企微应用看不到提交人]** → 保存绑定前只允许选择应用可见成员,提交前再次校验;权限变化时明确失败,不创建业务申请和审批实例。 +- **[管理员修改企微模板导致控件映射失效]** → 保存/发布场景时拉取模板详情并保存指纹,提交前校验关键控件;失效只阻断新提交,存量审批继续同步。 +- **[回调丢失或重复]** → 回调快速响应、按 `sp_no` 幂等同步,保留未终态轮询和时间窗补偿。 +- **[企微提交超时但实际成功]** → 标记结果未知并先查详情/批量单号,不直接重试创建,避免重复审批。 +- **[简单需求再次被扩张]** → tasks 明确每个切片的不迁移范围;实现阶段不得改变架构通道,确需改变必须先更新提案。 +- **[大批量影响接口响应]** → 实名批量限制 500 且同步事务;CSV 和导出走异步任务,列表/人员映射使用批量查询。 +- **[店铺开关不能立即踢掉在线用户]** → 这是本期有意取舍;接口和文档明确只阻止新登录。 + +## Migration Plan + +1. 先完成仓库证据核验,确认公共 Outbox、审批核心、通知、导入/导出和 `system_config` 可复用,禁止重复建表。 +2. 按纵向切片先交付无外部依赖的 Bug、字段、筛选、开关、实名、支付配置和本地通知。 +3. 增量迁移企微应用/场景/账号绑定及确实缺失的业务字段,迁移脚本保持向前兼容,不删除旧字段。 +4. 部署企微 Adapter、回调和轮询 Worker,配置测试应用、模板和账号绑定,完成真实退款/线下代充闭环后再关闭旧人工审批入口。 +5. 部署批量订购、导出、限速和设备批量分配,完成前后端联调与任务失败恢复验证。 +6. 完成生产代码、必要迁移文件、接口契约、配置说明和实施证据,以 `gofmt`、只读检查与 `git diff --check` 做静态收口并交付联调;自动化测试、构建、迁移执行和真实外部环境验收由后续流程承担。 + +回滚时,简单字段和兼容接口可回退应用版本;新增列/表暂不删除。产生企微审批、退款、充值、钱包流水、订单、通知、Outbox 或 Integration Log 后不得清表或恢复旧 Writer,只能暂停新入口并前向修复。旧人工审批入口仅在新链路验证完成后停用,因此切换前仍可回退。 + +## Open Questions + +无阻塞性业务问题。实施和部署阶段仅需提供测试环境企微应用 Secret、可见范围、模板 ID、可信 IP、回调 Token/EncodingAESKey,以及 Gateway 测试配置;这些是环境输入,不改变需求范围。 diff --git a/openspec/changes/deliver-july-iteration-confirmed-scope/proposal.md b/openspec/changes/deliver-july-iteration-confirmed-scope/proposal.md new file mode 100644 index 0000000..f849331 --- /dev/null +++ b/openspec/changes/deliver-july-iteration-confirmed-scope/proposal.md @@ -0,0 +1,53 @@ +## Why + +`complete-july-iteration-test-release` 将七月迭代中的字段、筛选和局部缺陷扩大成模块迁移、通用平台和长依赖链,且遗漏或误解了多条 CSV 需求,已经不能作为可靠实施契约。feature-2026-july-confirmed-scope 依据已填写的需求范围确认表重新建立一个可直接上线的最小交付 Change:优先复用现有代码,完成用户可见需求,不借本期工作重构未触碰模块。 + +## What Changes + +- 新 Change 完整替代 `complete-july-iteration-test-release` 的后续规划;旧 Change 在本 Change 校验通过前保留完成证据,之后归档为被替代,不继续执行其未完成任务。 +- 实施遵循“存量能力优先”:优先修改或复用现有接口、字段、Service、Query、任务和基础设施;只有现有能力确实无法承载已确认需求时,才允许做边界最小、可说明必要性的新增。 +- 本 Change 当前交付不编写或补齐测试代码,不运行单元、集成、验收或业务流程测试,也不执行 `go test`、`go build`、LSP 诊断、迁移和 OpenAPI 生成;只完成生产代码、必要迁移文件和契约文档至可联调状态,可使用 `gofmt`、只读检查与 `git diff --check` 做静态收口。 +- 将 #189、#181、#57 等缺陷和 #53、#44、#182 等字段/筛选需求收敛为旧 Service、Store 和现有 Query 上的局部修改,不进行 DDD 迁移。 +- 增加店铺级 C 端登录限制开关;仅阻止该店铺资产发起新登录,不建立代理 API 权限体系,也不强制吊销已登录 Token。 +- 补齐三种实名顺序及卡/设备批量配置,保持既有 `realname_policy` 模型和前后端接口约定。 +- 复用已有站内通知,完成换货单弹窗、固定 100 元钱包余额提醒、套餐 15/7/3 天临期列表和 C 端提醒,不建设通用营销平台。 +- 复用已完成的渠道无关审批核心,交付最小企业微信 Adapter:管理员从通讯录为系统账号绑定 `(corp_id, userid)`,后台创建模板,本系统配置业务场景/模板/控件映射,完成发起、回调、详情查询和轮询补偿;不做扫码绑定、本地流程设计器或未来渠道抽象扩建。 +- **BREAKING**:退款和员工线下代充值的人工审批结果改由企业微信终态驱动;新链路可用后停用原系统内人工通过/驳回入口。代理在线扫码充值后置。 +- 交付单列 CSV 批量订购、六类业务导出、固定档位限速、按资产类型配置支付方式、CSV 批量分配设备等已确认功能,均复用现有导入、导出、Gateway、`system_config` 和任务基础设施。 +- 冻结已经完成的 #45、#46、#55、#60、#86、#38、#94、#96、#98、#43,仅做接口联调或代码证据核验;冻结行业卡现有复机行为,不按旧提案改写。 +- 明确排除原路退款、聚水潭、跨品类换货、分销佣金提现、代理在线扫码充值、通用营销/ERP、自动限速、本地审批流引擎、全局 Audit Event 专项,以及已关闭且不处理的需求。 + +## Capabilities + +### New Capabilities + +- `shop-client-login-control`: 店铺级 C 端新登录开关及资产登录拦截。 +- `wecom-approval-integration`: 企微应用、通讯录账号映射、模板场景绑定、审批发起、回调和轮询补偿。 +- `package-expiration-reminder`: 套餐 15/7/3 天临期列表、站内通知和 C 端弹窗提醒。 +- `asset-package-batch-order`: 单列 CSV、整批统一套餐和支付方式的批量订购。 +- `business-data-export`: lot 卡、钱包流水、套餐、退款、换货和代理充值六类独立导出。 +- `asset-speed-tier-management`: 后台为有权限 IoT 卡手动选择固定限速档位并调用 Gateway;不提供设备限速。 + +### Modified Capabilities + +- `card-replacement`: 换货前拦截活跃退款,并修复换货套餐在原订单退款后未失效的问题。 +- `exchange-client-notification`: 创建物流换货单后复用站内通知在 C 端弹窗。 +- `order-management`: 修复 C 端订单渠道和订单资产标识返回,并支持从历史订单使用稳定资产/套餐引用发起新的下架套餐续费订单。 +- `refund-api`: 返回正确设备资产标识、提交人,并将退款审批结果切换为企微驱动。 +- `agent-recharge`: 返回提交人,仅保留员工线下代充值并接入企微审批,代理在线扫码充值后置。 +- `exchange-admin-management`: 换货列表和详情返回提交人。 +- `asset-realname-policy`: 支持三种实名顺序、单资产修改、卡/设备批量修改和 C 端生效策略字段。 +- `asset-queries`: 卡按自身实名状态筛选;设备任意一张有效绑定卡已实名即视为设备已实名。 +- `agent-wallet`: 主钱包低于固定 100 元时向店铺业务员发送一次站内预警。 +- `package-management`: 下架套餐从普通可购列表排除,但允许当前使用者通过续费入口购买。 +- `payment-dynamic-config`: 通过 `system_config` 分别配置卡和设备允许的支付方式;两类资产初始化均全选钱包、微信和支付宝,三项可独立取消,C 端再按强充场景过滤钱包并由订单端复核。 +- `device`: 复用现有导入任务模式,通过单列 CSV 批量分配设备所属代理或套餐系列。 + +## Impact + +- **后端范围**:局部触及换货、订单、退款、充值、资产、店铺、套餐、钱包、设备导入、导出、通知和 Gateway;只有审批终态与资金处理保留现有 Application/Domain 边界,简单字段、筛选和 Bug 沿用 `Handler → Service → Store → Model` 或既有 Query。 +- **API/前端**:新增店铺登录开关、企微账号选择/绑定、模板场景配置、实名批量配置、批量订购、导出、限速和设备批量分配接口;修改列表/详情字段、C 端初始化与支付方式返回。新增 Handler 必须同步 `cmd/api/docs.go`、`cmd/gendocs/main.go` 和 OpenAPI 文档。 +- **数据与基础设施**:使用 PostgreSQL、Redis/Asynq、现有 Outbox、Integration Log、对象存储、站内通知和 `system_config`;不新增外键或 GORM 关联标签,不引入新依赖。 +- **外部系统**:企业微信自建应用与 Gateway。企微上线需应用 Secret、审批权限、通讯录可见范围、可信 IP、回调 Token/EncodingAESKey 和模板 ID。 +- **性能**:列表保持分页并批量解析提交人/审批人,禁止 N+1;批量实名上限 500 且事务全成全败;CSV/导出沿用异步任务,外部接口设置超时、幂等和补偿。 +- **交付边界**:本 Change 以生产代码、迁移文件、接口契约、联调配置和实施证据齐备为完成标准;自动化测试、构建、LSP、迁移执行、OpenAPI 生成及真实企微/Gateway 环境验收均不在本次执行范围,后续联调或发布流程另行承担。 diff --git a/openspec/changes/deliver-july-iteration-confirmed-scope/specs/agent-recharge/spec.md b/openspec/changes/deliver-july-iteration-confirmed-scope/specs/agent-recharge/spec.md new file mode 100644 index 0000000..6d0b96b --- /dev/null +++ b/openspec/changes/deliver-july-iteration-confirmed-scope/specs/agent-recharge/spec.md @@ -0,0 +1,27 @@ +## ADDED Requirements + +### Requirement: 充值查询返回提交人 +代理充值列表和详情 SHALL 返回提交人账号 ID 和名称;账号名称 MUST 通过批量查询获得,代理仍只能查看自身店铺范围内记录。 + +#### Scenario: 查询线下代充值记录 +- **WHEN** 有权限用户查询员工提交的线下代充值 +- **THEN** 响应包含提交人 ID、提交人名称和当前审批状态 + +### Requirement: 员工线下代充值使用企微审批 +平台员工创建线下代充值申请时 MUST 固化目标店铺、金额、凭证和真实提交人,并关联唯一通用审批实例;approved 首次终态 SHALL 调用现有钱包入账用例,其他终态不得入账。代理在线扫码充值不属于本 Change。 + +#### Scenario: 企微审批通过线下代充值 +- **WHEN** 有效线下代充值申请首次取得 approved 标准决策 +- **THEN** 系统以唯一业务号幂等增加目标店铺主钱包余额并写入钱包流水 + +#### Scenario: 企微审批拒绝线下代充值 +- **WHEN** 申请取得 rejected、cancelled 或 deleted 标准决策 +- **THEN** 系统终结申请且不增加钱包余额 + +## REMOVED Requirements + +### Requirement: 线下充值确认 +**Reason**: 操作密码人工确认无法满足已确认的企微模板审批流程,并可能绕过部门领导和财务审批。 + +**Migration**: 企微审批闭环可用后停用 `POST /api/admin/agent-recharges/:id/offline-pay`;存量记录按切换清单处理,新申请只接受企微终态。 + diff --git a/openspec/changes/deliver-july-iteration-confirmed-scope/specs/agent-wallet/spec.md b/openspec/changes/deliver-july-iteration-confirmed-scope/specs/agent-wallet/spec.md new file mode 100644 index 0000000..099d117 --- /dev/null +++ b/openspec/changes/deliver-july-iteration-confirmed-scope/specs/agent-wallet/spec.md @@ -0,0 +1,13 @@ +## ADDED Requirements + +### Requirement: 主钱包低余额通知业务员 +店铺主钱包可用余额低于固定 100 元时,系统 SHALL 向店铺 `business_owner_account_id` 对应业务员发送站内通知;不提供按店铺自定义阈值,不向无业务员的店铺猜测接收人。 + +#### Scenario: 余额首次降至阈值以下 +- **WHEN** 主钱包余额从不少于 100 元变为少于 100 元且店铺已配置业务员 +- **THEN** 业务员收到一条低余额站内通知 + +#### Scenario: 阈值以下重复余额变化 +- **WHEN** 钱包已处于 100 元以下又发生多次扣减 +- **THEN** 系统不重复发送同一低余额周期通知;余额恢复后再次跌破可重新通知 + diff --git a/openspec/changes/deliver-july-iteration-confirmed-scope/specs/asset-package-batch-order/spec.md b/openspec/changes/deliver-july-iteration-confirmed-scope/specs/asset-package-batch-order/spec.md new file mode 100644 index 0000000..57e8320 --- /dev/null +++ b/openspec/changes/deliver-july-iteration-confirmed-scope/specs/asset-package-batch-order/spec.md @@ -0,0 +1,20 @@ +## ADDED Requirements + +### Requirement: 单列 CSV 创建批量订购任务 +内部员工 SHALL 上传仅包含资产标识的 CSV,并在请求中为整批选择一个套餐和一个支付方式;系统不得要求 CSV 包含代理、套餐系列或支付账户,也不得按行选择不同套餐。 + +#### Scenario: 创建合法批量订购任务 +- **WHEN** 员工上传单列资产 CSV 并选择有效套餐与支付方式 +- **THEN** 系统创建异步任务并通过统一响应返回任务 ID 和初始状态 + +### Requirement: 批量订购复用现有订单和扣款规则 +任务执行器 MUST 对每个资产执行现有购买资格、支付方式、价格和幂等校验,生成成功/失败明细;重复消费不得重复创建订单或扣款。 + +#### Scenario: 部分资产校验失败 +- **WHEN** 一批资产中部分资产不可购买所选套餐 +- **THEN** 系统保留每行失败原因并继续处理可独立成功的其他资产,任务汇总真实成功和失败数量 + +#### Scenario: 任务被重复投递 +- **WHEN** Asynq 重复执行同一批量订购任务 +- **THEN** 已成功行不重复下单或扣款 + diff --git a/openspec/changes/deliver-july-iteration-confirmed-scope/specs/asset-queries/spec.md b/openspec/changes/deliver-july-iteration-confirmed-scope/specs/asset-queries/spec.md new file mode 100644 index 0000000..ee57f3e --- /dev/null +++ b/openspec/changes/deliver-july-iteration-confirmed-scope/specs/asset-queries/spec.md @@ -0,0 +1,13 @@ +## ADDED Requirements + +### Requirement: 卡和设备列表支持实名状态筛选 +卡列表 SHALL 按卡自身有效实名状态筛选;设备列表 SHALL 在任意一张当前有效绑定卡已实名时将设备视为已实名,否则视为未实名。查询 MUST 使用现有表联查或子查询,不得为该筛选新增历史投影、Worker 或事件消费者。 + +#### Scenario: 设备有一张实名绑定卡 +- **WHEN** 设备绑定多张有效卡且其中任意一张已实名 +- **THEN** 设备出现在“已实名”筛选结果中且不出现在“未实名”结果中 + +#### Scenario: 设备没有实名绑定卡 +- **WHEN** 设备不存在任何已实名的有效绑定卡 +- **THEN** 设备出现在“未实名”筛选结果中 + diff --git a/openspec/changes/deliver-july-iteration-confirmed-scope/specs/asset-realname-policy/spec.md b/openspec/changes/deliver-july-iteration-confirmed-scope/specs/asset-realname-policy/spec.md new file mode 100644 index 0000000..0a4a94e --- /dev/null +++ b/openspec/changes/deliver-july-iteration-confirmed-scope/specs/asset-realname-policy/spec.md @@ -0,0 +1,23 @@ +## ADDED Requirements + +### Requirement: 实名顺序只使用三种策略 +资产实名策略 MUST 为 `none`、`before_order` 或 `after_order`;设备下卡存在策略冲突时,C 端有效流程 MUST 以设备策略为准,前端不得自行覆盖。 + +#### Scenario: 设备策略覆盖下卡策略 +- **WHEN** 设备与其绑定卡配置了不同实名策略 +- **THEN** C 端返回并执行设备的 effective_realname_policy + +### Requirement: 支持单资产和批量修改实名策略 +系统 SHALL 提供已约定的单资产 PATCH、卡批量 POST 和设备批量 POST;批量请求最多 500 个 `asset_ids`,MUST 先校验全部资产和权限后在单事务中全成全败。 + +#### Scenario: 批量修改包含无权限资产 +- **WHEN** 请求中的任一资产不存在、无权限或策略冲突 +- **THEN** 系统返回明确错误且所有资产策略均保持不变 + +### Requirement: C 端初始化返回生效策略 +C 端资产初始化响应 SHALL 包含 `effective_realname_policy`、`realname_required` 和 `realname_status`,并使用 `{code,msg,data,timestamp}` 统一响应。 + +#### Scenario: 无需实名资产初始化 +- **WHEN** 资产有效策略为 `none` +- **THEN** 响应返回 `effective_realname_policy=none` 且允许直接进入购买流程 + diff --git a/openspec/changes/deliver-july-iteration-confirmed-scope/specs/asset-speed-tier-management/spec.md b/openspec/changes/deliver-july-iteration-confirmed-scope/specs/asset-speed-tier-management/spec.md new file mode 100644 index 0000000..0a9d313 --- /dev/null +++ b/openspec/changes/deliver-july-iteration-confirmed-scope/specs/asset-speed-tier-management/spec.md @@ -0,0 +1,16 @@ +## ADDED Requirements + +### Requirement: 后台手动设置卡固定限速档位 +有权限的后台账号 SHALL 为 IoT 卡选择系统定义的固定限速档位,系统 MUST 校验卡权限和档位常量后调用现有 Gateway;Gateway 限速对象只能是卡 ICCID,系统不得提供设备限速或通过设备绑定关系间接限速,也不得根据流量、套餐或运营商规则自动计算档位。 + +#### Scenario: 成功设置限速档位 +- **WHEN** 管理员为有权限 IoT 卡选择有效固定档位 +- **THEN** 系统以该卡 ICCID 调用 Gateway 并返回统一响应,记录操作者、卡、档位和外部交互结果 + +#### Scenario: 设备不得调用限速 +- **WHEN** 用户尝试通过设备或设备绑定关系设置限速 +- **THEN** 系统不提供该接口且不得向 Gateway 发送设备 IMEI、设备 ID 或设备当前绑定卡 + +#### Scenario: Gateway 结果未知 +- **WHEN** Gateway 请求超时且结果不可确定 +- **THEN** 系统返回可识别的结果未知错误并保留 Integration Log,不盲目声称设置成功 diff --git a/openspec/changes/deliver-july-iteration-confirmed-scope/specs/business-data-export/spec.md b/openspec/changes/deliver-july-iteration-confirmed-scope/specs/business-data-export/spec.md new file mode 100644 index 0000000..5ebbb1a --- /dev/null +++ b/openspec/changes/deliver-july-iteration-confirmed-scope/specs/business-data-export/spec.md @@ -0,0 +1,16 @@ +## ADDED Requirements + +### Requirement: 六类业务数据可独立导出 +系统 SHALL 复用现有导出任务框架,分别提供 lot 卡、钱包流水、套餐、退款、换货和代理充值 datasource;每类导出按当前业务权限和筛选条件读取已有字段,不建设统一字段权限平台。 + +#### Scenario: 创建退款导出任务 +- **WHEN** 有权限用户以退款列表筛选条件创建退款导出 +- **THEN** 系统异步生成仅包含其可见退款数据的文件,并通过任务接口提供进度和下载结果 + +### Requirement: 导出字段必须有稳定来源 +每个 datasource MUST 明确列名、数据来源和格式;现有表或可批量关联数据无法提供的字段不得伪造,必须先补充经确认的最小数据字段后才能导出。 + +#### Scenario: 字段没有数据来源 +- **WHEN** 业务要求字段在现有数据中不存在且无法可靠推导 +- **THEN** 该 datasource 不得用空含义或错误值冒充字段,任务保持未交付直到字段契约确认 + diff --git a/openspec/changes/deliver-july-iteration-confirmed-scope/specs/card-replacement/spec.md b/openspec/changes/deliver-july-iteration-confirmed-scope/specs/card-replacement/spec.md new file mode 100644 index 0000000..b89d31c --- /dev/null +++ b/openspec/changes/deliver-july-iteration-confirmed-scope/specs/card-replacement/spec.md @@ -0,0 +1,16 @@ +## ADDED Requirements + +### Requirement: 未终结退款阻止换货 +现有换货创建用例 MUST 在写入换货单前查询该资产关联订单的未终结退款;存在时返回“该资产存在退款申请”,且不得创建换货单或修改资产。 + +#### Scenario: 资产存在活跃退款 +- **WHEN** 管理员为存在未终结退款申请的卡或设备创建换货单 +- **THEN** 系统拒绝操作并返回业务错误“该资产存在退款申请” + +### Requirement: 原订单退款必须失效迁移后的套餐权益 +当套餐权益已随换货迁移到新资产后,原购买订单退款终态处理 MUST 沿换货迁移关系定位新资产上的对应套餐使用记录并按现有退款规则失效,不得因仍按旧资产路径查询而遗漏。 + +#### Scenario: 换货后原订单退款通过 +- **WHEN** 旧资产套餐已迁移到新资产且原购买订单退款通过 +- **THEN** 新资产上由该订单产生的套餐权益被正确失效,其他订单权益不受影响 + diff --git a/openspec/changes/deliver-july-iteration-confirmed-scope/specs/device/spec.md b/openspec/changes/deliver-july-iteration-confirmed-scope/specs/device/spec.md new file mode 100644 index 0000000..b856eed --- /dev/null +++ b/openspec/changes/deliver-july-iteration-confirmed-scope/specs/device/spec.md @@ -0,0 +1,16 @@ +## ADDED Requirements + +### Requirement: CSV 批量分配设备代理或套餐系列 +系统 SHALL 复用现有设备导入任务模式,接收仅含设备标识的 CSV,并由请求参数选择本批操作类型和一个目标代理或套餐系列;不得为该需求新建独立任务平台。 + +#### Scenario: 批量分配目标代理 +- **WHEN** 管理员上传合法设备 CSV 并选择一个有权限目标代理 +- **THEN** 异步任务按现有分配规则处理设备并输出成功、失败明细 + +#### Scenario: 批量设置套餐系列 +- **WHEN** 管理员上传合法设备 CSV 并选择一个有效套餐系列 +- **THEN** 异步任务按现有设备系列绑定规则处理并输出结果 + +#### Scenario: 重复消费任务 +- **WHEN** 同一设备分配任务被重复投递 +- **THEN** 系统保持目标关系一致且不产生重复分配副作用 diff --git a/openspec/changes/deliver-july-iteration-confirmed-scope/specs/exchange-admin-management/spec.md b/openspec/changes/deliver-july-iteration-confirmed-scope/specs/exchange-admin-management/spec.md new file mode 100644 index 0000000..2e9b700 --- /dev/null +++ b/openspec/changes/deliver-july-iteration-confirmed-scope/specs/exchange-admin-management/spec.md @@ -0,0 +1,9 @@ +## ADDED Requirements + +### Requirement: 换货查询返回提交人 +换货列表和详情 SHALL 返回创建该换货单的提交人账号 ID 和名称;名称 MUST 批量解析并遵守当前平台/代理店铺权限。 + +#### Scenario: 查询换货列表 +- **WHEN** 有权限用户查询换货列表 +- **THEN** 每条可见记录包含提交人 ID 和提交人名称 + diff --git a/openspec/changes/deliver-july-iteration-confirmed-scope/specs/exchange-client-notification/spec.md b/openspec/changes/deliver-july-iteration-confirmed-scope/specs/exchange-client-notification/spec.md new file mode 100644 index 0000000..6784539 --- /dev/null +++ b/openspec/changes/deliver-july-iteration-confirmed-scope/specs/exchange-client-notification/spec.md @@ -0,0 +1,13 @@ +## ADDED Requirements + +### Requirement: 创建物流换货单后发送 C 端通知 +物流换货单创建成功后,系统 SHALL 复用现有个人客户站内通知能力向该资产关联客户发送一次换货提醒,C 端 SHALL 按既有未读通知机制弹窗;通知不包含通用营销配置、ERP 发货或自动创建其他单据。 + +#### Scenario: 成功创建物流换货单 +- **WHEN** 后台成功创建需要客户处理的物流换货单 +- **THEN** 关联客户收到一条可在 C 端弹窗展示的站内通知 + +#### Scenario: 重复处理创建事件 +- **WHEN** 同一换货单通知事件被重复消费 +- **THEN** 同一客户不会收到重复通知 + diff --git a/openspec/changes/deliver-july-iteration-confirmed-scope/specs/order-management/spec.md b/openspec/changes/deliver-july-iteration-confirmed-scope/specs/order-management/spec.md new file mode 100644 index 0000000..788b053 --- /dev/null +++ b/openspec/changes/deliver-july-iteration-confirmed-scope/specs/order-management/spec.md @@ -0,0 +1,19 @@ +## ADDED Requirements + +### Requirement: C 端订单保存并返回正确渠道 +C 端创建的卡或设备订单 MUST 使用既有 `purchase_role` 保存正确订单渠道;订单列表和详情 SHALL 返回渠道以及对应资产标识,卡使用 ICCID,设备优先使用 VirtualNo、为空时使用 IMEI,不得使用 SN 或误用卡字段导致标识为空。 + +#### Scenario: 查询 C 端设备订单 +- **WHEN** 管理员查询由 C 端创建的设备订单列表或详情 +- **THEN** 响应返回 `purchase_role` 以及按 VirtualNo 优先、IMEI 兜底生成的设备资产标识 + +### Requirement: 历史订单可发起新的下架套餐续费订单 +历史订单 SHALL 提供续费所需的稳定套餐和资产引用;当前使用该下架套餐的资产可以从历史订单发起一张新订单,并在创建前按当前配置选择支付方式。系统不得修改原历史订单,也不得为续费新增另一套下单接口。 + +#### Scenario: 从历史订单续费下架套餐 +- **WHEN** 客户从自身历史订单对仍在使用的下架套餐发起续费 +- **THEN** 系统使用现有创建订单接口按套餐当前价格、流量和支付配置创建新订单,并允许客户重新选择当前有效支付方式 + +#### Scenario: 支付既有待支付订单 +- **WHEN** 客户对已经创建的普通或续费待支付订单再次发起支付 +- **THEN** 系统只执行该订单创建时保存的支付方式,不允许在原订单上切换渠道 diff --git a/openspec/changes/deliver-july-iteration-confirmed-scope/specs/package-expiration-reminder/spec.md b/openspec/changes/deliver-july-iteration-confirmed-scope/specs/package-expiration-reminder/spec.md new file mode 100644 index 0000000..55ad6c8 --- /dev/null +++ b/openspec/changes/deliver-july-iteration-confirmed-scope/specs/package-expiration-reminder/spec.md @@ -0,0 +1,16 @@ +## ADDED Requirements + +### Requirement: 生成套餐临期节点 +系统 SHALL 每日识别预计套餐到期时间距离当前 15、7、3 天的资产;后台和代理端 SHALL 提供分页临期列表及数量,临期项可高亮并优先展示,查询必须遵守店铺层级权限。 + +#### Scenario: 资产进入七天节点 +- **WHEN** 某资产预计套餐到期时间距当前日期为 7 天 +- **THEN** 系统在有权限的临期列表和数量中包含该资产 + +### Requirement: C 端按节点接收站内提醒 +系统 SHALL 复用现有站内通知,在每个资产、到期日和 15/7/3 天节点最多向关联个人客户发送一次续费提醒;C 端继续使用既有未读通知弹窗,不新增营销内容配置。 + +#### Scenario: 定时任务重复执行 +- **WHEN** 同一天同一节点的扫描任务重复执行 +- **THEN** 同一接收人只产生一条对应资产的临期通知 + diff --git a/openspec/changes/deliver-july-iteration-confirmed-scope/specs/package-management/spec.md b/openspec/changes/deliver-july-iteration-confirmed-scope/specs/package-management/spec.md new file mode 100644 index 0000000..a3c274d --- /dev/null +++ b/openspec/changes/deliver-july-iteration-confirmed-scope/specs/package-management/spec.md @@ -0,0 +1,16 @@ +## ADDED Requirements + +### Requirement: 下架套餐只允许符合资格的续费 +普通 C 端可购套餐列表 MUST 排除下架套餐;当前资产在当前世代仍有该套餐的生效中使用记录时,客户可通过资产当前套餐或历史订单提供的稳定引用调用现有创建订单接口续费,代理代购和新客户普通购买仍 MUST 被拒绝。续费始终读取套餐当前价格、流量和其他配置,不沿用历史权益快照。 + +#### Scenario: 当前客户续费下架套餐 +- **WHEN** 客户资产正在使用某下架套餐并通过资产或历史订单引用提交现有创建订单接口 +- **THEN** 系统按套餐当前配置允许创建一张新订单 + +#### Scenario: 新客户购买下架套餐 +- **WHEN** 非当前使用者通过普通购买入口请求下架套餐 +- **THEN** 系统返回套餐不可购买错误 + +#### Scenario: 套餐重新上架后购买 +- **WHEN** 原下架套餐更新价格或流量后重新上架 +- **THEN** 套餐重新出现在普通可购列表,普通购买和续费均使用更新后的当前配置 diff --git a/openspec/changes/deliver-july-iteration-confirmed-scope/specs/payment-dynamic-config/spec.md b/openspec/changes/deliver-july-iteration-confirmed-scope/specs/payment-dynamic-config/spec.md new file mode 100644 index 0000000..5e42751 --- /dev/null +++ b/openspec/changes/deliver-july-iteration-confirmed-scope/specs/payment-dynamic-config/spec.md @@ -0,0 +1,46 @@ +## ADDED Requirements + +### Requirement: 按资产类型配置允许支付方式 +`system_config` SHALL 分别定义卡和设备允许的支付方式集合。两个配置首次初始化均 MUST 包含 `wallet`、`wechat`、`alipay`;管理员可独立取消或重新勾选任一方式,但每个集合至少保留一种合法方式。C 端 SHALL 返回配置集合与当前业务场景限制的交集,订单创建和支付准备 MUST 在后端再次校验;配置为空、包含未知值、重复值或无法读取时失败关闭,不得放开全部方式。 + +#### Scenario: 卡配置不允许微信支付 +- **WHEN** 卡允许支付方式不包含微信且客户端尝试创建微信支付订单 +- **THEN** 系统拒绝订单或支付准备请求并返回支付方式不可用错误 + +#### Scenario: 前端读取设备允许方式 +- **WHEN** C 端初始化设备购买流程 +- **THEN** 响应只返回设备配置允许的支付方式列表 + +#### Scenario: 管理员独立关闭钱包 +- **WHEN** 管理员在卡或设备支付配置中取消勾选钱包且仍保留至少一种其他方式 +- **THEN** 系统保存配置,后续 C 端购包响应不再返回钱包且后端拒绝钱包支付请求 + +#### Scenario: 管理员取消全部方式 +- **WHEN** 管理员尝试取消某类资产的全部支付方式 +- **THEN** 系统拒绝更新并保留原有效配置 + +### Requirement: 强充场景过滤钱包并保持自动购包 +系统判定当前资产仍命中既有强充规则时,C 端购包可选方式 MUST 在资产配置集合基础上剔除 `wallet`;后端创建订单时 MUST 执行相同校验。选择有效微信或支付宝后 SHALL 继续沿用充值入账和异步自动购包流程,不得降级为提示用户手工充值。 + +#### Scenario: 首次强充选择支付方式 +- **WHEN** 当前资产尚未触发对应系列的一次性佣金且本次购包命中强充规则 +- **THEN** 前端响应不包含钱包,微信或支付宝支付成功后系统自动完成充值入账和后续购包 + +#### Scenario: 绕过前端提交钱包强充 +- **WHEN** 客户绕过前端在强充购包请求中提交 `wallet` +- **THEN** 后端拒绝请求且不创建套餐订单、充值单或支付单 + +#### Scenario: 一次性佣金已经触发 +- **WHEN** 当前资产对应系列的一次性佣金已经触发 +- **THEN** 系统不再按该规则强充,购包可选方式重新按当前资产配置集合返回 + +### Requirement: 新订单选择方式并由待支付订单固化 +普通购买和从历史订单发起的续费 SHALL 在创建新订单前选择当前有效支付方式;新订单创建成功后 MUST 固化该方式,后续支付不得在原订单上切换。续费不是修改历史订单,历史订单和既有待支付订单均不得被覆盖支付方式。 + +#### Scenario: 从历史订单发起续费 +- **WHEN** 客户从历史已支付订单发起续费 +- **THEN** 客户可从当前有效集合重新选择方式并创建一张新订单 + +#### Scenario: 待支付订单尝试切换方式 +- **WHEN** 客户支付既有待支付订单时提交与订单保存值不同的方式 +- **THEN** 系统拒绝切换并保持订单原支付方式 diff --git a/openspec/changes/deliver-july-iteration-confirmed-scope/specs/refund-api/spec.md b/openspec/changes/deliver-july-iteration-confirmed-scope/specs/refund-api/spec.md new file mode 100644 index 0000000..4d1dd55 --- /dev/null +++ b/openspec/changes/deliver-july-iteration-confirmed-scope/specs/refund-api/spec.md @@ -0,0 +1,32 @@ +## ADDED Requirements + +### Requirement: 退款查询返回资产和提交人 +退款列表和详情 SHALL 返回提交人账号 ID、提交人名称及正确资产标识;设备退款必须返回设备标识而不是错误或空的卡标识。人员名称 MUST 批量查询,数据权限保持现有店铺层级规则。 + +#### Scenario: 查询设备退款 +- **WHEN** 有权限用户查询设备订单产生的退款 +- **THEN** 响应返回设备资产标识、提交人 ID 和提交人名称 + +### Requirement: 退款终态由企微审批驱动 +退款申请 SHALL 关联唯一通用审批实例;企微标准决策为 approved 时执行现有退款终结,rejected/cancelled/deleted 时按对应终态结束,不得由列表可见权限替代审批权限。 + +#### Scenario: 企微审批通过退款 +- **WHEN** 退款关联企微审批首次同步为 approved +- **THEN** 系统幂等执行退款通过后的订单、套餐、资产和佣金处理 + +#### Scenario: 重复同步审批终态 +- **WHEN** 同一 approved 决策被回调和轮询重复同步 +- **THEN** 退款、订单和资金副作用只执行一次 + +## REMOVED Requirements + +### Requirement: 审批通过接口 +**Reason**: 审批决定改由企业微信模板流程产生,系统内人工通过会绕过既定审批链。 + +**Migration**: 企微测试闭环可用后停用 `POST /api/admin/refunds/:id/approve`;存量退款在切换清单中按原 provider 完成,不伪造企微审批。 + +### Requirement: 审批拒绝接口 +**Reason**: 审批拒绝改由企业微信回调或轮询同步的标准决策驱动。 + +**Migration**: 企微测试闭环可用后停用 `POST /api/admin/refunds/:id/reject`,前端改为只读展示审批状态。 + diff --git a/openspec/changes/deliver-july-iteration-confirmed-scope/specs/shop-client-login-control/spec.md b/openspec/changes/deliver-july-iteration-confirmed-scope/specs/shop-client-login-control/spec.md new file mode 100644 index 0000000..45bc24b --- /dev/null +++ b/openspec/changes/deliver-july-iteration-confirmed-scope/specs/shop-client-login-control/spec.md @@ -0,0 +1,20 @@ +## ADDED Requirements + +### Requirement: 店铺可配置 C 端新登录限制 +系统 SHALL 为每个店铺保存 `client_login_disabled`,默认值 MUST 为 `false`;平台管理员可通过店铺管理接口修改并读取该字段,响应 MUST 使用 `{code,msg,data,timestamp}`,代理不得修改其他店铺配置。 + +#### Scenario: 管理员开启店铺登录限制 +- **WHEN** 平台管理员将某店铺 `client_login_disabled` 更新为 `true` +- **THEN** 系统保存配置并在店铺详情和列表中返回最新值 + +### Requirement: 受限店铺资产不得发起新登录 +C 端资产校验在签发短期资产令牌前 MUST 检查卡或设备所属店铺;受限店铺资产 MUST 返回统一禁止错误,未归属店铺的资产保持原行为,已有登录 Token 不因本开关被主动吊销。 + +#### Scenario: 受限店铺资产验证 +- **WHEN** 用户使用 `client_login_disabled=true` 店铺名下的卡或设备调用资产验证接口 +- **THEN** 系统返回禁止登录且不签发 asset token + +#### Scenario: 已登录用户保持会话 +- **WHEN** 店铺开启限制前用户已经取得有效 C 端 Token +- **THEN** 系统不因该配置主动吊销 Token,本需求只阻止后续新登录 + diff --git a/openspec/changes/deliver-july-iteration-confirmed-scope/specs/wecom-approval-integration/spec.md b/openspec/changes/deliver-july-iteration-confirmed-scope/specs/wecom-approval-integration/spec.md new file mode 100644 index 0000000..a93612b --- /dev/null +++ b/openspec/changes/deliver-july-iteration-confirmed-scope/specs/wecom-approval-integration/spec.md @@ -0,0 +1,60 @@ +## ADDED Requirements + +### Requirement: 管理员绑定系统账号与企微成员 +系统 SHALL 从已配置自建应用的可见通讯录分页查询成员,并允许管理员将系统账号绑定到 `(corp_id, userid)`;列表和详情 SHALL 返回 userid、姓名及绑定状态,手机号和邮箱不得作为绑定前置条件,不得提供用户扫码自助绑定流程。 + +#### Scenario: 从通讯录选择成员绑定账号 +- **WHEN** 管理员在账号列表为系统账号选择一个应用可见的企微成员 +- **THEN** 系统保存 corp_id、userid 和展示快照,后续审批可使用该 userid 作为申请人 + +#### Scenario: 绑定不可见成员 +- **WHEN** 管理员提交不在当前企微应用可见范围内的 userid +- **THEN** 系统返回参数或权限错误且不保存绑定 + +### Requirement: 业务场景绑定企微后台模板 +系统 SHALL 允许管理员配置业务类型、企微 `template_id` 和业务字段到控件 ID/类型/选项 key 的映射;模板必须已在企微后台创建,系统 MUST 通过模板详情接口校验映射,不得在本系统创建审批节点或审批人规则。 + +#### Scenario: 发布有效场景配置 +- **WHEN** 管理员提交的模板存在且所有必填业务字段均能映射到有效控件 +- **THEN** 系统启用该场景并保存模板结构校验结果 + +#### Scenario: 模板控件已经变化 +- **WHEN** 模板详情与已配置的控件 ID、类型或选项 key 不一致 +- **THEN** 系统阻止新审批提交并返回明确配置错误,不影响存量审批同步 + +### Requirement: 使用绑定成员发起模板审批 +退款和员工线下代充值 SHALL 通过现有 Approval Port 创建通用审批实例和提交 Outbox;企微 Adapter MUST 对已绑定的内部员工使用本人 userid,对代理等非企微账号使用应用已配置且当前可见的默认发起人 userid,并使用 template_id、`use_template_approver=1` 和映射后的控件值调用 `oa/applyevent`。成功后保存 `sp_no`,业务 Domain/Application 不得解析企微 DTO 或状态码;通用实例中的真实业务提交人不得被默认企微发起人覆盖。 + +#### Scenario: 成功发起审批 +- **WHEN** 提交人已绑定企微成员、场景有效且业务申请校验通过 +- **THEN** 系统原子保存业务单、通用审批实例和提交事件,并最终把企微 sp_no 关联到该实例 + +#### Scenario: 发起前置不完整 +- **WHEN** 内部提交人未绑定且应用未配置可用默认发起人、代理提交时默认发起人不可见、模板无效或 Adapter 未配置 +- **THEN** 系统在写入业务单、审批实例和 Outbox 前失败关闭 + +#### Scenario: 代理发起退款审批 +- **WHEN** 代理账号提交退款且应用已配置当前可见的默认发起人 +- **THEN** 企微审批以默认成员作为 creator_userid 发起,但本地业务申请和通用审批实例仍记录代理为真实提交人 + +#### Scenario: 提交结果未知 +- **WHEN** 调用企微超时且无法判断审批是否创建成功 +- **THEN** 系统标记结果未知并进入查询恢复,不得直接创建第二张审批单 + +### Requirement: 回调和轮询共同同步审批结果 +系统 MUST 验证并解密 `sys_approval_change` 回调、快速响应,再按 sp_no 获取审批详情并幂等翻译为标准决策;系统 MUST 周期性查询未终态审批并通过批量单号/详情接口补偿回调遗漏。 + +#### Scenario: 重复审批回调 +- **WHEN** 同一审批状态变化被企微重复推送 +- **THEN** 系统只产生一次对应标准终态和一次业务终态处理 + +#### Scenario: 回调丢失 +- **WHEN** 企微审批已终态但系统未收到回调 +- **THEN** 轮询任务通过审批详情发现终态并驱动相同的标准决策同步用例 + +### Requirement: 审批人员只做可选读取映射 +系统 SHALL 从已同步审批详情的流程节点取得审批人 userid,并批量映射系统账号;能映射时返回审批人 ID/名称,不能映射时返回空值,不得为展示字段实时逐条调用企微或建立本地审批人规则。 + +#### Scenario: 审批人已绑定系统账号 +- **WHEN** 列表关联的企微审批详情包含已绑定 userid +- **THEN** 列表返回对应系统账号 ID 和名称 diff --git a/openspec/changes/deliver-july-iteration-confirmed-scope/tasks.md b/openspec/changes/deliver-july-iteration-confirmed-scope/tasks.md new file mode 100644 index 0000000..6447484 --- /dev/null +++ b/openspec/changes/deliver-july-iteration-confirmed-scope/tasks.md @@ -0,0 +1,57 @@ +## 0. 需求证据与实施基线 + +- [x] 0.1 建立本 Change 的需求证据矩阵,逐项核对 #45、#46、#55、#60、#86、#38、#94、#96、#98、#43 已有接口和迁移,记录只需联调的证据;同时确认 #73 当前行业卡复机行为不改。【主:只读核验|边界:代码与契约证据|不迁移:任何业务模块】 + +## 1. 局部缺陷、字段与筛选 + +- [x] 1.1 修复 #189:沿现有换货迁移关系让原订单退款准确失效新资产上的对应套餐权益,不影响其他订单权益。【主:旧 Service|边界:单个退款后套餐失效路径|不迁移:换货、退款、套餐模块】 +- [x] 1.2 修复 #181:C 端订单使用既有 `purchase_role` 保存正确渠道,订单/退款列表与详情中卡返回 ICCID,设备按 VirtualNo 优先、IMEI 兜底返回资产标识。【主:旧 Service/Query|边界:字段来源与 DTO|不迁移:订单或退款模块】 +- [x] 1.3 完成 #182/#44 的退款、充值、换货提交人 ID/名称返回,使用批量账号查询避免 N+1。【主:Query/旧 Store|边界:人员字段投影|不迁移:审批模型或历史回填】 +- [x] 1.4 完成 #53 卡/设备实名筛选:卡按自身状态,设备以任意有效绑定卡已实名为已实名。【主:Query/旧 Store|边界:实时 EXISTS 查询|不新建:投影、Worker、事件消费者】 +- [x] 1.5 完成 #57:在现有换货创建入口以当前未终结退款状态拦截并返回“该资产存在退款申请”。【主:旧 Service|边界:创建前单次校验|不依赖:企微 Adapter】 +- [x] 1.6 为 1.1~1.5 更新 DTO description、错误码、中文注释和相关 API 契约;未新增 Handler,不改两个文档生成器装配文件。【主:API 契约|不扩展:其他字段】 + +## 2. 店铺、实名、套餐与支付配置 + +- [x] 2.1 交付 #41 店铺 C 端登录限制纵向切片:增量迁移 `client_login_disabled`,复用店铺管理更新/查询,在 `VerifyAsset` 签发令牌前拦截;默认允许登录且不吊销已有 Token。【主:简单写 + 旧 Service|不迁移:认证和代理权限体系】 +- [x] 2.2 交付 #62 实名策略纵向切片:保持三种枚举和单资产 PATCH,补齐卡/设备批量接口(最多 500、事务全成全败)及 C 端 `effective_realname_policy` 等字段。【主:简单写/Query|不迁移:资产模块】 +- [x] 2.3 交付 #40 下架套餐续费:普通可购列表排除下架套餐,当前使用者通过资产/历史订单稳定引用调用现有下单接口创建新订单并按当前套餐配置购买,代理代购/新客户仍拒绝;既有待支付订单保持原支付方式。【主:旧 Service/Query|不新增:续费接口|不迁移:套餐、订单模块】 +- [x] 2.4 交付 #48 支付方式配置:在 `system_config` 为卡/设备分别初始化全选 `wallet/wechat/alipay` 且支持三项独立启停、至少保留一项;C 端返回配置与业务场景交集,强充和普通充值剔除钱包,订单/支付准备后端强校验,强充继续自动购包,并修复 C 端强充资格遗漏已触发状态的问题;增量更新审计覆盖基线。【主:简单写 + Query|辅:既有 system_config/强充自动购包|仅现有能力无法承载时做最小新增】 + +## 3. 站内通知接线 + +- [x] 3.1 交付 #188:创建物流换货单成功后通过现有 Outbox/站内通知向关联个人客户发送一次 C 端弹窗提醒。【主:Application + 既有通知 Adapter|不建设:营销投放、地址表单、ERP】 +- [x] 3.2 交付 #97:主钱包余额首次跌破固定 100 元时通知店铺业务员,阈值以下不重复、恢复后可再次触发。【主:Application + Outbox|辅:既有钱包事实|不增加:可配置阈值】 +- [x] 3.3 交付 #33:每日识别 15/7/3 天节点,提供后台/代理分页临期列表、数量/高亮/优先标识,并向 C 端发送节点站内通知。【主:Application/Query + 既有通知|不建设:企微业务员提醒或营销平台】 + +## 4. 企业微信最小审批 Adapter + +- [x] 4.1 交付企微应用连接纵向切片:安全配置 corp_id/agent_id/Secret/回调凭据,按应用缓存 access_token、短锁防并发回源,并记录外部调用 Integration Log。【主:Infrastructure Adapter|不迁移:通用审批核心】 +- [x] 4.2 交付通讯录与账号绑定纵向切片:分页拉取/同步应用可见成员,账号列表/详情返回绑定字段,管理员从候选成员绑定 `(corp_id,userid)`;不做扫码和手机号自动匹配。【主:简单写 + Infrastructure/Query|不建设:组织模型】 +- [x] 4.3 交付业务场景与模板纵向切片:保存业务类型、template_id、控件/选项映射,调用模板详情校验并阻断失效配置;更新审计覆盖基线。【主:简单写 + Infrastructure Adapter|不建设:模板创建、审批节点、审批人规则】 +- [x] 4.4 实现 WeCom Adapter 对现有 Approval Port 的准备与提交:内部员工使用有效绑定 userid,代理等非企微账号使用应用默认发起人,按后台模板流程组装控件、必要时上传附件、调用 applyevent、保存 sp_no,并正确处理提交失败/结果未知;真实业务提交人保持不变。【主:Infrastructure Adapter + Outbox 消费|不修改:资金领域规则】 +- [x] 4.5 交付加密回调纵向切片:GET 验证、POST 验签解密、receiveid 校验、事件幂等、快速响应并异步拉取详情,将企微状态翻译为现有标准决策;新增 Handler 时同步两个文档生成器。【主:Infrastructure Adapter + Application|不直接执行:退款/充值终态】 +- [x] 4.6 交付主动恢复与读取投影:批量单号时间窗补偿、未终态详情轮询、结果未知恢复、审批节点 userid 批量映射系统账号。【主:Infrastructure Worker + Query|不实时逐行调用企微】 + +## 5. 退款与员工线下代充接入企微 + +- [x] 5.1 交付 #34 员工线下代充值纵向切片:业务申请、真实提交人、金额/凭证快照、通用审批实例和提交 Outbox 同事务创建;approved 通过既有钱包用例幂等入账,其他终态不入账。【主:复杂写 Application/Domain|辅:Approval Port|不实现:代理在线扫码充值】 +- [x] 5.2 交付 #35 退款企微审批纵向切片:现有退款申请关联唯一通用实例,标准终态驱动既有退款处理,确保换货迁移套餐、订单、资产和佣金副作用幂等。【主:复杂写 Application/Domain|辅:Approval Port|不实现:原路退款】 +- [x] 5.3 完成退款 approve/reject 和线下充值 offline-pay 旧入口的可控停用代码及存量 provider 切换清单;默认保留旧入口,待后续真实联调验收通过后再由发布配置停用,前端接口改为只读审批状态。【主:API 切换/迁移|不删除:历史业务事实】 + +## 6. 批量、导出与 Gateway + +- [x] 6.1 交付 #36 单列 CSV 批量订购纵向切片:复用现有上传/任务五态/对象存储,整批选择一个套餐和支付方式,不选择代理;逐行复用订单资格、价格、支付和幂等规则。【主:Infrastructure + 旧订单 Service|不新建:批量任务平台】 +- [x] 6.2 交付 #42 lot 卡与套餐两个导出 datasource,按现有筛选、权限和字段来源输出。【主:Query/既有导出 Infrastructure|不建设:统一字段权限平台】 +- [x] 6.3 交付 #42 钱包流水与代理充值两个导出 datasource,金额使用既有事实和稳定格式,代理数据权限正确。【主:Query/既有导出 Infrastructure|不修改:钱包流水】 +- [x] 6.4 交付 #42 退款与换货两个导出 datasource,复用本 Change 已补齐的资产和提交人字段。【主:Query/既有导出 Infrastructure|不依赖:企微实时详情】 +- [x] 6.5 交付 #47 IoT 卡固定档位限速纵向切片:定义中文注释常量、后台权限和卡 ICCID 解析,调用 Gateway 并记录操作者与 Integration Log;移除错误的设备限速入口,不通过设备绑定关系间接限速。【主:旧 Service + Gateway Adapter|不实现:设备限速或自动限速算法】 +- [x] 6.6 交付 #49 CSV 批量分配设备纵向切片:复用现有导入任务,单列设备标识,整批选择目标代理或套餐系列,沿用现有分配/系列绑定权限和幂等规则。【主:Infrastructure + 旧设备 Service|不新建:导入平台】 + +## 7. 文档与联调交付 + +- [x] 7.1 补齐所有新增/修改 API 的 DTO description、枚举名称字段、中文错误码、路由注释和统一响应示例;新增 Handler 同步 `cmd/api/docs.go`、`cmd/gendocs/main.go`,但本次不执行 OpenAPI 生成。【主:API 契约】 +- [x] 7.2 增量维护 `.scratch/tech-global-audit/审计覆盖基线.md`,逐项登记 Audit Event、Domain Ledger、Integration Log、Outbox 或 N/A 理由。【主:治理门禁】 +- [x] 7.3 对全部变更生产代码执行 `gofmt`、只读一致性检查和 `git diff --check`,记录未执行测试、构建、LSP、迁移和 OpenAPI 生成的验证缺口。【主:静态收口】 +- [x] 7.4 准备企微、Gateway、Redis/Asynq、对象存储和前端联调所需配置、接口说明、已知限制及回滚步骤;本次不执行真实环境闭环。【主:联调交付】 +- [x] 7.5 更新实施证据和任务状态,保留 `complete-july-iteration-test-release` 的历史完成证据;严格校验、真实联调验收和归档由后续流程处理。【主:OpenSpec 收口】 diff --git a/openspec/changes/deliver-july-iteration-confirmed-scope/实施证据.md b/openspec/changes/deliver-july-iteration-confirmed-scope/实施证据.md new file mode 100644 index 0000000..eb10e14 --- /dev/null +++ b/openspec/changes/deliver-july-iteration-confirmed-scope/实施证据.md @@ -0,0 +1,77 @@ +# 七月迭代已完成范围实施证据 + +> 核验日期:2026-07-25 +> 范围:OpenSpec `deliver-july-iteration-confirmed-scope` 任务 0.1~7.5。首个矩阵保留 0.1 对历史已完成范围的只读证据,后续章节记录本 Change 的生产代码、迁移、契约和静态收口证据。 + +## 需求证据矩阵 + +| 需求 | 已有实现与接口证据 | 迁移证据 | 自动化验证证据 | 结论与联调边界 | +| --- | --- | --- | --- | --- | +| #45 换货新旧资产展示与独立搜索 | `internal/service/exchange/asset_snapshot.go`、`internal/query/exchange/list.go`、`internal/handler/admin/exchange.go`;`GET /api/admin/exchanges` 支持 `old_asset_keyword` 和 `new_asset_keyword` | N/A:不新增表或字段 | `internal/service/exchange/asset_snapshot_test.go`、`internal/query/exchange/list_test.go`、`internal/handler/admin/exchange_test.go` | 后端快照与搜索已交付;前端页面与浏览器人工验收属联调。 | +| #46 资产预计最终到期 | `internal/query/packageexpiry/query.go`;后台解析、C 端资产信息和资产列表均返回预计到期投影 | `migrations/000164_add_package_expiry_query_indexes.*.sql` | `internal/query/packageexpiry/query_test.go`、`internal/routes/asset_exchange_trace_integration_test.go` | Query、HTTP 字段与索引已交付;页面展示为联调范围。 | +| #55 套餐分配生效条件 | `internal/domain/package/terms.go`、`internal/service/package/`;分配、系列授权和单条覆盖写入购买快照 | `migrations/000162_add_expiry_base_override_to_shop_package_allocation.*.sql`、`000163_add_package_usage_terms_snapshots.*.sql` | `internal/domain/package/terms_test.go`、`internal/store/postgres/package_terms_snapshot_integration_test.go`、`internal/service/package/activation_terms_integration_test.go`、`internal/handler/admin/shop_package_expiry_base_integration_test.go` | 快照写入、历史回退与异常拒绝已交付;前端选项联调待同窗验收。 | +| #60 店铺联系电话搜索 | `internal/service/shop/service.go`、`internal/query/shop/business_owner.go`;`GET /api/admin/shops?contact_phone=` 精确匹配 | `migrations/000160_add_shop_contact_phone_index.*.sql` | `internal/routes/shop_integration_test.go` 覆盖参数、分页、权限、精确匹配、店铺范围和索引 | 后端与索引已交付;筛选页面及四类账号人工验收为联调范围。 | +| #86 资产前代/后代换货标识 | `internal/query/asset/exchange_trace.go`、`internal/routes/asset.go`;资产解析返回 `exchange_trace` | `migrations/000161_add_exchange_trace_indexes.*.sql` | `internal/query/asset/exchange_trace_test.go`、`internal/routes/asset_exchange_trace_integration_test.go` | 单节点前/后代投影与可见性已交付;前端跳转和历史异常抽样为联调/发布验收。 | +| #38 代理主钱包信用额度 | `internal/domain/wallet/`、`internal/application/wallet/`、`internal/application/role/default_credit.go`、`internal/query/shop/fund_summary.go`;角色默认额度、店铺调额、资金概况和开放余额接口已装配 | `migrations/000171`~`000177` | 本次目标包编译通过;资金迁移异常查询、并发与真实 PostgreSQL/Redis/Asynq 验收在原总 Change 中明确延期 | 代码与接口已交付;真实资金并发和迁移数据门禁属测试环境验收。 | +| #94 状态同步与运营商回调 | `internal/application/cardobservation/`、`internal/infrastructure/cardobservation/`、`internal/handler/callback/{ctcc,cmcc,cucc}_realname.go`;公共 `ApplyCardObservation`、观测序列和四类回调已装配 | `migrations/000178`~`000181` | 本次目标包编译通过;原总结明确真实运营商样例、Gateway、19/20 位命中和迁移重放延期 | 后端代码与测试环境装配已完成;不宣称生产验收完成,只剩真实外部环境联调门禁。 | +| #96 店铺业务员归属 | `internal/query/shop/business_owner.go`、`internal/infrastructure/shop/recipient_resolver.go`;店铺创建/更新、列表/详情/候选人与通知接收人解析已装配 | `migrations/000169_add_shop_business_owner.*.sql` | 本次目标包编译通过;原总结明确 HTTP/迁移与真实数据库验收延期 | 后端纵向切片已交付;真实数据库和前端人工验收为联调范围。 | +| #98 换货新资产继承旧店铺 | `internal/service/exchange/service.go` 在完成换货时拒绝其他店铺资产,并让平台库存新资产继承旧资产 `shop_id`;`internal/service/exchange/migration.go` 沿用新归属写租户标签 | N/A:复用现有资产与换货表 | `go test ./internal/service/exchange` 通过;当前没有以 UR#98 命名的专项自动化用例 | 写侧实现已存在;平台库存、他店资产拒绝、完成换货和历史可见性需按 INT-02 联调验收。 | +| #43 系列套餐批量授权 | `internal/service/shop_series_grant/service.go`、`internal/routes/shop_series_grant.go`;复用创建、详情、更新和套餐管理接口,1~100 项在单事务内校验和写入 | N/A:复用现有授权模型 | `internal/handler/admin/shop_package_expiry_base_integration_test.go` 只覆盖系列授权写入的生效条件;`internal/service/shop_series_grant` 当前无专项测试 | 后端批量命令已交付;权限、价格、`remove`、原子性和真实并发验收仍是明确的发布验收缺口。 | +| #73 行业卡复机行为冻结 | `internal/service/iot_card/stop_resume_service.go` 明确保持 `card_category=industry` 无需实名即可进入复机判定;本 Change 不改写该规则 | N/A | `internal/service/iot_card/stop_resume_service_test.go` 当前只覆盖风险 Gateway 扩展判定,没有直接锁定行业卡复机的特征测试 | 代码行为已确认且本任务不修改;直接回归测试为已记录的后续风险。 | + +## 2026-07-25 目标验证记录 + +- 执行:`go test ./internal/service/exchange ./internal/query/exchange ./internal/query/asset ./internal/query/packageexpiry ./internal/service/package ./internal/routes ./internal/handler/admin ./internal/domain/wallet ./internal/application/wallet ./internal/query/shop ./internal/infrastructure/shop ./internal/service/iot_card ./internal/application/cardobservation ./internal/handler/callback` +- 结果:全部退出码为 0;有测试的包全部 PASS,无测试文件的包完成编译检查。 +- 诊断:对上表核心 Go 文件执行 `gopls check`,退出码为 0,无诊断输出。首次沙箱内执行因 Go 缓存目录不可写而无法加载 workspace,改为允许缓存写入后重跑成功。 + +## 未扩展的范围 + +- 本任务未修改换货、套餐、钱包、卡状态、店铺或授权业务代码。 +- #45、#46、#55、#60、#86 具备完整自动化证据,可归为只剩联调/人工验收。 +- #38、#43、#94、#96 有后端实现与迁移,但专项自动化或真实环境验证明确延期;#98 有实现但无归属继承专项测试。这些不得误写为“自动化/生产验收已完成”。 + +## 2026-07-25 局部缺陷、字段与筛选实现记录 + +> 用户明确要求本 Change 不编写或补齐测试代码、不运行测试,只完成生产代码至可联调状态;实现优先修改和复用现有能力,确实无法承载时才做最小新增。以下记录生产代码证据,不宣称自动化验证通过。 + +| 任务 | 生产代码证据 | 当前结论 | 未执行验证 | +| --- | --- | --- | --- | +| 1.1 #189 | `internal/service/package/activation_service.go` 按退款订单与套餐使用记录定位权益,不再用旧资产 ID 缩小查询范围 | 换货迁移后仍可失效原订单对应权益,其他订单权益不受影响 | 退款/换货目标测试、LSP 诊断 | +| 1.2 #181 | `internal/service/client_order/service.go` 写入既有 `purchase_role=self_purchase`;卡快照写 ICCID,设备按 VirtualNo→IMEI;`internal/service/order/service.go` 同步按 ID 下单设备快照顺序 | 未新增渠道字段,不读取兼容或回填历史空值,只保证新订单正确 | 订单/退款 HTTP 与 DB 测试、LSP 诊断 | +| 1.3 #182/#44 | 退款与换货以 `creator`、充值以 `user_id` 投影统一的 `submitter_id/submitter_name`;`GetDisplayAccountsByIDs` 批量读取账号展示名并支持软删除历史账号 | 列表无 N+1;保留既有 `creator` 字段 | 三类列表/详情权限与响应测试、LSP 诊断 | +| 1.4 #53 | 卡列表直接过滤 `tb_iot_card.real_name_status`;设备列表用有效绑定关系与卡实名状态的实时 `EXISTS/NOT EXISTS` | 无投影、Worker、事件消费者 | 分页、权限、多卡场景测试、LSP 诊断 | +| 1.5 #57 | `RefundStore.HasUnfinishedByAsset` 拦截待审批、已退回、已通过但资产处理未完成的退款;换货创建前失败关闭;新增错误码 1207 | 已拒绝或 `asset_reset=true` 的已完成退款放行,错误消息为“该资产存在退款申请” | 换货创建 HTTP/DB 测试、LSP 诊断 | +| 1.6 API 契约 | 更新订单/退款资产标识说明、实名筛选 DTO、提交人 DTO 和中文错误码;未新增 Handler | 相关 DTO 已进入生成后的 OpenAPI,无需新增 Handler 装配 | 契约测试、LSP 诊断 | + +## 2026-07-25 完整交付矩阵 + +| 任务 | 主要代码、迁移与文档证据 | 当前结论 | 后续验证边界 | +| --- | --- | --- | --- | +| 2.1 店铺登录限制 | `migrations/000182_*`、`internal/application/shop/update.go`、`internal/service/client_auth/service.go`、店铺 DTO | 店铺配置默认允许登录;开启后仅阻止新资产登录,不吊销已有 Token;测试库结构已落库 | 四类账号权限和 C 端登录联调 | +| 2.2 实名策略 | `internal/service/iot_card/realname_policy_batch.go`、`internal/service/device/realname_policy_batch.go`、C 端资产 DTO | 三种策略、单资产与最多 500 条批量全成全败、设备策略优先生效均已接线 | HTTP/DB 事务和设备多卡场景联调 | +| 2.3 下架套餐续费 | `internal/service/purchase_validation/service.go`、`internal/service/client_order/service.go`、订单 DTO | 普通列表排除下架套餐;当前使用者可用现有下单入口创建新续费订单,历史订单不修改 | 当前使用者、代理代购、新客户与待支付订单联调 | +| 2.4 支付方式配置 | `internal/bootstrap/payment_method_config.go`、`internal/service/paymentmethod/`、系统配置和 C 端订单/充值接线 | 卡、设备分别维护非空 `wallet/wechat/alipay` 集合;强充过滤钱包且后端复核 | 配置更新、缓存失败关闭、三种支付渠道真实联调 | +| 3.1~3.3 站内通知 | `internal/application/exchange/`、`internal/infrastructure/exchange/`、`internal/infrastructure/wallet/debit_consumer.go`、`internal/application/packageexpiry/`、`migrations/000183_*` | 换货提醒、主钱包首次跌破 100 元提醒、套餐 15/7/3 天提醒均复用现有通知和可靠事件 | Scheduler、重复消费、接收人和 C 端弹窗联调 | +| 4.1~4.6 企微 Adapter | `migrations/000184_*`~`000189_*`、`internal/application/wecom/`、`internal/infrastructure/wecom/`、企微 Handler/Route/Worker、`docs/wecom-application-connection/功能总结.md` | 应用连接、可见成员、账号绑定、模板映射、默认发起人、提交、加密回调、详情同步和主动恢复已装配 | 可信 IP、真实模板、回调公网、附件、结果未知和轮询闭环 | +| 5.1 员工线下代充值 | `migrations/000190_*`、`internal/application/agentrecharge/`、充值 Service/DTO | 真实提交人、明文业务快照、通用审批实例和提交 Outbox 同事务创建;approved 幂等入主钱包 | 真实企微终态、钱包流水和重复决策联调 | +| 5.2 退款企微审批 | `migrations/000191_*`、`internal/application/refundapproval/`、`internal/service/refund/approval_decision.go` | 唯一通用实例和企微标准终态驱动既有退款处理,钱包、佣金、套餐和换货迁移副作用保持幂等 | 全退款/部分退款、换货后退款和重复终态联调 | +| 5.3 旧入口切换 | `pkg/config/config.go`、`docs/wecom-application-connection/存量审批切换清单.md` | 旧入口默认开启;新企微记录禁止旧入口绕过,关闭后前端只读审批状态 | 存量 provider 清单和生产切换审批 | +| 6.1 批量订购 | `migrations/000192_*`、`internal/service/asset_package_batch_order/`、Handler/Route/Worker、`docs/asset-package-batch-order/功能总结.md` | 单列 CSV、统一套餐和支付方式、逐行复用订单/钱包幂等规则 | 对象存储、Worker、部分成功、钱包扣款和线下凭证联调 | +| 6.2~6.4 六类导出 | `internal/exporter/{iot_card,package,agent_wallet_transaction,agent_recharge,refund,exchange}_scene.go`、`docs/business-data-export/功能总结.md` | 六个 datasource 已注册,复用现有导出任务、筛选和数据权限 | 大数据量、文件下载、金额格式和各账号权限联调 | +| 6.5 卡固定档位限速 | `internal/service/iot_card/speed_tier.go`、Gateway flow-card、卡 Handler/Route、`docs/iot-card-speed-tier/功能总结.md` | 仅卡 ICCID 支持 `-1..8` 固定档位;无设备入口;超时记 unknown 且不盲目重试 | Gateway 全档位、明确失败、超时和人工核对 | +| 6.6 设备 CSV 批量分配 | `migrations/000193_*`、`internal/task/device_batch_allocation.go`、设备导入任务扩展、`docs/device-batch-allocation/功能总结.md` | 只复用导入任务外壳;`assign_shop/assign_series` 独立分支复用现有权限和业务方法 | CSV、对象存储、Worker 中断恢复和重复消费联调 | +| 7.1 API 契约 | DTO description/enum/status_name、路由注释、两个文档生成器和 `docs/admin-openapi.yaml` | 生成器执行成功;新接口和 DTO 存在,旧设备限速契约不存在 | 前端契约联调 | +| 7.2 审计基线 | `.scratch/tech-global-audit/审计覆盖基线.md` | 0.1~7.5 均有 Audit Event、Domain Ledger、Integration Log、Outbox 决定或 N/A 理由 | 后续生产治理评审 | +| 7.3 静态收口 | 本文“静态收口记录” | `gofmt` 和 `git diff --check` 通过;禁用模式未发现新增违规;测试库迁移已验证至 `000194` | 测试、完整构建和 LSP 未执行 | +| 7.4 联调交付 | `docs/7月迭代/七月迭代联调交付说明.md`、`docs/environment-variables.md` | 配置、接口、限制和回滚步骤齐备 | 真实环境闭环由后续流程执行 | + +## 2026-07-25 静态收口记录 + +- 对当前工作树 169 个新增或修改的非测试 Go 文件执行 `gofmt -w`,随后 `gofmt -d` 无差异。 +- 执行 `git diff --check`,退出码为 0。 +- 对新增行检查 `fmt.Errorf`、裸 `go func` 和 `database/sql`:仅 Worker 定时任务注册的启动期错误使用 `fmt.Errorf`;没有新增裸 goroutine,也没有新增 `database/sql` 依赖。模型中的 `database/sql/driver` 是既有 `driver.Valuer` 实现,不是直接数据库访问。 +- 检查生产路由、Handler、Service、Gateway、常量及生成后的 OpenAPI:仅保留 `PUT /api/admin/iot-cards/{iccid}/speed-tier` 卡 ICCID 限速;不存在设备限速路由、DTO 或通过设备绑定卡间接限速的入口。 +- 根据用户后续明确要求,已执行 `env GOCACHE=/private/tmp/junhong-go-build-cache go run cmd/gendocs/main.go`,生成成功;已确认卡限速与设备批量分配路径和 DTO 存在,旧设备限速契约不存在。因此“未执行 OpenAPI 生成”不再是验证缺口。 +- 根据用户后续明确要求,已校准测试库迁移元数据并执行 `000187`~`000194`;MCP 复核确认目标表列数、表注释、字段注释、增量字段、索引和约束均无缺口,最终迁移版本为干净的 `194`。 +- 本 Change 仍未执行单元/集成/验收测试、`go test`、完整 `go build`、LSP 诊断和真实企业微信/Gateway 联调。OpenAPI 生成器依赖树编译成功不能替代完整构建结论。 diff --git a/pkg/config/config.go b/pkg/config/config.go index fa5d888..c852387 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -30,6 +30,8 @@ type Config struct { PollingAutoTrigger PollingAutoTriggerConfig `mapstructure:"polling_auto_trigger"` Polling PollingConfig `mapstructure:"polling"` Worker WorkerConfig `mapstructure:"worker"` + WeCom WeComConfig `mapstructure:"wecom"` + Approval ApprovalConfig `mapstructure:"approval"` } // ServerConfig HTTP 服务器配置 @@ -168,6 +170,19 @@ type WorkerConfig struct { InstanceName string `mapstructure:"instance_name"` // Worker 实例名称,用于多实例日志区分 } +// ApprovalConfig 审批新旧入口切换配置。 +type ApprovalConfig struct { + LegacyRefundManualEnabled bool `mapstructure:"legacy_refund_manual_enabled"` // 是否保留退款人工通过与拒绝入口 + LegacyOfflineRechargePayEnabled bool `mapstructure:"legacy_offline_recharge_pay_enabled"` // 是否保留线下充值人工确认入口 +} + +// WeComConfig 企业微信 Adapter 运行配置。 +type WeComConfig struct { + BaseURL string `mapstructure:"base_url"` // 企业微信 API 基础地址 + Timeout time.Duration `mapstructure:"timeout"` // 外部请求超时时间 + CredentialEncryptionKey string `mapstructure:"credential_encryption_key"` // 数据库凭证 AES-256-GCM 加密密钥(Base64) +} + // PollingConfig 轮询通用配置 type PollingConfig struct { // VerboseLog 开启后,所有轮询 handler 在成功获取上游数据时以 Info 级别输出详细日志 diff --git a/pkg/config/defaults/config.yaml b/pkg/config/defaults/config.yaml index 2ea2990..969d491 100644 --- a/pkg/config/defaults/config.yaml +++ b/pkg/config/defaults/config.yaml @@ -137,3 +137,14 @@ polling_auto_trigger: worker: role: "all" instance_name: "" + +# 审批新旧入口切换配置 +approval: + legacy_refund_manual_enabled: true + legacy_offline_recharge_pay_enabled: true + +# 企业微信 Adapter 配置 +wecom: + base_url: "https://qyapi.weixin.qq.com" + timeout: "10s" + credential_encryption_key: "" # 启用企微前必填:JUNHONG_WECOM_CREDENTIAL_ENCRYPTION_KEY(32 字节 Base64) diff --git a/pkg/config/loader.go b/pkg/config/loader.go index 25f32a7..5684c30 100644 --- a/pkg/config/loader.go +++ b/pkg/config/loader.go @@ -130,6 +130,11 @@ func bindEnvVariables(v *viper.Viper) { "polling_auto_trigger.auto_trigger_system_user_id", "worker.role", "worker.instance_name", + "approval.legacy_refund_manual_enabled", + "approval.legacy_offline_recharge_pay_enabled", + "wecom.base_url", + "wecom.timeout", + "wecom.credential_encryption_key", "wechat.official_account.app_id", "wechat.official_account.app_secret", "wechat.official_account.token", diff --git a/pkg/constants/approval.go b/pkg/constants/approval.go index ff73809..11d3953 100644 --- a/pkg/constants/approval.go +++ b/pkg/constants/approval.go @@ -2,6 +2,54 @@ package constants import "time" +const ( + // ApprovalBusinessTypeRefund 表示退款审批业务场景。 + ApprovalBusinessTypeRefund = "refund_approval" + // ApprovalBusinessTypeOfflineRecharge 表示员工线下代充值审批业务场景。 + ApprovalBusinessTypeOfflineRecharge = "offline_recharge_approval" +) + +const ( + // ApprovalFieldRechargeNo 表示线下代充值单号业务字段。 + ApprovalFieldRechargeNo = "recharge_no" + // ApprovalFieldShopID 表示线下代充值目标店铺 ID 业务字段。 + ApprovalFieldShopID = "shop_id" + // ApprovalFieldShopName 表示线下代充值目标店铺名称业务字段。 + ApprovalFieldShopName = "shop_name" + // ApprovalFieldAmount 表示线下代充值元金额展示业务字段。 + ApprovalFieldAmount = "amount" + // ApprovalFieldAmountCent 表示线下代充值分金额业务字段。 + ApprovalFieldAmountCent = "amount_cent" + // ApprovalFieldPaymentVoucherKey 表示线下代充值凭证对象存储 Key 列表业务字段。 + ApprovalFieldPaymentVoucherKey = "payment_voucher_key" + // ApprovalFieldRemark 表示线下代充值运营备注业务字段。 + ApprovalFieldRemark = "remark" + // ApprovalFieldSubmitterID 表示真实业务提交人账号 ID 业务字段。 + ApprovalFieldSubmitterID = "submitter_id" + // ApprovalFieldSubmitterName 表示真实业务提交人账号名称业务字段。 + ApprovalFieldSubmitterName = "submitter_name" + // ApprovalFieldRefundNo 表示退款单号业务字段。 + ApprovalFieldRefundNo = "refund_no" + // ApprovalFieldOrderID 表示退款关联订单 ID 业务字段。 + ApprovalFieldOrderID = "order_id" + // ApprovalFieldOrderNo 表示退款关联订单号业务字段。 + ApprovalFieldOrderNo = "order_no" + // ApprovalFieldAssetIdentifier 表示退款资产标识业务字段。 + ApprovalFieldAssetIdentifier = "asset_identifier" + // ApprovalFieldAssetType 表示退款资产类型业务字段。 + ApprovalFieldAssetType = "asset_type" + // ApprovalFieldActualReceivedAmount 表示退款实收金额业务字段。 + ApprovalFieldActualReceivedAmount = "actual_received_amount" + // ApprovalFieldRequestedRefundAmount 表示申请退款金额业务字段。 + ApprovalFieldRequestedRefundAmount = "requested_refund_amount" + // ApprovalFieldRefundVoucherKey 表示退款凭证对象存储 Key 列表业务字段。 + ApprovalFieldRefundVoucherKey = "refund_voucher_key" + // ApprovalFieldRefundReason 表示退款原因业务字段。 + ApprovalFieldRefundReason = "refund_reason" + // ApprovalFieldPackageUsageID 表示退款指定套餐使用记录 ID 业务字段。 + ApprovalFieldPackageUsageID = "package_usage_id" +) + const ( // ApprovalStatusSubmitting 表示审批申请等待渠道提交。 ApprovalStatusSubmitting = 0 diff --git a/pkg/constants/asset_audit.go b/pkg/constants/asset_audit.go index 4964ace..6bf3d8f 100644 --- a/pkg/constants/asset_audit.go +++ b/pkg/constants/asset_audit.go @@ -38,7 +38,6 @@ const ( AssetAuditOpDeviceUnbindCard = "device_unbind_card" // 设备解绑卡 AssetAuditOpDeviceStop = "device_stop" // 设备停机 AssetAuditOpDeviceStart = "device_start" // 设备复机 - AssetAuditOpDeviceSpeedLimit = "device_speed_limit" // 设备限速 AssetAuditOpDeviceSetWiFi = "device_set_wifi" // 设备 WiFi 设置 AssetAuditOpDeviceSwitchCard = "device_switch_card" // 设备切卡 AssetAuditOpDeviceSwitchMode = "device_switch_mode" // 设备切卡模式 @@ -49,7 +48,9 @@ const ( AssetAuditOpAssetRealnamePolicy = "asset_realname_policy" // 统一入口实名策略更新 AssetAuditOpAssetPackageExpiresAt = "asset_package_expires_at" // 资产套餐过期时间更新 AssetAuditOpAssetPackageUsage = "asset_package_usage" // 资产套餐已用量更新 + AssetAuditOpCardSpeedTier = "card_speed_tier" // IoT 卡固定限速档位设置 AssetAuditOpDeviceImportTaskCreate = "device_import_task_create" // 设备导入任务创建 + AssetAuditOpDeviceBatchTaskCreate = "device_batch_task_create" // 设备CSV批量分配任务创建 AssetAuditOpIotCardImportTaskCreate = "iot_card_import_task_create" // 卡导入任务创建 ) diff --git a/pkg/constants/asset_package_batch_order.go b/pkg/constants/asset_package_batch_order.go new file mode 100644 index 0000000..b646b03 --- /dev/null +++ b/pkg/constants/asset_package_batch_order.go @@ -0,0 +1,32 @@ +package constants + +import "time" + +const ( + // StoragePurposeAssetPackageBatchOrder 表示资产套餐批量订购 CSV 上传用途。 + StoragePurposeAssetPackageBatchOrder = "batch_purchase" + // AssetPackageBatchOrderStoragePrefix 表示资产套餐批量订购文件的对象存储目录。 + AssetPackageBatchOrderStoragePrefix = "batch-purchases" + // AssetPackageBatchOrderMaxRows 表示单个批量订购 CSV 最大业务行数。 + AssetPackageBatchOrderMaxRows = 1000 + // AssetPackageBatchOrderMaxFileSize 表示单个批量订购 CSV 最大字节数(10MB)。 + AssetPackageBatchOrderMaxFileSize int64 = 10 * 1024 * 1024 + // AssetPackageBatchOrderTaskTimeout 表示单个批量订购任务的最长执行时间。 + AssetPackageBatchOrderTaskTimeout = 2 * time.Hour + // AssetPackageBatchOrderItemStatusSuccess 表示批量订购单行已成功创建订单。 + AssetPackageBatchOrderItemStatusSuccess = 3 + // AssetPackageBatchOrderItemStatusFailed 表示批量订购单行已失败并记录原因。 + AssetPackageBatchOrderItemStatusFailed = 4 +) + +// GetAssetPackageBatchOrderItemStatusName 返回批量订购单行状态中文名称。 +func GetAssetPackageBatchOrderItemStatusName(status int) string { + switch status { + case AssetPackageBatchOrderItemStatusSuccess: + return "成功" + case AssetPackageBatchOrderItemStatusFailed: + return "失败" + default: + return "未知" + } +} diff --git a/pkg/constants/card_observation.go b/pkg/constants/card_observation.go index 9a39fa8..1aa45a4 100644 --- a/pkg/constants/card_observation.go +++ b/pkg/constants/card_observation.go @@ -69,8 +69,59 @@ const ( IntegrationOperationGatewayTraffic = "query_flow" // 查询流量 IntegrationOperationGatewayNetwork = "query_card_status" // 查询网络状态 IntegrationOperationGatewayDeviceInfo = "query_device_info" // 查询设备信息 + IntegrationOperationGatewaySpeedTier = "set_speed_tier" // 设置或恢复固定限速档位 ) +// Gateway 固定限速档位编码。 +const ( + GatewaySpeedTierRestore = -1 // 恢复不限速 + GatewaySpeedTierZero = 0 // 限速为 0kbps + GatewaySpeedTier128K = 1 // 限速为 128Kbps + GatewaySpeedTier512K = 2 // 限速为 512Kbps + GatewaySpeedTier1M = 3 // 限速为 1Mbps + GatewaySpeedTier2M = 4 // 限速为 2Mbps + GatewaySpeedTier10M = 5 // 限速为 10Mbps + GatewaySpeedTier20M = 6 // 限速为 20Mbps + GatewaySpeedTier50M = 7 // 限速为 50Mbps + GatewaySpeedTier100M = 8 // 限速为 100Mbps +) + +// IsGatewaySpeedTier 判断编码是否为系统允许的固定限速档位。 +func IsGatewaySpeedTier(code int) bool { + return code >= GatewaySpeedTierRestore && code <= GatewaySpeedTier100M +} + +// GetGatewaySpeedTierName 返回固定限速档位中文名称。 +func GetGatewaySpeedTierName(code int) string { + switch code { + case GatewaySpeedTierRestore: + return "恢复不限速" + case GatewaySpeedTierZero: + return "0kbps" + case GatewaySpeedTier128K: + return "128Kbps" + case GatewaySpeedTier512K: + return "512Kbps" + case GatewaySpeedTier1M: + return "1Mbps" + case GatewaySpeedTier2M: + return "2Mbps" + case GatewaySpeedTier10M: + return "10Mbps" + case GatewaySpeedTier20M: + return "20Mbps" + case GatewaySpeedTier50M: + return "50Mbps" + case GatewaySpeedTier100M: + return "100Mbps" + default: + return "未知" + } +} + +// GatewaySpeedTierUnknownRecoveryStrategy 表示限速超时后的人工核对策略。 +const GatewaySpeedTierUnknownRecoveryStrategy = "通过 Gateway 运维侧按 ICCID 核对当前限速档位后,再决定是否重试" + // 卡实名观测场景 const ( CardObservationSceneRealnamePolling = "realname_polling" // 实名轮询 diff --git a/pkg/constants/constants.go b/pkg/constants/constants.go index b17f67c..745db9e 100644 --- a/pkg/constants/constants.go +++ b/pkg/constants/constants.go @@ -73,7 +73,8 @@ const ( TaskTypePackageDataReset = "package:data:reset" // 套餐流量重置 // 订单套餐失效任务类型 - TaskTypeOrderPackageInvalidate = "order:package:invalidate" // 批量失效订单套餐 + TaskTypeOrderPackageInvalidate = "order:package:invalidate" // 批量失效订单套餐 + TaskTypeAssetPackageBatchOrder = "asset:package:batch_order" // 资产套餐批量订购 // 订单超时任务类型 TaskTypeOrderExpire = "order:expire" // 订单超时自动取消 @@ -83,9 +84,12 @@ const ( TaskTypeAlertCheck = "alert:check" // 告警检查 TaskTypeDataCleanup = "data:cleanup" // 数据清理 TaskTypeNotificationCleanup = "notification:cleanup" // 站内通知保留清理 + TaskTypePackageExpiryReminder = "package:expiry:reminder" // 套餐 15/7/3 天临期提醒 TaskTypeDailyTrafficFlush = "traffic:daily:flush" // 每日流量落盘 TaskTypeOutboxDeliver = "outbox:deliver" // 公共 Outbox 事件投递 TaskTypeCardObservationSeries = "card_observation:series" // 卡观测事件序列尝试 + TaskTypeWeComApprovalSync = "wecom:approval:sync" // 企业微信审批详情异步同步 + TaskTypeWeComApprovalRecovery = "wecom:approval:recovery" // 企业微信审批主动恢复与轮询 ) // 用户状态常量 @@ -221,6 +225,7 @@ const ( QueuePackageQueueActivation = TaskTypePackageQueueActivation // 主套餐排队激活任务队列 QueuePackageDataReset = TaskTypePackageDataReset // 套餐流量重置任务队列 QueueOrderPackageInvalidate = TaskTypeOrderPackageInvalidate // 批量失效订单套餐任务队列 + QueueAssetPackageBatchOrder = TaskTypeAssetPackageBatchOrder // 资产套餐批量订购任务队列 QueueOrderExpire = TaskTypeOrderExpire // 订单超时取消任务队列 QueueAutoPurchase = TaskTypeAutoPurchaseAfterRecharge // 充值后自动购包任务队列 QueueAlertCheck = TaskTypeAlertCheck // 告警检查任务队列 @@ -228,6 +233,7 @@ const ( QueueDailyTrafficFlush = TaskTypeDailyTrafficFlush // 每日流量落盘任务队列 QueueOutboxDeliver = TaskTypeOutboxDeliver // 公共 Outbox 投递队列 QueueCardObservationSeries = TaskTypeCardObservationSeries // 卡观测事件序列队列 + QueueWeComApproval = "wecom:approval" // 企业微信审批同步与恢复队列 DefaultRetryMax = 5 // 默认任务最大重试次数 DefaultTimeout = 10 * time.Minute // 默认任务超时时间 @@ -275,6 +281,8 @@ func QueueForTaskType(taskType string) string { return QueuePackageDataReset case TaskTypeOrderPackageInvalidate: return QueueOrderPackageInvalidate + case TaskTypeAssetPackageBatchOrder: + return QueueAssetPackageBatchOrder case TaskTypeOrderExpire: return QueueOrderExpire case TaskTypeAutoPurchaseAfterRecharge: @@ -285,12 +293,16 @@ func QueueForTaskType(taskType string) string { return QueueDataCleanup case TaskTypeNotificationCleanup: return QueueDataCleanup + case TaskTypePackageExpiryReminder: + return QueueDataCleanup case TaskTypeDailyTrafficFlush: return QueueDailyTrafficFlush case TaskTypeOutboxDeliver: return QueueOutboxDeliver case TaskTypeCardObservationSeries: return QueueCardObservationSeries + case TaskTypeWeComApprovalSync, TaskTypeWeComApprovalRecovery: + return QueueWeComApproval default: return QueueDefault } @@ -306,6 +318,7 @@ func DefaultTaskQueueWeights() map[string]int { QueuePackageFirstActivation: 5, QueuePackageQueueActivation: 5, QueueOrderPackageInvalidate: 5, + QueueAssetPackageBatchOrder: 5, QueueOrderExpire: 4, QueueEmailSend: 4, QueueExportDispatch: 4, @@ -326,6 +339,7 @@ func DefaultTaskQueueWeights() map[string]int { QueueDailyTrafficFlush: 1, QueueOutboxDeliver: 4, QueueCardObservationSeries: 2, + QueueWeComApproval: 2, QueueDefault: 1, QueueLow: 1, } @@ -336,6 +350,15 @@ const ( ExportTaskSceneDevice = "device" // 导出场景:设备 ExportTaskSceneIotCard = "iot_card" // 导出场景:IoT 卡 ExportTaskSceneOrder = "order" // 导出场景:订单 + ExportTaskScenePackage = "package" // 导出场景:套餐 + // ExportTaskSceneAgentWalletTransaction 表示代理主钱包流水导出场景。 + ExportTaskSceneAgentWalletTransaction = "agent_wallet_transaction" + // ExportTaskSceneAgentRecharge 表示代理充值记录导出场景。 + ExportTaskSceneAgentRecharge = "agent_recharge" + // ExportTaskSceneRefund 表示退款记录导出场景。 + ExportTaskSceneRefund = "refund" + // ExportTaskSceneExchange 表示换货记录导出场景。 + ExportTaskSceneExchange = "exchange" ) // 导出文件格式常量 diff --git a/pkg/constants/device_batch_allocation.go b/pkg/constants/device_batch_allocation.go new file mode 100644 index 0000000..324c6b1 --- /dev/null +++ b/pkg/constants/device_batch_allocation.go @@ -0,0 +1,43 @@ +package constants + +import "time" + +// 设备导入任务业务类型。 +const ( + DeviceImportOperationCreate = "import" // 导入并创建设备 + DeviceImportOperationAssignShop = "assign_shop" // 按单列 CSV 分配目标代理店铺 + DeviceImportOperationAssignSeries = "assign_series" // 按单列 CSV 设置目标套餐系列 +) + +// 设备批量分配 CSV 约束。 +const ( + StoragePurposeDeviceBatchAllocation = "device_batch_allocation" // 设备批量分配 CSV 上传用途 + DeviceBatchAllocationStoragePrefix = "device-batch-allocations" // 设备批量分配对象存储目录 + DeviceBatchAllocationMaxRows = 1000 // 单个 CSV 最大设备行数 + DeviceBatchAllocationMaxFileSize = int64(10 * 1024 * 1024) // 单个 CSV 最大字节数 + DeviceBatchAllocationTaskTimeout = 2 * time.Hour // 单个任务最长执行时间 +) + +// IsDeviceImportOperation 判断是否为设备导入任务支持的业务类型。 +func IsDeviceImportOperation(operation string) bool { + switch operation { + case DeviceImportOperationCreate, DeviceImportOperationAssignShop, DeviceImportOperationAssignSeries: + return true + default: + return false + } +} + +// GetDeviceImportOperationName 返回设备导入任务业务类型中文名称。 +func GetDeviceImportOperationName(operation string) string { + switch operation { + case DeviceImportOperationCreate: + return "导入设备" + case DeviceImportOperationAssignShop: + return "分配目标代理" + case DeviceImportOperationAssignSeries: + return "设置套餐系列" + default: + return "未知" + } +} diff --git a/pkg/constants/notification.go b/pkg/constants/notification.go index d65fb0a..d77752e 100644 --- a/pkg/constants/notification.go +++ b/pkg/constants/notification.go @@ -34,6 +34,10 @@ const ( NotificationTypeSystemNotice = "system.notice" // NotificationTypePackageExpiring 表示个人客户套餐临期提醒。 NotificationTypePackageExpiring = "package.expiring" + // NotificationTypeExchangeShippingCreated 表示个人客户物流换货待处理提醒。 + NotificationTypeExchangeShippingCreated = "exchange.shipping.created" + // NotificationTypeAgentMainWalletLowBalance 表示代理主钱包余额低于固定阈值提醒。 + NotificationTypeAgentMainWalletLowBalance = "agent.main_wallet.low_balance" // NotificationRefTypeSystemConfig 表示系统配置资源引用。 NotificationRefTypeSystemConfig = "system_config" diff --git a/pkg/constants/package_export.go b/pkg/constants/package_export.go new file mode 100644 index 0000000..c57afb9 --- /dev/null +++ b/pkg/constants/package_export.go @@ -0,0 +1,67 @@ +package constants + +// GetPackageTypeName 返回套餐类型中文名称。 +func GetPackageTypeName(packageType string) string { + switch packageType { + case PackageTypeFormal: + return "正式套餐" + case PackageTypeAddon: + return "附加套餐" + default: + return "未知" + } +} + +// GetPackageCalendarTypeName 返回套餐周期类型中文名称。 +func GetPackageCalendarTypeName(calendarType string) string { + switch calendarType { + case PackageCalendarTypeNaturalMonth: + return "自然月" + case PackageCalendarTypeByDay: + return "按天" + default: + return "未知" + } +} + +// GetPackageDataResetCycleName 返回套餐流量重置周期中文名称。 +func GetPackageDataResetCycleName(cycle string) string { + switch cycle { + case PackageDataResetDaily: + return "每日" + case PackageDataResetMonthly: + return "每月" + case PackageDataResetYearly: + return "每年" + case PackageDataResetNone: + return "不重置" + default: + return "未知" + } +} + +// GetPackageExpiryBaseName 返回套餐到期时间基准中文名称。 +func GetPackageExpiryBaseName(expiryBase string) string { + switch expiryBase { + case PackageExpiryBaseFromActivation: + return "实名激活时起算" + case PackageExpiryBaseFromPurchase: + return "购买时起算" + default: + return "未知" + } +} + +// GetPackagePriceConfigStatusName 返回套餐价格配置状态中文名称。 +func GetPackagePriceConfigStatusName(status int) string { + switch status { + case PackagePriceConfigStatusGiftZero: + return "赠送0价" + case PackagePriceConfigStatusConfigured: + return "已配置非0" + case PackagePriceConfigStatusUnconfigured: + return "未配置" + default: + return "未知" + } +} diff --git a/pkg/constants/redis.go b/pkg/constants/redis.go index 2d82c01..7c8ed14 100644 --- a/pkg/constants/redis.go +++ b/pkg/constants/redis.go @@ -553,3 +553,13 @@ func RedisSystemOperationPasswordKey() string { func RedisPaymentConfigKey(configID uint) string { return fmt.Sprintf("payment:config:%d", configID) } + +// RedisWeComAccessTokenKey 返回企业微信应用 access_token 缓存 Key。 +func RedisWeComAccessTokenKey(applicationID uint) string { + return fmt.Sprintf("wecom:application:%d:access_token", applicationID) +} + +// RedisWeComAccessTokenLockKey 返回企业微信应用 access_token 回源短锁 Key。 +func RedisWeComAccessTokenLockKey(applicationID uint) string { + return fmt.Sprintf("wecom:application:%d:access_token:lock", applicationID) +} diff --git a/pkg/constants/system_config.go b/pkg/constants/system_config.go index e13a262..a0db02e 100644 --- a/pkg/constants/system_config.go +++ b/pkg/constants/system_config.go @@ -18,6 +18,12 @@ const ( SystemConfigCacheTTL = 5 * time.Minute // SystemConfigModuleCarrierCallback 是运营商回调配置模块。 SystemConfigModuleCarrierCallback = "carrier_callback" + // SystemConfigModulePayment 是 C 端支付方式配置模块。 + SystemConfigModulePayment = "c2b.payment" + // SystemConfigPaymentAllowedCard 定义卡资产允许的支付方式集合。 + SystemConfigPaymentAllowedCard = "c2b.payment.card_allowed_methods" + // SystemConfigPaymentAllowedDevice 定义设备资产允许的支付方式集合。 + SystemConfigPaymentAllowedDevice = "c2b.payment.device_allowed_methods" // SystemConfigCarrierCallbackCTCCRealnameEnabled 控制电信实名回调是否执行业务处理。 SystemConfigCarrierCallbackCTCCRealnameEnabled = "carrier_callback.ctcc_realname.enabled" // SystemConfigCarrierCallbackCMCCRealnameEnabled 控制移动实名回调是否执行业务处理。 diff --git a/pkg/constants/wallet.go b/pkg/constants/wallet.go index 92d9c28..703306b 100644 --- a/pkg/constants/wallet.go +++ b/pkg/constants/wallet.go @@ -12,6 +12,8 @@ import "fmt" const ( AgentWalletTypeMain = "main" // 主钱包 AgentWalletTypeCommission = "commission" // 分佣钱包 + // AgentMainWalletLowBalanceThreshold 表示主钱包低余额提醒阈值,单位为分(100 元)。 + AgentMainWalletLowBalanceThreshold int64 = 10000 ) // 代理主钱包信用管理权限 diff --git a/pkg/constants/wallet_export.go b/pkg/constants/wallet_export.go new file mode 100644 index 0000000..9638c4f --- /dev/null +++ b/pkg/constants/wallet_export.go @@ -0,0 +1,71 @@ +package constants + +// GetAgentTransactionTypeName 返回代理钱包交易类型中文名称。 +func GetAgentTransactionTypeName(transactionType string) string { + switch transactionType { + case AgentTransactionTypeRecharge: + return "充值" + case AgentTransactionTypeDeduct: + return "扣款" + case AgentTransactionTypeRefund: + return "退款" + case AgentTransactionTypeCommission: + return "分佣" + case AgentTransactionTypeWithdrawal: + return "提现" + case AgentTransactionTypeCommissionDeduct: + return "退款佣金回扣" + case AgentTransactionTypeAdjustment: + return "人工余额调整" + default: + return "未知" + } +} + +// GetTransactionStatusName 返回钱包交易状态中文名称。 +func GetTransactionStatusName(status int) string { + switch status { + case TransactionStatusSuccess: + return "成功" + case TransactionStatusFailed: + return "失败" + case TransactionStatusProcessing: + return "处理中" + default: + return "未知" + } +} + +// GetWalletAssetTypeName 返回钱包流水资产类型中文名称。 +func GetWalletAssetTypeName(assetType string) string { + switch assetType { + case AssetTypeIotCard: + return "物联网卡" + case AssetTypeDevice: + return "设备" + case "": + return "" + default: + return "未知" + } +} + +// GetBusinessPaymentMethodName 返回业务支付方式中文名称。 +func GetBusinessPaymentMethodName(method string) string { + switch method { + case PaymentMethodWallet: + return "钱包支付" + case RechargeMethodWechat: + return "微信支付" + case RechargeMethodAlipay: + return "支付宝支付" + case RechargeMethodBank: + return "银行转账" + case RechargeMethodOffline: + return "线下支付" + case "": + return "" + default: + return "未知" + } +} diff --git a/pkg/constants/wecom.go b/pkg/constants/wecom.go new file mode 100644 index 0000000..887946c --- /dev/null +++ b/pkg/constants/wecom.go @@ -0,0 +1,86 @@ +package constants + +import "time" + +const ( + // IntegrationProviderWeCom 表示企业微信外部系统。 + IntegrationProviderWeCom = "wecom" + // IntegrationOperationWeComAccessToken 表示获取企业微信应用 access_token。 + IntegrationOperationWeComAccessToken = "get_access_token" + // IntegrationOperationWeComVisibleMembers 表示获取应用可见成员。 + IntegrationOperationWeComVisibleMembers = "list_visible_members" + // IntegrationOperationWeComTemplateDetail 表示获取企微审批模板详情。 + IntegrationOperationWeComTemplateDetail = "get_template_detail" + // IntegrationOperationWeComAttachmentUpload 表示上传审批附件临时素材。 + IntegrationOperationWeComAttachmentUpload = "upload_approval_attachment" + // IntegrationOperationWeComApprovalSubmit 表示提交企业微信审批申请。 + IntegrationOperationWeComApprovalSubmit = "submit_approval" + // IntegrationOperationWeComApprovalCallback 表示接收企业微信审批状态回调。 + IntegrationOperationWeComApprovalCallback = "approval_callback" + // IntegrationOperationWeComApprovalDetail 表示获取企业微信审批详情。 + IntegrationOperationWeComApprovalDetail = "get_approval_detail" + // IntegrationOperationWeComApprovalInfo 表示按提交时间窗批量获取企业微信审批单号。 + IntegrationOperationWeComApprovalInfo = "get_approval_info" + // WeComApplicationResourceType 表示企业微信应用配置资源。 + WeComApplicationResourceType = "wecom_application" + // WeComApprovalSceneResourceType 表示企业微信审批场景配置资源。 + WeComApprovalSceneResourceType = "wecom_approval_scene" + // WeComApprovalInstanceResourceType 表示企业微信审批申请资源。 + WeComApprovalInstanceResourceType = "wecom_approval" + // WeComTokenRefreshAdvance 表示 access_token 提前刷新的安全窗口。 + WeComTokenRefreshAdvance = 5 * time.Minute + // WeComTokenLockTTL 表示单应用并发回源短锁有效期。 + WeComTokenLockTTL = 10 * time.Second + // WeComTokenWaitTimeout 表示等待其他实例完成 token 回源的最长时间。 + WeComTokenWaitTimeout = 2 * time.Second + // WeComTokenWaitPollInterval 表示等待其他实例回填 token 时的轮询间隔。 + WeComTokenWaitPollInterval = 100 * time.Millisecond + // WeComMaxResponseBodyBytes 表示企业微信响应正文允许读取的最大字节数。 + WeComMaxResponseBodyBytes int64 = 1 << 20 + // WeComDefaultHTTPTimeout 表示企业微信外呼默认超时时间。 + WeComDefaultHTTPTimeout = 10 * time.Second + // WeComDirectoryMaxResponseBodyBytes 表示通讯录响应正文允许读取的最大字节数。 + WeComDirectoryMaxResponseBodyBytes int64 = 10 << 20 + // WeComRootDepartmentID 表示企业微信根部门 ID。 + WeComRootDepartmentID int64 = 1 + // WeComMemberSyncBatchSize 表示可见成员快照批量写入大小。 + WeComMemberSyncBatchSize = 500 + // WeComApprovalMaxAttachmentCount 表示单张企微审批单最多允许的附件数量。 + WeComApprovalMaxAttachmentCount = 6 + // WeComApprovalMaxAttachmentBytes 表示企微普通文件临时素材最大字节数。 + WeComApprovalMaxAttachmentBytes int64 = 20 << 20 + // WeComApprovalInfoMaxPageSize 表示批量获取审批单号接口单页上限。 + WeComApprovalInfoMaxPageSize = 100 + // WeComApprovalInfoMaxWindow 表示批量获取审批单号接口允许的最大时间跨度。 + WeComApprovalInfoMaxWindow = 31 * 24 * time.Hour + // WeComApprovalRecoveryWindow 表示结果未知时围绕实际提交时刻查询的保守时间窗半径。 + WeComApprovalRecoveryWindow = 5 * time.Minute + // WeComApprovalSendingLease 表示提交中记录在进入未知恢复前允许占用的最长时间。 + WeComApprovalSendingLease = 5 * time.Minute + // WeComApprovalPollingInterval 表示未终态审批详情的最短轮询间隔。 + WeComApprovalPollingInterval = 2 * time.Minute + // WeComApprovalRecoveryBatchSize 表示单次恢复扫描的本地记录上限。 + WeComApprovalRecoveryBatchSize = 100 + // WeComApprovalUnknownRecoveryBatchSize 表示单轮结果未知外部查询的记录上限。 + WeComApprovalUnknownRecoveryBatchSize = 10 +) + +const ( + // WeComCreatorSourceBound 表示使用系统账号绑定的企微成员发起。 + WeComCreatorSourceBound = "bound" + // WeComCreatorSourceDefault 表示使用应用配置的默认成员发起。 + WeComCreatorSourceDefault = "default" +) + +const ( + // WeComSubmissionStatusReady 表示企微审批等待提交。 + WeComSubmissionStatusReady = 0 + // WeComSubmissionStatusSending 表示提交请求已被 Worker 领取,禁止并发重发。 + WeComSubmissionStatusSending = 1 + // WeComSubmissionStatusSubmitted 表示企微已返回审批单号。 + WeComSubmissionStatusSubmitted = 2 + // WeComSubmissionStatusFailed 表示企微明确确认提交失败。 + WeComSubmissionStatusFailed = 3 + // WeComSubmissionStatusUnknown 表示请求可能已到达企微但结果未知。 + WeComSubmissionStatusUnknown = 4 +) diff --git a/pkg/errors/codes.go b/pkg/errors/codes.go index 9c9132d..9b4df36 100644 --- a/pkg/errors/codes.go +++ b/pkg/errors/codes.go @@ -166,6 +166,12 @@ const ( CodeExchangeNewAssetNotInStock = 1204 // 新资产非在库状态 CodeExchangeAssetNotExchanged = 1205 // 资产未处于已换货状态,不允许转新 CodeExchangeMigrationFailed = 1206 // 数据迁移失败 + CodeExchangeActiveRefund = 1207 // 资产存在未终结退款申请 + CodePaymentMethodUnavailable = 1208 // 当前资产不支持所选支付方式 + + // 企业微信相关错误 (1210-1219) + CodeWeComApplicationNotFound = 1210 // 企业微信应用配置不存在 + CodeWeComCredentialInvalid = 1211 // 企业微信加密凭据不可用 // 服务端错误 (2000-2999) -> 5xx HTTP 状态码 CodeInternalError = 2001 // 内部服务器错误 @@ -306,6 +312,10 @@ var allErrorCodes = []int{ CodeExchangeNewAssetNotInStock, CodeExchangeAssetNotExchanged, CodeExchangeMigrationFailed, + CodeExchangeActiveRefund, + CodePaymentMethodUnavailable, + CodeWeComApplicationNotFound, + CodeWeComCredentialInvalid, CodeInternalError, CodeDatabaseError, CodeRedisError, @@ -439,6 +449,10 @@ var errorMessages = map[int]string{ CodeExchangeNewAssetNotInStock: "新资产非在库状态,不可用于换货", CodeExchangeAssetNotExchanged: "资产当前状态不允许转新", CodeExchangeMigrationFailed: "换货数据迁移失败", + CodeExchangeActiveRefund: "该资产存在退款申请", + CodePaymentMethodUnavailable: "当前资产不支持所选支付方式", + CodeWeComApplicationNotFound: "企业微信应用配置不存在或已禁用", + CodeWeComCredentialInvalid: "企业微信凭据配置无效", CodeInvalidCredentials: "用户名或密码错误", CodeAccountLocked: "账号已锁定", CodePasswordExpired: "密码已过期", diff --git a/pkg/openapi/handlers.go b/pkg/openapi/handlers.go index 6856a2f..b60a11f 100644 --- a/pkg/openapi/handlers.go +++ b/pkg/openapi/handlers.go @@ -71,9 +71,11 @@ func BuildDocHandlers() *bootstrap.Handlers { AgentRecharge: admin.NewAgentRechargeHandler(nil, nil), Refund: admin.NewRefundHandler(nil), OrderPackageInvalidate: admin.NewOrderPackageInvalidateHandler(nil), + AssetPackageBatchOrder: admin.NewAssetPackageBatchOrderHandler(nil, nil), ClientWechat: app.NewClientWechatHandler(nil, nil, nil), SuperAdmin: admin.NewSuperAdminHandler(nil), SystemConfig: admin.NewSystemConfigHandler(nil, nil), + WeCom: admin.NewWeComHandler(nil, nil), AgentOpenAPI: openapiHandler.NewHandler(nil, nil), } } diff --git a/pkg/queue/handler.go b/pkg/queue/handler.go index 40bd43f..36dec59 100644 --- a/pkg/queue/handler.go +++ b/pkg/queue/handler.go @@ -6,11 +6,15 @@ import ( "go.uber.org/zap" "gorm.io/gorm" + packageExpiryApp "github.com/break/junhong_cmp_fiber/internal/application/packageexpiry" "github.com/break/junhong_cmp_fiber/internal/exporter" "github.com/break/junhong_cmp_fiber/internal/gateway" "github.com/break/junhong_cmp_fiber/internal/infrastructure/integrationlog" + "github.com/break/junhong_cmp_fiber/internal/infrastructure/messaging/outbox" notification "github.com/break/junhong_cmp_fiber/internal/infrastructure/notification" + packageExpiryInfra "github.com/break/junhong_cmp_fiber/internal/infrastructure/packageexpiry" "github.com/break/junhong_cmp_fiber/internal/polling" + packageExpiryQuery "github.com/break/junhong_cmp_fiber/internal/query/packageexpiry" iot_card_svc "github.com/break/junhong_cmp_fiber/internal/service/iot_card" "github.com/break/junhong_cmp_fiber/internal/store/postgres" "github.com/break/junhong_cmp_fiber/internal/task" @@ -68,6 +72,7 @@ func (h *Handler) RegisterHandlers() *asynq.ServeMux { h.registerIotCardImportHandler() h.registerDeviceImportHandler() h.registerOrderPackageInvalidateHandler() + h.registerAssetPackageBatchOrderHandler() h.registerExportHandlers() h.registerCommissionStatsHandlers() h.registerCommissionCalculationHandler() @@ -78,6 +83,7 @@ func (h *Handler) RegisterHandlers() *asynq.ServeMux { h.registerAlertCheckHandler() h.registerDataCleanupHandler() h.registerNotificationCleanupHandler() + h.registerPackageExpiryReminderHandler() h.registerAutoPurchaseHandler() h.registerDailyTrafficFlushHandler() @@ -119,6 +125,18 @@ func (h *Handler) registerOrderPackageInvalidateHandler() { h.logger.Info("注册订单套餐批量失效任务处理器", zap.String("task_type", constants.TaskTypeOrderPackageInvalidate)) } +func (h *Handler) registerAssetPackageBatchOrderHandler() { + handler := task.NewAssetPackageBatchOrderHandler( + h.workerResult.Stores.AssetPackageBatchOrderTask, + h.workerResult.Stores.Shop, + h.workerResult.Services.AssetPackageOrderCreator, + h.storage, + h.logger, + ) + h.mux.HandleFunc(constants.TaskTypeAssetPackageBatchOrder, handler.Handle) + h.logger.Info("注册资产套餐批量订购任务处理器", zap.String("task_type", constants.TaskTypeAssetPackageBatchOrder)) +} + func (h *Handler) registerDeviceImportHandler() { deviceImportHandler := task.NewDeviceImportHandler( h.db, @@ -131,6 +149,7 @@ func (h *Handler) registerDeviceImportHandler() { h.workerResult.Stores.AssetIdentifier, h.storage, h.logger, + h.workerResult.Services.DeviceBatchAllocator, ) h.mux.HandleFunc(constants.TaskTypeDeviceImport, deviceImportHandler.HandleDeviceImport) @@ -284,6 +303,15 @@ func (h *Handler) registerNotificationCleanupHandler() { h.logger.Info("注册站内通知保留清理任务处理器", zap.String("task_type", constants.TaskTypeNotificationCleanup)) } +func (h *Handler) registerPackageExpiryReminderHandler() { + query := packageExpiryQuery.NewQuery(h.db) + publisher := packageExpiryInfra.NewReminderPublisher(h.db, outbox.NewRepository()) + service := packageExpiryApp.NewReminderService(query, publisher) + handler := task.NewPackageExpiryReminderHandler(service, h.logger) + h.mux.HandleFunc(constants.TaskTypePackageExpiryReminder, handler.Handle) + h.logger.Info("注册套餐临期节点提醒任务处理器", zap.String("task_type", constants.TaskTypePackageExpiryReminder)) +} + func (h *Handler) registerAutoPurchaseHandler() { autoPurchaseHandler := task.NewAutoPurchaseHandler( h.db, diff --git a/pkg/queue/types.go b/pkg/queue/types.go index 7b57ba1..f332169 100644 --- a/pkg/queue/types.go +++ b/pkg/queue/types.go @@ -9,6 +9,7 @@ import ( packagepkg "github.com/break/junhong_cmp_fiber/internal/service/package" pollingSvc "github.com/break/junhong_cmp_fiber/internal/service/polling" "github.com/break/junhong_cmp_fiber/internal/store/postgres" + "github.com/break/junhong_cmp_fiber/internal/task" ) // OrderExpirer 订单超时取消接口 @@ -21,6 +22,7 @@ type OrderExpirer interface { // WorkerStores Worker 侧所有 Store 的集合 type WorkerStores struct { AssetOperationLog *postgres.AssetOperationLogStore + AssetAllocationRecord *postgres.AssetAllocationRecordStore IotCardImportTask *postgres.IotCardImportTaskStore IotCard *postgres.IotCardStore DeviceImportTask *postgres.DeviceImportTaskStore @@ -50,22 +52,25 @@ type WorkerStores struct { PersonalCustomer *postgres.PersonalCustomerStore PersonalCustomerPhone *postgres.PersonalCustomerPhoneStore OrderPackageInvalidateTask *postgres.OrderPackageInvalidateTaskStore + AssetPackageBatchOrderTask *postgres.AssetPackageBatchOrderTaskStore } // WorkerServices Worker 侧所有 Service 的集合 type WorkerServices struct { - CardObservation *cardObservationApp.Service - CardObservationSeries *cardObservationApp.SeriesAttemptService - ObservationSeriesEvents cardObservationApp.SeriesEventWriter - CommissionCalculation *commission_calculation.Service - CommissionStats *commission_stats.Service - UsageService *packagepkg.UsageService - ActivationService *packagepkg.ActivationService - ResetService *packagepkg.ResetService - AlertService *pollingSvc.AlertService - CleanupService *pollingSvc.CleanupService - StopResumeService packagepkg.StopResumeCallback // 停复机服务,用于注入 Scheduler - OrderExpirer OrderExpirer // 订单超时取消服务(接口类型,避免循环依赖) + CardObservation *cardObservationApp.Service + CardObservationSeries *cardObservationApp.SeriesAttemptService + ObservationSeriesEvents cardObservationApp.SeriesEventWriter + CommissionCalculation *commission_calculation.Service + CommissionStats *commission_stats.Service + UsageService *packagepkg.UsageService + ActivationService *packagepkg.ActivationService + ResetService *packagepkg.ResetService + AlertService *pollingSvc.AlertService + CleanupService *pollingSvc.CleanupService + StopResumeService packagepkg.StopResumeCallback // 停复机服务,用于注入 Scheduler + OrderExpirer OrderExpirer // 订单超时取消服务(接口类型,避免循环依赖) + AssetPackageOrderCreator task.AssetPackageBatchOrderCreator // 批量订购复用后台单笔订单规则 + DeviceBatchAllocator task.DeviceBatchAllocationExecutor // 设备CSV批量分配复用现有业务规则 } // WorkerBootstrapResult Worker Bootstrap 结果 diff --git a/pkg/storage/service.go b/pkg/storage/service.go index eb487cb..6ac4448 100644 --- a/pkg/storage/service.go +++ b/pkg/storage/service.go @@ -10,6 +10,7 @@ import ( "github.com/google/uuid" "github.com/break/junhong_cmp_fiber/pkg/config" + "github.com/break/junhong_cmp_fiber/pkg/constants" "github.com/break/junhong_cmp_fiber/pkg/errors" ) @@ -35,6 +36,9 @@ func (s *Service) GenerateFileKey(purpose, fileName string) (string, error) { if ext == "" { ext = ".bin" } + if (purpose == constants.StoragePurposeAssetPackageBatchOrder || purpose == constants.StoragePurposeDeviceBatchAllocation) && !strings.EqualFold(ext, ".csv") { + return "", errors.New(errors.CodeInvalidParam, "批量业务文件必须为CSV格式") + } now := time.Now() id := uuid.New().String() diff --git a/pkg/storage/types.go b/pkg/storage/types.go index a8cfdab..e1b59a0 100644 --- a/pkg/storage/types.go +++ b/pkg/storage/types.go @@ -1,5 +1,7 @@ package storage +import "github.com/break/junhong_cmp_fiber/pkg/constants" + type PresignResult struct { URL string `json:"url"` FileKey string `json:"file_key"` @@ -15,4 +17,6 @@ var PurposeMappings = map[string]PurposeMapping{ "iot_import": {Prefix: "imports", ContentType: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"}, "export": {Prefix: "exports", ContentType: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"}, "attachment": {Prefix: "attachments", ContentType: ""}, + constants.StoragePurposeAssetPackageBatchOrder: {Prefix: constants.AssetPackageBatchOrderStoragePrefix, ContentType: "text/csv"}, + constants.StoragePurposeDeviceBatchAllocation: {Prefix: constants.DeviceBatchAllocationStoragePrefix, ContentType: "text/csv"}, } diff --git a/scripts/batch_exchange/exchanges.example.csv b/scripts/batch_exchange/exchanges.example.csv index 5e45e68..a1e8c5e 100644 --- a/scripts/batch_exchange/exchanges.example.csv +++ b/scripts/batch_exchange/exchanges.example.csv @@ -1,38 +1,177 @@ old_identifier,new_identifier -99840868838,89861590172420400342 -99840868841,89861590172420400343 -99840868842,89861590172420400344 -99840868843,89861590172420400345 -99840868844,89861590172420400346 -99840868845,89861590172420400347 -99840868847,89861590172420400348 -99840868848,89861590172420400349 -99840868850,89861590172420400351 -99840868851,89861590172420400352 -99840868852,89861590172420400353 -99840868854,89861590172420400354 -99840868856,89861590172420400356 -99840868863,89861590172420400363 -99840868865,89861590172420400364 -99820416468,89861590172420400365 -99840868917,89861590172420400319 -99840868918,89861590172420400320 -99840868919,89861590172420400321 -99840868920,89861590172420400322 -99840868922,89861590172420400323 -99840868923,89861590172420400324 -99840868924,89861590172420400325 -99840868925,89861590172420400326 -99840868938,89861590172420400327 -99840868939,89861590172420400328 -99840868940,89861590172420400329 -99840868941,89861590172420400330 -99840868942,89861590172420400331 -99840868943,89861590172420400332 -99840868944,89861590172420400333 -99840868945,89861590172420400334 -99840868946,89861590172420400335 -99840868948,89861590172420400336 -99840868949,89861590172420400337 -99840868951,89861590172420400338 -99840868952,89861590172420400339 +89861590192440868760,89861590172420396872 +89861590192440868761,89861590172420396873 +89861590192440868762,89861590172420396874 +89861590192440868763,89861590172420396875 +89861590192440868764,89861590172420396876 +89861590192440868765,89861590172420396877 +89861590192440868766,89861590172420396878 +89861590192440868767,89861590172420396879 +89861590192440868768,89861590172420396880 +89861590192440868769,89861590172420396881 +89861590192440868770,89861590172420396882 +89861590192440868771,89861590172420396883 +89861590192440868772,89861590172420396884 +89861590192440868773,89861590172420396885 +89861590192440868774,89861590172420396886 +89861590192440868775,89861590172420396887 +89861590192440868776,89861590172420396888 +89861590192440868777,89861590172420396889 +89861590192440868778,89861590172420396890 +89861590192440868779,89861590172420396891 +89861590192440868780,89861590172420396892 +89861590192440868781,89861590172420396893 +89861590192440868782,89861590172420396894 +89861590192440868783,89861590172420396895 +89861590192440868784,89861590172420396896 +89861590192440868785,89861590172420396897 +89861590192440868786,89861590172420396898 +89861590192440868787,89861590172420396899 +89861590192440868788,89861590172420396900 +89861590192440868789,89861590172420396901 +89861590192440868790,89861590172420396902 +89861590192440868791,89861590172420396903 +89861590192440868792,89861590172420396904 +89861590192440868793,89861590172420396905 +89861590192440868794,89861590172420396906 +89861590192440868795,89861590172420396907 +89861590192440868796,89861590172420396908 +89861590192440868797,89861590172420396909 +89861590192440868798,89861590172420396910 +89861590192440868799,89861590172420396911 +89861590192440868800,89861590172420396912 +89861590192440868801,89861590172420396913 +89861590192440868802,89861590172420396914 +89861590192440868803,89861590172420396915 +89861590192440868804,89861590172420396916 +89861590192440868805,89861590172420396917 +89861590192440868806,89861590172420396918 +89861590192440868807,89861590172420396919 +89861590192440868808,89861590172420396920 +89861590192440868809,89861590172420396921 +89861590192440868810,89861590172420396922 +89861590192440868811,89861590172420396923 +89861590192440868812,89861590172420396924 +89861590192440868813,89861590172420396925 +89861590192440868815,89861590172420396926 +89861590192440868816,89861590172420396927 +89861590192440868817,89861590172420396928 +89861590192440868818,89861590172420396929 +89861590192440868819,89861590172420396930 +89861590192440868820,89861590172420396931 +89861590192440868821,89861590172420396932 +89861590192440868822,89861590172420396933 +89861590192440868823,89861590172420396934 +89861590192440868824,89861590172420396935 +89861590192440868825,89861590172420396936 +89861590192440868826,89861590172420396937 +89861590192440868827,89861590172420396938 +89861590192440868828,89861590172420396939 +89861590192440868829,89861590172420396940 +89861590192440868831,89861590172420396941 +89861590192440868832,89861590172420396942 +89861590192440868833,89861590172420396943 +89861590192440868835,89861590172420396944 +89861590192440868866,89861590172420396945 +89861590192440868867,89861590172420396946 +89861590192440868868,89861590172420396947 +89861590192440868870,89861590172420396948 +89861590192440868871,89861590172420396949 +89861590192440868873,89861590172420396950 +89861590192440868874,89861590172420396951 +89861590192440868875,89861590172420396952 +89861590192440868876,89861590172420396953 +89861590192440868877,89861590172420396954 +89861590192440868878,89861590172420396955 +89861590192440868879,89861590172420396956 +89861590192440868880,89861590172420396957 +89861590192440868881,89861590172420396958 +89861590192440868882,89861590172420396959 +89861590192440868883,89861590172420396960 +89861590192440868884,89861590172420396961 +89861590192440868886,89861590172420396962 +89861590192440868887,89861590172420396963 +89861590192440868888,89861590172420396964 +89861590192440868889,89861590172420396965 +89861590192440868890,89861590172420396966 +89861590192440868891,89861590172420396967 +89861590192440868892,89861590172420396968 +89861590192440868893,89861590172420396969 +89861590192440868894,89861590172420396970 +89861590192440868895,89861590172420396971 +89861590192440868896,89861590172420396972 +89861590192440868897,89861590172420396973 +89861590192440868898,89861590172420396974 +89861590192440868899,89861590172420396975 +89861590192440868900,89861590172420396976 +89861590192440868901,89861590172420396977 +89861590192440868902,89861590172420396978 +89861590192440868903,89861590172420396979 +89861590192440868904,89861590172420396980 +89861590192440868905,89861590172420396981 +89861590192440868906,89861590172420396982 +89861590192440868907,89861590172420396983 +89861590192440868908,89861590172420396984 +89861590192440868909,89861590172420396985 +89861590192440868910,89861590172420396986 +89861590192440868911,89861590172420396987 +89861590192440868912,89861590172420396988 +89861590192440868913,89861590172420396989 +89861590192440868914,89861590172420396990 +89861590192440868915,89861590172420396991 +89861590192440868953,89861590172420396992 +89861590192440868954,89861590172420396993 +89861590192440868955,89861590172420396994 +89861590192440868956,89861590172420396995 +89861590192440868957,89861590172420396996 +89861590192440868958,89861590172420396997 +89861590192440868959,89861590172420396998 +89861590192440868960,89861590172420396999 +89861590192440868961,89861590172420397000 +89861590192440868962,89861590172420397001 +89861590192440868963,89861590172420397002 +89861590192440868964,89861590172420397003 +89861590192440868965,89861590172420397004 +89861590192440868966,89861590172420397005 +89861590192440868967,89861590172420397006 +89861590192440868968,89861590172420397007 +89861590192440868969,89861590172420397008 +89861590192440868970,89861590172420397009 +89861590192440868971,89861590172420397010 +89861590192440868972,89861590172420397011 +89861590192440868973,89861590172420397012 +89861590192440868974,89861590172420397013 +89861590192440868975,89861590172420397014 +89861590192440868976,89861590172420397015 +89861590192440868977,89861590172420397016 +89861590192440868978,89861590172420397017 +89861590192440868979,89861590172420397018 +89861590192440868980,89861590172420397019 +89861590192440868981,89861590172420397020 +89861590192440868982,89861590172420397021 +89861590192440868983,89861590172420397022 +89861590192440868984,89861590172420397023 +89861590192440868985,89861590172420397024 +89861590192440868987,89861590172420397025 +89861590192440868988,89861590172420397026 +89861590192440868989,89861590172420397027 +89861590192440868990,89861590172420397028 +89861590192440868991,89861590172420397029 +89861590192440868993,89861590172420397030 +89861590192440868995,89861590172420397031 +89861590192440868997,89861590172420397032 +89861590192440868998,89861590172420397033 +89861590192440869001,89861590172420397034 +89861590192440869002,89861590172420397035 +89861590192440868926,89861590172420397036 +89861590192440868927,89861590172420397037 +89861590192440868928,89861590172420397038 +89861590192440868929,89861590172420397039 +89861590192440868930,89861590172420397040 +89861590192440868931,89861590172420397041 +89861590192440868932,89861590172420397042 +89861590192440868933,89861590172420397043 +89861590192440868934,89861590172420397044 +89861590192440868935,89861590172420397045 +89861590192440868936,89861590172420397046 +89861590192440868937,89861590172420397047 diff --git a/scripts/batch_exchange/exchanges.example_换货结果_20260725_100636.csv b/scripts/batch_exchange/exchanges.example_换货结果_20260725_100636.csv new file mode 100644 index 0000000..b34c9df --- /dev/null +++ b/scripts/batch_exchange/exchanges.example_换货结果_20260725_100636.csv @@ -0,0 +1,177 @@ +line_no,old_identifier,new_identifier,status,http_status,code,msg,exchange_id,exchange_no,migration_completed,migration_balance +2,89861590192440868760,89861590172420396872,成功,200,0,success,419,EXC20260725100636139402,True,0 +3,89861590192440868761,89861590172420396873,成功,200,0,success,420,EXC20260725100636738010,True,0 +4,89861590192440868762,89861590172420396874,成功,200,0,success,421,EXC20260725100637022304,True,0 +5,89861590192440868763,89861590172420396875,成功,200,0,success,422,EXC20260725100637753014,True,0 +6,89861590192440868764,89861590172420396876,成功,200,0,success,423,EXC20260725100639078562,True,0 +7,89861590192440868765,89861590172420396877,成功,200,0,success,424,EXC20260725100648580188,True,0 +8,89861590192440868766,89861590172420396878,成功,200,0,success,425,EXC20260725100649141431,True,0 +9,89861590192440868767,89861590172420396879,成功,200,0,success,426,EXC20260725100649555553,True,0 +10,89861590192440868768,89861590172420396880,成功,200,0,success,427,EXC20260725100651874558,True,0 +11,89861590192440868769,89861590172420396881,成功,200,0,success,428,EXC20260725100652018544,True,0 +12,89861590192440868770,89861590172420396882,成功,200,0,success,429,EXC20260725100653292167,True,0 +13,89861590192440868771,89861590172420396883,成功,200,0,success,430,EXC20260725100653591499,True,0 +14,89861590192440868772,89861590172420396884,成功,200,0,success,431,EXC20260725100654548389,True,0 +15,89861590192440868773,89861590172420396885,成功,200,0,success,432,EXC20260725100655952364,True,0 +16,89861590192440868774,89861590172420396886,成功,200,0,success,433,EXC20260725100655719489,True,0 +17,89861590192440868775,89861590172420396887,成功,200,0,success,434,EXC20260725100656835956,True,0 +18,89861590192440868776,89861590172420396888,成功,200,0,success,435,EXC20260725100656759124,True,0 +19,89861590192440868777,89861590172420396889,成功,200,0,success,436,EXC20260725100656828927,True,0 +20,89861590192440868778,89861590172420396890,成功,200,0,success,437,EXC20260725100657069529,True,0 +21,89861590192440868779,89861590172420396891,成功,200,0,success,438,EXC20260725100657834762,True,0 +22,89861590192440868780,89861590172420396892,成功,200,0,success,439,EXC20260725100658371497,True,0 +23,89861590192440868781,89861590172420396893,成功,200,0,success,440,EXC20260725100658792150,True,0 +24,89861590192440868782,89861590172420396894,成功,200,0,success,441,EXC20260725100659463657,True,0 +25,89861590192440868783,89861590172420396895,成功,200,0,success,442,EXC20260725100659321734,True,0 +26,89861590192440868784,89861590172420396896,成功,200,0,success,443,EXC20260725100659189940,True,0 +27,89861590192440868785,89861590172420396897,成功,200,0,success,444,EXC20260725100700291421,True,0 +28,89861590192440868786,89861590172420396898,成功,200,0,success,445,EXC20260725100701831205,True,0 +29,89861590192440868787,89861590172420396899,成功,200,0,success,446,EXC20260725100701067974,True,0 +30,89861590192440868788,89861590172420396900,成功,200,0,success,447,EXC20260725100702290552,True,0 +31,89861590192440868789,89861590172420396901,成功,200,0,success,448,EXC20260725100703504608,True,0 +32,89861590192440868790,89861590172420396902,成功,200,0,success,449,EXC20260725100703820423,True,0 +33,89861590192440868791,89861590172420396903,成功,200,0,success,450,EXC20260725100704674967,True,0 +34,89861590192440868792,89861590172420396904,成功,200,0,success,451,EXC20260725100704189980,True,0 +35,89861590192440868793,89861590172420396905,成功,200,0,success,452,EXC20260725100705407023,True,0 +36,89861590192440868794,89861590172420396906,成功,200,0,success,453,EXC20260725100705054506,True,0 +37,89861590192440868795,89861590172420396907,成功,200,0,success,454,EXC20260725100705777775,True,0 +38,89861590192440868796,89861590172420396908,成功,200,0,success,455,EXC20260725100706498354,True,0 +39,89861590192440868797,89861590172420396909,成功,200,0,success,456,EXC20260725100706552556,True,0 +40,89861590192440868798,89861590172420396910,成功,200,0,success,457,EXC20260725100707078714,True,0 +41,89861590192440868799,89861590172420396911,成功,200,0,success,458,EXC20260725100707020565,True,0 +42,89861590192440868800,89861590172420396912,成功,200,0,success,459,EXC20260725100707450199,True,0 +43,89861590192440868801,89861590172420396913,成功,200,0,success,460,EXC20260725100708991089,True,0 +44,89861590192440868802,89861590172420396914,成功,200,0,success,461,EXC20260725100708585061,True,0 +45,89861590192440868803,89861590172420396915,成功,200,0,success,462,EXC20260725100709039744,True,0 +46,89861590192440868804,89861590172420396916,成功,200,0,success,463,EXC20260725100709597546,True,0 +47,89861590192440868805,89861590172420396917,成功,200,0,success,464,EXC20260725100710266570,True,0 +48,89861590192440868806,89861590172420396918,成功,200,0,success,465,EXC20260725100710608095,True,0 +49,89861590192440868807,89861590172420396919,成功,200,0,success,466,EXC20260725100710153407,True,0 +50,89861590192440868808,89861590172420396920,成功,200,0,success,467,EXC20260725100711846810,True,0 +51,89861590192440868809,89861590172420396921,成功,200,0,success,468,EXC20260725100711965320,True,0 +52,89861590192440868810,89861590172420396922,成功,200,0,success,469,EXC20260725100712065439,True,0 +53,89861590192440868811,89861590172420396923,成功,200,0,success,470,EXC20260725100712544448,True,0 +54,89861590192440868812,89861590172420396924,成功,200,0,success,471,EXC20260725100712856220,True,0 +55,89861590192440868813,89861590172420396925,成功,200,0,success,472,EXC20260725100713764741,True,0 +56,89861590192440868815,89861590172420396926,成功,200,0,success,473,EXC20260725100713050476,True,0 +57,89861590192440868816,89861590172420396927,成功,200,0,success,474,EXC20260725100714926718,True,0 +58,89861590192440868817,89861590172420396928,成功,200,0,success,475,EXC20260725100714686073,True,0 +59,89861590192440868818,89861590172420396929,成功,200,0,success,476,EXC20260725100715006061,True,0 +60,89861590192440868819,89861590172420396930,成功,200,0,success,477,EXC20260725100715048062,True,0 +61,89861590192440868820,89861590172420396931,成功,200,0,success,478,EXC20260725100716965603,True,0 +62,89861590192440868821,89861590172420396932,成功,200,0,success,479,EXC20260725100716611939,True,0 +63,89861590192440868822,89861590172420396933,成功,200,0,success,480,EXC20260725100717728074,True,0 +64,89861590192440868823,89861590172420396934,成功,200,0,success,481,EXC20260725100717888167,True,0 +65,89861590192440868824,89861590172420396935,成功,200,0,success,482,EXC20260725100718701220,True,0 +66,89861590192440868825,89861590172420396936,成功,200,0,success,483,EXC20260725100718063631,True,0 +67,89861590192440868826,89861590172420396937,成功,200,0,success,484,EXC20260725100718182100,True,0 +68,89861590192440868827,89861590172420396938,成功,200,0,success,485,EXC20260725100719833701,True,0 +69,89861590192440868828,89861590172420396939,成功,200,0,success,486,EXC20260725100719423016,True,0 +70,89861590192440868829,89861590172420396940,成功,200,0,success,487,EXC20260725100720154106,True,0 +71,89861590192440868831,89861590172420396941,成功,200,0,success,488,EXC20260725100720710282,True,0 +72,89861590192440868832,89861590172420396942,成功,200,0,success,489,EXC20260725100720660552,True,0 +73,89861590192440868833,89861590172420396943,成功,200,0,success,490,EXC20260725100721303351,True,0 +74,89861590192440868835,89861590172420396944,成功,200,0,success,491,EXC20260725100721217915,True,0 +75,89861590192440868866,89861590172420396945,成功,200,0,success,492,EXC20260725100722260176,True,0 +76,89861590192440868867,89861590172420396946,成功,200,0,success,493,EXC20260725100722836672,True,0 +77,89861590192440868868,89861590172420396947,成功,200,0,success,494,EXC20260725100722191616,True,0 +78,89861590192440868870,89861590172420396948,成功,200,0,success,495,EXC20260725100723812093,True,0 +79,89861590192440868871,89861590172420396949,成功,200,0,success,496,EXC20260725100723144974,True,0 +80,89861590192440868873,89861590172420396950,成功,200,0,success,497,EXC20260725100724623540,True,0 +81,89861590192440868874,89861590172420396951,成功,200,0,success,498,EXC20260725100724393623,True,0 +82,89861590192440868875,89861590172420396952,成功,200,0,success,499,EXC20260725100725295962,True,0 +83,89861590192440868876,89861590172420396953,成功,200,0,success,500,EXC20260725100725506162,True,0 +84,89861590192440868877,89861590172420396954,成功,200,0,success,501,EXC20260725100726669511,True,0 +85,89861590192440868878,89861590172420396955,成功,200,0,success,502,EXC20260725100726881081,True,0 +86,89861590192440868879,89861590172420396956,成功,200,0,success,503,EXC20260725100727034865,True,0 +87,89861590192440868880,89861590172420396957,成功,200,0,success,504,EXC20260725100727011800,True,0 +88,89861590192440868881,89861590172420396958,成功,200,0,success,505,EXC20260725100728262506,True,0 +89,89861590192440868882,89861590172420396959,成功,200,0,success,506,EXC20260725100728018182,True,0 +90,89861590192440868883,89861590172420396960,成功,200,0,success,507,EXC20260725100728200607,True,0 +91,89861590192440868884,89861590172420396961,成功,200,0,success,508,EXC20260725100729614220,True,0 +92,89861590192440868886,89861590172420396962,成功,200,0,success,509,EXC20260725100729575526,True,0 +93,89861590192440868887,89861590172420396963,成功,200,0,success,510,EXC20260725100730479152,True,0 +94,89861590192440868888,89861590172420396964,成功,200,0,success,511,EXC20260725100730954396,True,0 +95,89861590192440868889,89861590172420396965,成功,200,0,success,512,EXC20260725100731418918,True,0 +96,89861590192440868890,89861590172420396966,成功,200,0,success,513,EXC20260725100731579444,True,0 +97,89861590192440868891,89861590172420396967,成功,200,0,success,514,EXC20260725100732988847,True,0 +98,89861590192440868892,89861590172420396968,成功,200,0,success,515,EXC20260725100732389826,True,0 +99,89861590192440868893,89861590172420396969,成功,200,0,success,516,EXC20260725100733974528,True,0 +100,89861590192440868894,89861590172420396970,成功,200,0,success,517,EXC20260725100733525591,True,0 +101,89861590192440868895,89861590172420396971,成功,200,0,success,518,EXC20260725100734536123,True,0 +102,89861590192440868896,89861590172420396972,成功,200,0,success,519,EXC20260725100734841615,True,0 +103,89861590192440868897,89861590172420396973,成功,200,0,success,520,EXC20260725100734002630,True,0 +104,89861590192440868898,89861590172420396974,成功,200,0,success,521,EXC20260725100735483889,True,0 +105,89861590192440868899,89861590172420396975,成功,200,0,success,522,EXC20260725100735204630,True,0 +106,89861590192440868900,89861590172420396976,成功,200,0,success,523,EXC20260725100736895661,True,0 +107,89861590192440868901,89861590172420396977,成功,200,0,success,524,EXC20260725100736961218,True,0 +108,89861590192440868902,89861590172420396978,成功,200,0,success,525,EXC20260725100737411135,True,0 +109,89861590192440868903,89861590172420396979,成功,200,0,success,526,EXC20260725100737988932,True,0 +110,89861590192440868904,89861590172420396980,成功,200,0,success,527,EXC20260725100738112193,True,0 +111,89861590192440868905,89861590172420396981,成功,200,0,success,528,EXC20260725100738289346,True,0 +112,89861590192440868906,89861590172420396982,成功,200,0,success,529,EXC20260725100738177934,True,0 +113,89861590192440868907,89861590172420396983,成功,200,0,success,530,EXC20260725100739434515,True,0 +114,89861590192440868908,89861590172420396984,成功,200,0,success,531,EXC20260725100739415292,True,0 +115,89861590192440868909,89861590172420396985,成功,200,0,success,532,EXC20260725100740249886,True,0 +116,89861590192440868910,89861590172420396986,成功,200,0,success,533,EXC20260725100741607583,True,0 +117,89861590192440868911,89861590172420396987,成功,200,0,success,534,EXC20260725100742016321,True,0 +118,89861590192440868912,89861590172420396988,成功,200,0,success,535,EXC20260725100742817184,True,0 +119,89861590192440868913,89861590172420396989,成功,200,0,success,536,EXC20260725100743354372,True,0 +120,89861590192440868914,89861590172420396990,成功,200,0,success,537,EXC20260725100743443810,True,0 +121,89861590192440868915,89861590172420396991,成功,200,0,success,538,EXC20260725100744603100,True,0 +122,89861590192440868953,89861590172420396992,成功,200,0,success,539,EXC20260725100744972891,True,0 +123,89861590192440868954,89861590172420396993,成功,200,0,success,540,EXC20260725100745917651,True,0 +124,89861590192440868955,89861590172420396994,成功,200,0,success,541,EXC20260725100745439091,True,0 +125,89861590192440868956,89861590172420396995,成功,200,0,success,542,EXC20260725100746710285,True,0 +126,89861590192440868957,89861590172420396996,成功,200,0,success,543,EXC20260725100746189692,True,0 +127,89861590192440868958,89861590172420396997,成功,200,0,success,544,EXC20260725100746798717,True,0 +128,89861590192440868959,89861590172420396998,成功,200,0,success,545,EXC20260725100747934769,True,0 +129,89861590192440868960,89861590172420396999,成功,200,0,success,546,EXC20260725100748022130,True,0 +130,89861590192440868961,89861590172420397000,成功,200,0,success,547,EXC20260725100748149639,True,0 +131,89861590192440868962,89861590172420397001,成功,200,0,success,548,EXC20260725100748160790,True,0 +132,89861590192440868963,89861590172420397002,成功,200,0,success,549,EXC20260725100749933184,True,0 +133,89861590192440868964,89861590172420397003,成功,200,0,success,550,EXC20260725100749013229,True,0 +134,89861590192440868965,89861590172420397004,成功,200,0,success,551,EXC20260725100750519700,True,0 +135,89861590192440868966,89861590172420397005,成功,200,0,success,552,EXC20260725100750241264,True,0 +136,89861590192440868967,89861590172420397006,成功,200,0,success,553,EXC20260725100750831728,True,0 +137,89861590192440868968,89861590172420397007,成功,200,0,success,554,EXC20260725100751163363,True,0 +138,89861590192440868969,89861590172420397008,成功,200,0,success,555,EXC20260725100751147128,True,0 +139,89861590192440868970,89861590172420397009,成功,200,0,success,556,EXC20260725100752435210,True,0 +140,89861590192440868971,89861590172420397010,成功,200,0,success,557,EXC20260725100752908425,True,0 +141,89861590192440868972,89861590172420397011,成功,200,0,success,558,EXC20260725100753842884,True,0 +142,89861590192440868973,89861590172420397012,成功,200,0,success,559,EXC20260725100753266584,True,0 +143,89861590192440868974,89861590172420397013,成功,200,0,success,560,EXC20260725100755738038,True,0 +144,89861590192440868975,89861590172420397014,成功,200,0,success,561,EXC20260725100755434390,True,0 +145,89861590192440868976,89861590172420397015,成功,200,0,success,562,EXC20260725100756067074,True,0 +146,89861590192440868977,89861590172420397016,成功,200,0,success,563,EXC20260725100756574393,True,0 +147,89861590192440868978,89861590172420397017,成功,200,0,success,564,EXC20260725100757113048,True,0 +148,89861590192440868979,89861590172420397018,成功,200,0,success,565,EXC20260725100757992110,True,0 +149,89861590192440868980,89861590172420397019,成功,200,0,success,566,EXC20260725100757254405,True,0 +150,89861590192440868981,89861590172420397020,成功,200,0,success,567,EXC20260725100758666047,True,0 +151,89861590192440868982,89861590172420397021,成功,200,0,success,568,EXC20260725100758215742,True,0 +152,89861590192440868983,89861590172420397022,成功,200,0,success,569,EXC20260725100759091300,True,0 +153,89861590192440868984,89861590172420397023,成功,200,0,success,570,EXC20260725100759398994,True,0 +154,89861590192440868985,89861590172420397024,成功,200,0,success,571,EXC20260725100759318526,True,0 +155,89861590192440868987,89861590172420397025,成功,200,0,success,572,EXC20260725100800351313,True,0 +156,89861590192440868988,89861590172420397026,成功,200,0,success,573,EXC20260725100800545823,True,0 +157,89861590192440868989,89861590172420397027,成功,200,0,success,574,EXC20260725100801649823,True,0 +158,89861590192440868990,89861590172420397028,成功,200,0,success,575,EXC20260725100801782178,True,0 +159,89861590192440868991,89861590172420397029,成功,200,0,success,576,EXC20260725100801379166,True,0 +160,89861590192440868993,89861590172420397030,成功,200,0,success,577,EXC20260725100802328922,True,0 +161,89861590192440868995,89861590172420397031,成功,200,0,success,578,EXC20260725100803522394,True,0 +162,89861590192440868997,89861590172420397032,成功,200,0,success,579,EXC20260725100803344145,True,0 +163,89861590192440868998,89861590172420397033,成功,200,0,success,580,EXC20260725100804288456,True,0 +164,89861590192440869001,89861590172420397034,成功,200,0,success,581,EXC20260725100804886423,True,0 +165,89861590192440869002,89861590172420397035,成功,200,0,success,582,EXC20260725100805174189,True,0 +166,89861590192440868926,89861590172420397036,成功,200,0,success,583,EXC20260725100805961740,True,0 +167,89861590192440868927,89861590172420397037,成功,200,0,success,584,EXC20260725100805930588,True,0 +168,89861590192440868928,89861590172420397038,成功,200,0,success,585,EXC20260725100806331811,True,0 +169,89861590192440868929,89861590172420397039,成功,200,0,success,586,EXC20260725100806523422,True,0 +170,89861590192440868930,89861590172420397040,成功,200,0,success,587,EXC20260725100807939565,True,0 +171,89861590192440868931,89861590172420397041,成功,200,0,success,588,EXC20260725100807910109,True,0 +172,89861590192440868932,89861590172420397042,成功,200,0,success,589,EXC20260725100807231651,True,0 +173,89861590192440868933,89861590172420397043,成功,200,0,success,590,EXC20260725100808020742,True,0 +174,89861590192440868934,89861590172420397044,成功,200,0,success,591,EXC20260725100808136434,True,0 +175,89861590192440868935,89861590172420397045,成功,200,0,success,592,EXC20260725100809956855,True,0 +176,89861590192440868936,89861590172420397046,成功,200,0,success,593,EXC20260725100809559200,True,0 +177,89861590192440868937,89861590172420397047,成功,200,0,success,594,EXC20260725100809731455,True,0