9 Commits

Author SHA1 Message Date
邹进
2ead341f2f fix: 换货迁移按套餐是否已开始与新资产占位决定主套餐状态
- 未开始的主套餐(无生效、到期时间且无用量)迁移为待生效,新资产不满足实名条件(行业卡/已实名/无需实名)时标记等实名激活
- 已开始的主套餐在新资产无当前主套餐时原样迁移;新资产已有当前主套餐时改为待生效排队、清空周期与用量
- 迁移后重排新资产主套餐优先级,保证同一载体只有一个当前主套餐
- 换货审计记录套餐状态、优先级、用量的前后值

OpenSpec: fix-exchange-migration-package-state

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 23:14:19 +09:00
邹进
ee5772be2c docs: 记录换货迁移导致主套餐双生效的生产数据修正
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 13:19:04 +09:00
邹进
7c1026d65e fix: C 端设备局域网地址为空时默认 192.168.1.1
网关常不返回设备 lan_ip,H5 点后台管理时提示设备未开机;为空时回落到设备默认网关地址。

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 11:15:28 +09:00
邹进
f44f1be0d1 fix: 换货原因长度上限放宽到 500 字
从微信复制的多行换货说明常超过 100 字,创建换货单时报参数验证失败。
请求校验与模型字段长度同步改为 500;生产库已手动执行 ALTER,迁移文件随 Iteration/8-11 编号补充。

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 11:15:28 +09:00
邹进
adcd4f5e9d feat: 调整验证码短信文案
改为「您的验证码是XXXXXX,请在5分钟内输入验证。如非本人操作,请忽略本短信。」

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 15:56:22 +09:00
邹进
76f7d47167 fix: 卡或绑定设备关闭轮询时不再自动停复机
关闭轮询的卡/设备已导入其他卡管,EvaluateAndAct 入口直接返回,不停机也不复机。

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 15:56:22 +09:00
邹进
e9d8c95b44 fix: 设备管理接口不再拦截代理与企业账号
设备类型选项接口用 router.Group("/devices", 拦截函数) 限制仅平台访问,Fiber 会把该函数注册为整个 /devices 前缀的中间件,导致代理、企业账号的设备列表、设备下的卡、分配回收等接口全部返回 403。改为在 ListDeviceTypes 内部判断用户类型,其他设备接口恢复原有权限。
2026-10-25 17:11:07 +09:00
邹进
7ca79ebf28 fix: 富友订单支持退款,0 元退款不强制客户收款材料映射
- decideRefundMethods 将 fuiou 与 wechat、alipay 按线上支付处理,修复富友订单报"该订单的支付方式不支持退款"
- 客户收款信息退款仅在金额大于 0 时强制映射并提交客户收款信息与凭证;0 元退款允许客户收款信息为空

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 17:00:00 +09:00
邹进
1ffc923b94 feat: 支持 0 元退款(仅作废权益不退钱)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 11:17:29 +09:00
504 changed files with 1656 additions and 44181 deletions

View File

@@ -50,10 +50,8 @@ jobs:
- name: 部署到测试环境(仅八月迭代分支)
if: github.ref == 'refs/heads/Iteration/8-11'
run: |
# 确保部署目录存在:api、worker 各自的日志目录与 worker 的采集记录归档目录(配置已嵌入二进制文件)
mkdir -p ${{ env.DEPLOY_DIR }}/api/logs ${{ env.DEPLOY_DIR }}/worker/logs ${{ env.DEPLOY_DIR }}/worker/archive
# 容器以 uid 1000(appuser)运行,新目录需可写;无权限修改属主时保持原样
chown 1000:1000 ${{ env.DEPLOY_DIR }}/api/logs ${{ env.DEPLOY_DIR }}/worker/logs ${{ env.DEPLOY_DIR }}/worker/archive 2>/dev/null || true
# 确保部署目录存在(仅需日志目录,配置已嵌入二进制文件)
mkdir -p ${{ env.DEPLOY_DIR }}/logs
umask 077
{
@@ -62,8 +60,6 @@ jobs:
printf 'JUNHONG_APPROVAL_LEGACY_OFFLINE_RECHARGE_PAY_ENABLED=true\n'
printf 'JUNHONG_WORKER_ROLE=all\n'
printf 'JUNHONG_WORKER_INSTANCE_NAME=test-worker-all-1\n'
# 测试环境只读模式:tb_gateway_readonly_card 中的生产卡与设备只允许查询类网关调用(add-gateway-readonly-prod-import)
printf 'JUNHONG_GATEWAY_READONLY_ENABLED=true\n'
} > ${{ env.DEPLOY_DIR }}/.env
# 强制更新 docker-compose.prod.yml(确保使用最新配置)

View File

@@ -31,20 +31,13 @@ func generateOpenAPIDocs(outputPath string, logger *zap.Logger) {
handlers.BusinessUserGroup = admin.NewBusinessUserGroupHandler(nil, nil)
handlers.ShopBusinessOwnerImport = admin.NewShopBusinessOwnerImportHandler(nil)
handlers.PhoneAssetAssociation = admin.NewPhoneAssetAssociationHandler(nil, nil)
// 网关限流 Handler 必须同时进入文档工厂,避免新增管理接口遗漏文档注册。
handlers.GatewayRateLimit = admin.NewGatewayRateLimitHandler(nil, nil, nil)
handlers.CarrierScheduler = admin.NewCarrierSchedulerHandler(nil, nil)
handlers.PollingPause = admin.NewPollingPauseHandler(nil, nil, nil)
// 套餐真流量预警 Handler 必须同时进入文档工厂,避免新增管理接口遗漏文档注册。
handlers.PackageTrafficAlert = admin.NewPackageTrafficAlertHandler(nil, nil, nil, nil)
// 资产钱包自动续费配置 Handler 必须同时进入文档工厂,避免新增管理接口遗漏文档注册。
handlers.AssetAutoRenewal = admin.NewAssetAutoRenewalConfigHandler(nil, nil)
// 运营报表 Handler 必须同时进入文档工厂,避免新增管理接口遗漏文档注册。
handlers.OperationsReport = admin.NewOperationsReportHandler(nil, nil, nil)
handlers.StopResumeRule = admin.NewStopResumeRuleHandler(nil, nil, nil)
handlers.ClientPopup = apphandler.NewClientPopupHandler(nil, nil, nil)
handlers.ClientPackageSilence = apphandler.NewClientPackageSilenceHandler(nil, nil, nil)
handlers.PackageSilence = admin.NewPackageSilenceHandler(nil, nil, nil, nil, nil)
handlers.H5PopupConfiguration = admin.NewH5PopupConfigurationHandler(nil, nil, nil)
// 企业微信 Handler 在此显式装配,避免新增管理接口遗漏文档注册。
handlers.WeCom = admin.NewWeComHandler(nil, nil)

View File

@@ -2,7 +2,6 @@ package main
import (
stdErrors "errors"
auditInfra "github.com/break/junhong_cmp_fiber/internal/infrastructure/audit"
"os"
"os/signal"
"reflect"
@@ -23,11 +22,9 @@ import (
"github.com/break/junhong_cmp_fiber/internal/bootstrap"
"github.com/break/junhong_cmp_fiber/internal/gateway"
"github.com/break/junhong_cmp_fiber/internal/infrastructure/gatewaylimit"
internalMiddleware "github.com/break/junhong_cmp_fiber/internal/middleware"
"github.com/break/junhong_cmp_fiber/internal/routes"
"github.com/break/junhong_cmp_fiber/internal/service/verification"
"github.com/break/junhong_cmp_fiber/internal/store/postgres"
"github.com/break/junhong_cmp_fiber/pkg/auth"
pkgbootstrap "github.com/break/junhong_cmp_fiber/pkg/bootstrap"
"github.com/break/junhong_cmp_fiber/pkg/config"
@@ -73,12 +70,7 @@ func main() {
storageSvc := initStorage(cfg, appLogger)
// 9. 初始化 Gateway 客户端(可选)
gatewayClient := initGateway(cfg, db, appLogger)
// 卡网关调用按本系统运营商账户限流与统计(默认只统计不拦截)。
gatewayLimit := gatewaylimit.NewRuntime(db, redisClient, appLogger)
if gatewayClient != nil {
gatewayClient.SetCallGuard(gatewayLimit.Guard)
}
gatewayClient := initGateway(cfg, appLogger)
// 10. 初始化所有业务组件(通过 Bootstrap)
result, err := bootstrap.Bootstrap(&bootstrap.Dependencies{
@@ -91,7 +83,6 @@ func main() {
QueueClient: queueClient,
StorageService: storageSvc,
GatewayClient: gatewayClient,
GatewayLimit: gatewayLimit,
})
if err != nil {
appLogger.Fatal("初始化业务组件失败", zap.Error(err))
@@ -147,7 +138,6 @@ func initLogger(cfg *config.Config) *zap.Logger {
appLogger := logger.GetAppLogger()
appLogger.Info("应用程序启动中...", zap.String("address", cfg.Server.Address))
applyAuditPersistOverrides(cfg, appLogger)
return appLogger
}
@@ -451,7 +441,7 @@ func initStorage(cfg *config.Config, appLogger *zap.Logger) *storage.Service {
return storage.NewService(provider, &cfg.Storage)
}
func initGateway(cfg *config.Config, db *gorm.DB, appLogger *zap.Logger) *gateway.Client {
func initGateway(cfg *config.Config, appLogger *zap.Logger) *gateway.Client {
if cfg.Gateway.BaseURL == "" {
appLogger.Info("Gateway 未配置,跳过初始化")
return nil
@@ -463,10 +453,6 @@ func initGateway(cfg *config.Config, db *gorm.DB, appLogger *zap.Logger) *gatewa
cfg.Gateway.AppSecret,
appLogger,
).WithTimeout(time.Duration(cfg.Gateway.Timeout) * time.Second)
if cfg.Gateway.ReadonlyEnabled {
// 测试环境只读模式:只读资产表中的卡与设备只允许查询类调用(add-gateway-readonly-prod-import)。
client.WithReadonlyChecker(postgres.NewGatewayReadonlyCardStore(db).IsReadonly)
}
appLogger.Info("Gateway 客户端初始化成功",
zap.String("base_url", cfg.Gateway.BaseURL),
@@ -474,10 +460,3 @@ func initGateway(cfg *config.Config, db *gorm.DB, appLogger *zap.Logger) *gatewa
return client
}
// applyAuditPersistOverrides 加载审计落库策略的运行时覆盖;非法项忽略并告警。
func applyAuditPersistOverrides(cfg *config.Config, appLogger *zap.Logger) {
if invalid := auditInfra.SetPersistOverrides(cfg.Audit.PersistOverrides); len(invalid) > 0 {
appLogger.Warn("审计落库策略覆盖存在非法项,已忽略", zap.Strings("invalid", invalid))
}
}

View File

@@ -40,20 +40,13 @@ func generateAdminDocs(outputPath string) error {
handlers.BusinessUserGroup = admin.NewBusinessUserGroupHandler(nil, nil)
handlers.ShopBusinessOwnerImport = admin.NewShopBusinessOwnerImportHandler(nil)
handlers.PhoneAssetAssociation = admin.NewPhoneAssetAssociationHandler(nil, nil)
// 网关限流 Handler 必须同时进入文档工厂,避免新增管理接口遗漏文档注册。
handlers.GatewayRateLimit = admin.NewGatewayRateLimitHandler(nil, nil, nil)
handlers.CarrierScheduler = admin.NewCarrierSchedulerHandler(nil, nil)
handlers.PollingPause = admin.NewPollingPauseHandler(nil, nil, nil)
// 套餐真流量预警 Handler 必须同时进入文档工厂,避免新增管理接口遗漏文档注册。
handlers.PackageTrafficAlert = admin.NewPackageTrafficAlertHandler(nil, nil, nil, nil)
// 资产钱包自动续费配置 Handler 必须同时进入文档工厂,避免新增管理接口遗漏文档注册。
handlers.AssetAutoRenewal = admin.NewAssetAutoRenewalConfigHandler(nil, nil)
// 运营报表 Handler 必须同时进入文档工厂,避免新增管理接口遗漏文档注册。
handlers.OperationsReport = admin.NewOperationsReportHandler(nil, nil, nil)
handlers.StopResumeRule = admin.NewStopResumeRuleHandler(nil, nil, nil)
handlers.ClientPopup = apphandler.NewClientPopupHandler(nil, nil, nil)
handlers.ClientPackageSilence = apphandler.NewClientPackageSilenceHandler(nil, nil, nil)
handlers.PackageSilence = admin.NewPackageSilenceHandler(nil, nil, nil, nil, nil)
handlers.H5PopupConfiguration = admin.NewH5PopupConfigurationHandler(nil, nil, nil)
// 企业微信 Handler 在此显式装配,避免新增管理接口遗漏文档注册。
handlers.WeCom = admin.NewWeComHandler(nil, nil)

View File

@@ -1,115 +0,0 @@
// silence-backfill 是沉默期与首次购买套餐时间历史回填工具(add-package-silence-period,技术方案 §6、§17.4)。
//
// 用法(数据库连接只读取显式传入的 DB_* 环境变量):
//
// DB_HOST=... DB_PORT=... DB_USER=... DB_PASSWORD=... DB_NAME=... DB_SSLMODE=disable \
// go run ./cmd/silence-backfill -target silence # 只输出影响清单(默认 dry-run)
// go run ./cmd/silence-backfill -target silence -execute # 业务确认清单后执行
// go run ./cmd/silence-backfill -target first_purchase # 首次购买套餐时间:只输出可回填数与无订单数
// go run ./cmd/silence-backfill -target first_purchase -execute
//
// 两种回填都只处理为空的记录、分批执行、可重复执行;可在沉默期总开关关闭时运行。
package main
import (
"context"
"encoding/json"
"flag"
"fmt"
"os"
"strings"
"time"
"gorm.io/driver/postgres"
"gorm.io/gorm"
"gorm.io/gorm/logger"
packagesilenceapp "github.com/break/junhong_cmp_fiber/internal/application/packagesilence"
auditInfra "github.com/break/junhong_cmp_fiber/internal/infrastructure/audit"
"github.com/break/junhong_cmp_fiber/pkg/auditcontext"
"github.com/break/junhong_cmp_fiber/pkg/constants"
)
func main() {
target := flag.String("target", "", "回填对象:silence(套餐沉默期)/ first_purchase(资产首次购买套餐时间)")
execute := flag.Bool("execute", false, "执行写入;不传时只输出影响清单")
flag.Parse()
if *target != "silence" && *target != "first_purchase" {
fail("必须指定 -target silence 或 -target first_purchase")
}
db, err := openDB()
if err != nil {
fail(err.Error())
}
ctx := auditcontext.With(context.Background(), auditcontext.Context{
ActorKind: constants.AuditActorSystemTask, ActorID: "silence-backfill", ActorName: "沉默期历史回填工具",
Source: constants.AuditSourceWorker,
})
backfill := packagesilenceapp.NewBackfill(db, auditInfra.NewWriter(auditInfra.NewRegistry(), nil))
started := time.Now()
mode := "dry-run(只出清单)"
if *execute {
mode = "execute(执行写入)"
}
fmt.Printf("目标库:%s,对象:%s,模式:%s\n", os.Getenv("DB_NAME"), *target, mode)
var report any
switch *target {
case "silence":
silence, err := backfill.SilenceBackfill(ctx, *execute)
if err != nil {
fail(err.Error())
}
printSilence(silence)
report = silence
case "first_purchase":
firstPurchase, err := backfill.FirstPurchaseBackfill(ctx, *execute)
if err != nil {
fail(err.Error())
}
report = firstPurchase
}
encoded, _ := json.MarshalIndent(report, "", " ")
fmt.Println(string(encoded))
fmt.Printf("耗时:%s\n", time.Since(started).Round(time.Millisecond))
}
func printSilence(report *packagesilenceapp.SilenceBackfillReport) {
fmt.Printf("待回填记录 %d,可回填 %d,未配置默认沉默期 %d(不回填)\n", report.Candidates, report.Backfillable, report.UnconfiguredCount)
fmt.Printf("期内已实名(记为结束)%d,其中实名时间为空 %d;仍在期内 %d;打开总开关后将过期 %d,其中无法付费延期 %d\n",
report.EndedInPeriod, report.RealnamedWithoutTime, report.Waiting, report.WillExpire, report.WillExpireNoPaidExtend)
fmt.Println("按代理分布:")
for _, item := range packagesilenceapp.SortedCounts(report.ByShop) {
fmt.Printf(" %s:%d\n", item.Name, item.Count)
}
fmt.Println("按套餐分布:")
for _, item := range packagesilenceapp.SortedCounts(report.ByPackage) {
fmt.Printf(" %s:%d\n", item.Name, item.Count)
}
if len(report.UnconfiguredPackages) > 0 {
fmt.Println("未配置默认沉默期的套餐:")
for _, item := range packagesilenceapp.SortedCounts(report.UnconfiguredPackages) {
fmt.Printf(" %s:%d\n", item.Name, item.Count)
}
}
}
func openDB() (*gorm.DB, error) {
required := []string{"DB_HOST", "DB_PORT", "DB_USER", "DB_PASSWORD", "DB_NAME"}
for _, key := range required {
if strings.TrimSpace(os.Getenv(key)) == "" {
return nil, fmt.Errorf("缺少环境变量 %s(只使用显式传入的数据库连接)", key)
}
}
sslMode := os.Getenv("DB_SSLMODE")
if sslMode == "" {
sslMode = "disable"
}
dsn := fmt.Sprintf("host=%s port=%s user=%s password=%s dbname=%s sslmode=%s TimeZone=Asia/Shanghai",
os.Getenv("DB_HOST"), os.Getenv("DB_PORT"), os.Getenv("DB_USER"), os.Getenv("DB_PASSWORD"), os.Getenv("DB_NAME"), sslMode)
return gorm.Open(postgres.Open(dsn), &gorm.Config{Logger: logger.Default.LogMode(logger.Warn)})
}
func fail(message string) {
fmt.Fprintln(os.Stderr, "回填失败:"+message)
os.Exit(1)
}

View File

@@ -1,356 +0,0 @@
package main
import (
"context"
"encoding/json"
"os"
"strconv"
"strings"
"sync"
"time"
"go.uber.org/zap"
cardObservationApp "github.com/break/junhong_cmp_fiber/internal/application/cardobservation"
"github.com/break/junhong_cmp_fiber/internal/infrastructure/gatewaylimit"
"github.com/break/junhong_cmp_fiber/internal/infrastructure/messaging/outbox"
"github.com/break/junhong_cmp_fiber/internal/infrastructure/nsqbus"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/internal/polling"
"github.com/break/junhong_cmp_fiber/internal/polling/carrierqueue"
iot_card_svc "github.com/break/junhong_cmp_fiber/internal/service/iot_card"
"github.com/break/junhong_cmp_fiber/internal/task"
"github.com/break/junhong_cmp_fiber/pkg/config"
"github.com/break/junhong_cmp_fiber/pkg/constants"
"github.com/break/junhong_cmp_fiber/pkg/queue"
)
// carrierInitLockKey 新结构初始化锁:多实例时只由一个实例初始化。
const carrierInitLockKey = "polling:carrier:init_lock"
// carrierSchedulerRuntime 是按运营商账户调度在 Worker 内的运行组件:模式监听、初始化、分发器与 NSQ 消费者。
type carrierSchedulerRuntime struct {
cfg config.CarrierSchedulerConfig
runtime *workerRuntime
initializer *polling.CarrierInitializer
dispatcher *carrierqueue.Dispatcher
consumers *nsqbus.ConsumerManager
producer *nsqbus.Producer
alerts *carrierqueue.AlertWriter
executor *task.CarrierCardExecutor
// 停复机指令与设备判断的专用消费者:只看停复机出口开关,不受 scheduler_mode 限制。
srConsumers *nsqbus.ConsumerManager
devConsumers *nsqbus.ConsumerManager
srActive bool
// ingest 是轮询观测入库消费者与积压监控(add-polling-async-ingest);未配置 nsqd 时为 nil。
ingest *pollingIngestRuntime
singleton bool
instanceID string
logger *zap.Logger
mu sync.Mutex
lastMode string
reinitQueued bool
legacyRestarts func()
}
// newCarrierSchedulerParts 在 Worker 运行时初始化阶段创建调度参数、账户队列存储与模式接缝,
// 并注入旧调度相关组件(生命周期、轮询基类),使其在启动前即可按模式写入正确结构。
func newCarrierSchedulerParts(runtime *workerRuntime, appLogger *zap.Logger) {
settings := carrierqueue.NewDefaultSettingsProvider(runtime.db, runtime.redisClient, appLogger)
store := carrierqueue.NewStore(runtime.redisClient)
scheduling := polling.NewCarrierScheduling(store, settings, runtime.pollingConfigMgr, appLogger)
runtime.carrierSettings = settings
runtime.carrierStore = store
runtime.carrierScheduling = scheduling
runtime.lifecycleSvc.SetCarrierScheduling(scheduling)
runtime.pollingBase.SetSchedulerModeReader(func(ctx context.Context) string { return settings.Current(ctx).Mode })
if stopResumeSvc, ok := runtime.workerResult.Services.StopResumeService.(*iot_card_svc.StopResumeService); ok {
stopResumeSvc.SetCommandQueue(carrierqueue.NewCommandQueue(store, settings))
}
if runtime.gatewayLimit != nil && runtime.gatewayLimit.Classifier != nil {
classifier := runtime.gatewayLimit.Classifier
runtime.pollingBase.SetErrorClassifier(func(ctx context.Context, op, message string) string {
return classifier.Classify(ctx, op, message).Category
})
}
}
// startCarrierScheduler 启动模式监听;分发器只在 leader/all 运行(多实例安全),消费者在所有处理任务的实例运行。
func startCarrierScheduler(ctx context.Context, cfg *config.Config, runtime *workerRuntime, steps queue.PollingStepHandlers,
singleton bool, legacyRestart func(), appLogger *zap.Logger) *carrierSchedulerRuntime {
carrierCfg := cfg.Polling.Carrier
instanceID := cfg.Worker.InstanceName
if instanceID == "" {
instanceID, _ = os.Hostname()
}
instanceID += "-" + strconv.Itoa(os.Getpid())
alerts := carrierqueue.NewAlertWriter(runtime.db, runtime.redisClient, runtime.carrierSettings, appLogger)
alerts.Start(ctx)
executor := task.NewCarrierCardExecutor(runtime.pollingBase, runtime.carrierStore, runtime.carrierSettings, alerts,
steps.Realname, steps.Carddata, steps.CardStatus, steps.Package, carrierCfg, instanceID)
c := &carrierSchedulerRuntime{
cfg: carrierCfg, runtime: runtime, singleton: singleton, instanceID: instanceID, logger: appLogger,
alerts: alerts, executor: executor, legacyRestarts: legacyRestart,
initializer: polling.NewCarrierInitializer(runtime.carrierScheduling, runtime.pollingIotCardStore, appLogger),
}
if carrierCfg.NSQDAddress != "" {
producer, err := nsqbus.NewProducer(carrierCfg.NSQDAddress, appLogger)
if err != nil {
appLogger.Error("创建 NSQ 生产者失败,按运营商账户调度不可用", zap.Error(err))
} else {
c.producer = producer
}
c.consumers = nsqbus.NewConsumerManager(nsqbus.ConsumerConfig{
NSQDAddress: carrierCfg.NSQDAddress, NSQLookupdAddresses: nsqbus.SplitAddresses(carrierCfg.NSQLookupdAddresses),
MsgTimeout: carrierCfg.MsgTimeout, MaxAttempts: carrierCfg.MaxAttempts,
}, constants.NSQChannelWorker, executor.Handle, appLogger)
}
c.ingest = startPollingIngest(ctx, cfg, runtime, c.producer, singleton, c.grace(), appLogger)
c.setupStopResume(carrierCfg, alerts)
var budgets carrierqueue.BudgetProvider
if runtime.gatewayLimit != nil && runtime.gatewayLimit.Limiter != nil {
budgets = runtime.gatewayLimit.Limiter
}
if c.producer != nil {
c.dispatcher = carrierqueue.NewDispatcher(runtime.db, runtime.carrierStore, runtime.carrierSettings, c.producer, budgets,
carrierCfg, instanceID, appLogger)
if singleton {
go c.dispatcher.Run(ctx)
}
}
go c.watch(ctx)
return c
}
// RequestReinit 轮询配置变更时请求按新配置重新初始化新结构(仅 carrier 模式生效)。
func (c *carrierSchedulerRuntime) RequestReinit() {
if c == nil {
return
}
c.mu.Lock()
c.reinitQueued = true
c.mu.Unlock()
}
func (c *carrierSchedulerRuntime) watch(ctx context.Context) {
ticker := time.NewTicker(3 * time.Second)
defer ticker.Stop()
for {
c.step(ctx)
select {
case <-ctx.Done():
c.shutdown()
return
case <-ticker.C:
}
}
}
func (c *carrierSchedulerRuntime) step(ctx context.Context) {
defer func() {
if r := recover(); r != nil {
c.logger.Error("按运营商账户调度模式监听 panic,已恢复", zap.Any("panic", r))
}
}()
c.syncStopResume(ctx)
mode := c.runtime.carrierSettings.Current(ctx).Mode
c.mu.Lock()
previous := c.lastMode
c.lastMode = mode
reinit := c.reinitQueued
c.reinitQueued = false
c.mu.Unlock()
if mode == constants.PollingSchedulerModeCarrier {
if previous != mode {
c.logger.Info("卡轮询调度模式:按运营商账户调度", zap.String("previous", previous))
}
if c.singleton {
c.ensureInitialized(ctx, reinit)
}
c.syncConsumers(ctx)
return
}
if previous == constants.PollingSchedulerModeCarrier {
c.logger.Info("卡轮询调度模式回到旧分片调度:停止领取与消费,重建旧分片队列")
if c.consumers != nil {
c.consumers.StopAll(c.grace())
c.consumers.Reset()
}
if c.singleton {
c.runtime.redisClient.Del(ctx, constants.RedisCarrierSchedulerInitStateKey)
if c.legacyRestarts != nil {
c.legacyRestarts()
}
}
}
}
// ensureInitialized 切到 carrier 后初始化新结构;初始化完成前分发器不领取。多实例时用初始化锁协调。
func (c *carrierSchedulerRuntime) ensureInitialized(ctx context.Context, reinit bool) {
redisClient := c.runtime.redisClient
state, _ := redisClient.Get(ctx, constants.RedisCarrierSchedulerInitStateKey).Result()
if state == carrierqueue.InitStateDone && !reinit {
return
}
ok, err := redisClient.SetNX(ctx, carrierInitLockKey, c.instanceID, 30*time.Minute).Result()
if err != nil || !ok {
return
}
defer redisClient.Del(context.WithoutCancel(ctx), carrierInitLockKey)
if state != carrierqueue.InitStateDone {
redisClient.Set(ctx, constants.RedisCarrierSchedulerInitStateKey, carrierqueue.InitStateRunning, 0)
}
started := time.Now()
written, initErr := c.initializer.Run(ctx)
if initErr != nil {
c.logger.Error("按运营商账户调度初始化失败,下一轮重试", zap.Error(initErr))
return
}
redisClient.Set(ctx, constants.RedisCarrierSchedulerInitStateKey, carrierqueue.InitStateDone, 0)
c.logger.Info("按运营商账户调度初始化完成,开始领取", zap.Int64("cards", written), zap.Duration("duration", time.Since(started)),
zap.String("metric", "polling.carrier.initialized"))
}
// syncConsumers 按当前运营商账户订阅卡任务 topic,max_in_flight 取账户调度参数。
func (c *carrierSchedulerRuntime) syncConsumers(ctx context.Context) {
if c.consumers == nil || c.dispatcher == nil {
return
}
desired := map[string]int{}
for _, carrierID := range c.dispatcher.CarrierIDs(ctx) {
desired[constants.NSQCardTopic(carrierID)] = c.dispatcher.ParamsFor(carrierID).CardMaxInFlight
}
c.consumers.Sync(desired)
}
func (c *carrierSchedulerRuntime) grace() time.Duration {
if c.cfg.ShutdownGrace > 0 {
return c.cfg.ShutdownGrace
}
return 30 * time.Second
}
// setupStopResume 创建停复机指令执行器、设备判断执行器与两个专用消费者。
func (c *carrierSchedulerRuntime) setupStopResume(carrierCfg config.CarrierSchedulerConfig, alerts *carrierqueue.AlertWriter) {
service, ok := c.runtime.workerResult.Services.StopResumeService.(*iot_card_svc.StopResumeService)
if !ok || carrierCfg.NSQDAddress == "" {
return
}
var classifier *gatewaylimit.Classifier
if c.runtime.gatewayLimit != nil {
classifier = c.runtime.gatewayLimit.Classifier
}
params := func(carrierID uint) carrierqueue.CarrierParams {
if c.dispatcher != nil {
return c.dispatcher.ParamsFor(carrierID)
}
return carrierqueue.MergeParams(model.CarrierPollingParam{}, false, carrierCfg)
}
srExecutor := task.NewStopResumeExecutor(c.runtime.carrierStore, c.runtime.carrierSettings, params, alerts, service, classifier,
c.instanceID, c.logger)
devExecutor := task.NewDeviceEvalExecutor(c.runtime.carrierStore, service, c.instanceID, c.logger)
consumerCfg := nsqbus.ConsumerConfig{
NSQDAddress: carrierCfg.NSQDAddress, NSQLookupdAddresses: nsqbus.SplitAddresses(carrierCfg.NSQLookupdAddresses),
MsgTimeout: carrierCfg.MsgTimeout, MaxAttempts: carrierCfg.MaxAttempts,
}
c.srConsumers = nsqbus.NewConsumerManager(consumerCfg, constants.NSQChannelWorker, srExecutor.Handle, c.logger)
c.devConsumers = nsqbus.NewConsumerManager(consumerCfg, constants.NSQChannelWorker, devExecutor.Handle, c.logger)
}
// syncStopResume 停复机出口开关开启时订阅各账户停复机 topic 与设备判断 topic;关闭时停止订阅(队列中的指令保留)。
func (c *carrierSchedulerRuntime) syncStopResume(ctx context.Context) {
if c.srConsumers == nil || c.dispatcher == nil {
return
}
enabled := c.runtime.carrierSettings.Current(ctx).StopResumeQueueEnabled
if !enabled {
if c.srActive {
c.logger.Info("停复机出口开关已关闭:停止停复机指令消费,未执行的指令保留")
c.srConsumers.StopAll(c.grace())
c.srConsumers.Reset()
c.devConsumers.StopAll(c.grace())
c.devConsumers.Reset()
c.srActive = false
}
return
}
if !c.srActive {
c.logger.Info("停复机出口开关已开启:自动停复机经停复机指令队列执行")
c.srActive = true
}
desired := map[string]int{}
for _, carrierID := range c.dispatcher.CarrierIDs(ctx) {
desired[constants.NSQStopResumeTopic(carrierID)] = c.dispatcher.ParamsFor(carrierID).SRMaxInFlight
}
c.srConsumers.Sync(desired)
c.devConsumers.Sync(map[string]int{constants.NSQDeviceEvalTopic: deviceEvalMaxInFlight})
}
// deviceEvalMaxInFlight 是每进程设备判断消费者并发。
const deviceEvalMaxInFlight = 10
// shutdown 优雅退出:停止订阅,宽限期内等待执行中的卡任务完成;未完成的由租约到期后重新领取。
func (c *carrierSchedulerRuntime) shutdown() {
// 三类消费者并行停止,共用同一宽限期。
var wg sync.WaitGroup
for _, manager := range []*nsqbus.ConsumerManager{c.consumers, c.srConsumers, c.devConsumers} {
if manager == nil {
continue
}
wg.Add(1)
go func(m *nsqbus.ConsumerManager) {
defer wg.Done()
m.StopAll(c.grace())
}(manager)
}
wg.Wait()
// 卡任务消费者停止后不再产生入库消息,再停入库与采集记录消费者,最后停生产者。
c.ingest.shutdown()
if c.producer != nil {
c.producer.Stop()
}
}
// InitProgress 返回新结构初始化进度。
func (c *carrierSchedulerRuntime) InitProgress() polling.InitProgress {
return c.initializer.Progress()
}
// carrierAwareSeriesConsumer 是业务事件观测序列 Outbox 消费者的模式接缝:按运营商账户调度时把业务事件改为按场景请求检查
// (事件在原业务事务中写入,消费即在事务提交之后),旧分片调度时沿用原观测序列。
type carrierAwareSeriesConsumer struct {
legacy outbox.EventConsumer
requester *polling.CheckRequester
}
// Consume 消费业务观测序列请求事件。
func (c *carrierAwareSeriesConsumer) Consume(ctx context.Context, envelope outbox.DeliveryEnvelope) error {
if c.requester == nil || !c.requester.Active(ctx) {
return c.legacy.Consume(ctx, envelope)
}
var event cardObservationApp.SeriesRequestedEvent
if err := json.Unmarshal(envelope.Payload, &event); err != nil {
return nil
}
cardIDs := []uint{event.ResourceID}
if event.ResourceType == constants.CardObservationResourceTypeDevice {
cardIDs = event.ResourceIDs
}
c.requester.RequestCards(ctx, businessEventCheckScene(event), cardIDs, event.ExpectedValue)
return nil
}
// businessEventCheckScene 把业务观测事件映射为请求检查场景:订单支付、套餐激活、自动续费购包按事件来源区分,停复机成功按观测场景。
func businessEventCheckScene(event cardObservationApp.SeriesRequestedEvent) string {
switch {
case strings.HasPrefix(event.EventID, "card-observation:order:"):
return constants.PollingCheckSceneOrderPaid
case strings.HasPrefix(event.EventID, "card-observation:auto-purchase:"), strings.HasPrefix(event.EventID, "asset-auto-renewal:"):
return constants.PollingCheckSceneAutoRenewalPurchase
case event.Scene == constants.CardObservationSceneBusinessStop || event.Scene == constants.CardObservationSceneBusinessResume:
return constants.PollingCheckSceneStopResumeSucceeded
default:
return constants.PollingCheckScenePackageActivated
}
}

View File

@@ -3,7 +3,6 @@ package main
import (
"context"
"fmt"
packagesilenceApp "github.com/break/junhong_cmp_fiber/internal/application/packagesilence"
"os"
"os/signal"
"strconv"
@@ -28,7 +27,6 @@ import (
refundchannelApp "github.com/break/junhong_cmp_fiber/internal/application/refundchannel"
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/domain/cardflow"
"github.com/break/junhong_cmp_fiber/internal/gateway"
approvalInfra "github.com/break/junhong_cmp_fiber/internal/infrastructure/approval"
assetAutoRenewalInfra "github.com/break/junhong_cmp_fiber/internal/infrastructure/assetautorenewal"
@@ -36,7 +34,6 @@ import (
cardObservationInfra "github.com/break/junhong_cmp_fiber/internal/infrastructure/cardobservation"
carrierThresholdInfra "github.com/break/junhong_cmp_fiber/internal/infrastructure/carrierthreshold"
commissionDelivery "github.com/break/junhong_cmp_fiber/internal/infrastructure/commissiondelivery"
"github.com/break/junhong_cmp_fiber/internal/infrastructure/gatewaylimit"
"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"
@@ -47,7 +44,6 @@ import (
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"
"github.com/break/junhong_cmp_fiber/internal/polling/carrierqueue"
iot_card_svc "github.com/break/junhong_cmp_fiber/internal/service/iot_card"
pollingSvc "github.com/break/junhong_cmp_fiber/internal/service/polling"
refundSvc "github.com/break/junhong_cmp_fiber/internal/service/refund"
@@ -103,14 +99,6 @@ type workerRuntime struct {
carrierThresholdService *carrierThresholdApp.Service
// assetAutoRenewalService 是资产钱包自动续费的每日扫描、复机消费者与恢复扫描的唯一用例实例。
assetAutoRenewalService *assetAutoRenewalApp.Service
// gatewayLimit 是卡网关运营商账户限流组件(限流、统计、停复机记录)。
gatewayLimit *gatewaylimit.Runtime
// 按运营商账户调度:调度参数、账户队列存储与模式接缝(模式为 carrier 时各入口写新结构)。
carrierSettings *carrierqueue.SettingsProvider
carrierStore *carrierqueue.Store
carrierScheduling *polling.CarrierScheduling
// carrierReinit 在 carrier 模式下轮询配置变更时请求重新初始化新结构。
carrierReinit func()
}
func main() {
@@ -151,9 +139,6 @@ func runWorker(cfg *config.Config) {
defer func() {
_ = logger.Sync() // 忽略 sync 错误
}()
if invalid := auditInfra.SetPersistOverrides(cfg.Audit.PersistOverrides); len(invalid) > 0 {
logger.GetAppLogger().Warn("审计落库策略覆盖存在非法项,已忽略", zap.Strings("invalid", invalid))
}
retentionLogger, syncRetentionLogger := logger.NewRetentionLogger(cfg.Logging.Level, logger.LogRotationConfig{
Filename: cfg.Logging.RetentionLog.Filename, MaxSize: cfg.Logging.RetentionLog.MaxSize,
MaxBackups: cfg.Logging.RetentionLog.MaxBackups, MaxAge: cfg.Logging.RetentionLog.MaxAge, Compress: cfg.Logging.RetentionLog.Compress,
@@ -177,19 +162,12 @@ func runWorker(cfg *config.Config) {
if runsSingletonModules(cfg.Worker.Role) {
pollingInitializer = startPollingInitializer(ctx, runtime, appLogger)
startTrafficCollectionPartitionMaintainer(ctx, runtime, appLogger)
pollingScheduler = startPollingScheduler(ctx, runtime, pollingInitializer, appLogger)
asynqScheduler = startAsynqScheduler(cfg, runtime.redisAddr, appLogger)
}
taskHandler := createTaskHandler(runtime, appLogger)
taskHandler.RegisterHandlers()
carrierRuntime := startCarrierScheduler(ctx, cfg, runtime, taskHandler.PollingSteps(), runsSingletonModules(cfg.Worker.Role), func() {
if pollingInitializer != nil {
pollingInitializer.Restart(ctx)
}
}, appLogger)
runtime.carrierReinit = carrierRuntime.RequestReinit
registerWeComApprovalTasks(taskHandler.GetMux(), runtime, cfg, appLogger)
registerAgentRechargeRecoveryTask(taskHandler.GetMux(), runtime, appLogger)
registerRefundChannelRecoveryTask(taskHandler.GetMux(), runtime, appLogger)
@@ -200,16 +178,6 @@ func runWorker(cfg *config.Config) {
registerAuditArchiveTask(taskHandler.GetMux(), runtime, cfg.Worker.AuditRetentionCleanupEnabled, cfg.Worker.AuditArchiveTasksEnabled, appLogger, retentionLogger)
outboxHandler := outbox.NewHandler(runtime.outboxConsumers)
taskHandler.GetMux().HandleFunc(constants.TaskTypeOutboxDeliver, outboxHandler.Handle)
partitionMaintainer := gatewaylimit.NewPartitionMaintainer(postgres.NewCardStopResumeRecordStore(runtime.db), runtime.gatewayLimit.Settings, appLogger)
taskHandler.GetMux().HandleFunc(constants.TaskTypeStopResumeRecordPartition, partitionMaintainer.Handle)
if runtime.workerResult.Services.AssetPolling != nil {
importHandler := task.NewPollingStatusImportHandler(runtime.db, runtime.storageSvc, runtime.workerResult.Services.AssetPolling, appLogger)
taskHandler.GetMux().HandleFunc(constants.TaskTypePollingStatusImport, importHandler.Handle)
}
dueRecalculator := polling.NewDueRecalculator(runtime.db, runtime.redisClient, runtime.pollingConfigMgr, runtime.pollingQueueMgr, appLogger)
dueRecalculator.SetCarrierScheduling(runtime.carrierScheduling)
recalcHandler := polling.NewRecalculateTaskHandler(dueRecalculator, runtime.redisClient, appLogger)
taskHandler.GetMux().HandleFunc(constants.TaskTypePollingRecalculateDue, recalcHandler.Handle)
startOutboxRelay(ctx, runtime, cfg.Worker.InstanceName, appLogger)
rescuePendingImportTasks(ctx, runtime, appLogger)
@@ -297,12 +265,7 @@ func initWorkerRuntime(ctx context.Context, cfg *config.Config, appLogger *zap.L
}
storageSvc := initStorage(cfg, appLogger)
gatewayClient := initGateway(cfg, db, appLogger)
// 卡网关调用按本系统运营商账户限流与统计(默认只统计不拦截)。
gatewayLimit := gatewaylimit.NewRuntime(db, redisClient, appLogger)
if gatewayClient != nil {
gatewayClient.SetCallGuard(gatewayLimit.Guard)
}
gatewayClient := initGateway(cfg, appLogger)
asynqClient := asynq.NewClient(asynq.RedisClientOpt{
Addr: redisAddr,
@@ -319,7 +282,6 @@ func initWorkerRuntime(ctx context.Context, cfg *config.Config, appLogger *zap.L
QueueClient: workerQueueClient,
StorageService: storageSvc,
GatewayClient: gatewayClient,
GatewayLimit: gatewayLimit,
}
workerResult, err := bootstrap.BootstrapWorker(workerDeps)
@@ -327,19 +289,11 @@ func initWorkerRuntime(ctx context.Context, cfg *config.Config, appLogger *zap.L
appLogger.Fatal("Worker Bootstrap 失败", zap.Error(err))
}
workerServer := queue.NewServer(redisClient, &cfg.Queue, appLogger, queue.WithRateLimitedRetry(queue.RateLimitedRetry{
Match: gateway.IsRateLimited,
Delay: func() time.Duration {
return gatewayLimit.Settings.Current(context.Background()).RetryDelay
},
OnDeferred: gatewayLimit.TrackDeferral,
}))
workerServer := queue.NewServer(redisClient, &cfg.Queue, appLogger)
outboxQueueClient := workerQueueClient
pollingConfigStore := postgres.NewPollingConfigStore(db)
pollingConfigMgr := polling.NewPollingConfigManager(pollingConfigStore, redisClient, appLogger)
pollingConfigMgr.SetTierResolver(polling.NewTierResolver(postgres.NewPackageUsageStore(db, redisClient),
postgres.NewDeviceSimBindingStore(db, redisClient), postgres.NewCarrierStore(db), appLogger))
if err := pollingConfigMgr.Load(ctx); err != nil {
appLogger.Warn("加载轮询配置失败,使用空配置继续", zap.Error(err))
}
@@ -370,16 +324,9 @@ func initWorkerRuntime(ctx context.Context, cfg *config.Config, appLogger *zap.L
cfg.Polling.VerboseLog,
cfg.Worker.PollingTotalMaxConcurrency,
)
pollingBase.SetRateLimitedDelay(func() time.Duration {
return gatewayLimit.Settings.Current(context.Background()).RetryDelay
})
if stopResumeSvc, ok := workerResult.Services.StopResumeService.(*iot_card_svc.StopResumeService); ok {
stopResumeSvc.SetPollingCallback(lifecycleSvc)
}
if workerResult.Services.IotCardPolling != nil {
// 轮询开关导入与订单自动恢复轮询经该卡服务改开关,开启后按生命周期重新入队。
workerResult.Services.IotCardPolling.SetPollingCallback(lifecycleSvc)
}
if workerResult.Services.CarrierThreshold == nil {
appLogger.Fatal("运营商通道流量阈值用例未配置")
}
@@ -390,7 +337,6 @@ func initWorkerRuntime(ctx context.Context, cfg *config.Config, appLogger *zap.L
db: db,
storageSvc: storageSvc,
gatewayClient: gatewayClient,
gatewayLimit: gatewayLimit,
asynqClient: asynqClient,
workerResult: workerResult,
workerServer: workerServer,
@@ -406,7 +352,6 @@ func initWorkerRuntime(ctx context.Context, cfg *config.Config, appLogger *zap.L
// 自动续费的复机执行同样复用既有停复机单一事实源。
assetAutoRenewalService: workerResult.Services.AssetAutoRenewal,
}
newCarrierSchedulerParts(runtime, appLogger)
registerNotificationOutboxConsumer(runtime, appLogger)
registerWalletOutboxConsumer(runtime, appLogger)
registerCardObservationOutboxConsumer(runtime, appLogger)
@@ -569,8 +514,6 @@ func registerWeComApprovalOutboxConsumer(runtime *workerRuntime, cfg *config.Con
constants.ApprovalBusinessTypeCommissionWithdrawal: distributionwithdrawalApp.NewWithdrawalApprovalHandler(
runtime.db, auditWriter,
),
// 沉默期审批(购买时调整、线下付费延期、特殊延期):总开关关闭时照常处理
constants.ApprovalBusinessTypePackageSilence: packagesilenceApp.NewDecisionHandler(runtime.workerResult.Services.SilenceExtensions),
},
owner,
appLogger,
@@ -742,10 +685,7 @@ func registerCardObservationOutboxConsumer(runtime *workerRuntime, appLogger *za
queue.NewCardObservationSeriesScheduler(runtime.outboxQueueClient),
cardObservationInfra.NewSeriesAttemptLogger(integrationlog.NewRepository(runtime.db)),
)
seriesConsumer := &carrierAwareSeriesConsumer{
legacy: cardObservationInfra.NewSeriesRequestedConsumer(seriesTrigger),
requester: polling.NewCheckRequester(runtime.carrierScheduling, runtime.db, appLogger),
}
seriesConsumer := cardObservationInfra.NewSeriesRequestedConsumer(seriesTrigger)
if err := runtime.outboxConsumers.Register(constants.OutboxEventTypeCardSeriesRequested, seriesConsumer); err != nil {
appLogger.Fatal("注册业务卡观测序列 Outbox 消费者失败",
zap.String("event_type", constants.OutboxEventTypeCardSeriesRequested), zap.Error(err))
@@ -863,43 +803,6 @@ func startOutboxRelay(ctx context.Context, runtime *workerRuntime, instanceName
appLogger.Info("Outbox Relay 已启动", zap.String("lease_owner", owner))
}
// startTrafficCollectionPartitionMaintainer 为 leader/all 维护卡流量采集记录(成功与失败)月分区并扫描采集告警:
// 每 12 小时预建本月起 2 个月的分区,到期分区导出为 csv.gz、校验后删除;每天扫描一次前一天的采集告警。
func startTrafficCollectionPartitionMaintainer(ctx context.Context, runtime *workerRuntime, appLogger *zap.Logger) {
archiver := cardObservationInfra.NewCollectionArchiver(runtime.db, constants.TrafficCollectionArchiveDir, constants.TrafficCollectionArchiveKeepFiles, appLogger)
alerts := cardObservationInfra.NewTrafficAlertScanner(runtime.db, appLogger)
go func() {
ticker := time.NewTicker(time.Hour)
defer ticker.Stop()
var lastMaintained time.Time
var lastScannedDay string
for {
now := time.Now()
if now.Sub(lastMaintained) >= 12*time.Hour {
if err := archiver.Maintain(ctx, now); err != nil && ctx.Err() == nil {
appLogger.Error("维护卡流量采集记录分区失败", zap.Error(err))
} else {
lastMaintained = now
}
}
// 每天扫描一次前一天(上海日期)的采集告警。
yesterday := cardflow.ShanghaiDate(now).AddDate(0, 0, -1)
if day := yesterday.Format("2006-01-02"); day != lastScannedDay {
if err := alerts.Scan(ctx, yesterday); err != nil && ctx.Err() == nil {
appLogger.Error("扫描卡流量采集告警失败", zap.Error(err))
} else {
lastScannedDay = day
}
}
select {
case <-ctx.Done():
return
case <-ticker.C:
}
}
}()
}
// startPollingInitializer 为 leader/all 启动渐进式初始化器,并保留配置变更重启逻辑。
func startPollingInitializer(ctx context.Context, runtime *workerRuntime, appLogger *zap.Logger) *polling.PollingInitializer {
pollingInitializer := polling.NewPollingInitializer(
@@ -912,13 +815,6 @@ func startPollingInitializer(ctx context.Context, runtime *workerRuntime, appLog
pollingInitializer.StartBackground(ctx)
runtime.pollingConfigMgr.WatchChanges(ctx, func(hadConfigs, hasConfigs bool) {
if runtime.carrierScheduling.Active(ctx) {
// 按运营商账户调度时按新配置重新初始化新结构,旧分片队列在回退时再重建。
if runtime.carrierReinit != nil {
runtime.carrierReinit()
}
return
}
if hasConfigs {
appLogger.Info("轮询配置已变更,触发队列重新初始化",
zap.Bool("had_configs", hadConfigs))
@@ -971,7 +867,6 @@ func startPollingScheduler(
dataResetHandler,
)
pollingScheduler.SetInitializer(pollingInitializer)
pollingScheduler.SetCarrierScheduling(runtime.carrierScheduling)
if err := pollingScheduler.Start(ctx); err != nil {
appLogger.Error("启动轮询调度器失败", zap.Error(err))
@@ -1090,8 +985,6 @@ func registerAsynqScheduleTasks(asynqScheduler *asynq.Scheduler, auditArchiveEna
nil,
asynq.MaxRetry(3),
asynq.Timeout(2*time.Minute),
// 同一时刻只保留一个恢复任务:队列拥堵时不再堆积重复的恢复任务。
asynq.Unique(2*time.Minute),
asynq.Queue(constants.QueueForTaskType(constants.TaskTypeWeComApprovalRecovery)),
)); err != nil {
return fmt.Errorf("注册企业微信审批主动恢复定时任务失败: %w", err)
@@ -1103,14 +996,6 @@ func registerAsynqScheduleTasks(asynqScheduler *asynq.Scheduler, auditArchiveEna
)); err != nil {
return fmt.Errorf("注册数据清理定时任务失败: %w", err)
}
if _, err := asynqScheduler.Register("CRON_TZ=Asia/Shanghai 30 2 * * *", asynq.NewTask(
constants.TaskTypeStopResumeRecordPartition,
nil,
asynq.MaxRetry(3),
asynq.Queue(constants.QueueDataCleanup),
)); err != nil {
return fmt.Errorf("注册停复机记录分区维护定时任务失败: %w", err)
}
if _, err := asynqScheduler.Register("15 2 * * *", asynq.NewTask(
constants.TaskTypeNotificationCleanup,
nil,
@@ -1128,28 +1013,6 @@ func registerAsynqScheduleTasks(asynqScheduler *asynq.Scheduler, auditArchiveEna
)); err != nil {
return fmt.Errorf("注册每日套餐临期提醒扫描定时任务失败: %w", err)
}
// 沉默期延期订单恢复:每 5 分钟查单、超时关闭、异常订单原路退款;不受总开关影响。
if _, err := asynqScheduler.Register(constants.SilenceOrderRecoverCron, asynq.NewTask(
constants.TaskTypePackageSilenceOrderRecover,
nil,
asynq.MaxRetry(3),
asynq.Timeout(4*time.Minute),
asynq.Unique(4*time.Minute),
asynq.Queue(constants.QueueForTaskType(constants.TaskTypePackageSilenceOrderRecover)),
)); err != nil {
return fmt.Errorf("注册沉默期延期订单恢复定时任务失败: %w", err)
}
// 沉默期扫描:每 10 分钟;总开关关闭时任务直接返回(技术方案 §5.4)。
if _, err := asynqScheduler.Register(constants.SilenceScanCron, asynq.NewTask(
constants.TaskTypePackageSilenceScan,
nil,
asynq.MaxRetry(constants.SilenceScanMaxRetry),
asynq.Timeout(constants.SilenceScanUniqueTTL),
asynq.Unique(constants.SilenceScanUniqueTTL),
asynq.Queue(constants.QueueForTaskType(constants.TaskTypePackageSilenceScan)),
)); err != nil {
return fmt.Errorf("注册沉默期扫描定时任务失败: %w", err)
}
if _, err := asynqScheduler.Register("CRON_TZ=Asia/Shanghai 0 6 * * *", asynq.NewTask(
constants.TaskTypePackageTrafficAlertScan,
nil,
@@ -1448,7 +1311,7 @@ func initStorage(cfg *config.Config, appLogger *zap.Logger) *storage.Service {
}
// initGateway 初始化 Gateway 客户端
func initGateway(cfg *config.Config, db *gorm.DB, appLogger *zap.Logger) *gateway.Client {
func initGateway(cfg *config.Config, appLogger *zap.Logger) *gateway.Client {
if cfg.Gateway.BaseURL == "" {
appLogger.Info("Gateway 未配置,跳过初始化(轮询任务将无法查询真实数据)")
return nil
@@ -1460,10 +1323,6 @@ func initGateway(cfg *config.Config, db *gorm.DB, appLogger *zap.Logger) *gatewa
cfg.Gateway.AppSecret,
appLogger,
).WithTimeout(time.Duration(cfg.Gateway.Timeout) * time.Second)
if cfg.Gateway.ReadonlyEnabled {
// 测试环境只读模式:只读资产表中的卡与设备只允许查询类调用(add-gateway-readonly-prod-import)。
client.WithReadonlyChecker(postgres.NewGatewayReadonlyCardStore(db).IsReadonly)
}
appLogger.Info("Gateway 客户端初始化成功",
zap.String("base_url", cfg.Gateway.BaseURL),

View File

@@ -1,160 +0,0 @@
package main
import (
"context"
"time"
"go.uber.org/zap"
"github.com/break/junhong_cmp_fiber/internal/infrastructure/nsqbus"
"github.com/break/junhong_cmp_fiber/internal/store/postgres"
"github.com/break/junhong_cmp_fiber/internal/task"
"github.com/break/junhong_cmp_fiber/pkg/config"
"github.com/break/junhong_cmp_fiber/pkg/constants"
)
// pollingIngestRuntime 是轮询观测入库异步化在 Worker 内的运行组件(add-polling-async-ingest):
// 入库消费者、采集记录批量消费者、活动优先项索引重建与积压监控。入库消费者常驻订阅,
// 使切回同步模式后队列中已有的入库消息仍会被处理完。
type pollingIngestRuntime struct {
runtime *workerRuntime
nsqdAddress string
executor *task.IngestExecutor
collector *task.CollectionBatchConsumer
ingest *nsqbus.ConsumerManager
collection *nsqbus.ConsumerManager
rebuilder *task.PriorityIndexRebuilder
stopCollect context.CancelFunc
grace time.Duration
logger *zap.Logger
}
// startPollingIngest 注入入库发布器并启动入库与采集记录消费者;未配置 nsqd 时返回 nil,轮询保持同步入库。
func startPollingIngest(ctx context.Context, cfg *config.Config, runtime *workerRuntime, producer *nsqbus.Producer,
singleton bool, grace time.Duration, logger *zap.Logger) *pollingIngestRuntime {
carrierCfg := cfg.Polling.Carrier
if producer == nil || carrierCfg.NSQDAddress == "" {
return nil
}
checkIngestConnBudget(cfg, logger)
publisher := task.NewIngestPublisher(producer)
runtime.pollingBase.SetIngest(publisher, func(ctx context.Context) bool {
return runtime.carrierSettings.Current(ctx).IngestAsync()
})
observation := runtime.workerResult.Services.CardObservation
if observation != nil {
observation.SetCollectionSink(publisher)
}
r := &pollingIngestRuntime{
runtime: runtime, nsqdAddress: carrierCfg.NSQDAddress, grace: grace, logger: logger,
executor: task.NewIngestExecutor(runtime.pollingBase, observation, cfg.Polling.Ingest.EffectiveTimeout()),
collector: task.NewCollectionBatchConsumer(runtime.db, cfg.Polling.Collection.EffectiveBatchSize(), logger),
rebuilder: task.NewPriorityIndexRebuilder(postgres.NewPollingPriorityItemStore(runtime.db), runtime.redisClient, logger),
}
collectCtx, stopCollect := context.WithCancel(context.Background())
r.stopCollect = stopCollect
r.collector.Start(collectCtx)
consumerCfg := nsqbus.ConsumerConfig{
NSQDAddress: carrierCfg.NSQDAddress, NSQLookupdAddresses: nsqbus.SplitAddresses(carrierCfg.NSQLookupdAddresses),
MsgTimeout: carrierCfg.MsgTimeout, UnlimitedAttempts: true,
}
r.ingest = nsqbus.NewRetryConsumerManager(consumerCfg, constants.NSQChannelWorker, r.executor.Handle, logger)
r.collection = nsqbus.NewRetryConsumerManager(consumerCfg, constants.NSQChannelWorker, r.collector.Handle, logger)
r.ingest.Sync(map[string]int{constants.NSQCardIngestTopic: cfg.Polling.Ingest.EffectiveConcurrency()})
r.collection.Sync(map[string]int{constants.NSQCardCollectionTopic: constants.PollingCollectionMaxInFlight})
logger.Info("轮询观测入库消费者已启动", zap.Int("ingest_concurrency", cfg.Polling.Ingest.EffectiveConcurrency()),
zap.Duration("ingest_timeout", cfg.Polling.Ingest.EffectiveTimeout()),
zap.Int("collection_batch_size", cfg.Polling.Collection.EffectiveBatchSize()))
if singleton {
go r.loop(ctx)
}
return r
}
// checkIngestConnBudget 启动时检查入库并发与采集记录写并发不超过数据库连接数减去预留,超过只记 ERROR、不阻止启动。
func checkIngestConnBudget(cfg *config.Config, logger *zap.Logger) {
maxOpen := cfg.Database.MaxOpenConns
if maxOpen <= 0 {
maxOpen = constants.DefaultMaxOpenConns
}
need := cfg.Polling.Ingest.EffectiveConcurrency() + constants.PollingIngestCollectionWriters
if budget := maxOpen - constants.PollingIngestConnReserve; need > budget {
logger.Error("入库并发超出数据库连接预算,请调小 polling.ingest.concurrency 或调大数据库连接数",
zap.Int("ingest_concurrency", cfg.Polling.Ingest.EffectiveConcurrency()),
zap.Int("collection_writers", constants.PollingIngestCollectionWriters),
zap.Int("max_open_conns", maxOpen), zap.Int("reserved", constants.PollingIngestConnReserve))
}
}
// loop 在 leader 上定期重建活动优先项索引并检查入库积压。
func (r *pollingIngestRuntime) loop(ctx context.Context) {
r.rebuilder.Rebuild(ctx)
rebuild := time.NewTicker(constants.PollingPriorityIndexRebuildEvery)
monitor := time.NewTicker(constants.PollingIngestMonitorInterval)
defer rebuild.Stop()
defer monitor.Stop()
for {
select {
case <-ctx.Done():
return
case <-rebuild.C:
r.rebuilder.Rebuild(ctx)
case <-monitor.C:
r.checkBacklog(ctx)
}
}
}
// checkBacklog 记录入库与采集记录积压,超过告警阈值或等待过久记 ERROR;入库积压超过反压阈值时暂停派发新卡任务,降到一半以下恢复。
func (r *pollingIngestRuntime) checkBacklog(ctx context.Context) {
defer func() {
if recovered := recover(); recovered != nil {
r.logger.Error("入库积压检查 panic,已恢复", zap.Any("panic", recovered))
}
}()
settings := r.runtime.carrierSettings.Current(ctx)
ingestDepth, ingestErr := nsqbus.TopicBacklog(ctx, r.nsqdAddress, constants.NSQCardIngestTopic)
collectionDepth, collectionErr := nsqbus.TopicBacklog(ctx, r.nsqdAddress, constants.NSQCardCollectionTopic)
if ingestErr != nil || collectionErr != nil {
r.logger.Warn("读取入库积压失败", zap.NamedError("ingest_error", ingestErr), zap.NamedError("collection_error", collectionErr))
return
}
lag := r.executor.LastLag()
fields := []zap.Field{zap.Int64("ingest_backlog", ingestDepth), zap.Int64("collection_backlog", collectionDepth),
zap.Duration("ingest_lag", lag), zap.String("ingest_mode", settings.IngestMode)}
r.logger.Info("轮询入库积压", append(fields, zap.String("metric", "polling.ingest.backlog"))...)
alertDepth := int64(settings.IngestAlertDepth)
if ingestDepth > alertDepth || collectionDepth > alertDepth || lag > constants.PollingIngestLagAlert {
r.logger.Error("轮询入库积压超过告警阈值", append(fields, zap.Int64("alert_depth", alertDepth))...)
}
r.applyBackpressure(ctx, ingestDepth, int64(settings.IngestPauseDepth))
}
// applyBackpressure 按积压置位或清除暂停标记;标记带 TTL,leader 失联时自动解除。
func (r *pollingIngestRuntime) applyBackpressure(ctx context.Context, depth, pauseDepth int64) {
redisClient := r.runtime.redisClient
paused, _ := redisClient.Exists(ctx, constants.RedisPollingIngestPausedKey).Result()
ttl := 3 * constants.PollingIngestMonitorInterval
switch {
case depth > pauseDepth:
redisClient.Set(ctx, constants.RedisPollingIngestPausedKey, depth, ttl)
if paused == 0 {
r.logger.Error("入库积压超过反压阈值,暂停派发新卡任务", zap.Int64("ingest_backlog", depth), zap.Int64("pause_depth", pauseDepth))
}
case paused > 0 && depth > pauseDepth/2:
redisClient.Expire(ctx, constants.RedisPollingIngestPausedKey, ttl)
case paused > 0:
redisClient.Del(ctx, constants.RedisPollingIngestPausedKey)
r.logger.Info("入库积压已降到反压阈值一半以下,恢复派发卡任务", zap.Int64("ingest_backlog", depth), zap.Int64("pause_depth", pauseDepth))
}
}
// shutdown 先停入库消费者(处理中的消息在宽限期内完成),再停采集记录消费者,最后停止攒批循环(写完已缓冲的一批)。
func (r *pollingIngestRuntime) shutdown() {
if r == nil {
return
}
r.ingest.StopAll(r.grace)
r.collection.StopAll(r.grace)
r.stopCollect()
}

View File

@@ -36,15 +36,15 @@ services:
- "3000:3000"
environment:
# 数据库配置(必填)
- JUNHONG_DATABASE_HOST=kt.whcxd.cn
- JUNHONG_DATABASE_PORT=15434
- JUNHONG_DATABASE_HOST=cxd.whcxd.cn
- JUNHONG_DATABASE_PORT=16159
- JUNHONG_DATABASE_USER=erp_pgsql
- JUNHONG_DATABASE_PASSWORD=erp_2025
- JUNHONG_DATABASE_DBNAME=junhong_cmp_test
- JUNHONG_DATABASE_SSLMODE=disable
# Redis 配置(必填)
- JUNHONG_REDIS_ADDRESS=kt.whcxd.cn
- JUNHONG_REDIS_PORT=16380
- JUNHONG_REDIS_ADDRESS=cxd.whcxd.cn
- JUNHONG_REDIS_PORT=16299
- JUNHONG_REDIS_PASSWORD=cpNbWtAaqgo1YJmbMp3h
- JUNHONG_REDIS_DB=6
# JWT 配置(必填)
@@ -54,9 +54,6 @@ services:
# 日志配置
- JUNHONG_LOGGING_LEVEL=info
- JUNHONG_LOGGING_DEVELOPMENT=false
# 应用日志轮换:单文件 100MB(默认),保留 40 个压缩备份,api 与 worker 合计约 1GB
- JUNHONG_LOGGING_APP_LOG_MAX_BACKUPS=40
- JUNHONG_LOGGING_APP_LOG_MAX_AGE=14
# 跨域配置
- JUNHONG_MIDDLEWARE_CORS_ENABLED=true
- JUNHONG_MIDDLEWARE_CORS_ALLOW_ORIGINS=*
@@ -75,13 +72,11 @@ services:
- JUNHONG_GATEWAY_APP_ID=LfjL0WjUqpwkItQ0
- JUNHONG_GATEWAY_APP_SECRET=K0DYuWzbRE6zg5bX
- JUNHONG_GATEWAY_TIMEOUT=30
- JUNHONG_GATEWAY_READONLY_ENABLED=${JUNHONG_GATEWAY_READONLY_ENABLED:-false}
# 七月迭代:企业微信 Adapter 与旧审批入口切换
- JUNHONG_WECOM_BASE_URL=https://qyapi.weixin.qq.com
- JUNHONG_WECOM_TIMEOUT=10s
- JUNHONG_APPROVAL_LEGACY_REFUND_MANUAL_ENABLED=${JUNHONG_APPROVAL_LEGACY_REFUND_MANUAL_ENABLED:-true}
- JUNHONG_APPROVAL_LEGACY_OFFLINE_RECHARGE_PAY_ENABLED=${JUNHONG_APPROVAL_LEGACY_OFFLINE_RECHARGE_PAY_ENABLED:-true}
# 卡流量日快照计量(API 与 Worker 必须一致;默认与未配置时行为不变)
# 短信服务配置
- JUNHONG_SMS_GATEWAY_URL=https://gateway.sms.whjhft.com:8443
- JUNHONG_SMS_USERNAME=JH0001
@@ -91,7 +86,7 @@ services:
- JUNHONG_MIDDLEWARE_CORS_ALLOW_ORIGINS=https://cmp-admin.boss160.cn,https://cmp-agent.boss160.cn,https://cmp-c.boss160.cn
- JUNHONG_MIDDLEWARE_CORS_ALLOW_CREDENTIALS=true
volumes:
- ./api/logs:/app/logs
- ./logs:/app/logs
networks:
- junhong-network
healthcheck:
@@ -112,24 +107,17 @@ services:
restart: unless-stopped
environment:
# 数据库配置(必填)
- JUNHONG_DATABASE_HOST=kt.whcxd.cn
- JUNHONG_DATABASE_PORT=15434
- JUNHONG_DATABASE_HOST=cxd.whcxd.cn
- JUNHONG_DATABASE_PORT=16159
- JUNHONG_DATABASE_USER=erp_pgsql
- JUNHONG_DATABASE_PASSWORD=erp_2025
- JUNHONG_DATABASE_DBNAME=junhong_cmp_test
- JUNHONG_DATABASE_SSLMODE=disable
# Redis 配置(必填)
- JUNHONG_REDIS_ADDRESS=kt.whcxd.cn
- JUNHONG_REDIS_PORT=16380
- JUNHONG_REDIS_ADDRESS=cxd.whcxd.cn
- JUNHONG_REDIS_PORT=16299
- JUNHONG_REDIS_PASSWORD=cpNbWtAaqgo1YJmbMp3h
- JUNHONG_REDIS_DB=6
# worker 并发轮询与按账户并行分发共用连接池,默认 10 不够
- JUNHONG_REDIS_POOL_SIZE=100
# 任务并发:默认 10 时流量扣减事件投递跟不上(白天积压 1 万+、延迟 2.5 小时),调到 50
- JUNHONG_QUEUE_CONCURRENCY=50
# 数据库连接池随任务并发放大(默认 25/10);测试库 max_connections=200,API 另占 25
- JUNHONG_DATABASE_MAX_OPEN_CONNS=60
- JUNHONG_DATABASE_MAX_IDLE_CONNS=20
# JWT 配置(必填)
- JUNHONG_JWT_SECRET_KEY=dev-secret-key-for-testing-only-32chars!
# 客户端配置(可选,默认 true;false=不强制绑定手机号)
@@ -137,9 +125,6 @@ services:
# 日志配置
- JUNHONG_LOGGING_LEVEL=info
- JUNHONG_LOGGING_DEVELOPMENT=false
# 应用日志轮换:单文件 100MB(默认),保留 40 个压缩备份,api 与 worker 合计约 1GB
- JUNHONG_LOGGING_APP_LOG_MAX_BACKUPS=40
- JUNHONG_LOGGING_APP_LOG_MAX_AGE=14
# 对象存储配置
- JUNHONG_STORAGE_PROVIDER=s3
- JUNHONG_STORAGE_S3_ENDPOINT=https://obs-helf.cucloud.cn
@@ -154,60 +139,20 @@ services:
- JUNHONG_GATEWAY_APP_ID=LfjL0WjUqpwkItQ0
- JUNHONG_GATEWAY_APP_SECRET=K0DYuWzbRE6zg5bX
- JUNHONG_GATEWAY_TIMEOUT=30
- JUNHONG_GATEWAY_READONLY_ENABLED=${JUNHONG_GATEWAY_READONLY_ENABLED:-false}
- JUNHONG_POLLING_VERBOSE_LOG=true
# 七月迭代:单 Worker 测试环境同时承担调度和消费
- JUNHONG_WORKER_ROLE=${JUNHONG_WORKER_ROLE:-all}
- JUNHONG_WORKER_INSTANCE_NAME=${JUNHONG_WORKER_INSTANCE_NAME:-test-worker-all-1}
# 卡流量日快照计量(API 与 Worker 必须一致;默认与未配置时行为不变)
# Worker 执行企微审批提交、同步和恢复任务
- JUNHONG_WECOM_BASE_URL=https://qyapi.weixin.qq.com
- JUNHONG_WECOM_TIMEOUT=10s
# 按运营商账户调度的 NSQ(卡轮询分发;调度模式在受控系统配置 polling.scheduler.mode 切换)
- JUNHONG_POLLING_CARRIER_NSQD_ADDRESS=${JUNHONG_POLLING_CARRIER_NSQD_ADDRESS:-nsqd:4150}
- JUNHONG_POLLING_CARRIER_NSQLOOKUPD_ADDRESSES=${JUNHONG_POLLING_CARRIER_NSQLOOKUPD_ADDRESSES:-nsqlookupd:4161}
volumes:
- ./worker/logs:/app/logs
# 采集记录到期归档压缩包(archive/traffic-collection),由同步服务器拉走
- ./worker/archive:/app/archive
- ./logs:/app/logs
networks:
- junhong-network
depends_on:
api:
condition: service_healthy
nsqd:
condition: service_started
logging:
driver: "json-file"
options:
max-size: "10m"
max-file: "3"
# NSQ:只承担卡轮询的执行分发,Redis 是唯一权威状态(add-carrier-polling-scheduler)
nsqlookupd:
image: nsqio/nsq:v1.3.0
container_name: junhong-cmp-nsqlookupd
restart: unless-stopped
command: /nsqlookupd
networks:
- junhong-network
logging:
driver: "json-file"
options:
max-size: "10m"
max-file: "3"
nsqd:
image: nsqio/nsq:v1.3.0
container_name: junhong-cmp-nsqd
restart: unless-stopped
command: /nsqd --lookupd-tcp-address=nsqlookupd:4160 --broadcast-address=nsqd --data-path=/data --mem-queue-size=0 --max-msg-timeout=15m
volumes:
- ./nsq-data:/data
networks:
- junhong-network
depends_on:
- nsqlookupd
logging:
driver: "json-file"
options:

View File

@@ -1,118 +0,0 @@
# 实名即生效套餐沉默期 — 前端接口变更说明
> 对应 OpenSpec Change `add-package-silence-period`;规则以 `docs/沉默期技术方案/实名即生效套餐沉默期技术方案_v3.md` 为准。接口字段以 `docs/admin-openapi.yaml` 为准,本文只说明页面要做什么。
## 1. 总开关对页面的影响
沉默期总开关 `package.silence.enabled` **默认关闭**。
| 页面 / 功能 | 关闭时 | 打开时 |
|---|---|---|
| 资产套餐列表、H5 套餐历史里的 `silence` 对象 | **不返回**(字段为空),页面不显示沉默期 | 返回,显示状态、截止日 |
| "延期"入口 | 不显示 | `silence.extendable = true` 时显示 |
| 延期资格、试算、下单、线下登记、特殊延期 | 返回错误码 **1274** | 可用 |
| 套餐默认沉默期、延期政策、企微审批场景配置 | 可用 | 可用 |
| 购买时调整沉默期 | 可用 | 可用 |
| 卡 / 设备首次购买套餐时间 | 不受开关影响 | 不受开关影响 |
后台"系统配置"通用接口不能修改总开关(只读),请用专用接口:
- `GET /api/admin/package-silence/switch`:当前值 + 打开前 4 项检查(`precheck[]`:code、name、passed、count)。
- `PUT /api/admin/package-silence/switch` `{ "enabled": true|false }`:仅超级管理员。打开前检查不通过返回 **1275**,`data.failed_items` 为未满足项,直接展示即可;关闭不检查。
## 2. 套餐配置
- 新增 / 编辑套餐增加 `silence_period_days`(1~90)、`silence_extension_enabled`(默认 true)。
- **实名即生效的正式套餐必填**默认沉默期,新建时前端预填 30;不填或越界返回 **1260**。
- 购买即生效套餐、加油包不显示、不提交这两个字段。
- 套餐响应增加 `silence_period_days`、`silence_extension_enabled`、`silence_config_required`(是否必须配置)。
- 实名即生效套餐改为购买即生效、店铺分配(单个、批量、系列授权)里把实名即生效套餐覆盖为购买即生效,返回 **1262**。购买即生效改为实名即生效时,同一请求必须带 `silence_period_days`。
- 购买即生效的正式套餐也可以选填 `silence_period_days`(1~90,不强制;`silence_config_required=false`),加油包不能填。店铺分配把购买即生效套餐覆盖为实名即生效时,套餐已配置默认沉默期才允许,否则返回 **1262**。
- "未配置沉默期的实名即生效套餐"清单:套餐列表加 `silence_unconfigured=true`(包含本身是购买即生效、但有店铺分配覆盖为实名即生效且未配置默认沉默期的套餐);导出用套餐导出场景,筛选同样传 `silence_unconfigured`。套餐导出新增"默认沉默期(天)""是否支持付费延期"两列。
## 3. 后台购买时调整沉默期
`POST /api/admin/orders`(后台给资产购买套餐)新增可选字段:
- `silence_period_days`(≥ 1,不设上限)、`silence_reason`(设置时必填)。
- 需要 `package_silence:override` 权限,无权限传值返回 **1261**。
- 不超过默认值:直接生效;超过默认值:按延期政策直接生效,或**先按默认值购买并提交企微审批**(审批中套餐不会过期)。发起人未绑定企微成员时购买被拒。
- 只对"资产未实名时购买的实名即生效主套餐"生效,其他情况忽略。
## 4. 资产套餐展示
后台资产套餐历史 / 当前套餐、H5 套餐历史(`/api/c/v1/asset/package-history`)每笔套餐增加 `silence` 对象(总开关打开且适用沉默期时返回):
| 字段 | 说明 |
|---|---|
| `silence_status` / `silence_status_name` | `waiting` 待实名(延期后显示"延期待实名")/ `expired` 沉默期已过期 / `ended` 沉默期已结束;待实名但已过截止的直接按 `expired` 返回 |
| `silence_deadline_at`、`silence_start_at`、`silence_period_days` | 截止时间(截止日 23:59:59)、起算时间、天数 |
| `silence_extended_days`、`silence_paid_extended_days`、`silence_extension_count`、`silence_free_extension_used` | 延期累计 |
| `silence_expired_at`、`silence_adjust_pending` | 过期时间、调整审批中 |
| `extendable` | 是否显示"延期"入口 |
套餐沉默期流水:后台 `GET /api/admin/package-silence/records?package_usage_id=`,H5 `GET /api/c/v1/package-silence/records?package_usage_id=`。
## 5. 延期(后台)
均在 `/api/admin/package-silence` 下:
| 接口 | 权限 | 说明 |
|---|---|---|
| `GET /eligibility?package_usage_id=` | — | 资格:`can_extend`、`special_extension_available`、`reason`、`min_days`/`max_days`、`min_months`/`max_months`、`unit_price`、`options[]`(每档计费天数、金额、新截止日)。**金额全部由后端算,前端只传天数** |
| `GET /quote?package_usage_id=&days=` | — | 输入天数试算(按 30 天向上取整:1→30、31→60) |
| `POST /orders/qrcode` | `package_silence:extend` | 运营生成支付二维码:入参 `package_usage_id`、`days`、`payment_method`(wechat/alipay)、`request_id`、`evidence_refs`;返回 `qr_content` 渲染为二维码。已有客户 H5 待支付订单时后端先查单,未付自动关闭旧单 |
| `POST /orders/offline` | `package_silence:offline` | 登记线下付款:`offline_payment_method_id`(线下收款方式字典)、`offline_trade_no`、`paid_amount`(必须等于计算金额,否则 **1273**)、`paid_at`、`payment_voucher_keys`(1~5)、`evidence_refs`、`request_id`。交易单号重复 **1272**。默认进企微审批 |
| `GET /orders`、`GET /orders/:id` | — | 延期订单列表 / 详情,含 `payment_method`、`payment_merchant_name`、`third_party_trade_no`;导出场景 `package_silence_order` |
| `POST /special` | `package_silence:extend`(批量另需 `package_silence:extend_batch`) | 特殊延期:`extension_type`(free/discount/complaint_compensation/system_compensation/special_customer)、`items[]`(package_usage_id + days)、`reason`、`basis`、`evidence_refs`、`amount`(免费为 0)、优惠价有收款时填收款方式 / 交易单号 / 凭证。批量明细逐行校验,不通过返回 `data.rows[]`(行号与原因)。免费次数用完 **1267** |
| `GET /batches`、`GET /batches/:id` | — | 批次与明细(`has_exception=true` 只看含异常的) |
| `POST /extensions/:id/handle` | `package_silence:exception` | 异常明细 `action=retry|close` + `reason`;在线订单关闭时原路退款 |
| `GET /usages?view=expiring|expired|all` | — | 即将过期(默认 7 天内)/ 已过期套餐;导出场景 `package_silence_usage` |
延期政策(`package_silence:policy`):`GET/POST /policies`、`GET/PUT /policies/:id`、`PUT /policies/:id/status`、`GET /policies/match?package_id=`。同级重叠返回 **1269**。
## 6. 延期(H5)
均在 `/api/c/v1/package-silence` 下,资产须属于当前客户:
- `GET /eligibility`、`GET /quote`:同后台。
- `POST /orders`:自助下单(`package_usage_id`、`days`、`payment_method`、`request_id`),返回 `qr_content`(支付链接或二维码内容);支付成功自动延期。
- `GET /orders`、`GET /orders/:id`:我的延期订单。
- `GET /records`:沉默期流水。
## 7. 退款
沉默期已过期的套餐申请退款返回 **1268**(总开关打开时)。
## 8. 卡 / 设备首次购买套餐时间
- 卡列表、设备列表返回 `first_purchase_at`,支持 `first_purchase_start`、`first_purchase_end`(RFC3339)筛选;卡详情、设备详情、资产解析返回 `first_purchase_at`。
- 卡、设备导出末尾新增"首次购买套餐时间"列。
- 首次购买套餐记录:`GET /api/admin/package-silence/assets/:asset_type/:id/first-purchase-records`(asset_type 为 card / device),按世代倒序,含来源(首次购买套餐 / 历史回填)、订单号、结束时间与原因。
- 换货后的新资产不会因换货得到首次购买套餐时间,要等它在当前世代真正支付套餐订单;旧资产换货回库后该时间清空,旧世代记录带结束时间与原因"换货回库"。
- 绑定在设备上的卡不记录首次购买套餐时间;只读,不提供修改。
## 9. 错误码
| 码 | 名称 | 说明 |
|---|---|---|
| 1260 | silence_period_out_of_range | 沉默期天数不在允许范围 |
| 1261 | silence_override_forbidden | 无权限调整沉默期 |
| 1262 | package_expiry_base_change_forbidden | 不允许变更该套餐的生效条件 |
| 1263 | silence_extension_not_expired | 套餐未过期 / 无沉默期 / 已激活 / 已退款(`data.reason` 细分) |
| 1264 | silence_extension_disabled | 该套餐不支持付费延期(可申请特殊延期) |
| 1265 | silence_extension_exceeds_limit | 超过可延期期限(`data.min_months`、`data.max_months`) |
| 1266 | silence_extension_months_invalid | 延期天数不在允许范围(`data.min_days`、`data.max_days`) |
| 1267 | silence_free_extension_used_up | 免费延期次数已用完 |
| 1268 | silence_expired_not_refundable | 沉默期已过期的套餐不能退款 |
| 1269 | silence_policy_overlap | 延期政策重叠 |
| 1270 | silence_extension_no_policy | 未匹配到延期政策 |
| 1271 | silence_extension_order_pending | 已有未结束的延期订单或申请 |
| 1272 | silence_offline_trade_no_duplicate | 线下交易单号已登记 |
| 1273 | silence_offline_amount_mismatch | 实收金额与计算金额不一致(`data.expected_amount`) |
| 1274 | silence_feature_disabled | 沉默期功能未开启 |
| 1275 | silence_switch_precheck_failed | 总开关打开前检查未通过(`data.failed_items`) |
## 10. 企微审批模板
超级管理员在"企微审批场景"配置业务类型 **沉默期审批(package_silence_approval)**,模板需有"申请类型"单选控件,选项映射:`购买时调整沉默期`、`特殊延期`、`线下付费延期`。可映射字段见场景配置页的业务字段列表(申请单号、资产、套餐、原截止日期、延期天数、计费天数、费用、原因、附件、收款方式、交易单号、凭证等)。

View File

@@ -1,246 +0,0 @@
# 订单按类别拆分 → 套餐沉默期:前端修改汇总
日期:2026-10-27
范围:`Iteration/8-11` 提交 `eb9f12d1`(后台订单按类别拆分)至 `af57de0d`(沉默期配置口径调整),均已部署测试环境。
接口字段以 `docs/admin-openapi.yaml` 为准;本文说明页面要改什么。
## 一、总览
| # | 变更 | 前端是否要改 | 章节 |
|---|---|---|---|
| 1 | 后台订单按平台 / 代理商 / 在线支付拆分列表与导出 | **要改**:拆三个页面 | 二 |
| 2 | 企业账号只能访问授权给本企业的资产 | **要改**:企业账号隐藏部分资产操作 | 三 |
| 3 | 实名即生效套餐沉默期、延期办理 | **要改**:套餐配置、购买、资产套餐展示、延期页面、总开关、政策 | 四 |
| 4 | 卡 / 设备首次购买套餐时间 | **要改**:列表列、筛选、详情、记录弹窗 | 五 |
| 5 | 后台线下下单不再绕开实名 | 不改接口,展示需知晓 | 六 |
| 6 | 退款 0 元可提交审批 | 不改接口 | 六 |
| 7 | 排队激活停复机、孤儿套餐、微信查单 | 不涉及前端 | — |
---
## 二、后台订单按类别拆分
### 2.1 新接口
| 页面 | 列表接口 | 导出场景(`POST /api/admin/export-tasks` 的 `scene`) | 包含 |
|---|---|---|---|
| 平台订单 | `GET /api/admin/orders/platform` | `order_platform` | 后台下单且线下支付:平台自营、赠送、平台代购代理名下的卡 |
| 代理商订单 | `GET /api/admin/orders/agent` | `order_agent` | 后台下单且从代理钱包扣款:代理自购、代理给下级购买、平台代扣代理钱包、代理 OpenAPI 下单 |
| 在线支付订单 | `GET /api/admin/orders/online` | `order_online` | 个人客户在客户端下的单:微信、支付宝、卡钱包支付,自动续费,自动购买,待支付 |
- 三个列表接口的查询参数、返回结构与 `GET /api/admin/orders` 完全相同,**不需要也不接受类别参数**。
- 三类互不重叠:相同筛选条件下三个 `total` 之和等于 `GET /orders` 的 `total`。
- 导出 `query.filters` 与 `scene=order` 相同,表头相同。导出任务列表里新任务的 `scene` 是新值,显示中文请按上表映射。
- 订单详情仍用 `GET /api/admin/orders/{id}`;`GET /orders` 与场景 `order` 保留不变。
```json
{ "scene": "order_online", "format": "xlsx",
"query": { "filters": { "payment_status": 2, "start_time": "2026-10-01T00:00:00+08:00", "end_time": "2026-10-26T23:59:59+08:00" } } }
```
### 2.2 账号可见范围
| 账号 | 平台订单 | 代理商订单 | 在线支付订单 |
|---|---|---|---|
| 超管、平台 | 全部 | 全部 | 全部 |
| 代理 | 平台为本店名下卡代购的单 | 买家是本店的钱包订单 | **始终为空**,隐藏此页面 |
| 企业 | 无权限 | 无权限 | 无权限 |
企业账号调用以下接口返回无权限("企业账号无权访问订单"):`GET /orders`、`/orders/platform|agent|online`、`GET /orders/{id}`、`POST /orders/purchase-check`。
### 2.3 前端改动
1. 订单管理下拆三个页面,分别调用对应列表接口,导出按钮传对应场景。
2. 原"订单列表"页可保留或下线。
3. 代理账号隐藏"在线支付订单";企业账号隐藏订单菜单。
---
## 三、企业账号的资产访问范围
`/api/admin/assets/:identifier/*` 对企业账号收紧:
- **只能访问当前授权给本企业的卡或设备**。未授权与不存在一样返回"未找到"。涉及资产解析、实时状态、刷新、套餐、当前套餐、钱包、订单、操作日志、停机 / 复机。
- 以下操作**企业账号一律无权限**(即使资产已授权),返回 `CodeForbidden`"企业账号无权执行该资产操作":
| 接口 | 操作 |
|---|---|
| `PATCH /assets/:identifier/packages/:package_usage_id/expires-at` | 改套餐有效期 |
| `PATCH /assets/:identifier/packages/:package_usage_id/used-data` | 改已用量 |
| `PATCH /assets/:identifier/deactivate` | 停用 |
| `PATCH /assets/:identifier/polling-status` | 开关轮询 |
| `PATCH /assets/:identifier/realname-status` | 改实名状态 |
- 停机 / 复机(`POST /stop`、`/start`)对已授权资产仍可用。
**前端改动**:企业账号登录时隐藏上表 5 个操作按钮;资产搜索结果为"未找到"时按普通未找到提示,不要提示"无权限"。
---
## 四、实名即生效套餐沉默期
> 规则见 `docs/沉默期技术方案/实名即生效套餐沉默期技术方案_v3.md` 与 OpenSpec `add-package-silence-period`。
### 4.1 总开关对页面的影响
总开关 `package.silence.enabled` **默认关闭**。
| 页面 / 功能 | 关闭时 | 打开时 |
|---|---|---|
| 资产套餐列表、H5 套餐历史里的 `silence` 对象 | **不返回**,页面不显示沉默期 | 返回,显示状态、截止日 |
| "延期"入口 | 不显示 | `silence.extendable = true` 时显示 |
| 延期资格、试算、下单、线下登记、特殊延期 | 返回 **1274** | 可用 |
| 套餐默认沉默期、延期政策、企微审批场景配置 | 可用 | 可用 |
| 购买时调整沉默期 | 可用 | 可用 |
总开关只能用专用接口切换(通用"系统配置"接口里只读):
- `GET /api/admin/package-silence/switch`:返回 `enabled` 与打开前 4 项检查 `precheck[]`(`code`、`name`、`passed`、`count`)。
- `PUT /api/admin/package-silence/switch` `{ "enabled": true|false }`:仅超级管理员。打开前检查不通过返回 **1275**,`data.failed_items` 为未满足项,直接展示;关闭不检查。
**前端改动**:新增"沉默期总开关"设置页(超管可见),展示 4 项检查结果与开关。
### 4.2 套餐配置
- 新增 / 编辑套餐增加 `silence_period_days`(1~90)、`silence_extension_enabled`(是否支持付费延期,默认 true)。
- **实名即生效的正式套餐必填**默认沉默期,新建时预填 30;不填或越界返回 **1260**。
- **购买即生效的正式套餐可选填**(只在店铺分配覆盖为实名即生效时使用)。
- 加油包不显示、不提交。
- 套餐响应新增 `silence_period_days`、`silence_extension_enabled`、`silence_config_required`(是否必填,用来决定表单必填标记)。
- 生效条件变更限制(返回 **1262**):
- 实名即生效套餐改为购买即生效:禁止。
- 店铺分配(单个、批量、系列授权)把实名即生效套餐覆盖为购买即生效:禁止。
- 店铺分配把购买即生效套餐覆盖为实名即生效:**套餐已配置默认沉默期才允许**。
- 套餐由购买即生效改为实名即生效:同一请求必须带 `silence_period_days`。
- "未配置沉默期的实名即生效套餐"清单:套餐列表加查询参数 `silence_unconfigured=true`(含"本身购买即生效、但有店铺分配覆盖为实名即生效"的套餐);套餐导出同样传该筛选。套餐导出新增"默认沉默期(天)""是否支持付费延期"两列。
**前端改动**:套餐表单加两个字段(按 `expiry_base`、`package_type` 控制显示与必填);套餐列表加"未配置沉默期"筛选。
### 4.3 后台购买时调整沉默期
`POST /api/admin/orders` 新增可选字段:
- `silence_period_days`(≥ 1,不设上限)、`silence_reason`(填了天数时必填)。
- 需要 `package_silence:override` 权限,无权限传值返回 **1261**。
- 不超过默认值直接生效;超过默认值时按延期政策直接生效,或**先按默认值购买并提交企微审批**。发起人未绑定企微成员时购买被拒。
- 只对"资产未实名时购买的实名即生效主套餐"生效,其他情况忽略。
**前端改动**:后台下单弹窗增加"本次沉默期(天)""调整原因",有权限才显示。
### 4.4 资产套餐展示
后台资产套餐历史 / 当前套餐、H5 套餐历史(`GET /api/c/v1/asset/package-history`)每笔套餐增加 `silence` 对象(总开关打开且适用时返回):
| 字段 | 说明 |
|---|---|
| `silence_status` / `silence_status_name` | `waiting` 待实名(延期后为"延期待实名")/ `expired` 沉默期已过期 / `ended` 沉默期已结束;待实名但已过截止的直接按 `expired` 返回 |
| `silence_deadline_at`、`silence_start_at`、`silence_period_days` | 截止时间(当日 23:59:59)、起算时间、天数 |
| `silence_extended_days`、`silence_paid_extended_days`、`silence_extension_count`、`silence_free_extension_used` | 延期累计 |
| `silence_expired_at`、`silence_adjust_pending` | 过期时间、调整审批中 |
| `extendable` | 是否显示"延期"入口 |
沉默期流水:后台 `GET /api/admin/package-silence/records?package_usage_id=`,H5 `GET /api/c/v1/package-silence/records?package_usage_id=`。
**前端改动**:套餐列表显示沉默期状态与截止日、"延期"入口、"沉默期流水"弹窗(后台与 H5)。
### 4.5 延期(后台)
均在 `/api/admin/package-silence` 下:
| 接口 | 权限 | 说明 |
|---|---|---|
| `GET /eligibility?package_usage_id=` | — | 资格:`can_extend`、`special_extension_available`、`reason`、`min_days`/`max_days`、`min_months`/`max_months`、`unit_price`、`options[]`(每档计费天数、金额、新截止日)。**金额由后端算,前端只传天数** |
| `GET /quote?package_usage_id=&days=` | — | 按天数试算(按 30 天向上取整:1→30、31→60) |
| `POST /orders/qrcode` | `package_silence:extend` | 生成支付二维码:`package_usage_id`、`days`、`payment_method`(wechat/alipay)、`request_id`、`evidence_refs`;返回 `qr_content`,前端渲染为二维码 |
| `POST /orders/offline` | `package_silence:offline` | 登记线下付款:`package_usage_id`、`days`、`offline_payment_method_id`、`offline_trade_no`、`paid_amount`(必须等于计算金额,否则 **1273**)、`paid_at`、`payment_voucher_keys`(1~5)、`evidence_refs`、`request_id`。交易单号重复 **1272**。默认进企微审批 |
| `GET /orders`、`GET /orders/:id` | — | 延期订单列表 / 详情;导出场景 `package_silence_order` |
| `POST /special` | `package_silence:extend`(批量另需 `package_silence:extend_batch`) | 特殊延期:`extension_type`(free/discount/complaint_compensation/system_compensation/special_customer)、`items[]`(`package_usage_id` + `days`)、`reason`、`basis`、`evidence_refs`、`amount`;优惠价有收款时填收款方式、交易单号、凭证。批量逐行校验,失败返回 `data.rows[]`(行号与原因)。免费次数用完 **1267** |
| `GET /batches`、`GET /batches/:id` | — | 批次与明细(`has_exception=true` 只看含异常的) |
| `POST /extensions/:id/handle` | `package_silence:exception` | 异常明细 `action=retry|close` + `reason` |
| `GET /usages?view=expiring|expired|all` | — | 即将过期(默认 7 天内)/ 已过期套餐;导出场景 `package_silence_usage` |
延期政策(`package_silence:policy`):`GET/POST /policies`、`GET/PUT /policies/:id`、`PUT /policies/:id/status`、`GET /policies/match?package_id=`。同级重叠返回 **1269**。
**前端改动**:新增页面——延期弹窗(资格 + 试算 + 二维码 / 线下登记 / 特殊延期三种方式)、延期订单、延期批次与异常处理、即将过期 / 已过期套餐、延期政策管理。
### 4.6 延期(H5)
均在 `/api/c/v1/package-silence` 下,资产须属于当前客户:
- `GET /eligibility`、`GET /quote`:同后台。
- `POST /orders`:自助下单(`package_usage_id`、`days`、`payment_method`、`request_id`),返回 `qr_content`(支付链接或二维码内容);支付成功自动延期。
- `GET /orders`、`GET /orders/:id`:我的延期订单。
- `GET /records`:沉默期流水。
### 4.7 退款
沉默期已过期的套餐申请退款返回 **1268**(总开关打开时)。
### 4.8 企微审批场景
在"企微审批场景"配置业务类型 **沉默期审批(`package_silence_approval`)**。模板需有"申请类型"单选控件,选项映射:`购买时调整沉默期`、`特殊延期`、`线下付费延期`。可映射的业务字段见场景配置页。
**前端改动**:审批场景页的业务类型下拉加入"沉默期审批"。
### 4.9 错误码
| 码 | 名称 | 说明 |
|---|---|---|
| 1260 | silence_period_out_of_range | 沉默期天数不在允许范围 |
| 1261 | silence_override_forbidden | 无权限调整沉默期 |
| 1262 | package_expiry_base_change_forbidden | 不允许变更该套餐的生效条件 |
| 1263 | silence_extension_not_expired | 套餐未过期 / 无沉默期 / 已激活 / 已退款(`data.reason` 细分) |
| 1264 | silence_extension_disabled | 该套餐不支持付费延期(可申请特殊延期) |
| 1265 | silence_extension_exceeds_limit | 超过可延期期限(`data.min_months`、`data.max_months`) |
| 1266 | silence_extension_months_invalid | 延期天数不在允许范围(`data.min_days`、`data.max_days`) |
| 1267 | silence_free_extension_used_up | 免费延期次数已用完 |
| 1268 | silence_expired_not_refundable | 沉默期已过期的套餐不能退款 |
| 1269 | silence_policy_overlap | 延期政策重叠 |
| 1270 | silence_extension_no_policy | 未匹配到延期政策 |
| 1271 | silence_extension_order_pending | 已有未结束的延期订单或申请 |
| 1272 | silence_offline_trade_no_duplicate | 线下交易单号已登记 |
| 1273 | silence_offline_amount_mismatch | 实收金额与计算金额不一致(`data.expected_amount`) |
| 1274 | silence_feature_disabled | 沉默期功能未开启 |
| 1275 | silence_switch_precheck_failed | 总开关打开前检查未通过(`data.failed_items`) |
### 4.10 新增权限点
`package_silence:override`、`package_silence:extend`、`package_silence:extend_batch`、`package_silence:offline`、`package_silence:exception`、`package_silence:policy`。前端按权限控制按钮显示,角色配置页需能勾选。
---
## 五、卡 / 设备首次购买套餐时间
- 卡列表、设备列表返回 `first_purchase_at`,支持 `first_purchase_start`、`first_purchase_end`(RFC3339)筛选;卡详情、设备详情、资产解析返回 `first_purchase_at`。
- 卡、设备导出末尾新增"首次购买套餐时间"列。
- 首次购买套餐记录:`GET /api/admin/package-silence/assets/:asset_type/:id/first-purchase-records`(`asset_type` 为 `card` / `device`),按世代倒序,含来源(首次购买套餐 / 历史回填)、订单号、结束时间与原因。
- 换货后的新资产不会因换货得到该时间,要等它在当前世代真正支付套餐订单;旧资产换货回库后该时间清空,旧记录带结束原因"换货回库"。
- 绑定在设备上的卡不记录;只读,不提供修改。不受沉默期总开关影响。
**前端改动**:卡 / 设备列表加列与时间范围筛选,详情显示该时间,加"首次购买套餐记录"弹窗。
---
## 六、只影响展示、不改接口
- **后台线下下单不再绕开实名**(`69ec98aa`):后台给**平台自营资产**线下下单、或线下下单**赠送套餐**时,若资产未实名、套餐是实名即生效,套餐现在是"待生效(等待实名)",实名后才生效;以前会直接"生效中"。订单 `buyer_type` 仍是 `personal`。前端不用改,测试和运营看到状态变化属正常。
- **退款 0 元可提交审批**(`c9e5d655`):以前 0 元退款提交审批报数据库错误,现在正常。前端若对退款金额做了 `> 0` 校验,可放宽为 `≥ 0`。
---
## 七、前端改动清单
- [ ] 订单管理拆三页(平台 / 代理商 / 在线支付),各自导出场景;代理隐藏在线支付页;企业隐藏订单菜单
- [ ] 企业账号隐藏资产的改有效期、改已用量、停用、开关轮询、改实名状态 5 个按钮
- [ ] 沉默期总开关设置页(超管)
- [ ] 套餐表单:默认沉默期、是否支持付费延期;套餐列表"未配置沉默期"筛选
- [ ] 后台下单弹窗:本次沉默期、调整原因(`package_silence:override`)
- [ ] 资产套餐列表(后台 + H5):沉默期状态、截止日、延期入口、沉默期流水
- [ ] 后台延期:资格 / 试算、二维码、线下登记、特殊延期(单个 + 批量导入)
- [ ] 延期订单、延期批次与异常处理、即将过期 / 已过期套餐页面(含导出)
- [ ] 延期政策管理页
- [ ] H5:延期资格、试算、自助下单、我的延期订单、流水
- [ ] 企微审批场景业务类型加"沉默期审批"
- [ ] 角色权限配置加 6 个 `package_silence:*` 权限点
- [ ] 卡 / 设备:首次购买套餐时间列、筛选、详情、记录弹窗、导出列
- [ ] 错误码 1260~1275 的提示文案

View File

@@ -1,440 +0,0 @@
# 审计日志写入改造技术方案
> 日期:2026-11-05
> 状态:方案已定,未实施(结论见第 10 节)
> 关联:`openspec/changes/add-audit-integration-write-switches`(全局写入开关,本方案是其后的"按动作分级")
> **范围:本方案基于 `Iteration/8-11` 迭代实施,只部署到测试环境**(推送 8-11 由 `.gitea/workflows/deploy.yaml` 自动部署)。本方案不修改生产环境的代码、配置或服务;第 1 节的生产数据只用来说明问题和估算效果。
## 1. 背景
### 1.1 现状数据(生产近 14 天,只读统计)
| 指标 | 数值 |
|---|---|
| `tb_audit_event` 日均新增 | 约 122 万条(含停机重试风暴);扣掉风暴约 20 万条 |
| 机器发起(`system_task` / `scheduled_job`)占比 | 99% 以上 |
| 人、开放接口、个人客户、外部系统发起 | 约 1,210 条/天 |
| 每条事件含索引 | 约 1.9 KB;每条资源快照约 1.2 KB;平均 2–3 条资源 |
| 日均新增存储 | 约 6 GB(风暴期)/ 约 1 GB(平时) |
| 索引数 | `tb_audit_event` 14 个、`tb_audit_event_resource` 7 个、`tb_integration_log` 13 个 |
测试环境日均约 12.6 万条,几乎全是系统任务:流量同步 5.6 万、扣减 2.6 万、网络状态同步 2.5 万、优先轮询约 1.4 万。
### 1.2 问题
1. **审计表被当成过程日志用。** 每次轮询同步、每次扣减、每次对接调用开始、每次失败重试都写一条事件加 2–3 条资源快照。一次业务动作产生多次写入,而且每次写入要维护 21 个索引。
2. **失败重试没有收敛。** 退款后处理停机失败(`refund.process_asset`)和自动停机失败(`iot_card.auto_stop`),每重试一次就写一条审计。11-05 10:16–11:47,86 张卡产生了 26.1 万次停机失败,一个风暴就占了当天审计量的六成以上。
3. **对接日志顺带写审计。** `integrationlog.Repository.Start` 在没有上游审计事件时,会补写一条 `integration.attempt_started`(`internal/infrastructure/integrationlog/repository.go:119`),这一项每天约 45.6 万条。它与 `tb_integration_log` 本身完全重复。
4. **后果。** 11-03 生产 PostgreSQL IO 打满,轮询停摆约 1.7 天,审计和对接日志的写入与留存扫描是主要 IO 来源之一。
### 1.3 老系统做法(`/home/zoujin/works/golang/kg/qc`,供参考)
- **人工后台操作**:写 `tbl_system_log`。
- **业务过程**:按业务各写一张表,如 `tbl_order_log`、`tbl_card_wallet_auto_renew_log`、`tbl_next_month_log`,以及充值、短信日志。
- **下游代理调用我方接口**:只这一类写 `tbl_agent_pool_api_call_log`。
- **网关调用和轮询**:全部写 log4j 按天滚动的文本日志,不进数据库。
## 2. 目标与原则
**目标**:审计表只回答两个问题:
1. **人(或代表人的外部系统)做了什么**;
2. **钱和套餐、卡的业务状态是怎么变的**。
审计日均降到几千条,一年不到 10 GB,不再依赖归档和清理来扛量。
**原则**:
| 原则 | 说明 |
|---|---|
| 人工操作全留 | 后台账号、个人客户、开放接口、外部系统发起的动作,成功和失败都写审计 |
| 机器动作只留"结果" | 系统任务和定时任务只在资金或业务状态**真正改变**时写审计;无变化、同步、读取不写 |
| 每次失败都可查,审计只记结论 | 自动重试的每一次尝试都完整记在业务尝试记录表里(时间、第几次、错误码、上游返回),可分页查询;审计只记这件事的最终结论:成功,或重试用尽转人工 |
| 过程数据去业务表 | 已有专门记录表的(停复机记录、采集记录、扣减效果、日流量),审计不再重复写 |
| 排查用的走文本日志 | 只用于排查、不需要按业务查询的,写入现有应用日志 `app.log`,沿用现有轮换,由日志同步服务器归集 |
| 不改变业务结果 | 写不写审计,都不影响业务事务成功与否(与现状 `Append` 吞错一致) |
## 3. 数据分层
| 层 | 存储 | 放什么 | 保留 |
|---|---|---|---|
| L1 审计 | `tb_audit_event` + `tb_audit_event_resource` | 人工操作;机器发起的资金、套餐生命周期、停复机成功等状态变化 | 按现有留存策略 |
| L2 业务记录 | 各业务专表 | 停复机调用 `tb_card_stop_resume_record`、流量采集 `tb_card_traffic_collection`、扣减效果与日流量、对接调用 `tb_integration_log`、退款单上的后处理状态 | 按各表策略 |
| L3 文本日志 | 现有应用日志 `app.log`(zap JSON + lumberjack 轮换) | 机器过程类动作的结构化日志,例如停机原因更新、网络状态同步、通知投递、优先轮询、流量重置明细 | 沿用 `logging.app_log` 轮换;长期留存由日志同步服务器负责 |
| L4 不写 | — | 与 L1/L2 完全重复的,例如 `integration.attempt_started` | — |
## 4. 动作去向清单
### 4.1 保留在审计(L1)
**人工及外部发起**(约 1,210 条/天,全部保留):
| 发起方 | 日均 | 主要动作 |
|---|---|---|
| 后台账号 | 约 404 | 手动刷新卡、设备切卡、回收、设 WiFi、手动复机、改套餐到期、设备重启、下单、退款、审批、登录等 |
| 开放接口 | 约 612 | 下单、代理钱包扣款、开放接口复机 |
| 个人客户 | 约 182 | 绑资产、建手机号关联、更新微信身份、绑手机号 |
| 外部系统 | 约 9 | 支付确认、外部回调接收 |
**机器发起但改变资金或业务状态**(方案 A 约 3,510 条/天):
| 动作 | 日均 | 方案 B 去向 |
|---|---|---|
| `iot_card.auto_start` 自动复机 | 1,127 | 移到 L2 停复机记录 |
| `package_usage.reset_traffic` 流量重置 | 1,049 | 移到 L2(套餐自身的重置时间加日流量记录) |
| `iot_card.auto_stop` 自动停机,**仅成功** | 380 | 移到 L2 停复机记录 |
| `commission.calculate` 分佣计算 | 238 | 保留 |
| `package_usage.expire` / `activate` / 退款失效 | 176 / 90 / 6 | 保留 |
| 导入建卡、建设备、设备分配 | 93 / 52 / 44 | 保留 |
| 系统下单、订单套餐失效明细、员工代收账单 | 77 / 74 / 51 | 保留 |
| 外部回调接收、审批同步、退款批准、佣金回溯判定 | 27 / 13 / 6 / 6 | 保留 |
| `refund.process_asset`,**仅成功** | 4 | 保留 |
### 4.2 移出审计
| 动作 | 现在日均(估算) | 去向 | 说明 |
|---|---|---|---|
| `integration.attempt_started` | 约 45.6 万 | L4 不写 | `tb_integration_log` 已有同样内容;`audit_event_id` 留空 |
| `refund.process_asset` 失败 | 约 32.6 万 | L2 退款后处理尝试记录 | 每次尝试一条,记在新表 `tb_refund_post_process_attempt`;退款单上保留当前状态、次数和最后错误;审计只在最终成功或转人工时写一条 |
| `iot_card.auto_stop` 失败 | 约 31.5 万 | L2 停复机记录 | `tb_card_stop_resume_record` 每次实际调用一条,已含错误分类 |
| `iot_card.auto_stop_reason_update` | 约 3.8 万 | L3 文本 | 只改停机原因、不改停复机状态 |
| `iot_card.worker_traffic_sync` | 约 3.1 万(正常时 6–8 万) | L2 采集记录 | `tb_card_traffic_collection` 已经记录每次采集 |
| `iot_card.worker_network_sync` | 约 2.6 万(正常时 6–8 万) | L3 文本 | 结果就是卡上的网络状态字段 |
| `package_usage.deduct_traffic` | 约 2.1 万 | L2 | 采集记录、扣减效果、日流量三张表已经能还原 |
| `notification.deliver`、`polling_priority.*` 等 | 约 0.5 万 | L3 文本 | — |
### 4.3 方案选择
采用**方案 B**。停复机和流量重置都已经有更合适的业务表,审计里只留人工操作和资金、套餐生命周期类动作。
| 方案 | 审计事件/天 | 占现在 | 每天新增存储 |
|---|---|---|---|
| 现在 | 约 122 万 | 100% | 约 6 GB |
| 方案 A:4.1 全留 | 约 4,700 | 0.39% | 约 24 MB |
| **方案 B**:停复机、流量重置移到 L2 | 约 **2,200** | 0.18% | 约 11 MB |
## 5. 技术设计
### 5.1 注册表增加"落库策略"
`internal/infrastructure/audit/registry.go` 的 `ActionDefinition` 新增字段:
```go
// PersistPolicy 决定动作是否写入审计表。
type PersistPolicy string
const (
PersistAlways PersistPolicy = "always" // 成功失败都写(人工动作默认)
PersistOnSuccess PersistPolicy = "on_success" // 只写成功;失败写文本日志
PersistOutcome PersistPolicy = "outcome" // 只写最终结论:成功、或重试用尽转人工;中间失败不写审计(每次尝试在业务尝试记录表)
PersistLogOnly PersistPolicy = "log_only" // 不写表,写 L3 文本日志
PersistNone PersistPolicy = "none" // 什么都不写
)
type ActionDefinition struct {
// ……现有字段
Persist PersistPolicy // 为空时按操作者决定,见 5.2
}
```
动作的策略在注册表中**集中定义**,调用方不改代码。这样一份清单就能看清哪些动作进审计,评审和后续调整也只需改这一处。
### 5.2 默认规则
`Persist` 为空时,按本次写入的操作者类型决定:
| 操作者 `actor_kind` | 默认策略 |
|---|---|
| `account`、`personal_customer`、`openapi`、`external_system` | `always` |
| `system_task`、`scheduled_job` | `log_only` |
因此新增机器动作**默认不进审计**,要进审计必须在注册表显式声明 `always`、`on_success` 或 `outcome`。这能防止后面新增的轮询、同步类动作再次把审计表写满。
同一动作可能同时由人和机器触发,例如 `order.create`、`iot_card.manual_start` 与开放接口复机。策略按"动作 + 操作者"判断:注册表里的 `Persist` 只约束机器操作者,人工操作者始终是 `always`。
### 5.3 Writer 改造
只改 `internal/infrastructure/audit/writer.go` 的 `AppendAndGet`,在注册校验通过后、组装事件前分流:
```go
action, ok := w.registry.Action(input.ActionCode)
// ……现有校验
switch w.persistDecision(ctx, tx, action, input) {
case decisionSkip:
return nil, nil
case decisionLog:
w.machineLog.Info("audit", auditLogFields(action, input)...)
return nil, nil
}
// ……现有写表逻辑
```
要点:
1. **调用方兼容**:`Append` 本来就吞错并返回 nil,调用方不受影响。`AppendAndGet` 有 11 处调用(`refund/clawback.go`、`refund/service.go`、`businessusergroup`、`packagetrafficalert`、`distribution`、`refund_channel`、`integrationlog`),实施时逐一确认能处理返回的 `nil` 事件。这 11 处大多是人工或资金动作,仍然是 `always`,实际不会拿到 nil。
2. **`outcome` 的判定**:由调用方在写审计时标明这次是不是最终结论,`AppendInput` 增加 `Final bool`。
- 中间失败(`Final=false`)不写审计表,只写一行应用日志,日志里带尝试记录 ID 方便对照;
- 最终成功,或重试用尽转人工(`Final=true`)写一条审计,`metadata` 带总尝试次数、首次失败时间、最后错误码;
- 用业务键作 `EventID`(例如 `refund-asset:{refund_id}:outcome`),保证同一件事的结论只有一条。
- **每一次尝试的细节都不丢**,都在业务尝试记录表里(见 5.5),可分页查询(见 5.9)。
3. **文本日志写入现有应用日志**:不新建日志文件,也不新增轮换配置。Writer 直接使用 `logger.GetAppLogger().Named("audit")`,写入现有的 `app.log`,轮换沿用 `logging.app_log`(测试环境配置见 5.8),由日志同步服务器自动归集。`NewWriter` 签名不变,调用方不用改。
- 每行固定 `"logger":"audit"`、`"msg":"audit_event"`,便于在同步端按 logger 名过滤和建索引;
- 字段:`action_code`、`actor_kind`、`actor_id`、`source`、`result`、`resource_type`、`resource_key`、`correlation_id`、`request_id`、`error_code`、`summary`;中间失败额外带 `attempt`(第几次)和尝试记录 ID;
- 级别:成功为 Info,失败为 Warn;
- **不写** before/after 快照与主体数据,避免敏感数据进入文本日志;需要变化值的动作(如流量重置前后用量)只写必要的数值字段。
4. **运行时覆盖**:在 `add-audit-integration-write-switches` 的全局开关之外,增加一个按动作覆盖的配置 `audit.persist_overrides`(动作编码 → 策略)。出问题时不发版就能把某个动作临时降为 `log_only`,或恢复为 `always`。
### 5.4 对接日志不再补写审计
`internal/infrastructure/integrationlog/repository.go` 的 `Start`:
- **删除** `input.AuditEventID == nil` 时补写 `integration.attempt_started` 的逻辑(第 118–120 行)。上游有审计事件时照常关联;没有时 `audit_event_id` 留空。
- 注册表里的 `integration.attempt_started` 设为 `none`,作为兜底。
- `auditEventIDForSeries` 按 `trigger_series` 回查审计的逻辑保留,但需要确认它不会走慢查询(它会查审计表)。
### 5.5 失败重试收敛(与停机风暴止血配套)
审计只记结论之后,排查问题靠的是每一次尝试的记录,同时重试本身要收敛。这两件可以单独开 Change,但**必须先于或同时上线**:
1. **退款后处理**(`internal/service/refund/approval_decision.go` 的 `ProcessAssetPostProcessing`,`stopAsset` → `ManualStopCard`):
- 退款单增加或复用后处理字段:`asset_post_status`、`asset_post_attempts`、`asset_post_last_error`、`asset_post_next_at`,表示当前状态;
- **新增尝试记录表** `tb_refund_post_process_attempt`,每次尝试一条,不修改、不删除。字段:`refund_id`、`step`(停机、套餐失效、钱包回收等)、`asset_type`、`asset_id`、`iccid`、`attempt`、`trigger`(outbox 投递、补偿扫描、人工重试)、`result`、`error_code`、`error_message`、`stop_resume_record_id`(关联到那次停机调用)、`duration_ms`、`created_at`。按月分区。每单最多 10 次,量可控;
- 失败按指数退避,超过 N 次(建议 10 次)标记为"需人工处理",不再自动重试;
- outbox 与 `RecoverRefundPostProcessing` 都按这几个字段判断,不再无限重投。
2. **自动停机**:8-11 上的停机已经走停复机指令队列(`stop-resume-command-queue`),按错误分类处理:终态不可操作不重试,限流按退避处理。退款停机只请求停机评估。本次不改这部分。
- 每次实际调用上游,已经在 `tb_card_stop_resume_record` 记一条,包括第几次、错误类别、上游返回码与信息、耗时。
- 指令在调用上游前就结束的情况(已是目标状态、条件不再满足、卡不允许停复机)现在没有记录。改为在停复机记录里写 `result=skipped`,并写明原因,保证"为什么没停"也能查到。限流推迟后还会再执行,不写记录,沿用现有的分钟计数。
### 5.6 方案 B 的业务表补位
| 移出的动作 | 补位方式 | 现状 |
|---|---|---|
| `iot_card.auto_start` / `auto_stop` 成功 | `tb_card_stop_resume_record`(每次实际调用一条,含来源、场景、原因、结果) | Iteration/8-11 已有 |
| `package_usage.reset_traffic` | 套餐使用记录的 `last_reset_at` / `next_reset_at` 加日流量记录 | 已有;重置前后用量写一行 `app.log`(`logger=audit`)备查 |
| `iot_card.auto_start` / `auto_stop` 的停机原因、触发场景 | `tb_card_stop_resume_record` 的 `source`、`scene`、`reason` | 已有;原审计里的 before/after 卡状态不再保留,卡当前状态以卡表为准 |
后台"卡操作记录"之类的页面如果要看到停复机时间线,查询侧改为合并审计和停复机记录两个来源,不要回头再写审计。
### 5.7 审计覆盖治理
`internal/governance/auditcoverage/scanner.go` 扫描每个入口的 `AuditEvent`、`DomainLedger`、`IntegrationLog` 分类。本次需要做三件事:
- 对移到 L2、L3、L4 的入口,把 `AuditEvent` 分类改为不写,并在 `DomainLedger` 里写明对应的业务表;
- 覆盖扫描增加一条校验:机器操作者的动作必须在注册表显式声明 `Persist`,否则扫描失败;
- 同步更新 `openspec/specs/operations-audit` 和 `external-integration` 中"每个入口都写审计"的要求。
### 5.8 应用日志增量与轮换(测试环境)
测试环境由 `docker-compose.prod.yml` 部署,api、worker 两个容器的现状:
- 日志级别是 `LEVEL=info`、`DEVELOPMENT=false`,即 JSON 格式,可以按 `"logger":"audit"` 过滤;
- 两个容器都把宿主机 `./logs` 挂载到 `/app/logs`,且都**没有设置** `JUNHONG_LOGGING_APP_LOG_FILENAME`,所以都使用默认的 `/app/logs/app.log`;
- 轮换参数也是默认值:100 MB 一个文件、3 个备份、7 天、压缩。
**问题**:api 和 worker 写的是宿主机上**同一个** `/opt/junhong_cmp/logs/app.log`。两个进程各自用 lumberjack 按大小轮换,会互相把对方正在写的文件改名、压缩,导致丢日志和备份错乱。这个问题与审计改造无关,但 L3 日志要落在 `app.log` 里,所以要一并修。
**调整原则**:每个服务写在自己运行目录下的 `logs/`。审计日志不新增路径、不改文件名,跟着所在进程的应用日志走。测试环境的问题只在于两个容器共用了同一个宿主机目录,所以只改挂载:
| 服务 | 挂载(宿主机 → 容器) | 宿主机日志位置 |
|---|---|---|
| api | `./api/logs:/app/logs` | `/opt/junhong_cmp/api/logs/app.log`、`access.log` |
| worker | `./worker/logs:/app/logs` | `/opt/junhong_cmp/worker/logs/app.log` |
- 容器内仍然是默认的 `/app/logs/app.log`,**不设置** `APP_LOG_FILENAME`;目录结构与生产 `/opt/junhong_cmp/api/logs`、`/opt/junhong_cmp/worker/logs` 一致;
- 轮换只加两项:`JUNHONG_LOGGING_APP_LOG_MAX_BACKUPS=40`、`JUNHONG_LOGGING_APP_LOG_MAX_AGE=14`,其余用默认值(100 MB、压缩);
- 容量:两个进程合计约 1 GB,每个进程是 100 MB 当前文件加 40 个压缩备份。每个备份约 10 MB 是估算值,部署后按实际备份大小微调;
- `.gitea/workflows/deploy.yaml` 里 `mkdir -p ${DEPLOY_DIR}/logs` 改为创建 `api/logs`、`worker/logs`;
- 原来的 `/opt/junhong_cmp/logs` 不再写入,旧文件保留不动。
- **新增量**:方案 B 在测试环境移到 L3 的动作,每天约 8 万–10 万行:流量同步改由采集记录承担,不写日志;需要写日志的有网络状态同步约 2.5 万、优先轮询约 1.4 万、停机原因更新与通知若干。按每行约 400 B 计算,每天约 30–40 MB(压缩前),相对单文件 100 MB 没有压力。
文本日志是排查补充。丢几行不影响业务,也不影响审计表。写日志失败时不重试、不报错。
### 5.9 记录查询接口
审计里移出去的数据,要能在后台查得到,否则等于丢了。凡是独立成表的记录,都要提供**分页 + 条件筛选**的查询接口。L3 文本日志不提供接口,由日志同步服务器检索。
**公共约定**(与现有 `ListStopResumeRecords` 一致):
| 项 | 约定 |
|---|---|
| 分页 | `page` 默认 1;`page_size` 默认 20,最大 100(采集记录沿用现有的最大 200);返回 `total`、`page`、`page_size`、`items` |
| 时间范围 | `start_time`、`end_time` 必填,跨度不超过 31 天,按上海时间解析;单卡接口为空时默认取最近 31 天 |
| 卡标识 | `identifier` 支持虚拟号、ICCID、MSISDN,先解析成卡 ID 再查,保证走"卡 ID + 时间"索引;找不到卡时直接返回空页 |
| 排序 | 按记录时间倒序,同一时间按 ID 倒序 |
| 单个与全部 | 每类记录提供一个列表接口,`identifier` 选填:传了就查单卡或单资产,不传就查全部。现有的单卡接口保留给前端兼容,与列表接口共用同一个查询 |
| 筛选 | 按业务排查需要来定,下面逐表列出,不受现有索引限制;为这些筛选新建或重建索引 |
| 时间范围的作用 | 表按月分区,时间范围用于分区裁剪,所以查全部时也只带时间范围、不要求其他条件 |
| 权限 | 平台账号;代理和企业账号是否开放、按归属范围过滤,另行确定 |
**接口清单**:
| 记录 | 表 | 列表接口 | 筛选条件 | 现状 |
|---|---|---|---|---|
| 运营商流量采集记录(成功,失败见 5.10) | `tb_card_traffic_collection` | `GET /traffic-collections` | `identifier`、`carrier_id`(运营商账户)、`decision`(扣减判定)、`source`、`scene`、`has_delta`(是否产生增量)、`stale_guarded` | 只有单卡接口 `GET /assets/:identifier/traffic-collections`,只能按时间分页 |
| 停复机记录 | `tb_card_stop_resume_record` | `GET /card-stop-resume-records` | `identifier`、`carrier_id`、`action`、`source`、`scene`、`reason`、`result`、`error_category`、`command_id` | 已有跨卡接口,但只能用 ICCID 查卡,也不能按场景、原因、指令号筛选;单卡接口没有筛选 |
| 套餐日流量详单 | `tb_package_usage_daily_record` | `GET /package-usage-daily-records` | `identifier`、`package_usage_id`、`package_id`、`carrier_id`、日期范围 | 只有单套餐接口 `GET /package-usages/:id/daily-records`,不分页 |
| 卡日流量 | `tb_card_daily_usage` | `GET /card-daily-usages` | `identifier`、`carrier_id`、日期范围、`min_usage_mb`(用量下限,排查异常大流量) | 无接口 |
| 套餐流量冲正流水 | `tb_package_usage_adjustment` | `GET /package-traffic-adjustments` | `identifier`、`package_usage_id`、操作人、调整方向(补扣或退回)、时间范围 | 只有单套餐接口,没有时间筛选 |
| 对接日志 | `tb_integration_log` | 现有 `GET /audit/integrations` | 现有条件已覆盖 | 不改 |
| 退款后处理尝试记录 | `tb_refund_post_process_attempt`(见 5.5) | `GET /refund-post-process-attempts` | `refund_id`、退款单号、`identifier`、`step`、`result`、`error_code`、`trigger`、时间范围 | 新增。退款列表同时增加 `asset_post_status` 筛选(可查"需人工处理"),列表和详情返回后处理次数、最后错误、下次重试时间 |
**索引**:按上面的筛选设计,需要时新建或重建。分区表的索引建在父表上,会自动应用到各个分区。
| 表 | 索引 |
|---|---|
| `tb_card_traffic_collection` | 保留 `(iot_card_id, observed_at DESC)`;新增冗余列 `carrier_id`,采集时写入;新增 `(observed_at DESC)`、`(carrier_id, observed_at DESC)`、`(decision, observed_at DESC)`;失败表索引见 5.10 |
| `tb_card_stop_resume_record` | 现有 `(card_id, created_at)`、`(carrier_id, created_at)`、`(result, created_at)` 重建为倒序;新增 `(created_at DESC)`、`(scene, created_at DESC)`、`(command_id)` |
| `tb_card_daily_usage` | 现有唯一键 `(iot_card_id, date)`;新增 `(date, usage_mb DESC)`,支撑查全部和按用量下限筛选 |
| `tb_package_usage_daily_record` | 新增 `(package_usage_id, date DESC)`、`(date)` |
| `tb_package_usage_adjustment` | 新增 `(iot_card_id, created_at DESC)`、`(created_at DESC)` |
| `tb_refund_post_process_attempt` | `(refund_id, created_at DESC)`、`(created_at DESC)`、`(result, created_at DESC)`、`(error_code, created_at DESC)` |
其余低选择性的条件,如 `source`、`stale_guarded`、调整方向,和上面的索引组合使用即可,不单独建索引。上线后在测试环境看慢查询日志,按实际情况调整。
**说明**:
- **流量扣减结果**直接从采集记录查:每条采集记录都带 `decision`、`delta_mb`、`month_used_mb`、`event_id`,再结合日流量详单,能还原每次扣了多少。
- **扣减效果表** `tb_card_observation_effect` 不对外提供接口。它只保存 outbox 事件的处理进度,没有卡 ID。排查扣减卡住时,在采集记录里按 `event_id` 关联查。
- 新增接口都要在路由 `RouteSpec` 中声明输入、输出 DTO,并同步生成接口文档。
### 5.10 流量采集记录成功与失败分表
**现状**(测试库近 3 天,只读统计):
| 读数结果 | 扣减判定 | 条数 | 平均行大小 |
|---|---|---|---|
| `ok` | `written`(写入增量) | 104.2 万 | 432 B |
| `ok` | `queue_not_full`(队列未满,暂不判断) | 17.3 万 | 285 B |
| `ok` | `slope_negative`、`baseline`、`settled_existing` | 800 | 约 420 B |
| `failed` | — | 14.5 万 | 136 B |
| `no_reading` | — | 400 | 144 B |
- 成功和失败写在同一张 `tb_card_traffic_collection` 里,失败约占 11%,测试环境每天约 4.8 万条。
- 失败记录只存了失败类型。`polling_carddata_handler` 在调用 `recordUnreadable` 时**丢掉了具体错误**,查不到失败原因,只能再去对接日志里按 `observation_id` 找。
- 失败记录也只给"采集时刻有套餐"的卡写,为此每条失败都要额外查一次有效套餐。
**调整**:
| 表 | 写什么 | 保留 |
|---|---|---|
| `tb_card_traffic_collection`(现有) | 只写成功读数(`result=ok`),包括所有扣减判定;新增原始请求与响应字段 | 在线保留 2 个月,到期月分区导出为压缩包后删除(见下文"到期归档") |
| `tb_card_traffic_collection_failure`(新增) | 只写失败:`failed`、`rate_limited`、`invalid_payload`、`no_reading`、`invalid_reading`;带原始请求与响应 | 与成功表相同:在线 2 个月,到期归档后删除 |
失败表字段:`iot_card_id`、`iccid`、`carrier_id`、`observed_at`、`observation_id`(等于对接日志的 `integration_id`,可直接关联)、`source`、`scene`、`result`(失败类型)、`error_category`(按网关错误分类:超时、限流、上游报错、响应格式错等)、`provider_code`、`provider_message`(截断到 500 字)、`duration_ms`、`created_at`,加上下面的原始数据字段。按 `observed_at` 按月分区,只新增不修改。
**原始数据**:成功表和失败表都保存运营商网关的原始请求与原始响应,有据可查。
| 字段 | 内容 |
|---|---|
| `raw_request` | 加密前的业务请求体(明文 JSON,只有卡号等查询参数),jsonb |
| `http_status` | HTTP 状态码;没拿到响应(超时、连接失败)时为空 |
| `gateway_code`、`gateway_msg`、`gateway_trace_id` | 网关响应外层的 `code`、`msg`、`trace_id` |
| `raw_response` | 网关原始响应体,原样保存。能解析成 JSON 的存 jsonb,不能解析的按文本存在 `raw_response_text`。超过 4 KB 时截断,并标记 `raw_truncated=true` |
| `gateway_attempts` | 网关客户端内部重试次数;保存的是最后一次的请求和响应 |
- **获取方式**:网关客户端(`internal/gateway/client.go` 的 `executeWithRetry`)已经读到了完整响应体和外层的 `code`、`msg`、`trace_id`,但只返回 `data`。新增按请求上下文挂载的原始数据收集器 `gateway.WithRawCapture(ctx)`,调用方挂上后,客户端把最后一次的请求体、状态码、响应体和重试次数写进收集器。没挂收集器的调用不受影响。
- **写入**:`polling_carddata_handler` 调 `QueryFlow` 前挂上收集器,成功读数和各类失败都把收集器内容带进 `TrafficObservation`,写入对应的表。
- **安全**:只存明文业务请求体,不存加密后的报文、签名、`app_id`、密钥和请求头。
- **量的估算**:流量查询响应很小(`iccid`、`used`、`unit` 加外层字段,约 150–250 B),每行增加约 300–400 B。测试环境成功读数每天约 40 万条,每天增加约 120–160 MB;在线保留 2 个月,原始数据部分约 7–10 GB。失败每天约 4.8 万条,2 个月约 1 GB。部署后按实际行大小复核。
**到期归档**:
- **在线保留**:当月加上最近 2 个整月。月分区的月末早于"当前时间减 2 个月"时,判定为到期。例如 2027-01-01 起,2026-10 的分区到期。常量 `TrafficCollectionRetentionDays=90` 改为 `TrafficCollectionRetentionMonths=2`,成功表和失败表共用。
- **执行位置**:沿用 leader worker 现有的分区维护循环 `startTrafficCollectionPartitionMaintainer`,每 12 小时运行一次。原来"到期直接 `DROP TABLE`"的逻辑,改为下面的四步:
1. **摘出分区**:`ALTER TABLE ... DETACH PARTITION`,到期分区不再参与在线查询。
2. **导出压缩包**:用 `COPY (SELECT * FROM 分区 ORDER BY observed_at, id) TO STDOUT WITH (FORMAT csv, HEADER)` 流式读出,边读边 gzip 写到 worker 运行目录下的 `archive/traffic-collection/` 目录,不在内存里攒整个月的数据。文件名为 `tb_card_traffic_collection_202610.csv.gz` 或 `tb_card_traffic_collection_failure_202610.csv.gz`。原始请求与响应以 JSON 文本形式保存在 CSV 列里。
3. **校验**:写完后重新读一遍压缩包,统计行数并与分区 `count(*)` 比对;同时生成同名的 `.manifest.json`,记录表名、月份、行数、最早和最晚采集时间、文件大小、SHA-256 和导出时间。
4. **删除分区**:行数一致后才 `DROP TABLE` 已摘出的分区。导出或校验失败时保留已摘出的表,记 Error 日志,下一轮重试;不会出现"数据删了但没有压缩包"的情况。
- **文件同步**:压缩包和 manifest 由你们的同步服务器拉走。本地只保留最近 3 个月的压缩包,更早的自动删除,个数可配置。先写成 `.tmp`,校验通过后再改名为正式文件名,避免同步到写了一半的文件。
- **测试环境挂载**:worker 容器增加 `./worker/archive:/app/archive`,宿主机目录为 `/opt/junhong_cmp/worker/archive/traffic-collection/`。
- **压缩包大小估算**:测试环境成功表每月约 1,200 万行,CSV 约 8–10 GB,gzip 后约 1–1.5 GB;失败表每月约 150 万行,压缩后约 50 MB。
- **回查**:要查归档月份的数据,解压后用 CSV 工具查看,或者临时 `COPY FROM` 导回一张临时表。线上接口只查在线的 2 个月。
**写入规则**:
- 失败记录**不再判断是否有套餐**,所有轮询到的卡都写,省掉每条失败的有效套餐查询。运营商接口出问题和有没有套餐无关,排查时需要看全量。
- `TrafficObservation` 增加失败详情(错误类别、上游返回码与信息、耗时),由 `polling_carddata_handler` 在网关调用失败时填入,不再丢弃错误。
- 成功表仍在观测事务之外写入,规则不变;写入失败只告警,不影响扣减。
- 成功表已有的失败历史数据保留,到期随分区删除;新数据按新规则分表写入。
**查询接口**(与 5.9 公共约定一致:可查单个也可查全部,分页,时间范围必填):
| 接口 | 筛选条件 | 索引 |
|---|---|---|
| `GET /traffic-collections`(成功) | `identifier`、`carrier_id`、`decision`、`source`、`scene`、`has_delta`、`stale_guarded` | 见 5.9 |
| `GET /traffic-collection-failures`(失败) | `identifier`、`carrier_id`、`result`、`error_category`、`provider_code`、`source`、`scene` | `(iot_card_id, observed_at DESC)`、`(observed_at DESC)`、`(carrier_id, observed_at DESC)`、`(error_category, observed_at DESC)`、`(observation_id)` |
成功表不再有失败数据,所以 5.9 里成功表的 `result` 筛选和 `(result, observed_at DESC)` 索引都去掉。单卡接口 `GET /assets/:identifier/traffic-collections` 增加 `include_failures` 参数,默认不含失败;传了就把两张表合并后按时间倒序返回,方便在一张卡上连起来看。
## 6. 存量数据
- **不删除、不迁移**已有审计数据,由现有留存和归档任务按策略自然过期。
- 如果要尽快释放空间,可以另行评估"按动作编码清理机器过程类历史数据"的一次性任务。本方案不包含它。
## 7. 分支与范围
- 基于 **Iteration/8-11** 实施。该分支已有 `tb_card_stop_resume_record`、采集记录和扣减效果表,方案 B 的补位表都存在。
- **只部署测试环境**:8-11 推送后自动部署到测试环境;不在生产执行任何代码替换、配置修改或服务重启。生产什么时候、以什么方式合入,不在本方案范围内。
- 全局写入开关 `add-audit-integration-write-switches` 已在 8-11 上,本方案与它互不替代:全局开关用于应急**全停**,本方案是常态下的**按动作分级**。
## 8. 实施步骤
| 步骤 | 内容 | 涉及 |
|---|---|---|
| 1 | 注册表增加 `Persist`,补齐全部机器动作的策略,并加上 5.2 的默认规则 | `internal/infrastructure/audit/registry.go`、`pkg/constants/audit.go` |
| 2 | Writer 按策略分流:`outcome` 只写最终结论;`log_only` 写现有应用日志(`logger=audit`) | `internal/infrastructure/audit/writer.go` |
| 3 | 对接日志去掉 `attempt_started` 补写 | `internal/infrastructure/integrationlog/repository.go` |
| 4 | 退款后处理加重试上限和退避,新增尝试记录表;停机业务性失败不重投,调用前被拦截的写 `skipped` 停复机记录 | `internal/service/refund`、`internal/service/iot_card`、`migrations` |
| 5 | 增加 `audit.persist_overrides` 运行时覆盖配置 | `pkg/config` |
| 6 | 审计覆盖扫描和 OpenSpec 规格同步 | `internal/governance/auditcoverage`、`openspec/specs` |
| 7 | 查询侧:卡、设备时间线合并停复机记录(方案 B) | `internal/query/audit` |
| 8 | 测试环境 api、worker 各自挂载自己的 `logs/` 目录,轮换改为 40 个备份(见 5.8) | `docker-compose.prod.yml`、`.gitea/workflows/deploy.yaml` |
| 9 | 记录查询接口:每类记录一个列表接口,可查单个也可查全部;按 5.9 的筛选条件建立或重建索引;采集记录增加 `carrier_id` 冗余列 | `internal/routes`、`internal/handler/admin`、`internal/model/dto`、`internal/store/postgres`、`migrations` |
| 10 | 采集记录成功与失败分表:新增失败表,补全失败详情,失败不再判断有无套餐;两张表保存网关原始请求与响应(网关客户端新增原始数据收集器);提供失败查询接口(见 5.10) | `internal/gateway/client.go`、`internal/application/cardobservation`、`internal/task/polling_carddata_handler.go`、`internal/model`、`internal/store/postgres`、`migrations` |
| 11 | 采集记录到期归档:分区维护改为摘出、导出 `csv.gz`、校验、删除四步;测试环境 worker 挂载 `archive` 目录 | `internal/store/postgres/card_traffic_collection_store.go`、`cmd/worker/main.go`、`pkg/constants`、`docker-compose.prod.yml` |
建议按"1–3 → 4 → 5–11"分三次提交,每次推送 8-11 后在测试环境验证。
## 9. 测试与验证(测试环境)
**测试环境逐项验证**(项目规定自动化测试为 N/A,按 ENG-TEST-001 在测试环境验证,以下各项是验证要点):
- 每种策略在 `account`、`openapi`、`system_task` 下的写入结果;
- `outcome`:中间失败不写审计,尝试记录表每次都有一条;最终成功或转人工写一条审计,带总次数;同一件事重复写结论只保留一条;
- `log_only` 不写表、文本日志有记录且不含快照;
- 覆盖配置能把 `always` 降为 `log_only`,也能反过来恢复。
- 对接调用没有上游审计时,`tb_integration_log.audit_event_id` 为空,审计表无新增;
- 退款后处理失败超过上限后状态为"需人工处理",outbox 不再重投。
- 流量查询成功写成功表、各类失败写失败表,两张表都带原始请求与响应;失败记录有错误类别和上游返回码;无套餐的卡失败也写;
- 网关响应超过 4 KB 时截断并标记;原始请求里没有密钥、签名或加密报文;
- 到期归档:构造一个过期月分区,执行维护后生成 `csv.gz` 和 manifest,行数与 SHA-256 一致后分区被删除;模拟导出失败时,已摘出的表保留,下一轮重试成功;
- 记录查询接口:每个接口都能查单卡、也能查全部,分页总数正确,各筛选条件生效。
**测试环境观察**(部署后 24 小时):
- 审计日增从约 12.6 万降到约 300 条(只读查询走 `created_at` 索引、按小时统计);
- `app.log` 中出现 `logger=audit` 的行,轮换正常,日志同步服务器能收到并按 logger 名过滤;
- 停复机记录、扣减效果写入量没有变化,业务结果没有变化;成功表加失败表的写入量合计,与改造前采集记录的量相当(失败表多出无套餐卡的失败);
- 新接口在测试库数据量下没有慢查询;
- `/opt/junhong_cmp/worker/archive/traffic-collection/` 目录可写,同步服务器能拉到文件。第一次真实归档要等到在线数据满 2 个月之后,在此之前用单元测试和集成测试覆盖。
## 10. 已定事项(2026-11-05)
| 事项 | 结论 |
|---|---|
| 方案 | 方案 B |
| 范围 | 基于 Iteration/8-11,只部署测试环境 |
| L3 文本日志 | 写入所在服务自己运行目录下的 `logs/app.log`,沿用现有轮换,由日志同步服务器归集 |
| 测试环境日志保留 | api、worker 各自的 `logs/` 目录,各保留 40 个压缩备份,合计约 1 GB;部署后按实际备份大小微调 |
| 退款后处理重试上限 | 10 次,指数退避,最长间隔 1 小时;超过后标记"需人工处理" |
| 失败记录 | 每次尝试都记录在业务尝试记录表(退款后处理尝试、停复机记录),可分页查询;审计只记最终结论,不按首次失败去重 |
| 停机业务性失败 | 按网关错误分类判定为不可重试,不再重投 |
| 运行时覆盖 | 提供 `audit.persist_overrides`,可按动作临时调整策略 |
| 存量审计数据 | 不删除、不迁移,按现有留存策略自然过期 |
| 采集记录分表 | 成功写现有表,失败写新表 `tb_card_traffic_collection_failure`,带完整错误详情,所有卡都写;两张表都保存网关原始请求与响应 |
| 采集记录保留 | 两张表在线保留 2 个月(当月加最近 2 个整月);到期月分区先导出为 `csv.gz` 压缩包并校验,再删除;压缩包由同步服务器拉走,本地保留 3 个月 |
| 记录查询 | 所有独立成表的记录都提供分页加条件筛选接口,可查单个也可查全部;筛选条件按排查需要来定,索引随之新建或重建,清单见 5.9;文本日志不提供接口 |
| 页面依赖 | 实施第 7 步(时间线合并停复机记录)前,与前端或运营确认是否有页面依赖机器动作审计 |

View File

@@ -1,230 +0,0 @@
# 轮询入库异步化技术方案
- 日期:2026-11-06
- 分支:`Iteration/8-11`(只部署测试环境,不涉及生产配置与重启)
- 关联:`add-carrier-polling-scheduler`(按运营商账户调度)、`add-card-traffic-queue-and-observed-deduction`(采集队列与按观测扣减)、`refactor-audit-log-persistence`(采集记录分表)
## 1. 背景
### 1.1 测试环境现状(2026-11-06 上午,只读统计)
| 指标 | 数值 |
|---|---|
| worker 报 `context deadline exceeded` 的 SQL | 每分钟 90–220 次,平均约 130 次,即每小时约 8,000 次,部署审计改造前已存在 |
| 慢查询告警(> 500ms) | 每小时约 13.8 万条,占 worker 日志行数 53% |
| 按主键查单行 `tb_iot_card` | 经常 0.7–2.4 秒 |
| worker 日志量 | 每小时约 26 万行,18 分钟写满一个 100MB 文件,40 个备份只能留约 12 小时 |
| 数据库连接 | worker 连接池 `MAX_OPEN_CONNS=60`,测试库上 61 个连接,基本占满;有一次 30 个会话同时等锁 |
| 采集记录丢失 | 写采集成功表/失败表 3 秒超时,约每 16 分钟丢 60 条 |
### 1.2 原因:消费并发没有总量限制
NSQ 只限制了每个运营商账户的并发(保护网关),没有限制全部账户加起来的并发(保护数据库):
| 来源 | 规则 | 理论上限 | 实测 |
|---|---|---|---|
| 卡轮询 `polling.card.<账户>` | 每账户 20(`default_card_max_in_flight`,`tb_carrier_polling_param` 无记录) | 41 × 20 = 820 | 约 310 |
| 停复机 `polling.stop_resume.<账户>` | 每账户 10 | 41 × 10 = 410 | 约 22 |
| 设备评估 `polling.device_eval` | 10 | 10 | 10 |
| 异步任务队列 | `JUNHONG_QUEUE_CONCURRENCY=50` | 50 | — |
| 合计 | | 约 1,290 | 约 380 |
`worker.polling_total_max_concurrency: 100` 只传给旧分片轮询(`cmd/worker/main.go:371`),按账户调度的 NSQ 消费者不受它约束。
每个轮询步骤在**同一个任务里**先调网关、再同步写库,所有写库都在步骤超时内完成。三百多个任务抢 60 个连接,等连接的时间计入超时,数据库稍有抖动就成批超时。
### 1.3 超时分布(按调用栈,约 16 分钟样本)
| 占比 | 位置 | 说明 |
|---|---|---|
| 约 40% | 配置匹配查套餐状态(`TierResolver.Prefill`,`internal/polling/config_manager.go:191`,共用 3 秒) | 绑定设备的卡连查 3 次;失败后把"未知"结果写进缓存 |
| 约 20% | `writeTrafficFailureAudit` | 沿用已超时的上下文再查卡,属连带报错 |
| 约 20% | 写采集成功表、失败表(`collectionRecordTimeout = 3s`) | 记录直接丢失 |
| 其余 | 流量观测事务、网络状态观测、实名缓存失效等 | |
## 2. 目标与原则
1. **调上游与入库分开控制**:调网关按账户并发(保持现有每账户上限),入库单独发消息、由单独的消费者处理。
2. **数据库并发有总上限**:入库消费者的并发是一个固定、可配置的数,与账户数量无关;加账户不会挤爆数据库。
3. **消费慢一点没关系**:消息在 NSQ 里排队,入库按数据库承受的速度处理;不丢、不重复算。
4. **轮询步骤只做"调网关 + 发消息"**:调网关前必要的读查询走缓存,尽量不碰数据库。
5. **可切回**:保留同步入库路径,用系统配置一键切换,异常时可回退。
6. **范围**:只在 `Iteration/8-11` 上实现,只部署测试环境。
## 3. 现状链路(一次流量步骤)
```
NSQ 卡任务 ──► 步骤(15s 超时)
├─ 读:优先轮询项 FindActive(tb_polling_priority_item)
├─ 读:卡(先清缓存再查库 tb_iot_card)
├─ 读:轮询范围校验(tb_carrier、tb_device、tb_device_sim_binding)
├─ 调网关 QueryFlow
├─ 写:ApplyTrafficObservation
│ ├─ Redis 采集队列 Push
│ ├─ 事务:锁卡行、查有效套餐、写日快照、更新卡、写流量事件 outbox、通道阈值评估、审计
│ └─ 写采集成功表/失败表(3s)
├─ 写:对接日志、优先项完成
└─ 返回 TriggeredEvaluation(决定是否跳过本轮套餐步骤)
```
网络状态步骤(`ApplyNetworkObservation`)、实名步骤(`ApplyCardObservation`)结构相同;套餐步骤不调网关,只读写库。
## 4. 技术设计
### 4.1 两段式
```
采集段(按账户并发,现有 NSQ 卡任务) 入库段(固定小并发)
┌────────────────────────────────┐ ┌──────────────────────────────┐
│ 读缓存 → 调网关 → 发入库消息 │──NSQ──► │ card.ingest 消费者(并发 N) │
│ 网关失败 → 发采集失败消息 │ │ Apply*Observation(事务) │
└────────────────────────────────┘ │ → 发采集记录消息 │
└──────────────┬───────────────┘
│NSQ
┌──────────────▼───────────────┐
│ card.collection 消费者 │
│ 攒批写成功表/失败表 │
└──────────────────────────────┘
```
- **采集段**:现有卡任务消费者,并发规则不变(每账户 `card_max_in_flight`)。步骤拿到网关结果后发一条入库消息就结束,步骤结果只反映"网关是否成功"。
- **入库段**:新 topic `card.ingest`,单独消费者,`max_in_flight` = `polling.ingest.concurrency`(默认 30)。
- **采集记录**:新 topic `card.collection`,单独的批量消费者(见 4.5)。
### 4.2 入库消息
topic `card.ingest`,JSON:
| 字段 | 说明 |
|---|---|
| `kind` | `traffic` / `network` / `realname` |
| `card_id`、`carrier_id`、`iccid` | 卡标识 |
| `observation_id` | 现有 `attempt.IntegrationID`,幂等键 |
| `observed_at` | 网关返回时刻(不是入库时刻) |
| `source`、`scene`、`correlation_id` | 现有观测元数据 |
| `payload` | 流量:`reading_mb`、`reading_result`、`reset_day`;网络:`status`、`extend`、`imei`;实名:`verified` |
| `raw`、`failure` | 网关原始数据(截断规则同采集记录) |
| `priority_item_id` | 本轮认领的优先轮询项,入库成功后由入库段完成 |
| `published_at` | 发出时刻,用于积压与延迟统计 |
### 4.3 入库消费者
处理一条消息:
1. 幂等检查:Redis `ingest:done:<observation_id>` 存在则直接 FIN。
2. 按 `kind` 调用现有 `ApplyTrafficObservation` / `ApplyNetworkObservation` / `ApplyCardObservation`,上下文超时 `polling.ingest.timeout`(默认 30s),从拿到消息开始计时。
3. 成功后:写 `ingest:done:<observation_id>`(TTL 48h);流量有变化时写 Redis 标记 `flow_evaluated:<card_id>`(见 4.6)。
- 本轮认领了优先轮询项的观测不走消息,仍在卡任务内同步入库,优先项完成与对接日志沿用原逻辑。
- 网络观测命中风险停机或已销户时,卡任务按网关结果直接停止该卡轮询,入库段写库时关闭轮询开关。
4. 失败:
- 数据库超时、连接错误等可重试错误:NSQ 延迟重投,退避 5s、15s、30s、1m、2m,之后每 5 分钟一次,**不设次数上限、不丢弃**(入库消费者的 NSQ `max_attempts` 设为 0);第 10 次起每次重投记 ERROR,带 `observation_id`、卡号与错误,便于排查。
- 业务性错误(卡不存在、数据校验失败):不重投,记 WARN 后 FIN。
- 本期不做死信表。长时间重试的消息会体现在积压量与最老消息等待时间上(见 4.7)。
### 4.4 顺序与幂等
NSQ 不保证顺序,也可能重投。逐项说明现有逻辑能否承受:
| 风险 | 处理 |
|---|---|
| 同一张卡两条流量消息乱序 | 采集队列按 `observed_at` 插入,"新点在队尾才写"——旧消息后到时不在队尾,不写快照,只记采集记录。现有规则已覆盖 |
| 同一张卡两条消息并发处理 | 事务内 `FOR UPDATE` 锁卡行,串行执行 |
| 同一条消息重投 | ① `ingest:done` 键挡掉已成功的;② 事务提交后、写键前崩溃:日快照取当日最大值、增量按"本月最大 − 已结算"计算,重放得到 0 增量;流量事件 outbox ID 由 `observation_id` 生成,重复写入被唯一键挡掉;③ 采集队列 Push 同一个点需改为按 `observed_at` 去重(Lua 内判断) |
| 网络/实名状态乱序 | 现有 `ApplyNetworkObservation`、`ApplyCardObservation` 需确认按 `observed_at` 比较"更新的才生效";没有的补上 |
| 采集记录重复 | 成功表/失败表按 `(observation_id, observed_at)` 建唯一索引(含分区键),批量写用 `ON CONFLICT DO NOTHING` |
### 4.5 采集记录批量写
- 入库段写完事务后,把采集成功记录发到 `card.collection`;采集段网关失败、无读数时直接把失败记录发到 `card.collection`,不经过入库段。
- `card.collection` 消费者 `max_in_flight` = 500,攒满 `polling.collection.batch_size`(默认 200)或等满 1 秒就批量写一次(多行 INSERT),成功后统一 FIN,失败统一重投。
- 写库并发固定 1–2 个连接。按测试环境每小时约 2.1 万条计算,每秒约 6 条,一次批量写即可。
- 现有 3 秒超时和"写失败只记日志"的逻辑删除,不再丢记录。
### 4.6 采集段剩余读查询
步骤里调网关前的读查询不能异步,改为走缓存:
| 读查询 | 现状 | 改为 |
|---|---|---|
| 卡信息 | 每步先清缓存再查库 | 读 Redis 卡缓存;入库段写完后失效缓存 |
| 优先轮询项 `FindActive` | 每步每类型查一次库 | Redis 维护"有活动优先项的卡"集合,不在集合里不查库 |
| 运营商实名开关、重置日 | 每步查库 | 进程内缓存 1 分钟 |
| 设备轮询开关、绑定关系 | 每步查库 | 并入卡缓存 |
| 套餐状态(配置匹配) | 3 次查询共用 3 秒,失败缓存"未知" | 每次查询各自计时;失败不写缓存,下次重查 |
套餐步骤跳过逻辑:现在根据同一任务里流量步骤的 `TriggeredEvaluation` 跳过。异步后改为读 Redis 标记 `flow_evaluated:<card_id>`(入库段写,TTL = 套餐检查间隔)。
### 4.7 积压与反压
- 监控:每分钟读 nsqd `/stats`,记录 `card.ingest`、`card.collection` 的积压量和最老消息等待时间,写应用日志。
- 告警:积压超过 `polling.ingest.alert_depth`(默认 200,000)或等待超过 10 分钟,记 ERROR(接现有告警通道)。
- 反压:积压超过 `polling.ingest.pause_depth`(默认 500,000,须大于告警阈值)时,调度器暂停发新的卡任务;降到一半以下恢复。账户限流状态不受影响。
### 4.8 NSQ 持久化
nsqd 当前参数 `--mem-queue-size=10000`:消息先放内存,nsqd 意外退出会丢掉内存里的部分。
- 测试环境改为 `--mem-queue-size=0`:所有消息直接落盘(`./nsq-data` 已挂载),按现有量级(每秒几十条)性能没有影响。
- 卡任务 topic 也会一起落盘,对现有调度无行为变化。
- 磁盘占用:入库消息带网关原始数据,单条约 2KB;积压到反压阈值 50 万条约 1GB,测试服务器剩余 32G,足够。
### 4.9 不改的同步路径
以下入口量小、需要立即返回结果,保持同步入库:后台人工刷新、运营商回调(移动/联通/电信实名)、激活前结算、观测序列(series runner)。
### 4.10 配置项
| 配置 | 默认 | 说明 |
|---|---|---|
| `polling.ingest.mode` | `sync` | `sync` 为现在的同步入库;部署确认无误后切 `async` |
| `polling.ingest.concurrency` | 30 | 入库消费者并发 |
| `polling.ingest.timeout` | 30s | 单条入库超时 |
| `polling.collection.batch_size` | 200 | 采集记录批量大小 |
| `polling.ingest.alert_depth` / `pause_depth` | 200,000 / 500,000 | 告警与反压阈值 |
`mode` 与阈值放系统配置(改了几秒内生效),并发放启动配置。启动时检查:入库并发 + 采集记录写并发(2)不超过 `MAX_OPEN_CONNS` − 20(给采集段读查询、异步任务队列与其他查询留出至少 20 个连接),超过记 ERROR。测试环境 `MAX_OPEN_CONNS=60`:入库 30 + 采集记录 2 = 32 ≤ 40,满足。
### 4.11 发布失败兜底
采集段发消息失败(nsqd 不可用)时,降级为当前的同步入库,并记 WARN;不因 nsqd 故障丢观测。
## 5. 影响与代价
- 入库延迟:正常几秒;积压时更长。卡流量、状态在后台的刷新会比现在晚几秒。
- 停复机判断:超量停机、通道阈值停机依赖入库结果,跟着入库段触发,晚几秒。
- 优先轮询(加急):完成时间从"网关返回"推迟到"入库成功"。
- 同一张卡的数据库写入从"一个任务内"变成"跨两个消费者",排查时按 `observation_id` 串联日志。
## 6. 分支与范围
- 基于 `Iteration/8-11`,只部署测试环境;不写生产配置、不涉及生产重启。
- 依赖 `add-carrier-polling-scheduler`、`add-card-traffic-queue-and-observed-deduction` 的现有实现,不改它们的业务规则。
## 7. 实施步骤
1. 采集队列 Push 按 `observed_at` 去重;网络/实名观测补"更新的才生效"判断(如缺)。
2. 采集记录唯一索引迁移;`card.collection` 批量消费者;采集段失败记录改发消息。
3. `card.ingest` 消息、入库消费者、幂等键、不限次数的退避重试。
4. 三个轮询步骤改为发消息;优先项完成、对接日志、`StopPolling`、`flow_evaluated` 移到入库段;发布失败降级。
5. 采集段读查询走缓存;套餐状态查询各自计时、失败不缓存。
6. 积压监控、告警、反压;启动并发检查。
7. 系统配置与回退开关;测试环境 nsqd 改 `--mem-queue-size=0`。
## 8. 测试与验证(测试环境)
| 验证项 | 通过标准 |
|---|---|
| SQL 超时 | `context deadline exceeded` 从每小时约 8,000 次降到个位数 |
| 慢查询 | 每小时告警数明显下降,worker 日志留存能达到 14 天目标的量级 |
| 采集记录完整 | 每小时:入库消息数 = 成功表 + 失败表新增行数(按 `observation_id` 对账) |
| 计量正确 | 抽样卡日快照、套餐扣减与同步模式结果一致;重投、乱序不重复扣减 |
| 积压 | `card.ingest` 等待时间常态 < 30 秒 |
| 回退 | `polling.ingest.mode=sync` 后恢复现有行为 |
| 降级 | 停掉 nsqd 时采集段改为同步入库,不丢观测 |
## 9. 已定事项(2026-11-06)
1. 入库消费者默认并发 30。
2. 测试环境 nsqd 改为 `--mem-queue-size=0`,所有消息直接落盘。
3. 本期不做死信:可重试错误不限次数退避重试,不丢弃;业务性错误记 WARN 后丢弃。
4. 积压告警阈值 200,000 条;反压(暂停派发新卡任务)阈值 500,000 条。

View File

@@ -1,242 +0,0 @@
# 后台订单按类别拆分列表技术方案
最后更新:2026-10-26
状态:技术方案(未实现)。已定事项与后续事项见第六部分。
文档结构:
- 第一部分:现状逻辑(As-Is)
- 第二部分:老卡管对照
- 第三部分:方案(To-Be)
- 第四部分:改动范围
- 第五部分:验证
- 第六部分:已定事项与后续事项
---
## 第一部分:现状逻辑(As-Is)
### 1. 接口
| 接口 | 实现 | 说明 |
|---|---|---|
| `GET /api/admin/orders` | `OrderHandler.List`(`internal/handler/admin/order.go:90`) | 订单列表,平台、代理商、在线支付三类订单混在一起返回 |
| `GET /api/admin/orders/:id` | `OrderHandler.Get` | 订单详情 |
| `POST /api/admin/orders` | `OrderHandler.Create` | 后台下单 |
| `POST /api/admin/orders/:id/cancel` | `OrderHandler.Cancel` | 取消订单,仅代理账号 |
| `POST /api/admin/orders/purchase-check` | `OrderHandler.PurchaseCheck` | 套餐购买预检 |
| `POST /api/admin/export-tasks`(`scene=order`) | `OrderDataSource`(`internal/exporter/order_scene.go`) | 订单导出,与列表一样三类混在一起 |
路由在 `internal/routes/order.go`,`GET /orders` 在第 22 行,`GET /orders/:id` 在第 30 行。
### 2. 列表的权限与数据范围
两层条件,全部在服务端决定,前端不能改:
1. Handler 层:代理账号固定买家条件 `buyer_type = agent AND buyer_id = 当前店铺`;平台账号与超管不加买家条件。
2. Store 层:`OrderStore.List`(`internal/store/postgres/order_store.go:85`)叠加 `applyOrderScope`(第 200 行)。代理账号限定为买家、销售店铺或操作者属于本店及下级店铺;平台账号与超管不限。
后台认证 `AdminAuth` 允许超管、平台、代理、企业四类账号访问。
订单导出(`OrderDataSource.applyFilters`,`order_scene.go:130`)复刻了上面两层条件:创建导出任务时冻结账号类型、店铺范围(`ScopeShopIDs`)与发起店铺(`CreatorShopID`),执行时按冻结值过滤;代理账号同样固定"买家是本店"。导出任务只允许超管、平台、代理创建(`internal/service/export_task/service.go`)。
导出场景的接入点:
- 场景常量:`pkg/constants/constants.go:400` `ExportTaskSceneOrder = "order"`。
- 注册:`exporter.NewDefaultRegistry`(`internal/exporter/registry.go`)按数据源的 `Scene()` 建表,创建任务时用 `IsSupported` 校验场景。
- 时间筛选:`timeFilterScenes`(`internal/exporter/time_filters.go`)登记了 `order`,创建任务时按统一格式冻结 `start_time`/`end_time`。
### 3. 订单的来源与支付方式
| 字段 | 取值 | 说明 |
|---|---|---|
| `source` | `admin` 后台 / `client` 客户端 | 非空,默认 `admin`(`pkg/constants/order_source.go`) |
| `payment_method` | `wallet` / `offline` / `wechat` / `alipay` | 后台下单只支持 `wallet` 与 `offline` |
| `buyer_type` | `agent` / `personal` | 买家类型 |
| `operator_account_type`、`operator_type` | `platform` / `agent` / `personal_customer` / 空 | 操作账号类型;历史订单有空值 |
### 4. 各下单场景
后台下单 `CreateAdminOrder`(`internal/service/order/service.go:214`):
| 场景 | 操作账号 | 支付方式 | 买家 | 扣谁的钱 | 退款方式 |
|---|---|---|---|---|---|
| 平台自营 | 平台 | 线下 | 个人(id 0) | 不扣钱包,线下收款 | 退到客户账户 |
| 赠送 | 平台 | 线下 | 个人(id 0) | 不收钱 | 退到客户账户 |
| 平台代购 | 平台 | 线下 | 资源所属代理 | 不扣钱包,线下收款 | 退到客户账户 |
| 平台代扣代理钱包 | 平台 | 钱包 | 资源所属代理 | 该代理主钱包 | 退回代理钱包 |
| 代理自购 | 代理 | 钱包 | 本代理 | 本代理主钱包 | 退回代理钱包 |
| 代理给下级购买 | 代理 | 钱包 | 下级代理 | 本代理(操作方)主钱包 | 退回代理钱包 |
| 代理 OpenAPI 下单 | 代理 | 钱包 | 同代理后台下单 | 代理主钱包 | 退回代理钱包 |
| 资产套餐批量订购 | 同发起账号 | 同上 | 同上 | 同上 | 同上 |
客户端下单(`source = client`):个人客户在 H5 下单,用微信、支付宝或卡钱包支付,含待支付订单;自动续费(`internal/application/assetautorenewal/renew.go`)、自动购买(`internal/task/auto_purchase.go`)也记为客户端订单。
平台代扣代理钱包与代理自购的扣款钱包、买家、成本价、订单角色(都是 `self_purchase`)、退款方式、佣金基准完全相同,只有操作人字段不同。
### 5. 测试环境数据分布(165 笔)
| 来源 | 支付方式 | 场景 | 笔数 |
|---|---|---|---|
| client | 微信、支付宝、卡钱包、空(待支付) | 个人客户在线下单 | 53 |
| admin | wallet | 代理操作 | 21 |
| admin | wallet | 平台代扣代理钱包 | 31 |
| admin | offline | 平台代购 | 41 |
| admin | offline | 平台自营、赠送等(买家个人) | 19 |
生产分布未查到(查询时数据库连接工具断开),实施前需补查,见第五部分。
### 6. 现状问题
- 运营无法按"平台订单 / 代理商订单 / 在线支付订单"分页面查看,只能靠支付方式、订单角色间接筛,而且这两个条件都区分不出"平台代扣代理钱包"和"平台代购"。
---
## 第二部分:老卡管对照
源码 `kyhl-src`(`apps/kyhl-crm`)中三类订单是三张表、三个页面:
| 老页面 | 表 | 内容 | 谁能看 |
|---|---|---|---|
| 微信订单(`agentWeixinOrder/list`) | `tbl_agent_weixin_order` | C 端公众号订单:微信支付、余额支付(卡钱包)、自动续费 | 平台全部;代理看自己与下级卡上的订单 |
| 代理商订单(平台 `agentOrder/syslist`,代理 `agentOrder/list`) | `tbl_agent_order` | 代理账号在后台给卡加套餐,从代理预存款余额扣款(`CardViewComp.agentRecharge`) | 平台全部;代理只看自己的 |
| 平台加套餐 | 不生成订单 | 平台账号给卡加套餐只写套餐记录(`addCardOrBatchImportPackage`) | — |
结论:老系统判断是否为代理商订单,看的是"是否扣代理余额",不看操作人。新系统的平台线下订单(自营、赠送、代购)在老系统里没有订单记录。
附:老系统首页统计的"平台订单 / 代理商订单"按收款公众号划分(`weixin_agent_id` 是否为空),与列表页口径不同,不作为本方案的依据。
---
## 第三部分:方案(To-Be)
### 1. 接口
保留 `GET /api/admin/orders`,参数、响应、行为均不变。新增三个列表接口:
| 接口 | 类别 |
|---|---|
| `GET /api/admin/orders/platform` | 平台订单 |
| `GET /api/admin/orders/agent` | 代理商订单 |
| `GET /api/admin/orders/online` | 在线支付订单 |
- 类别由路径固定,不接收类型参数。
- 查询参数、分页、响应结构与原列表相同:入参 `dto.OrderListRequest`,出参 `dto.OrderListResponse`。
- 认证、用户类型准入、数据范围与原列表相同。
- 订单导出同样分三类,见本部分第 6 节。
- 订单详情、取消、购买预检、后台下单不变。
### 2. 类别划分:按钱从哪来,不按操作人
| 类别 | 条件 | 包含 |
|---|---|---|
| 在线支付订单 | `source = 'client'` | 个人客户微信、支付宝、卡钱包支付,自动续费,自动购买,待支付 |
| 代理商订单 | `source = 'admin' AND payment_method = 'wallet'` | 代理自购、代理给下级购买、平台代扣代理钱包、代理 OpenAPI 下单 |
| 平台订单 | `source = 'admin' AND payment_method <> 'wallet'`(含空) | 平台自营、赠送、平台代购 |
- `source` 非空且只有两个取值,后台订单按是否用代理钱包支付二分,所以三类互斥且覆盖全部订单。相同条件下,三个新接口的总数之和等于原接口的总数。
- 不走代理钱包的后台订单一律不进代理商订单接口。平台代购的买家虽然记为代理,但按线下收款、退款走客户账户,归平台订单;后续如有要求再调整。
- 不依赖操作人字段,历史订单操作人为空也不影响分类。
放弃的方案:按操作账号类型划分(`operator_account_type`,为空回退 `operator_type`)。这样会把平台代扣代理钱包归到平台订单,与资金归属和老卡管口径都不一致,还要兜底历史空值。
### 3. 实现
1. 常量:在 `pkg/constants/order_category.go` 定义订单类别 `platform` / `agent` / `online`。
2. Store:`OrderStore.List` 支持内部过滤键 `order_category`,类别条件集中在一个函数里,三个接口与导出共用(见第 6 节)。
3. Service:`order.Service.List` 增加订单类别入参并透传给 Store;原调用传空类别,行为不变。
4. Handler:把现有 `List` 的参数解析、时间范围解析、代理买家条件提取为内部方法 `list(c, category)`。原 `List` 以空类别调用,新增 `ListPlatform`、`ListAgent`、`ListOnline` 分别以固定类别调用。权限逻辑只有一份。
5. 路由:三个静态路径必须注册在 `GET /orders/:id`(`internal/routes/order.go:30`)之前,否则会被当作订单 ID 解析,返回"无效的订单ID"。
类别不进 DTO,所以请求里即使带 `order_category` 等参数也不会被解析。
### 4. 代理账号看到的结果
权限不变,代理账号在新接口看到的是原接口结果按类别切开的部分:
| 接口 | 代理账号能看到 |
|---|---|
| 平台订单 | 平台为本店名下的卡代购的订单 |
| 代理商订单 | 买家是本店的钱包订单:本店自购、平台代扣本店钱包、上级给本店购买 |
| 在线支付订单 | 始终为空:原接口对代理只返回买家是本店的订单,客户端订单的买家是个人客户 |
代理在"给下级购买"时,订单买家是下级,原接口对该代理不可见,新接口也不可见。
### 4.1 企业账号不能访问订单接口(修复现有问题)
现状:后台认证允许企业账号,原列表对非代理账号不加买家条件,店铺范围对企业账号也不生效,接口权限中间件未接入路由,所以企业账号调用 `GET /orders` 与 `GET /orders/:id` 会看到全部订单。下单已拒绝企业账号,取消只允许代理,导出任务不允许企业账号创建。
做法:`OrderHandler` 增加企业账号校验,原列表、三个新列表、订单详情、购买预检对企业账号返回无权限。校验放在 Handler,不放 Store 的 `applyOrderScope`,因为后者被资产订单查询等其他场景共用。资产订单接口 `GET /assets/{identifier}/orders` 本次不改;测试环境实测它对企业账号未按企业授权范围校验(企业 4 无授权卡仍能查到卡 11486 的订单),归入企业账号仅限 H5 的后续收口。
这是本方案中唯一的破坏性变更,只影响企业账号。
### 5. 性能
类别条件只用 `source` 与 `payment_method`,不加专用索引。实施后在测试环境看执行计划;生产订单量明显增长时,再评估 `(source, payment_method, created_at)` 组合索引。
### 6. 导出分三类
保留 `scene=order`,行为不变。新增三个导出场景,与三个列表接口一一对应:
| 场景 | 对应列表 |
|---|---|
| `order_platform` | `GET /orders/platform` |
| `order_agent` | `GET /orders/agent` |
| `order_online` | `GET /orders/online` |
- 与列表一致:类别由场景决定,`filters` 里不加类别参数;筛选条件、表头、列内容、数据范围与 `scene=order` 完全相同,只多一个固定的类别条件。
- 用新场景而不是给 `order` 加参数,原因是导出本来就靠 `scene` 区分数据,和列表"路径决定类别"是同一个思路,前端每个页面各传自己的场景即可。
实现:
1. 场景常量:在 `pkg/constants` 新增 `ExportTaskSceneOrderPlatform`、`ExportTaskSceneOrderAgent`、`ExportTaskSceneOrderOnline`。
2. 数据源:`OrderDataSource` 增加类别字段,`NewOrderDataSource(db, category)`;`Scene()` 按类别返回场景,类别为空时返回 `order`;`applyFilters` 在现有条件后叠加类别条件。`Count`、`Fetch` 共用 `applyFilters`,条数与内容自动一致。
3. 类别条件只写一份:放在 `internal/query/ordercategory`,按表别名生成条件(导出查询用别名 `o`,Store 无别名),列表与导出共用,保证口径一致。与不轮询清单共用 `internal/query/pollingpause` 的做法一致。
4. 注册:`NewDefaultRegistry` 再注册三个类别的 `OrderDataSource`;`timeFilterScenes` 加入三个新场景,时间格式校验与 `order` 相同;`IsSupportedScene` 同步加入。
5. 导出任务创建接口的场景说明(`internal/routes/export_task.go`)补充三个新场景。
---
## 第四部分:改动范围
| 文件 | 改动 |
|---|---|
| `pkg/constants`(订单相关常量文件) | 新增订单类别常量 |
| `internal/store/postgres/order_store.go` | `List` 支持 `order_category` |
| `internal/service/order/service.go` | `List` 透传订单类别 |
| `internal/handler/admin/order.go` | 提取内部列表方法,新增三个 Handler;企业账号校验 |
| `internal/routes/order.go` | 注册三个路由,写明类别口径和"参数、响应、权限同原订单列表" |
| `pkg/constants/constants.go` | 新增三个订单导出场景常量 |
| `internal/exporter/order_scene.go` | 数据源支持类别,叠加类别条件 |
| `internal/exporter/registry.go`、`internal/exporter/time_filters.go` | 注册三个新场景,纳入时间筛选 |
| `internal/routes/export_task.go` | 场景列表说明补充三个新场景 |
| OpenAPI 文档、前端接口变更说明 | 补充三个列表接口与三个导出场景 |
- 不新增字段,不需要迁移,不改历史数据。
- 原导出场景 `order` 不变;不改订单详情、取消、下单。
- 无自动化测试(项目无 `*_test.go`),在测试环境验证。
---
## 第五部分:验证(测试环境)
1. 平台账号,相同条件调用三个新接口与原接口:三个新接口的总数之和等于原接口总数;抽查平台代扣代理钱包在代理商订单、平台代购在平台订单、自动续费在在线支付订单。
2. 平台账号,叠加支付状态、时间、资产标识、订单号等原有条件,结果正确;原接口结果与上线前一致。
3. 代理账号,三个新接口的结果都是原接口结果的子集,总数之和等于原接口;在线支付订单为空。
4. `GET /orders/:id` 仍正常;`/orders/platform` 等路径不会被当作订单 ID。
4.1 企业账号调用原列表、三个新列表、订单详情、购买预检返回无权限;`GET /assets/{identifier}/orders` 查询本企业资产不受影响。
5. 导出:平台账号与代理账号分别用三个新场景导出,条数与对应列表接口的总数一致,表头与 `scene=order` 相同;`scene=order` 导出结果与上线前一致;新场景传旧时间格式时与 `order` 一样被拒绝。
6. 上线前补查生产分布:按 `source`、`payment_method` 统计,确认没有 `source` 为其他值的订单、后台订单只有钱包和线下两种支付方式。
---
## 第六部分:已定事项与后续事项
已定:
- 原接口保留,新接口不传类型参数;超管、平台、代理的权限与数据范围不变。
- 企业账号不能访问 `/orders` 下的列表、详情、购买预检,修复其可看到全部订单的问题。
- 按钱从哪来划分:代理钱包扣款的后台订单(含平台代扣)归代理商订单;不走代理钱包的后台订单归平台订单。
- 导出也分三类:新增 `order_platform`、`order_agent`、`order_online` 三个场景,原 `order` 场景保留。
后续(有要求再做):
- 平台代购是否改归代理商订单。
- 代理账号是否需要在在线支付订单接口看到自己资产上的个人客户订单(属于权限调整)。
- 在线支付订单是否增加老卡管微信订单页的"订单来源、收款方"筛选。

View File

@@ -283,9 +283,9 @@
- **最小正例**:外部失败写脱敏 Integration Log,HTTP 请求写 Access Log。
- **最小反例**:把完整凭证写 app.log。
- **机械检查/人工原因**:人工核对 Logger 字段、Sanitizer 与事实存储;本地按 request_id/correlation_id 查询日志。
- **例外条件**:第三方要求回显的非敏感渠道码可保留。卡网关调用(add-carrier-gateway-rate-limit):普通轮询查询不写 Integration Log,失败原因与调用量由 Redis 按运营商账户的分钟计数与卡最近错误承载(同时是优先执行的轮次仍写);系统自动停复机不写 Integration Log 与 Audit Event,每次上游调用写停复机记录表 `tb_card_stop_resume_record`;后台人工点击、通道阈值、自动续费及账户、个人客户、OpenAPI 发起的停复机保持原写法。
- **例外条件**:第三方要求回显的非敏感渠道码可保留。
- **Owner**:审计与安全负责人
- **最后验证日期**:2026-10-24
- **最后验证日期**:2026-08-07
- **更新触发条件**:日志字段、保留策略或外部集成变化
## ENG-SECRET-001

File diff suppressed because it is too large Load Diff

View File

@@ -1,109 +0,0 @@
# 后台代客下单误判为"C端已实名购买",套餐跳过实名门槛直接生效(Bug,已修复)
> 2026-10-27 已修复:`activateMainPackage` 的 `isCEndPurchase` 增加 `order.Source == client` 条件,后台平台自营与赠送套餐线下订单不再跳过实名门槛(同时可正常进入沉默期)。
## 起因
排查设备 `60003081756`(换货前旧资产)与其换货后新资产 `60003080119` 的换货记录(换货单 `EXC20260922173715169105`)时发现:换货完成后,操作人蒋恒在后台为新设备(`tb_device.id=19452`)代客户下单购买套餐(订单 `ORD20260922175155311314`,`payment_status=paid`),套餐(`tb_package_usage.id=83144`)在下单瞬间(`2026-09-22 17:51:55`)就变成"生效中"(`status=1`),而此时这张卡(`tb_iot_card.id=132322`,iccid `89860624660030801193`)的 `real_name_status` 仍是 `0`(未实名)。本记录仅做事实归档,**未修改任何代码**,全部结论来自对生产库 `junhong_cmp_prod` 的只读查询(`root@116.162.101.91`,仅执行 `SELECT`)与仓库 Git 历史核对。
## 结论
- **Bug 真实存在,当前代码未修复,仍会触发。**
- 触发条件:后台管理员为**一台当时还没有分配店铺归属(`shop_id` 为空)的设备/卡**,用 `offline` 或 `wallet` 支付方式代客户下单购买套餐时,系统会把订单误判为"客户本人在 App 自助下单",从而跳过套餐本该有的"等实名认证后再激活"逻辑,导致套餐在客户尚未实名的情况下立即生效、开始计时。
- 本次涉及的客户(personal customer_id=1008)事后约 18 分钟自行在运营商侧完成了实名(详见下方"实名完成时间核对"),影响较小;但历史上已有 3 起同类真实案例(非数据迁移脚本产生),不能排除客户未及时实名、白白损失部分套餐有效期的情况。
## 根因
### 1. 直接原因:`isCEndPurchase` 判断条件遗漏 `Source` 检查
- **代码位置**:`internal/service/order/service.go:2574-2596`,函数 `activateMainPackage`。
- 决定"套餐是否要等实名才激活"的开关:
```go
// 判断是否 C 端购买(C 端购买前已做实名前置检查,直接激活)
isCEndPurchase := order.BuyerType == model.BuyerTypePersonal
if !isCEndPurchase {
// 后台囤货路径:按 expiry_base 决定是否等实名
if terms.ExpiryBase != constants.PackageExpiryBaseFromPurchase {
if !currentlyRealnamed {
status = constants.PackageUsageStatusPending
pendingRealnameActivation = true
}
}
}
// C 端购买已完成实名前置校验,直接激活。
```
只要 `order.BuyerType == "personal"` 就认定"客户已在 App 里做过实名前置校验,可以直接激活",**完全没有检查订单来源(`order.Source`)**。
- 同一文件里另一处判断"是否真的是客户在 App 自助下单"时,写法是**严谨的**(`internal/service/order/service.go:2868`):
```go
if order.Source == constants.OrderSourceClient && order.BuyerType == model.BuyerTypePersonal {
```
两处对"C端购买"的判定标准不一致,`activateMainPackage` 里少了 `Source == client` 这一层。
### 2. 触发路径:`buyer_type=personal` 是怎么产生的
- **代码位置**:`internal/service/order/service.go:284-333`,函数 `CreateAdminOrder`(后台代客下单,线下支付分支)。
- `resourceShopID = resolvedDevice.ShopID`(取设备当前的 `shop_id`):
- 若 `resourceShopID == nil || *resourceShopID == 0`(设备当时没有店铺归属),判定为"平台自营,直接卖给终端客户",**强制**把 `orderBuyerType` 设为 `model.BuyerTypePersonal`。
- 本次案例中,设备 `19452` 在下单那一刻(`17:51:55`)确实还没有分配店铺——分配店铺的动作(`device.allocate`,`shop_id: null → 670`)发生在 `17:52:44`,**在下单之后**。于是订单被自动打上 `buyer_type=personal`,进而在 `activateMainPackage` 里命中上述 `isCEndPurchase=true` 分支。
### 3. 时间线为何这两段代码会"撞车"
| 时间 | 提交 | 内容 |
|---|---|---|
| 2026-03-02 | `8efe7952` | 「修复平台自营资源(未分配代理)无法线下下单的问题」——引入"设备无店铺归属 → `buyer_type=personal`"这条路径 |
| 2026-03-28 | `7dd55630` | 「activateMainPackage 三维决策重构 REALNAME-02」——引入 `isCEndPurchase := order.BuyerType == Personal` 假设 |
`7dd55630` 落地时,`8efe7952` 引入的"后台平台自营代客下单也会产出 `buyer_type=personal`"这条路径已经存在近一个月。`isCEndPurchase` 的假设从写下的那一刻起就没有覆盖到这条路径,二者从未真正对齐过。
## 实名完成时间核对(本案例,非普遍规律)
```
2026-09-22 17:51:55 订单创建,套餐立即激活(status=1,跳过实名等待)
2026-09-22 18:10:38 iot_card.worker_realname_sync(后台定时轮询任务,从运营商侧同步)
before: {"real_name_status": 0}
after: {"real_name_status": 1, "first_realname_at": "2026-09-22T10:10:37Z"(UTC,即北京时间 18:10:37)}
```
客户是在运营商侧真实完成的实名认证,系统通过定时轮询同步过来,不是人工手动改的状态。套餐已提前激活约 18 分钟,这段时间里客户理论上是在为一个"计时中但用不了"(网络仍是停机状态)的套餐付费/计时。
## 数据验证:历史命中范围
用以下条件在生产库上识别同类命中(后台代客下单、`buyer_type=personal`、套餐配置为"需等实名激活"、但落库时直接是生效状态):
```sql
-- 设备类套餐(usage_type='device')
SELECT count(*) FROM tb_package_usage pu
JOIN tb_order o ON o.id = pu.order_id
WHERE o.source = 'admin' AND o.buyer_type = 'personal'
AND pu.expiry_base_snapshot = 'from_activation'
AND pu.pending_realname_activation = false
AND pu.status = 1 AND pu.master_usage_id IS NULL
AND pu.usage_type = 'device'
AND NOT EXISTS (
SELECT 1 FROM tb_device_sim_binding b
JOIN tb_iot_card c2 ON c2.id = b.iot_card_id
WHERE b.device_id = pu.device_id AND b.bind_status = 1 AND c2.real_name_status = 1
);
```
粗查命中 5210 条,但其中 **5207 条来自一次历史数据迁移脚本**(`order_no` 以 `MIG-` 开头,2026-09-03~09-04 一天多内由 2 个系统账号批量写入),属于把老系统里客户本来就已生效的历史套餐批量导入新系统,跳过实名门槛是迁移脚本的预期行为,**不算本 bug 命中**。
刨除迁移脚本后,**真正由人工后台操作触发的同类历史案例只有 3 起**,加上本次共 4 起:
| 订单号 | 操作人 | 设备 | 时间 |
|---|---|---|---|
| ORD20260905144024517558 | 王鹏 | 18914 | 2026-09-05 14:40 |
| ORD20260905144241735458 | 王鹏 | 18913 | 2026-09-05 14:42 |
| ORD20260916112035085745 | 王鹏 | 19108 | 2026-09-16 11:20 |
| ORD20260922175155311314 | 蒋恒 | 19452 | 2026-09-22 17:51(本次) |
## 后续整改(代码侧,尚未执行)
1. **建议修复**:将 `internal/service/order/service.go:2575` 的 `isCEndPurchase` 判断改为与 `service.go:2868` 一致,同时检查 `order.Source == constants.OrderSourceClient && order.BuyerType == model.BuyerTypePersonal`,使"后台代客下单"不再被误判为"客户 App 自助下单"。
2. **建议核实**:王鹏那 3 起历史案例(设备 18913、18914、19108)对应的卡后续是否及时完成了实名认证,是否存在客户实际损失套餐有效期天数的情况,需要跟客服/王鹏本人核对。
3. 本记录暂未提交 OpenSpec Change,代码修复建议后续单独立项处理;此文档仅作为问题发现与数据取证的归档,供后续排期与客诉复盘引用。

View File

@@ -5,8 +5,7 @@
| 日期 | 2026-10-20 |
| 状态 | 方案,待评审,**代码尚未修改** |
| 思路 | **流量怎么算,照搬老卡管;套餐那边一行不动**:只替换"增量从哪来",增量照旧通过 V1 事件交给现有 `DeductDataUsage` 扣减 |
| 修订 | 第 2 版(2026-10-20):对照反编译结果与新卡管代码复核后修订——增加切换水位(4.6)、增量改为按月对账(4.5)、已切换卡的通道阈值判定(4.5)、去掉"读数是否有效"标记、更正对老卡管的几处描述;新增第 10 章停复机规则与实名管控;已切换卡的卡表本月用量改由快照覆盖(4.5),切换与退回要求全部停止后再启动(5.2) |
| 改动范围 | 流量部分(第 1~9 章):观测链路(算增量)+ 新增卡日快照表 + 切换水位表 + 日流量读取 + 已切换卡的通道阈值判定条件;**套餐表、套餐服务、V1 消费者、激活/重置/改已用量/换货/退款入口、扣减顺序、停复机全部不改**。停复机部分(第 10 章):停机与复机只看套餐和流量(通道开启实名管控时加上实名)、复机不看停机原因、实名默认不管控、暂停轮询的卡不自动停复机;独立开发、独立上线 |
| 改动范围 | 观测链路(算增量)+ 新增卡日快照表 + 日流量读取。**套餐表、套餐服务、激活/重置/改已用量/换货/退款入口、扣减顺序、停复机全部不改** |
| 与旧方案关系 | 替代 `docs/流量事实模型技术方案.md`(第 5 版)的计量部分 |
| 老卡管依据 | 反编译 `kyhl-api/WEB-INF/lib` 下 `kyhl-service-1.0.jar`、`kyhl-dal-1.0.jar`、`kyhl-common-1.0.jar`、`kyhl-common-jdbk-1.0.jar` |
@@ -18,12 +17,10 @@
2. **老卡管不依赖基线**:每张卡每天存一条"本月累计"快照,任何一段时间的用量都用快照现算(区间内最大值 − 区间开始前的最大值)。它没有"读数下降"的概念,已经稳定运行多年。
3. **本方案**:
- 流量计算照搬老卡管:日快照、按各运营商的重置日(`tb_carrier.data_reset_day`)换算成自然月、重置日防旧值、按区间取最大值做差。
- 每次观测的增量 = **本月最大快照 − 本月已结算合计**(按月对账),不再用卡表基线。
- 卡第一次走新路径时记一条**切换水位**:本次仍按老路径的增量发,之前的部分视为老路径已结算。切换衔接、补数据截断、冻结卡判定都以它为准。
- 每次观测的增量 = **今天的快照用量 − 今天已经发出的增量**,不再用卡表基线。
- 增量照旧写进 V1 事件 `CardTrafficIncremented`,由现有消费者调用 `DeductDataUsage` 扣减。**套餐分配、加油包顺序、虚流量阈值、停复机、流量详单全部沿用现有代码。**
- 已切换的卡,通道阈值按"读数有效且未被防旧值处理"判定,不再受卡表基线影响,冻结卡切换后通道达量停机随即恢复。
4. **二次销售**:卡没有生效套餐时,增量照样计算,但**只记为已结算、不发事件**。激活之前被采到的用量不会扣到新套餐上;精度到观测粒度:激活后第一次观测里,"上次观测到激活"这一段仍会算给新套餐。
5. **先做卡流量计算,补数据放到后期**:新方案上线后,冻结卡从切换起恢复正常计量;切换前少扣的部分由后期的独立脚本补(5.5)。补数据依据运营商读数(未切换的卡现场查询,已切换的卡取切换水位里切换时的读数),只能补执行时所在运营商周期内的部分,更早的补不回来。
4. **二次销售**:卡没有生效套餐时,增量照样计算,但**只推进"已发出"标记、不发事件**。所以激活之前的用量永远不会扣到新套餐上,新套餐只扣激活之后的增长。
5. **先做卡流量计算,补数据放到后期**:新方案上线后,冻结卡从切换起恢复正常计量;切换前少扣的部分由后期的独立脚本补(5.5)。补数据依据的是运营商当前读数,只能补执行时所在运营商周期内的部分,更早的补不回来。
### 1.1 已定决策(2026-10-20)
@@ -32,17 +29,12 @@
| D1 | 流量计算 | 照搬老卡管:日快照、写入时按运营商重置日(`tb_carrier.data_reset_day`,任意 1~28 号)换算成自然月、按区间取最大值做差,没有读数下降判定。重置日不写死,运营商配置改了随之生效 |
| D2 | 套餐侧 | 一行不改:增量照旧走 V1 事件与 `DeductDataUsage` |
| D3 | 重置日防旧值 | **完全照搬老卡管**(含比较基准):只在重置日当天判断,`R = 1` 比上月最大快照、`R > 1` 比本月 1~R−1 号最大快照,超过 0.9 倍按 0 记;第二天起照常累加,不挂起、不告警。老卡管多年运行没有暴露问题 |
| D4 | 卡无生效套餐时的增量 | 只记入 `settled_mb`(已结算),不发事件,用量丢弃 |
| D4 | 卡无生效套餐时的增量 | 只推进 `emitted_mb`,不发事件,用量丢弃 |
| D5 | 快照记录范围 | 全部被采集的卡 |
| D6 | 上线方式 | 按名单灰度,单位是扣减载体,设备整台切;第一批为 103154 所在设备 15276,核对后扩批再全量。非灰度卡行为与现在一致(4.8) |
| D7 | 积压的无套餐回执 | 业务后续手动调整,不在本方案内;处理前不得重放 |
| D8 | 顺序 | 先上卡流量计算,补数据放后期(5.5) |
| D9 | 补数据:本周期中间重置过套餐的冻结卡 | 不自动补,进清单,附按天数比例的估算值,由人工决定 |
| D10 | 增量口径 | 按月对账:`Δ = 本月最大快照 − 本月已结算合计`,不按天对账(4.5) |
| D11 | 切换衔接 | 记切换水位:卡第一次走新路径时,本次照发老路径增量,之前的部分记为已结算;不依赖阶段 1 是否覆盖整月(4.6) |
| D12 | 已切换卡的通道阈值 | 读数有效且本次读数未被防旧值按 0 处理即判定,不再看老路径是否接受读数(4.5) |
| D13 | 套餐在扣减前刚好用完 | 消费者不改,写入已知限制并监控(第 9 节) |
| D14~D17 | 停复机规则、实名管控、暂停轮询 | 见 10.3 |
## 2. 现在的漏洞(已验证,详见旧方案第 2 节)
@@ -53,21 +45,18 @@
| 3 | **无效响应按 0 处理**:`used` 为空、null、无法解析时当成 0,进而触发第 2 条 | `internal/gateway/models.go:55-78` |
| 4 | **二次销售多计**:新用户激活时,卡上的基线如果不是激活那一刻的值,差额就扣到了新套餐上 | 资产回收换代见 `docs/资产回收换代技术方案.md` |
| 5 | **日流量从来没存成功**:`tb_card_daily_usage` 的表结构和代码不一致,表里 0 行 | 旧方案 P14 |
| 6 | **无套餐期间的增量会被扣到下一个套餐上**:老路径不管卡有没有套餐都发 V1 事件;消费者调 `DeductDataUsage` 遇到"没有可用套餐"直接返回错误,任务由 Asynq 重试,重试期间套餐一激活,积压的增量就被扣到新套餐上——这是二次销售多计的另一条路径 | `application/cardobservation/traffic.go:73-76` 不判断有无套餐,有正增量就发事件;`infrastructure/cardobservation/event.go:276-277` 报错即返回;`usage_service.go:110`。**生产核实(2026-10-20)**:回执停在"处理中"(扣减未完成)的有 18,979 条、增量约 11.4 TB;其中 14,838 条对应的卡当前没有生效套餐,**4,141 条(2,404 张卡)对应的卡当前有生效套餐**,这批任务若被重试或人工重放,会扣到现在的套餐上,不得重放。**积压部分由业务后续手动调整,本方案只保证新增的不再产生** |
| 6 | **无套餐期间的增量会被扣到下一个套餐上**:老路径不管卡有没有套餐都发 V1 事件;消费者调 `DeductDataUsage` 遇到"没有可用套餐"直接返回错误,任务由 Asynq 重试,重试期间套餐一激活,积压的增量就被扣到新套餐上——这是二次销售多计的另一条路径 | `application/cardobservation/traffic.go:73-76` 无套餐判断;`infrastructure/cardobservation/event.go:276-277` 报错即返回;`usage_service.go:110`。**生产核实(2026-10-20)**:回执停在"处理中"(扣减未完成)的有 18,979 条、增量约 11.4 TB;其中 14,838 条对应的卡当前没有生效套餐,**4,141 条(2,404 张卡)对应的卡当前有生效套餐**,这批任务若被重试或人工重放,会扣到现在的套餐上,不得重放。**积压部分由业务后续手动调整,本方案只保证新增的不再产生** |
## 3. 老卡管怎么算(反编译核实)
### 3.1 日快照:每卡每天一条,存"本月累计"
出处:`YidongDayFlowService.saveYidongDayFlow`、`PtLiantongDayFlowService.saveLiantongDayFlow`。
出处:`YidongDayFlowService.saveYidongDayFlow`、`PtLiantongDayFlowService.savePtLiantongDayFlow`。
- 表 `tbl_*_day_flow`:`iccid_mark`、`flow_date`、`used_flow`(自然月累计 MB);普联通另存 `vendor_used_flow`(运营商原始读数)。
- 同一天多次查询:用新值覆盖。
- 移动:新值比旧值小就跳过(每月 1 号除外)。
- 普联通:读数 ≤ 0 不覆盖;当天第一条就是 0 时,沿用昨天的值;其余情况后写覆盖,可以变小。
- 电信、广电、广电物联网、5G:与移动、普联通同类(1 号防旧值 + 同一天覆盖),细节略有差异。
- 老卡管按 category 分派到各自的表(`CardFlowUtils.getCardFlow`):电信 1、移动 3、联通 4/8/9、5G 108、广电 112/113 等。设备是主卡、副卡各自按区间取值后相加(`CardFlowUtils.getTraffic`)。
- 电信采集时按"本月 1 号~今天"向运营商查询(`TelecomUtilNew.queryThisMonthDianxinDayFlow`),拿到的是自然月累计,之后同样写日快照、按快照计费。
- 普联通:读数 ≤ 0 不覆盖;当天第一条就是 0 时,沿用昨天的值。
### 3.2 联通:写入时把运营商周期换算成自然月
@@ -78,16 +67,14 @@
| 27 号~月底 | 运营商读数 + 本月 1~26 号的最大快照 |
| 1~26 号 | 运营商读数 − 上月 27 号~月底这段的用量(= 上月最大快照 − 上月 27 号前的最大快照) |
> 核实:老代码取"上月 1~26 号最大快照"用的是 `findFlowByBeforeDate`,SQL 为 `flow_date >= 1号 and flow_date <= 26号`,包含 26 号,取值正确;27 号起取"本月 1~26 号最大快照"用的是 `getMaxUsedFlow`,SQL 为 `flow_date < 27号`,同样正确。
> 老代码的小缺陷:`if ((flow -= subFlow) < 0) subFlow = 0` 改的是 `subFlow` 而不是 `flow`,负值会写进快照,靠查询时 `used_flow > 0` 过滤掉。本方案直接按 0 处理。
> 老代码第二行取"上月 27 号前"时用的是 `< 26 号`,少算了一天,本方案按"1 号~重置日前一天"修正。
### 3.3 重置日防旧值:只有一条规则
| 运营商 | 规则 |
|---|---|
| 移动、电信、广电、广电物联网、5G(1 号清零) | 每月 1 号,读数 > 上月最大快照 × 0.9 → 记为 0 |
| 普联通 category 9(27 号清零) | 每月 27 号,读数 > 本月 1~26 号最大快照 × 0.9 → 运营商读数按 0 计(代码写作 `appendFlow > 0 && flow > appendFlow × 1.9`,`flow` 已含 `appendFlow`) |
| 联通 category 4、8 | 没有防旧值,只做自然月换算 |
| 移动(1 号清零) | 每月 1 号,读数 > 上月最大快照 × 0.9 → 记为 0 |
| 普联通(27 号清零) | 每月 27 号,读数 > 本月 1~26 号最大快照 × 0.9 → 运营商读数按 0 计 |
意思是:重置日读到的值还接近上个周期的总量,就认为运营商还没清零或者读到了旧缓存,当天先不增长。除此之外,老卡管**没有任何读数下降的判定**。
@@ -113,13 +100,13 @@
| # | 原则 |
|---|---|
| R1 | **流量照搬老卡管**:日快照 + 自然月换算 + 重置日防旧值 + 区间取最大值做差 |
| R2 | **套餐侧零改动**:增量照旧走 V1 事件和 `DeductDataUsage`;套餐表、套餐入口、分配顺序、停复机都不改(停复机的调整见第 10 章,与流量部分无关) |
| R2 | **套餐侧零改动**:增量照旧走 V1 事件和 `DeductDataUsage`;套餐表、套餐入口、分配顺序、停复机都不改 |
| R3 | **不再用卡表基线算增量**:增量只由快照得出;卡表三列照旧由老逻辑维护,只作兼容展示 |
| R4 | **日期一律按 `Asia/Shanghai`** |
### 4.2 数据模型
新增两张表,**不改任何现有表**。迁移从当前最新编号之后开始,成对 up/down。
只新增一张表,**不改任何现有表**。迁移从当前最新编号之后开始,成对 up/down。
**`tb_card_flow_day`(卡日快照,永久保留;仓库现无分区表,一年约 3,200 万行,靠唯一索引足够,本期不分区)**
@@ -130,32 +117,13 @@
| `flow_date` | date | 上海日期 |
| `month_used_mb` | numeric(14,3) | 自然月累计(换算后),计算用 |
| `vendor_used_mb` | numeric(14,3) | 当天最后一次有效的运营商原始读数,排障用 |
| `settled_mb` | numeric(14,3) | 当天已结算的增量合计,默认 0。包括:发出 V1 的增量、无生效套餐时丢弃的增量、切换时记为"老路径已结算"的部分(见 4.5、4.6) |
| `emitted_mb` | numeric(14,3) | 当天已经发出的增量合计(见 4.5),默认 0 |
| `stale_guarded` | boolean | 重置日当天是否触发过防旧值(读数按 0 记) |
| `obs_count` | int | 当天有效观测次数 |
| `created_at`、`updated_at` | timestamptz | |
唯一键 `(iot_card_id, flow_date)`。每卡每天一行,8.9 万卡约 3200 万行/年。
**`tb_card_flow_switch`(切换水位,每卡一行)**
卡第一次走新路径时写入(4.6),之后只读。退回老路径时删除(5.2)。
| 列 | 类型 | 含义 |
|---|---|---|
| `id` | bigserial | |
| `iot_card_id` | bigint | 唯一 |
| `switched_at` | timestamptz | 第一次走新路径的观测时刻 |
| `month_used_mb` | numeric(14,3) | 切换时本月最大快照(写入本次读数后) |
| `vendor_used_mb` | numeric(14,3) | 切换时的运营商读数 `r` |
| `old_baseline_mb` | numeric(14,3) | 切换时卡表 `last_gateway_reading_mb` |
| `old_month_usage_mb` | numeric(14,3) | 切换时卡表 `current_month_usage_mb` |
| `old_reading_accepted` | boolean | 切换这次观测老路径是否接受读数(冻结卡为 false) |
| `old_increment_mb` | numeric(14,3) | 切换这次观测老路径算出的增量(照此发 V1) |
| `created_at` | timestamptz | |
作用:切换衔接(4.6)、补数据截断与冻结卡判定(5.5)。
### 4.3 写快照(照搬 3.1~3.3,按运营商重置日 `R` 统一)
`R` 取 `tb_carrier.data_reset_day`。**运营商在每月 `R` 号 0 点(北京时间,`Asia/Shanghai`)清零**:联通为 27 号 0 点(业务确认,见 `docs/engineering/套餐流量计算逻辑核对.md`;与老卡管 `DateUtil.getLtPeriod` 一致),其余为 1 号 0 点。周期边界直接复用现有 `carrierthreshold.PeriodStart`(上海时区重置日 0 点),下文"今天日号""重置日当天"都按北京时间的日期判断。**查询失败、为空或不在 1~28 时,按卡的运营商类型兜底:`tb_iot_card.carrier_type = CUCC`(联通)取 27,其余取 1**,并记日志。不能沿用现有 `CarrierStore.GetDataResetDay` 一律返回 1 的做法(`carrier_store.go:103-104`)——联通卡被错当成 1 号重置会让自然月换算出错、累计值被抬高,按取最大值的规则会多扣且收不回。老路径继续用原函数,不改。
@@ -164,73 +132,55 @@
每次观测(轮询、人工刷新、系列观测三个入口)读到 `r`:
1. **无效读数和 0 不写**:查询失败、`used` 为空 / null / 无法解析 / `< 0`、或 `r = 0`,一律不写快照、不发增量。(老卡管普联通对 0 是"当天已有行不覆盖、当天第一条沿用昨天的值",移动是写一条 0;本方案直接不写,因为计算只看最大值,效果相同。)Gateway 的 `FlexFloat` 已把空、null、无法解析都转成 0(`internal/gateway/models.go:55-78`),与读数 0 走同一处理,**所以不需要新增"读数是否有效"标记**,Gateway 与三个观测入口都不用改。
1. **无效读数和 0 不写**:查询失败、`used` 为空 / null / 无法解析 / `< 0`、或 `r = 0`,一律不写快照、不发增量。(老卡管普联通对 0 是"当天已有行不覆盖、当天第一条沿用昨天的值",移动是写一条 0;本方案直接不写,因为计算只看最大值,效果相同。)`FlowUsageResp` 增加"读数是否有效"标记,只给新代码用。
2. **换算成自然月累计 `m`**:
- `R = 1`:`m = r`;
- `R > 1`,今天日号 `≥ R`:`m = r + max(本月 1 号~R−1 号的 month_used_mb)`;
- `R > 1`,今天日号 `< R`:`m = r − (上月最大 month_used_mb − 上月 1 号~R−1 号的最大 month_used_mb)`,< 0 按 0。
3. **重置日防旧值(完全照搬老卡管,比较基准也一致)**:只在重置日当天判断,原始读数照存 `vendor_used_mb`:
- `R = 1`:`r > 上月最大 month_used_mb × 0.9` 时按 `r = 0` 计算 `m`(同 `YidongDayFlowService.saveYidongDayFlow`;上月没有快照时比较值为 0,任何正读数当天都按 0 记,次日如实写入,与老卡管一致);
- `R > 1`:本月 1 号~R−1 号的最大 `month_used_mb` 大于 0,且 `r` 超过它的 0.9 倍时,按 `r = 0` 计算 `m`(同 `PtLiantongDayFlowService.saveLiantongDayFlow`:`appendFlow > 0 && flow > appendFlow × 1.9`);本月前 R−1 天没有快照时不判断;
- `R = 1`:`r > 上月最大 month_used_mb × 0.9` 时按 `r = 0` 计算 `m`(同 `YidongDayFlowService.java:123`;上月没有快照时比较值为 0,任何正读数当天都按 0 记,次日如实写入,与老卡管一致);
- `R > 1`:本月 1 号~R−1 号的最大 `month_used_mb` 大于 0,且 `r` 超过它的 0.9 倍时,按 `r = 0` 计算 `m`(同 `PtLiantongDayFlowService.java:126`:`appendFlow > 0 && flow > appendFlow × 1.9`);本月前 R−1 天没有快照时不判断;
- 触发时置 `stale_guarded = true`;第二天起不再判断,读数照常写入。
4. **同一天合并**:当天已有行时,`month_used_mb = max(旧值, m)`。这一步是改进,不是照搬:老卡管普联通同一天后写覆盖、可以变小,但新方案的增量一旦发出就收不回,所以快照只增不减。
5. **写法**:一律用 `INSERT ... ON CONFLICT (iot_card_id, flow_date) DO UPDATE SET month_used_mb = GREATEST(tb_card_flow_day.month_used_mb, EXCLUDED.month_used_mb), ...`,不先查后写。已切换卡在事务 1 内写,卡行已被锁(`traffic.go:40`),天然串行;未切换卡在事务 1 提交后写,没有卡锁,靠这条原子语句防止轮询与人工刷新并发写丢值。
4. **同一天合并**:当天已有行时,`month_used_mb = max(旧值, m)`。
### 4.4 每天的快照用量(照搬 3.4,用于日流量展示)
### 4.4 今天的快照用量(照搬 3.4)
```
U(卡, 某天) = max(那天的 month_used_mb) − max(本月 1 号 ~ 前一天的 month_used_mb)
< 0 按 0;那天是本月第一条快照时减数为 0
今天用量 U(卡, 今天) = max(今天的 month_used_mb) − max(本月 1 号 ~ 昨天的 month_used_mb)
< 0 按 0;今天是本月第一条快照时减数为 0
```
- 中间有几天没采到,那几天的用量自然并入采到的那天,不会丢。
- 中间有几天没采到,那几天的用量自然并入今天,不会丢。
- 每天的日流量就是 `U(卡, 那天)`,直接替代 Redis 日流量缓冲、02:00 落盘和 `tb_card_daily_usage`。
- **算增量不用这个公式**,用 4.5 的按月对账。两者按月合计相同,但按天对账遇到乱序观测会重复扣(见 4.5 说明)。
### 4.5 算增量并发出(替代 `r − 基线`)
在观测事务(事务 1,现有 `ApplyTraffic` 所在事务)里,对**已切换且已有切换水位的卡**(没有水位的走 4.6):
在观测事务(事务 1,现有 `ApplyTraffic` 所在事务)里,对**已切换的卡**:
1. 卡行已在事务开头加锁(`traffic.go:40`),同卡观测天然串行,不再单独锁快照行;
2. 按 4.3 写今天的快照;
3. 按月对账:`M = 本月最大 month_used_mb`(写入后),`S = 本月各天 settled_mb 合计`,增量 `Δ = M − S`;
4. `Δ > 0` 且**该卡有可扣的生效套餐**(与 `resolveActivePackages` 同口径:卡自身有**生效中(status=1)**的套餐,或卡当前绑定的设备有生效中的套餐;**已用完(2)、待生效(0)不算**;只读查询,不加锁):写一条 V1 事件 `CardTrafficIncremented`,载荷与现在完全相同(只是增量值换了来源),同时今天的 `settled_mb += Δ`;
5. `Δ > 0` 但**没有生效套餐**:不发事件,只把今天的 `settled_mb += Δ`。这部分用量不属于任何套餐,丢弃(日流量照常可见);
1. `SELECT ... FOR UPDATE` 锁住该卡今天的快照行(不存在就插入);
2. 按 4.3 更新快照;
3. 增量 `Δ = U(卡, 今天) − emitted_mb`;
4. `Δ > 0` 且**该卡有可扣的生效套餐**(与 `resolveActivePackages` 同口径:卡自身有**生效中(status=1)**的套餐,或卡当前绑定的设备有生效中的套餐;**已用完(2)、待生效(0)不算**;只读查询,不加锁):写一条 V1 事件 `CardTrafficIncremented`,载荷与现在完全相同(只是增量值换了来源),同时 `emitted_mb += Δ`;
5. `Δ > 0` 但**没有生效套餐**:不发事件,只 `emitted_mb += Δ`。这部分用量不属于任何套餐,丢弃(日流量照常可见);
6. `Δ ≤ 0`:不发事件。
7. **通道阈值判定**:本次读数有效(`r > 0`)且本次读数未被防旧值按 0 处理时,用 `r` 调用 `EvaluateInTx`,不再看老路径是否接受读数(见下方说明)。
说明:
- **为什么按月对账**:若按天对账(`今天用量 − 今天已发出`),一次迟到的观测改写了昨天的快照,昨天会补发一笔,而今天的增量已按旧的"此前最大值"发过,两笔重叠的部分会扣两次(跨零点时轮询与人工刷新并发即可出现)。按月对账只看"本月最大值"和"本月已结算合计",与观测先后无关。
- **跨月**:新月份 `S = 0`,`M` 只取新月份的快照,第一条快照的增量就是本月累计。重置日为 1 号的卡,"上月最后一次采到 → 清零"之间的用量拿不到(见第 9 节"旧周期尾部")。
- **增量与已结算标记在同一事务里**,和 Outbox 事件一起提交或一起回滚,不会重复发、也不会漏发。
- **增量与发出标记在同一事务里**,和 Outbox 事件一起提交或一起回滚,不会重复发、也不会漏发。同卡并发观测由快照行锁串行。
- **消费者完全不改**:现有消费者照旧调用 `DeductDataUsage` 扣减(先加油包、后主套餐、超额记最后一个),写流量详单,调用 `EvaluateAndAct` 判断停复机,回执表照旧保证幂等。
- **没有生效套餐时不发事件**:现有消费者遇到"没有可用套餐"会报错重试(第 2 节第 6 条),所以不能把这部分增量发出去。只记为已结算:**激活之前被采到的用量不会扣到新套餐上**。精度到观测粒度:激活后第一次观测里,"上次观测到激活"这一段仍会算给新套餐(老卡管按天取值,激活当天的全部用量都算给新套餐)。
- 是否有生效套餐只在事务 1 里只读判断一次,不改套餐侧代码;判断出错时按"有套餐"处理并发事件(保持现有行为,不丢量)。事务 1 看到有生效套餐、但消费者扣减前套餐刚好被前一条事件扣完的情况,仍会报错重试,见第 9 节。
- 卡表三列(`last_gateway_reading_mb` 等):老 `ApplyTraffic` 照常计算、照常写,只是它算出的增量对已切换的卡不再使用。`last_gateway_reading_mb` 是观测的 CAS 条件,新代码只读不写。
- **卡表本月用量**:卡表 `current_month_usage_mb` 被资产详情(`service/asset/service.go:482`)、卡列表(`service/iot_card/service.go:501`)、设备导出(`exporter/device_scene.go:423`)直接读取。冻结卡按老逻辑这一列一直是 0,所以**已切换的卡在事务 1 里用本月最大快照 `M` 覆盖这一列**(写在同一条 `Updates` 里,老逻辑算出的值对已切换卡不用),三处读取都不用改。这一列只用于展示;补数据判定冻结卡时,已切换卡看水位、不看这一列(5.5),不受影响。跨月时老逻辑照常把上月值转入 `last_month_total_mb`,已切换卡转入的就是上月快照的最大值。
- **通道阈值**:老路径只在读数被接受时判定(`traffic.go:84`),冻结卡读数一直被拒,所以通道达量停机永远不触发;不改的话,全量切换后依然如此,只能等卡在重置窗口内被采到、基线自行重置。`EvaluateInTx` 只做"达量即写停机锁",没有"读数低就复机",所以放开判定不会误复机;排除被防旧值处理的读数,是为了防止重置日读到上周期旧总量、在新周期误停机;同一天之后读到的真实读数照常判定。此改动只在已切换分支内。
- **已切换的卡不再发老增量的 V1**:老 `ApplyTraffic` 算出的 `decision.IncrementMB` 对已切换的卡丢弃(切换那一次除外,见 4.6),现在第 73-76 行的发事件逻辑只在未切换分支执行;每次观测只会发一条 V1,Outbox 量不增加。
- **没有生效套餐时不发事件**:现有消费者遇到"没有可用套餐"会报错重试(第 2 节第 6 条),所以不能把这部分增量发出去。只推进 `emitted_mb`,**激活之前的用量永远不会扣到新套餐上**,二次销售天然正确,而且比老卡管按天算更精确:激活当天、激活之前的那部分也不会算给新用户。
- 是否有生效套餐只在事务 1 里只读判断一次,不改套餐侧代码;判断出错时按"有套餐"处理并发事件(保持现有行为,不丢量)。
- 卡表三列(`last_gateway_reading_mb` 等)和通道阈值判定:老 `ApplyTraffic` 照常计算、照常写,只是它算出的增量对已切换的卡不再使用。`last_gateway_reading_mb` 是观测的 CAS 条件,新代码只读不写。
- **已切换的卡不再发老增量的 V1**:老 `ApplyTraffic` 算出的 `decision.IncrementMB` 对已切换的卡丢弃,现在第 73-76 行的发事件逻辑只在未切换分支执行;每次观测只会发一条 V1,Outbox 量不增加。
- 只有已用完或待生效套餐的卡,增量同样丢弃。这和现状一致:现状下这类增量会因"没有可用套餐"扣减失败并积压(第 2 节第 6 条),并不会扣到已用完的套餐上。
**未切换的卡**:事务 1 一切照旧(老增量、老 V1、老通道阈值条件);快照在事务 1 提交后按 4.3 第 5 步原子写入,失败只告警(见 4.7),用于切换前积累数据和比对;`settled_mb` 不动。
**未切换的卡**:事务 1 一切照旧(老增量、老 V1);快照在事务 1 提交后单独写,失败只告警(见 4.7),用于切换前积累数据和比对。
### 4.6 切换衔接:切换水位
### 4.6 切换当天的衔接
卡已在新路径名单中、但 `tb_card_flow_switch` 里还没有它的行时,这次观测是它的**第一次切换观测**,在事务 1 内:
卡切换后的第一次观测,如果今天的快照行已经存在(切换前已经写过),先把 `emitted_mb` 初始化为"写入本次读数之前的 `U(卡, 今天)`",再按 4.5 计算。这样今天切换前已经由老路径扣过的部分不会再扣一次,只发本次观测带来的增长。
1. 按 4.3 写今天的快照,得到 `M`(本月最大)与 `S`(本月已结算合计,正常为 0);
2. 本次增量**照用老路径的结果** `decision.IncrementMB`:`> 0` 且有生效套餐时发 V1;无生效套餐时不发(与 4.5 一致,老路径这时会发);
3. 今天的 `settled_mb += max(M − S, 0)`,即把本月到此刻为止全部记为已结算;
4. 写入切换水位行(4.2),记下 `M`、`r`、卡表基线、卡表本月计入、老路径是否接受读数、老增量;
5. 本次的通道阈值仍按老路径条件判定,从下一次观测起按 4.5 第 7 步;卡表本月用量从本次起按 4.5 用 `M` 覆盖。
效果:
- 切换这一次的增量与老路径相同,之后只发切换之后的增长,**不依赖切换前本月是否已有快照**,阶段 1 不需要覆盖整月。
- 冻结卡:老路径本次增量为 0,切换前少扣的部分不在这里处理,由 5.5 按水位补。
- 退回老路径时删除水位行(5.2);再切回会重新走一次本节,退回期间老路径扣过的部分不会重扣。
- 写水位与快照、Outbox 在同一事务,失败随事务 1 回滚。
限制:同一张卡**退回老路径后当天不要再切回**。当天快照上的 `emitted_mb` 已经不是 0,再切回时不会重新初始化,退回期间老路径扣过的部分会被再扣一次。
### 4.7 失败处理
@@ -238,7 +188,7 @@ U(卡, 某天) = max(那天的 month_used_mb) − max(本月 1 号 ~ 前一天
|---|---|
| Gateway 查询失败 / 无效读数 / 读数为 0 | 不写快照、不发增量 |
| 运营商重置日查不到或非法 | 按运营商类型兜底:联通(`CUCC`)27 号 0 点,其余 1 号 0 点,北京时间;记日志。老路径照旧 |
| 已切换卡:快照、已结算标记或切换水位写入失败 | 随事务 1 回滚,观测重入队(与现在观测失败的处理一致) |
| 已切换卡:快照写入失败 | 随事务 1 回滚,观测重入队(与现在观测失败的处理一致) |
| 未切换卡:快照写入失败 | 只告警,不影响观测;总开关可整体停写 |
| 某天没采到 | 用量并入下一个有快照的日子 |
@@ -248,17 +198,14 @@ U(卡, 某天) = max(那天的 month_used_mb) − max(本月 1 号 ~ 前一天
| # | 接触点 | 设计 | 结论 |
|---|---|---|---|
| 1 | 写快照(阶段 1 起全部卡都写) | 非灰度卡在观测事务**提交之后**用一条 `INSERT ... ON CONFLICT DO UPDATE` 原子写入(4.3 第 5 步),失败、超时只告警,不让观测失败、不重入队;总开关 `JUNHONG_TRAFFIC_FLOW_SNAPSHOT_ENABLED` 关掉即停写,不用发版 | 不影响扣费与观测;数据库多一次写入(现约 2,400 次/小时),上线后观察轮询耗时,异常即关开关 |
| 1 | 写快照(阶段 1 起全部卡都写) | 非灰度卡在观测事务**提交之后**单独写,失败、超时只告警,不让观测失败、不重入队;总开关 `JUNHONG_TRAFFIC_FLOW_SNAPSHOT_ENABLED` 关掉即停写,不用发版 | 不影响扣费与观测;数据库多一次写入(现约 2,400 次/小时),上线后观察轮询耗时,异常即关开关 |
| 2 | 判断走新路径还是老路径 | 观测事务内用启动时载入内存的卡集合判断,不查库;判断出错一律走老路径 | 不影响 |
| 3 | 增量计算 | 非灰度卡仍用"读数 − 基线",仍发 V1 事件,代码不改 | 不影响 |
| 4 | 无套餐时不发事件 | 只对灰度卡生效 | 不影响 |
| 5 | Gateway 与三个观测入口 | 不改(不新增"读数是否有效"标记,见 4.3 第 1 步) | 不影响 |
| 5 | Gateway 返回加"读数是否有效"标记 | 只给新代码用,老路径照旧(无效仍按 0 处理) | 不影响 |
| 6 | 日流量查询 | 非灰度卡仍读老来源 | 不影响 |
| 7 | 数据库迁移 | 只新建表,不改现有表 | 不影响 |
| 8 | 设备部分卡切换 | 不允许:设备整台切(5.2) | 不影响 |
| 9 | 通道阈值判定条件 | 只在已切换分支改为"读数有效且本次未被防旧值处理";未切换分支仍是"老路径接受读数" | 不影响 |
| 10 | 切换水位表 | 只有已切换卡读写 | 不影响 |
| 11 | 卡表 `current_month_usage_mb` | 只对已切换卡用快照覆盖;未切换卡仍由老逻辑写 | 不影响 |
**唯一无法靠设计消除的风险**:`ApplyTraffic` 是所有卡共用的代码,改动出错可能波及全部卡。约束:新逻辑只放在"判定为灰度卡"的分支里,老分支一行不改;评审重点核对这一处;测试环境回归"非灰度卡观测一次"的结果与改动前完全一致。
@@ -283,25 +230,23 @@ U(卡, 某天) = max(那天的 month_used_mb) − max(本月 1 号 ~ 前一天
| 阶段 | 内容 | 对业务影响 |
|---|---|---|
| 1 写快照 | 建 `tb_card_flow_day`,全部卡在观测事务提交后写快照 | 无,只写不用 |
| 2 灰度切换 | 名单内的卡第一次观测按 4.6 写切换水位,之后按 4.5 算增量;其余照旧 | 只影响名单内的卡 |
| 2 灰度切换 | 名单内的卡按 4.5 算增量;其余照旧 | 只影响名单内的卡 |
| 3 全量 | 全部卡切换;之后下线 Redis 日流量缓冲与 02:00 落盘 | — |
阶段 1 至少跑满一个完整自然日再进入阶段 2。切换衔接靠切换水位(4.6),**不要求阶段 1 覆盖整月**。但防旧值和联通换算要用上个周期的快照:重置日为 1 号的卡,最好上月已有快照;联通卡最好上月 1~26 号已有快照,否则阶段 1 第一个月的联通快照会把上月 27 号以后的用量算进本月(只影响日流量展示的分摊,不影响按月对账的增量)。
阶段 1 至少跑满一个完整自然日再进入阶段 2;跨月或跨运营商重置日时,最好已有上个周期的快照(防旧值要用)。
### 5.2 切换方式
- 环境变量 `JUNHONG_TRAFFIC_CALC_MODE`:`off`(默认)/ `gray` / `full`;`JUNHONG_TRAFFIC_CALC_CARDS`:灰度名单,写扣减载体:`iot_card:103154`(单卡套餐的卡)或 `device:15276`(设备,展开为当前绑定的全部卡);`JUNHONG_TRAFFIC_FLOW_SNAPSHOT_ENABLED`:快照写入总开关,默认开。改完重启 API 和全部 Worker(见下方配置位置)。
- 判定在事务 1 里做,用启动时展开的内存集合,不查库;判定出错一律按老路径。
- **切换、扩批、退回都要先停掉 API 和全部 Worker,再一起启动,不逐个重启**。原因:逐个重启时会有新旧进程并存的窗口,同一张卡如果先被新进程写了水位、再被老进程观测(多为人工刷新),老进程发的那笔增量不计入已结算合计,下次新路径观测会再扣一次。窗口只有几分钟,概率很低,但停后再启即可完全避免。
- **配置位置**:新增 `pkg/config/config.go` 的 `TrafficCalc` 配置段与 `pkg/config/defaults/config.yaml` 的 `traffic_calc:` 默认值(`mode: off`、名单为空、快照开关开),沿用现有"嵌入默认配置 + `JUNHONG_` 环境变量覆盖"机制。生产上由维护者写入各运行目录的 `.env.prod`:**API(`/opt/junhong_cmp/api/.env.prod`,人工刷新走 API 进程)、调度 Worker(`/opt/junhong_cmp/worker/.env.prod`)和三个执行 Worker(`worker-1/2/3/.env.prod`)五处必须一致**,否则同一张卡在不同进程里会走不同路径。改完重启对应服务。
- **灰度单位是扣减载体,设备必须整台切**:名单里的设备,当前绑定的全部卡一起切换;名单里写 `iot_card:X` 但这张卡实际用的是设备套餐(与 `resolveActivePackages` 同口径),启动日志报错并跳过该项,不阻止 worker 启动。否则同一个设备套餐会同时收到新旧两种算法的增量,设备上没切换的卡也会被连带影响。
- **灰度期间灰度设备不换绑、不新绑卡**:名单在启动时展开成卡集合,之后新绑到灰度设备上的卡要重启后才会切换,这期间设备处于"部分切换"状态。确需换绑的,换绑后立即重启 API 和全部 Worker 重新展开;或先把该设备移出名单。
- **退回**:从名单删除或改回 `off`,**删除这些卡在 `tb_card_flow_switch` 里的水位行**,再重启。老 `ApplyTraffic` 一直在维护卡表基线,退回后老路径接着算(冻结卡会回到冻结状态)。水位行必须删:否则以后再切回时不会重新走 4.6,退回期间老路径扣过的增长会被新路径再扣一次。退回与再切回可以在同一天。
- 灰度设备换绑后重启:新绑上的卡第一次走新路径时按 4.6 写水位,不会重复扣;换绑前后这台设备仍短暂处于"部分切换",所以上一条"不换绑"的约束保留。
- **退回**:从名单删除或改回 `off` 并重启。老 `ApplyTraffic` 一直在维护卡表基线,退回后老路径接着算(冻结卡会回到冻结状态)。
### 5.3 冻结卡与历史误差
- 切换后冻结卡(如 103154)立即恢复正常计量:切换那一次老路径增量为 0(4.6),之后增量只看快照,不看卡表基线;通道阈值判定同时恢复(4.5 第 7 步)。
- 切换后冻结卡(如 103154)立即恢复正常计量:增量只看快照,不看卡表基线。
- **切换前少扣的部分放到后期补**,用独立脚本执行,见 5.5。
- **上个周期及更早少扣的补不回来**:运营商读数已经清零,没有任何记录;期间套餐已经重置的,那个计费周期也已结束。
- **第一批灰度:103154 所在设备 15276**(联通 103154、移动 99426、电信 106882,三张卡本月计入都为 0)。核对无误后再放其他冻结卡、电信读数常为 0 的卡、二次销售的卡、联通卡、带加油包的载体。
@@ -336,16 +281,15 @@ U(卡, 某天) = max(那天的 month_used_mb) − max(本月 1 号 ~ 前一天
```
- 每张卡按自己运营商的重置日确定周期起点,设备下混着不同运营商的卡也能用;不需要按套餐拆分日记录。
- **未切换的卡**:`r` 在执行时现场查询 Gateway,限速执行;查询失败、无效、读数为 0 的跳过,进清单。
- **已切换的卡**:`r` 取切换水位的 `vendor_used_mb`(切换时的运营商读数),不现场查。切换之后的用量已由新路径计算,用水位截断,不会重复。切换时刻不在执行时所在运营商周期内的,进清单。
- `r` 在执行时现场查询 Gateway,限速执行;查询失败、无效、读数为 0 的跳过,进清单。
**判定为冻结卡(全部满足才自动补)**:
| 条件 | 原因 |
|---|---|
| 卡(或其所在设备)有**生效中(status=1)**的套餐(与 4.5 同口径) | 只有已用完或待生效套餐时,`DeductDataUsage` 找不到可扣套餐会报错,补扣事件会积压(第 2 节第 6 条);这类卡进清单 |
| 未切换卡:最近两天在被采集,本月计入(`current_month_usage_mb`)为 0。已切换卡:水位的 `old_reading_accepted = false` 且 `old_month_usage_mb = 0` | 本周期没有任何读数被老路径接受。已切换卡切换后卡表仍由老逻辑维护、本月计入可能一直是 0,所以只能看水位,不能看卡表 |
| `r` < 基线(未切换卡用现场读数与卡表 `last_gateway_reading_mb`;已切换卡用水位的 `vendor_used_mb` 与 `old_baseline_mb`) | 基线属于上个周期或被异常值抬高,读数一直被拒 |
| 最近两天在被采集,本月计入(`current_month_usage_mb`)为 0 | 本周期没有任何读数被老路径接受 |
| 现场读数 `r` < 卡表基线 `last_gateway_reading_mb` | 基线属于上个周期或被异常值抬高,读数一直被拒 |
| 卡所在扣减载体的套餐计费起点(激活或最近一次重置)**早于本运营商周期起点** | 否则 `r` 里有一部分属于已经结束的上个计费周期,不该补到新周期 |
| 本周期内没有回收换代、换绑 | 读数和套餐对不上;也排除了二次销售 |
@@ -355,7 +299,7 @@ U(卡, 某天) = max(那天的 month_used_mb) − max(本月 1 号 ~ 前一天
1. **预演(只读)**:输出每张卡的卡号、运营商、扣减载体、`r`、基线、补扣量、补扣后是否超额;汇总补扣总量、预计停机数、清单与原因。
2. 确认后正式执行:每张卡写一条一次性的 V1 事件,事件 ID 由卡、运营商周期、批次组成,重复执行同一批次不重复写;由现有消费者调用 `DeductDataUsage` 扣减,超额照常停机,补扣量照常写入 `daily_record`。
3. 已切换到新方案的卡,切换之后的用量由新方案计算,补扣只补切换之前那段:补扣量取水位的 `vendor_used_mb`,避免与新方案重复。
3. 已切换到新方案的卡,切换之后的用量由新方案计算,补扣只补切换之前那段;脚本按"切换时刻已知的读数"截断,避免与新方案重复。
4. 补扣写审计,注明"冻结卡补扣"与补扣量。
5. 现有消费者会把每条 V1 事件的增量累加进当天的 Redis 日流量缓冲,所以**未切换卡的日流量会在补扣当天出现一次突增**(已切换卡的日流量读快照,不受影响)。执行前告知运营,或在预演清单中标出。
@@ -363,11 +307,12 @@ U(卡, 某天) = max(那天的 month_used_mb) − max(本月 1 号 ~ 前一天
| 位置 | 改动 |
|---|---|
| `migrations/` | 新建 `tb_card_flow_day`、`tb_card_flow_switch` |
| 新增 `internal/domain/cardflow/` | 纯函数:自然月换算、重置日防旧值、每天用量、按月对账增量;表驱动单测 |
| 新增 `internal/store/postgres/card_flow_day_store.go` | 快照原子写入(`ON CONFLICT DO UPDATE`)、本月最大值与已结算合计查询 |
| 新增 `internal/store/postgres/card_flow_switch_store.go` | 切换水位读写 |
| `internal/application/cardobservation/traffic.go` | 已切换的卡:增量改为 4.5 的按月对账,第一次切换观测按 4.6 写水位,通道阈值按 4.5 第 7 步判定,卡表 `current_month_usage_mb` 用本月最大快照覆盖,V1 事件载荷不变,按卡的 `carrier_id` 严格查重置日;未切换的卡:老逻辑不变,提交后写快照。快照写入都在 `ApplyTrafficObservation` 里完成,三个观测入口与 Gateway 不改 |
| `migrations/` | 新建 `tb_card_flow_day` |
| `internal/gateway/models.go` | `FlowUsageResp` 增加"读数是否有效"标记(老路径行为不变) |
| 新增 `internal/domain/cardflow/` | 纯函数:自然月换算、重置日防旧值、今天用量;表驱动单测 |
| 新增 `internal/store/postgres/card_flow_day_store.go` | 快照读写(含行锁) |
| `internal/application/cardobservation/traffic.go` | 已切换的卡:增量改为 4.5 的算法,V1 事件载荷不变,按卡的 `carrier_id` 严格查重置日;未切换的卡:老逻辑不变,提交后写快照 |
| 三个观测入口(`polling_carddata_handler.go`、`service/iot_card/service.go`、`series_runner.go`) | 只把"读数是否有效"传进 `TrafficObservation`(新增字段 `ReadingInvalid`,默认值表示有效,老调用方不受影响);快照的写入都在 `ApplyTrafficObservation` 里完成:已切换的卡在事务 1 内写,未切换的卡在事务 1 提交后写 |
| 日流量查询 | 已切换的卡读快照 |
| `pkg/config` | 三个环境变量(模式、灰度名单、快照写入总开关) |
| 后期:补数据脚本(`cmd/`) | 预演(只读)+ 正式执行(写 V1 事件),见 5.5 |
@@ -384,7 +329,7 @@ U(卡, 某天) = max(那天的 month_used_mb) − max(本月 1 号 ~ 前一天
| 重置日 `R = 15`(任意非 1 号) | 换算规则同上,以 15 号为界,结果连续 |
| 重置日 `R = 1` | 不换算,`month_used_mb` = 读数 |
| 运营商的 `data_reset_day` 被修改 | 从修改后的下一次写入起按新重置日换算 |
| `R > 1` 时 1 号~R−1 号换算 | 减去上月 R 号~月底的用量(= 上月最大 − 上月 1~R−1 号最大,含 R−1 号) |
| `R > 1` 时 1 号~R−1 号换算 | 减去上月 R 号~月底的用量,按"1 号~R−1 号"取值,不少一天 |
| 重置日当天读到上周期旧值(> 90%) | 当天按 0 记,`stale_guarded = true`;之后读到真实低值正常累计 |
| 重置日次日起仍读到旧值 | 照常累加(与老卡管一致) |
| 重置日当天真实用量超过上周期 90% | 当天按 0 记;次日如实写入,取最大值把这部分带回来,只晚一天,不丢 |
@@ -395,30 +340,20 @@ U(卡, 某天) = max(那天的 month_used_mb) − max(本月 1 号 ~ 前一天
| `R = 1` 重置日当天读数 > 上月最大快照 × 0.9 | 当天按 0 记 |
| `R = 27` 重置日当天读数 > 本月 1~26 号最大快照 × 0.9 | 当天按 0 记 |
| 中间 3 天没采到 | 3 天的用量并入采到的那天 |
| 跨自然月 | 本月第一条快照减数为 0,从本月累计开始算;按月对账时新月份已结算合计为 0 |
| 按月对账:本月最大 500、已结算合计 450 | 增量 50 |
| 乱序:今天已发出后,一次迟到观测抬高了昨天的快照 | 按月对账的增量 = 本月最大 − 已结算合计,不会把重叠部分扣两次 |
| 跨自然月 | 本月第一条快照减数为 0,从本月累计开始算 |
**应用层**
| 场景 | 期望 |
|---|---|
| **卡 103154 类:卡表基线 72535,运营商读数 48808** | 切换后增量按快照正常发出,套餐恢复增长 |
| **二次销售:上一个用户本月用到 3000;新用户 15 号 14:00 激活;激活前当天又读到 3020,激活后读到 3050** | 3020 及之前的增量在无套餐时只记入 `settled_mb`、不发事件;新套餐只扣 30 |
| **二次销售:上一个用户本月用到 3000;新用户 15 号 14:00 激活;激活前当天又读到 3020,激活后读到 3050** | 3020 及之前的增量在无套餐时只推进 `emitted_mb`、不发事件;新套餐只扣 30 |
| 卡只有已用完或待生效的套餐 | 不发事件,增量丢弃 |
| 灰度卡观测一次 | 只发一条 V1(新算法增量),不再发老增量的 V1 |
| 无套餐期间连续观测,之后激活 | Outbox 里没有该卡积压的 V1 事件;激活后只扣激活之后的增长 |
| 同一次观测重复处理 / 同卡并发观测 | 卡行锁串行,按月对账保证每段增长只结算一次 |
| 事务 1 回滚 | 快照、`settled_mb`、切换水位、Outbox 一起回滚,不重不漏 |
| 第一次切换观测:正常卡,本月已有快照 | 只发老路径这次的增量;写水位;本月已结算合计 = 本月最大 |
| 第一次切换观测:正常卡,本月还没有快照 | 同上,不会把整月累计当增量发出 |
| 第一次切换观测:冻结卡(老路径拒绝读数) | 不发增量;水位记 `old_reading_accepted = false`、`vendor_used_mb = r`;下一次观测起按快照正常发 |
| 退回老路径(删水位)后当天再切回 | 重新按 4.6 写水位,退回期间老路径扣过的部分不重扣 |
| 冻结卡切换后读数超过通道阈值 | 写通道停机锁(老路径下不会触发) |
| 冻结卡切换后 | 资产详情、卡列表、设备导出的本月用量 = 本月最大快照,不再是 0 |
| 重置日当天先触发防旧值、之后读到真实低值 | 真实读数照常做通道阈值判定 |
| 重置日当天读到上周期旧总量并触发防旧值 | 不做通道阈值判定,不误停机 |
| 未切换的卡并发写快照(轮询与人工刷新同时) | 原子写入,`month_used_mb` 取较大值,不丢 |
| 同一次观测重复处理 / 同卡并发观测 | 行锁串行,`emitted_mb` 保证每段增长只发一次 |
| 事务 1 回滚 | 快照、`emitted_mb`、Outbox 一起回滚,不重不漏 |
| 切换当天切换前老路径已扣过一部分 | 按 4.6 初始化,不重复扣 |
| 名单写 `device:15276` | 设备当前绑定的 3 张卡全部切换 |
| 联通卡观测时运营商表查询失败(入口传进来的 `ResetDay` 已被兜底成 1) | 新逻辑不用入口的值,自行严格查询,失败按 27 号处理 |
| 名单写 `iot_card:X`,但该卡实际用设备套餐 | 启动日志报错并跳过该项,worker 正常启动,该卡仍走老路径 |
@@ -440,8 +375,6 @@ U(卡, 某天) = max(那天的 month_used_mb) − max(本月 1 号 ~ 前一天
| 冻结卡只有已用完的套餐 | 不补,进清单 |
| 少扣为负(多扣) | 不退,进清单 |
| 同一批次重复执行 | 事件 ID 相同,不重复写 |
| 已切换的冻结卡 | 补扣量取水位的 `vendor_used_mb`,不现场查;切换之后的用量不重复补 |
| 已切换的正常卡(水位 `old_reading_accepted = true`) | 不补 |
| 套餐计费起点在本周期内的冻结卡 | 不自动补,进清单并附按时间比例的估算值 |
**测试环境验证**:103154 同类卡、三卡共用设备、二次销售卡、重置日为 27 号与 1 号的运营商各跨一次重置日、另构造一个非常规重置日(如 15 号)各一组,逐日核对快照、增量与套餐已用量。
@@ -451,16 +384,14 @@ U(卡, 某天) = max(那天的 month_used_mb) − max(本月 1 号 ~ 前一天
| # | 任务 | 人天 |
|---|---|:-:|
| 1 | 迁移与模型 | 0.25 |
| 2 | 领域层:自然月换算、防旧值、每天用量、按月对账 + 单测 | 1.5 |
| 3 | 快照存储(原子写入)+ 切换水位存储 | 0.75 |
| 4 | `ApplyTraffic` 已切换卡:按月对账、切换水位、通道阈值条件 | 1.25 |
| 2 | 领域层:自然月换算、防旧值、今天用量 + 单测 | 1.5 |
| 3 | 快照存储(含行锁)+ 三个观测入口写快照 + 无效读数标记 | 1.0 |
| 4 | `ApplyTraffic` 已切换卡的增量替换与切换当天衔接 | 1.0 |
| 5 | 切换机制(模式、名单、启动展开) | 0.5 |
| 6 | 日流量读侧 | 0.5 |
| 7 | 集成测试与测试环境验证 | 1.5 |
| | 合计 | 约 6.25 |
与第 1 版相比:去掉"读数是否有效"标记和三个入口的改动,省下的工作量用于切换水位与通道阈值条件,合计不变。
后期补数据脚本(5.5:预演、条件判断、写 V1 事件、清单)另计约 1.5 人天。
不含灰度观察期:至少跨过一次运营商清零,约 30 天内。
@@ -470,128 +401,11 @@ U(卡, 某天) = max(那天的 month_used_mb) − max(本月 1 号 ~ 前一天
| 限制 | 说明 |
|---|---|
| 切换前的误差 | 后期按 5.5 补,只能补执行时所在运营商周期内的部分;更早的补不回来;不满足条件的进人工清单 |
| 扣减前套餐刚好用完 | 事务 1 看到有生效套餐并发出事件,但消费者扣减前,同卡的前一条事件已把唯一的套餐扣到"已用完",这条事件扣减时报"没有可用套餐"并重试,之后可能扣到新激活的套餐上(第 2 节第 6 条的同一条路径)。需要同卡在很短时间内有两条事件,概率低。消费者不改(D13),灰度期监控"处理中"回执的新增数,确有发生再评估是否让消费者对已切换卡直接结束 |
| 二次销售精度 | 只精确到观测粒度:激活后第一次观测里"上次观测到激活"的用量会算给新套餐。要更精确需要激活时触发一次立即观测(改激活入口,本期不做) |
| 切换那一次沿用老路径增量 | 第一次切换观测发的是老路径算出的增量(4.6),如果这一次正好撞上老路径的问题(例如重置窗口读到 0 把基线置 0 后,又读到真实值整笔计入),会照样扣一次。每张卡最多一次 |
| 换绑后第一次观测 | 设备换上一张本月没有快照的卡,第一次观测(走 4.6)发出的是老路径的增量,与现状一致:老路径对从未采过的卡基线为 0,会把整张卡的周期读数算给设备套餐 |
| 上一代套餐仍为"生效中" | `DeductDataUsage` 只按状态选套餐、不看世代,回收换代后上一代套餐如果仍为生效中,新用户的增量可能扣到它上面。这是套餐侧问题,按 `docs/资产回收换代技术方案.md` 单独修复,本方案不改套餐侧 |
| 旧周期尾部 | 旧周期最后一次采集到清零之间的用量拿不到,老卡管同样如此 |
| 防旧值 | 完全照搬老卡管:阈值 0.9、只在重置日当天判断。运营商延迟清零超过一天时,次日的旧值会被累加(老卡管同样如此,多年未暴露问题) |
| 积压的无套餐回执 | 18,979 条卡在"处理中"的回执(含 4,141 条对应卡当前有生效套餐)由业务后续手动调整,不在本方案内;处理前不得重放 |
| 修改运营商重置日 | 月中修改 `data_reset_day` 会让当月换算前后不连续,可能产生一次跳变多扣。修改应安排在运营商实际重置日前后,并在修改后核对受影响卡的当日增量 |
| 采集不足 | 轮询吞吐问题(约 2.4k 次/小时,平均每卡约 1.5 天一次)不在本方案范围;采不到只会晚扣,不会丢。但每张卡在自己的重置日(1 号或联通 27 号),最后一次采集到清零之间的用量会丢("旧周期尾部"),采得越稀丢得越多;重置日当天没采到的卡也不会触发防旧值 |
| 采集不足 | 轮询吞吐问题(约 2.4k 次/小时)不在本方案范围;采不到只会晚扣,不会丢 |
| 扣错收不回 | 套餐侧仍是增量扣减,一旦扣错只能人工改已用量。快照可以完整复算每天应扣多少,作为核对依据 |
| 电信 | 老卡管的电信在采集时按"本月 1 号~今天"向运营商查询(`TelecomUtilNew.queryThisMonthDianxinDayFlow`),拿到自然月累计后同样写日快照、按快照计费;新卡管 Gateway 目前只返回当前周期累计,本期统一按快照处理,读数为 0 的电信卡仍扣不到。**后续安排 Gateway 支持按日期区间查询**:支持后,电信卡改为按区间查询的结果写快照,算增量的逻辑不变;同时可以用来补旧周期尾部、补更早周期的少扣数据 |
## 10. 停复机规则与实名管控
本章与流量计算相互独立,可以分开开发、分开上线;放在同一份方案里一起评审。
### 10.1 停复机原则(已定)
| | 条件 | 说明 |
|---|---|---|
| **停机** | 卡开机,且满足任一:① 没有套餐(套餐到期);② 流量用完;③ 卡所属通道开启了实名管控(`realname_control_enabled = 1`)且卡未实名 | 只有这三条。谁开的机不重要 |
| **复机** | 卡停机,且同时满足:① 有生效套餐;② 流量没用完;③ 卡已实名,**或**卡所属通道关闭了实名管控(`realname_control_enabled = 0`) | **不看停机原因**:外部停的、卡管自动停的、管理员手动停的、设备手动停的,满足条件都复机 |
| 不动作 | 卡或所在设备暂停轮询;卡持有本周期通道阈值锁(属于"流量用完",锁期内不复机) | 见 10.4 |
- **外部停机与卡管无关**:外部环境导致的停机,卡管判断该开机就复机,**除非这张卡(或所在设备)已被加入暂停轮询**。卡管不判断是谁停的,只按上面的条件决定开或停;外部反复停卡,卡管就按规则反复复机,不设次数限制。
- **实名默认不管控**:通道上开启"实名管控"后,未实名的卡会被停机,停机后实名了才复机;没开启的通道,实名不参与停复机。"实名管控"是通道上**新增的独立开关**(`realname_control_enabled`),只管停复机,**不复用通道现有的实名相关字段**(如实名链接类型 `realname_link_type`,它只决定实名链接怎么生成)。开启后,通道下所有卡都按"是否已实名"判断,**不再区分行业卡,也不再看卡或设备上的实名策略**(实名策略只用于购买流程)。
- `stop_reason` 仍照常记录(流量耗尽、无套餐、未实名、手动、运营商侧停机、通道阈值),**只用于展示和审计,不参与复机判断**。
### 10.2 现状与差异(代码核实)
| # | 现在的行为 | 与原则的差异 | 代码 |
|---|---|---|---|
| 1 | 自动复机只处理停机原因为流量耗尽、无套餐、未实名的卡 | 外部停机(记为 `carrier_stopped`)、手动停机(`manual`)、设备保护期停机(`protect_period`)满足条件也不复机,只能人工处理 | `stop_resume_service.go` 的 `isPollingStopReason`、`EvaluateAndAct` 停机分支、`resumeSingleCard`;`iot_card_store.go` 的 `ListByDeviceIDAndPollingStopReasons` |
| 2 | 非行业卡、未实名、卡或设备实名策略不是 `none` 时一律停机且不能复机 | 实名默认管控,且与行业卡、资产实名策略耦合;想放开只能逐张改实名策略 | `isRealnameOK`,被 `checkStopReasons`、`shouldResume`、`resumeDeviceCards`、`ManualStartCard` 共用;保护期一致性检查跳过未实名卡(`polling_protect_handler.go:77`) |
停机这一侧现在已经只看无套餐、流量耗尽、未实名,符合原则;停机和复机里的实名都改成按通道判断。
### 10.3 已定决策(2026-10-20)
| # | 事项 | 决定 |
|---|---|---|
| D14 | 停机条件 | 无套餐、流量用完、通道开启实名管控且卡未实名,三者任一 |
| D15 | 复机条件 | 有生效套餐、流量没用完、卡已实名或通道关闭了实名管控,三条同时满足即复机;**不看停机原因**(含外部停机、手动停机、设备手动停机) |
| D16 | 实名管控 | 通道(`tb_carrier`)新增独立开关 `realname_control_enabled`,不复用现有实名字段,**默认 0 = 不管控**;设为 1 时通道下所有卡按是否已实名判断(未实名停机、实名后才复机),不区分行业卡、不看资产实名策略。**本期只加通道开关,不迁移、不修改任何卡或设备数据**;卡级、设备级的实名管控以后需要时再加(同样用独立字段,默认继承通道) |
| D17 | 暂停轮询的卡 | 卡或所在设备暂停轮询时,卡管不自动停复机,人工停复机照常。风险停机、机卡分离停机、已销户等运营商异常状态**由人工判断并手动暂停轮询**,卡管不自动处理;现有"独立卡风险停机/已销户自动暂停轮询"的逻辑不改 |
### 10.4 设计
**统一判断**:新增一个"目标状态"判断,`EvaluateAndAct` 开机分支、停机分支、`resumeSingleCard`、`resumeDeviceCards`、`ResumeCardIfStopped` 都用它,不再各自判断。
- 开头先看暂停轮询:卡 `enable_polling = false`,或卡所在设备 `enable_polling = false`(与轮询范围 `ShouldEnqueue` 同口径),本次不动作。
- 卡开机:命中 10.1 的停机条件即停机,停机原因按命中的条件记录。设备维度扩散规则不变(无套餐、流量用完扩散到设备;未实名只停该卡)。
- 卡停机:满足 10.1 的复机条件即复机,**删除"只处理轮询原因停机"的限制**(`isPollingStopReason`)。持有通道阈值锁时不复机(现有 `channelThresholdBlocked`,不变)。
- 设备复机:`ListByDeviceIDAndPollingStopReasons` 改为取设备下全部停机卡,逐张按复机条件判断。
- 套餐判断沿用现有口径(`HasValidByCarrier`:生效中或待生效都算有套餐;流量用完沿用 `isTrafficExhausted`),本章不改套餐口径。
**实名管控(D16)**:
- 迁移:`tb_carrier` 增加 `realname_control_enabled smallint not null default 0`。
- `isRealnameOK`(停机判断、复机判断、设备复机、人工复机共用)改为:卡所属通道(`card.carrier_id`)`realname_control_enabled = 0` → 满足;= 1 → 卡 `real_name_status` 为已实名才满足。**删除行业卡放行和卡/设备实名策略判断**。查询通道失败时本次不动作并记日志(不按任何默认值停机或复机)。
- 保护期一致性检查:只在通道开启实名管控且卡未实名时跳过。
- 人工复机(`ManualStartCard`)同样经过 `isRealnameOK`,自动一致。
- 通道管理接口(`service/carrier` 创建、修改、详情)增加该字段,修改写审计。从 0 改为 1 时,管理端二次确认并提示该通道未实名的开机卡数(下一次评估会被停机);从 1 改为 0 后,该通道因未实名停机的卡会在下一次评估时复机。
- 卡和设备上的实名策略(`none` / `before_order` / `after_order`)、首次实名激活套餐,只用于购买与激活流程,不受影响。
**暂停轮询的卡(D17)**:
- 运营商异常状态(风险停机、机卡分离停机、已销户)不做自动处理,由人工判断后在管理端暂停轮询(现有接口)。现有独立卡风险停机/已销户自动暂停轮询的逻辑(`ShouldStopPollingForRisk`)保持不变。
- 暂停轮询后卡管不自动停复机(10.4 开头的检查),人工处理完再开启轮询,之后按 10.1 正常管理。
- 没有被暂停轮询的异常状态卡,卡管照常按 10.1 判断;复机请求被上游拒绝时按现有失败处理。
### 10.5 上线
新规则上线后会有一批卡集中动作,用一个总开关控制:
| 开关 | 默认 | 作用 |
|---|---|---|
| `JUNHONG_STOP_RESUME_UNIFIED_RULE_ENABLED` | `false` | 开启 10.1 的新规则(复机不看停机原因、实名按通道判断);关闭时与现在完全一致 |
| `tb_carrier.realname_control_enabled` | `0` | 按通道配置;只在总开关开启后生效 |
暂停轮询的卡不自动停复机(D17)也受总开关控制。
步骤:
1. 发版,总开关保持关闭。
2. **确认哪些通道需要实名管控**,在管理端设为 1。新规则默认不管控实名,没设置的通道,现在因未实名停机的卡在总开关打开后都会复机。
3. **打开总开关前预演(只读 SQL)**,统计下一次评估会集中动作的卡:
- 会被复机:停机、未暂停轮询、有套餐、流量没用完、(通道开启实名管控时已实名),按停机原因分组(`carrier_stopped`、`manual`、`protect_period`、`not_realname` 等)。
- 会被停机:开机、所属通道开启实名管控、未实名的卡(含行业卡,原来行业卡不停)。
4. 业务确认后打开总开关,先停掉 API 和全部 Worker 再一起启动(与 5.2 相同);观察停复机审计和上游失败数。
### 10.6 改动清单
| 位置 | 改动 |
|---|---|
| `migrations/` | `tb_carrier` 增加 `realname_control_enabled` |
| `internal/model/carrier.go`、`service/carrier`、相关 DTO | 实名管控字段的读写与审计 |
| `internal/service/iot_card/stop_resume_service.go` | 目标状态判断(10.4);复机不看停机原因;`isRealnameOK` 改为按通道判断;暂停轮询不动作;总开关 |
| `internal/store/postgres/iot_card_store.go` | 设备复机改为取设备下全部停机卡 |
| `internal/task/polling_protect_handler.go` | 只在通道开启实名管控且卡未实名时跳过 |
| `pkg/config` | 总开关 |
| **外部停机仍记 `carrier_stopped`、套餐口径、流量侧、购买与激活流程的实名策略** | **不改** |
### 10.7 测试用例
| 场景 | 期望 |
|---|---|
| 开机卡套餐到期 | 停机,原因 `no_package`(设备卡扩散到设备) |
| 开机卡流量用完 | 停机,原因 `traffic_exhausted` |
| 外部停机的卡,有套餐、流量没用完 | 自动复机 |
| 管理员手动停机的卡,有套餐、流量没用完 | 下一次评估自动复机 |
| 设备手动停机,保护期结束后 | 下一次评估按复机条件复机 |
| 停机卡没有套餐 | 保持停机;充值激活后复机 |
| 停机卡持有通道阈值锁 | 锁期内不复机 |
| 通道未开启实名管控(默认),未实名卡有套餐 | 不停机;已因未实名停机的卡复机;人工复机不因未实名被拒 |
| 通道开启实名管控,未实名的卡开着机、有套餐 | 停机,原因 `not_realname`,只停这张卡 |
| 通道开启实名管控,未实名的卡停着机、有套餐 | 不复机;实名后下一次评估复机;人工复机被拒 |
| 通道开启实名管控,未实名的行业卡 | 停机(不再区分行业卡) |
| 设备复机时设备下有一张未实名卡(通道开启实名管控) | 跳过这张卡(停机原因记为 `not_realname`),其他卡照常复机 |
| 通道开启实名管控,卡已实名、实名策略为 `before_order` | 按有套餐、流量判断,实名策略不参与 |
| 查询通道实名管控失败 | 本次不动作,记日志 |
| 设备下混有开启与未开启实名管控的通道的卡 | 各卡按自己通道判断实名;套餐与流量按设备维度 |
| 暂停轮询的卡(卡或所在设备暂停),充值激活触发评估 | 不停不复;人工停复机照常 |
| 总开关关闭 | 与现在完全一致 |
| 电信 | 老卡管的电信是直接按日期区间向运营商查询(`TelecomUtilNew.queryThisMonthDianxinDayFlow`);新卡管 Gateway 目前只返回当前周期累计,本期统一按快照处理,读数为 0 的电信卡仍扣不到。**后续安排 Gateway 支持按日期区间查询**:支持后,电信卡改为按区间查询的结果写快照,算增量的逻辑不变;同时可以用来补旧周期尾部、补更早周期的少扣数据 |

View File

@@ -1,443 +0,0 @@
# 卡流量采集写入与扣减技术方案
| 项目 | 内容 |
|---|---|
| 日期 | 2026-10-28 |
| 状态 | 技术方案已确认,2026-10-28 按评审意见修订(T18–T25,T6 照搬老系统、按配置的重置日),**代码尚未修改** |
| 前置方案 | `docs/卡流量计算技术方案-参考老卡管.md`(第 2 版):日快照、按重置日换算、重置日防旧值、按月对账、切换水位。本方案在它的基础上补齐"读数写入前的过滤""采集留痕""扣减口径""换卡""冲正" |
| 对应 Change | `add-card-traffic-queue-and-observed-deduction`(基于已实现的 `rebuild-card-traffic-metering-and-stop-resume`) |
| 范围 | 测试环境 |
---
## 1. 结论
1. **核心问题是多扣和少扣。** 现在的卡表基线算法(`last_gateway_reading_mb` + 重置窗口)一个读数就直接生效:暴涨立即扣、窗口内抖动重复扣、延迟重置整月不扣。灰度路径(日快照 + 本月最大值 − 已结算)解决了重置相关的问题,但没有过滤异常点,而且当天快照取最大值,暴涨会被留下来。
2. **本方案:**
- 读数先进 **Redis 队列**,队列内读数不下降(允许小容差)才写**最后一个点**,异常点在写入前拦下。
- **一次性全量切换**为日快照 + 本月最大值 − 已结算,卡表基线算法、灰度名单、切换水位下线。
- 有套餐的卡**每次采集都留记录**,日快照读数为 0 也写;没套餐的卡只在读数 > 0 时写日快照。
- 扣减保持**增量扣减**,扣费按**采集时刻**确定套餐,日期按采集时间算。
- 扣错了走**人工冲正流水**;初期不做自动对账,由人工排查。
### 1.1 已定决策(2026-10-28)
| # | 事项 | 决定 |
|---|---|---|
| T1 | 无效读数 | 接口失败、限流、缺字段、`used` 为 null / 空 / 解析失败、负数、NaN:不入队、不写快照;`FlexFloat` 要能区分"没值"和 0 |
| T2 | 队列存储 | Redis,每卡一个 key,Lua 原子更新 |
| T3 | 队列长度 | N = 3,可配置 |
| T4 | 写入条件 | 队列满 N 个点,相邻两段都不下降(回退在容差内算持平),写**最后一个点**(即本次采集的点);新点不在队尾时不写 |
| T5 | 跨天清空 | 可配置开关 `traffic.queue.clear_on_new_day`,默认打开;打开时新点与队列里的点不在同一天(上海时间),先清空再入队 |
| T6 | 重置日 | 照搬老系统(移动 `YidongDayFlowService`、联通 `PtLiantongDayFlowService`),只把重置日换成通道配置的重置日:按配置的重置日换算成自然月累计;重置日当天全天防旧值(读数 > 比较基准 × 0.9 按 0 计;比较基准为 0 时当天任何正读数都按 0 计,不论配置的重置日是几号),不按小时区分,不设多日警惕期;重置日当天没取到有效数据,第二天起照常按新周期计;重置日早上的旧周期读数不记回前一天,月末尾巴丢失 |
| T7 | 路径统一 | 测试环境一次性全量切换,不再灰度:所有卡走日快照 + 本月最大值 − 已结算;卡表基线算法、`TrafficCalcPolicy`、切换水位下线;切换时所有卡打"待建起点"标记(见 6.2) |
| T8 | 写入范围 | 有套餐:每次采集写采集记录;日快照读数为 0 也写;套餐日用量每天一行,没扣也写 0。没套餐:不写采集记录,读数 > 0 才写日快照 |
| T9 | 没套餐期间的用量 | 记为已结算,之后买了套餐**不补扣** |
| T10 | 扣减方式 | 增量扣减;按采集时刻生效的套餐扣,套餐到扣费时已用完或到期也照扣 |
| T11 | 停复机判断 | 通道阈值只在**写入的点**上判断,被队列拦下的点不参与 |
| T12 | 其他来源 | 人工刷新、激活前结算与轮询进同一个队列,规则一致(没有运营商流量推送);人工刷新返回本次读数、是否写入、原因 |
| T13 | 当天没有写入 | 不补写日快照,靠告警发现 |
| T14 | 换卡 | 换卡完成时给新卡打"待建起点"标记;新卡第一次**通过队列写入**时建起点(本月最大快照全部记为已结算,不扣费),之后正常扣 |
| T15 | 扣错 | 人工冲正,走冲正流水 |
| T16 | 对账 | 初期不做,人工排查 |
| T17 | "写入值 ≥ 上次写入值" | 不单独判断:日快照取最大值、增量 = 最大值 − 已结算,小的值本来就不产生增量 |
| T18 | "有套餐"口径 | 采集侧(写入范围、是否发事件、采集记录)与扣费侧用同一个"采集时刻有效套餐"判断,**已用完但仍在有效期内的套餐算有套餐**(见 6.1) |
| T19 | 按采集时刻扣费的边界 | 只对当前"生效中"的套餐做状态迁移;停机按当前状态判断;扣费时一个套餐都查不到按成功跳过;采集后套餐流量重置的,扣在重置后的周期(见 8.1) |
| T20 | 读数 0 | 读数校验放宽为"有限且 ≥ 0",有没有套餐决定 0 写不写,不再由读数校验拦(见第 6 节) |
| T21 | 采集间隔上限 | 留 2 次冗余:开关打开时间隔 ≤ 24h ÷ (N+2),关闭时 ≤ `max_point_age_hours` ÷ (N+2)(见 4.4) |
| T22 | 回退容差 | 相邻两点回退 ≤ `traffic.queue.drop_tolerance_mb`(默认 1 MB)算持平;重置等大幅下降仍被拦下 |
| T23 | 冲正 | 锁套餐行、按当前值加减;退回后已用量不能小于 0;提交后走停复机判断,不直接复机(见第 11 节) |
| T24 | 日期 | 整条链路的日期统一用上海日期(`cardflow.ShanghaiDate`),包括消费者的日流量缓冲 key |
| T25 | 套餐日用量 0 行 | 在观测事务里写:当天第一次写入时,为采集时刻的每个有效套餐确保当天一行 |
---
## 2. 整体流程
```
运营商接口
│
├─ ① 解析读数 ──────────── 无效:记采集记录(有套餐的卡),结束
│
├─ ② Redis 队列(Lua 原子):跨天清空(可配)→ 丢弃过期点 → 入队 → 只留最后 N 个
│
├─ ③ 写入判断:新点在队尾、队列满 N 个点且不下降(容差内)→ 写最后一个点 ── 不满足:记采集记录(有套餐的卡),结束
│
├─ ④ 数据库事务(卡级行锁)
│ 有"待建起点"标记:写快照 → 本月最大全部记为已结算 → 清标记,结束(第 10 节)
│ 换算月累计(重置日防旧值)→ 写日快照(取最大)
│ → Δ = 本月最大快照 − 本月已结算 → 记已结算
│ → 采集时刻有套餐:确保当天套餐日用量行(T25);Δ > 0 时写流量事件(outbox,带采集时间、采集 ID)
│ → 通道阈值判断(只在写入的点上)
│ → 写采集记录(有套餐的卡)
│
└─ ⑤ 异步消费流量事件
按采集时刻生效的套餐,按优先级扣减(容量按虚流量阈值)
→ 套餐日用量 upsert(日期 = 采集日期)→ 扣费审计 → 套餐用完触发停机
```
---
## 3. 读数解析
| 运营商返回 | 入队 | 写快照 | 采集记录(有套餐的卡) |
|---|---|---|---|
| 接口失败、超时、非 200 | 否 | 否 | 记,`result = failed` |
| 限流 | 否 | 否 | 记,`result = rate_limited` |
| 响应缺 ICCID 等必要字段 | 否 | 否 | 记,`result = invalid_payload` |
| `used` 为 null、空、无法解析 | 否 | 否 | 记,`result = no_reading`,读数为空 |
| 负数、NaN、Inf | 否 | 否 | 记,`result = invalid_reading` |
| ≥ 0 的数 | 是 | 看第 5 节 | 记 |
**改动**:`internal/gateway/models.go:55` 的 `FlexFloat` 目前把 null、空、无法解析都转成 0。改为带"是否有值"的类型(或 `*float64`),流量查询结果据此区分"没值"和真实的 0;其他使用 `FlexFloat` 的字段保持原行为。
新类型的**零值必须表示"没值"**:响应里完全没有 `used` 字段时,JSON 解析不会调用 `UnmarshalJSON`,字段停在零值。用 `*float64`(nil)或带 `Valid` 标志的结构(`Valid = false`)都满足;不能用零值为 0 的类型。
---
## 4. 采集队列(Redis)
### 4.1 存储
| 项 | 内容 |
|---|---|
| key | `traffic:queue:{card_id}` |
| 值 | 最近 N 个点,每个点 = 采集时间(Unix 毫秒)+ 运营商原始读数(MB,原样,不换算) |
| 过期 | 每次更新后设为第二天 01:00(上海时间);跨天清空关闭时设为 `max_point_age_hours` 之后 |
| 内存 | 每卡约 150 字节,100 万张卡约 150 MB |
队列只是写入前的过滤器,**不是账**:
- key 丢了(Redis 重启、淘汰)→ 队列从空开始,晚写 N−1 次,不多扣也不少扣。
- 入队成功、后面的数据库事务失败 → 队列里多一个点,下次采集重新判断即可。
Redis 淘汰策略:测试环境已确认为 `noeviction`、未设 `maxmemory`,队列不会被淘汰;内存需随卡数观察。
### 4.2 Lua 脚本
入参:`card_id`、新点(采集时间、读数)、`N`、`clear_on_new_day`、`max_point_age_ms`、过期时间。上海日期由 Go 侧算好传入(每个点存日号),Lua 里不做时区换算。
```
points = 读出队列
if clear_on_new_day and points 非空 and 上海日期(points 最后一个点) != 上海日期(新点):
points = []
if max_point_age_ms > 0:
丢掉 points 中 新点时间 − 点时间 > max_point_age_ms 的点
按采集时间把新点插入 points(同一时间的点去重)
只保留最后 N 个
写回,设置过期
return points, 新点是否在队尾
```
按采集时间插入,保证同一张卡同时有两次采集(轮询 + 人工刷新)时顺序正确;观测事务本身仍然加卡级行锁。
**乱序**:新点插入后不在队尾(比队列里已有的点更早到达顺序靠后),或者插进去就被裁掉,本次**不写**,`decision = out_of_order`。队尾的点之前已经判断过,再写一次虽然不影响账(GREATEST + 增量公式),但会让这条采集记录看起来写了自己的读数,排查时误导。
### 4.3 配置项
放在系统配置里,读法与沉默期总开关 `package.silence.enabled` 相同(`internal/application/packagesilence/settings.go`,带短缓存),改了不用重新发布。
| key | 默认 | 说明 |
|---|---|---|
| `traffic.queue.size` | 3 | 队列长度 N,最小 2 |
| `traffic.queue.clear_on_new_day` | true | 跨天是否清空 |
| `traffic.queue.max_point_age_hours` | 72 | 队列里超过这个时长的点,入队时丢掉;0 表示不限 |
| `traffic.queue.drop_tolerance_mb` | 1 | 相邻两点回退不超过这个值算持平;0 表示不允许回退 |
### 4.4 跨天清空开关
| | 打开(默认) | 关闭 |
|---|---|---|
| 队列里的点 | 只有当天的 | 可以跨天,最长 `max_point_age_hours` |
| 每天首次写入 | 当天攒满 N 个点 | 跨天后的新点进来就能判断 |
| 前一天最后一段用量 | 算进第二天第一次写入 | 跨天的点能直接写 |
| 采集间隔上限 | **间隔 ≤ 24h ÷ (N+2)**(N=3 时为 4.8 小时) | 间隔 ≤ `max_point_age_hours` ÷ (N+2)(默认为 14.4 小时) |
关闭时的重置日:队列里存的是原始读数,重置时读数大幅下降、斜率为负,自然被拦下,新周期攒满 N 个不下降的点后才写,不会把新旧周期混着写。
`max_point_age_hours` 的作用:卡停用几天后重新采集时,不用几天前的旧点来确认当前读数是否稳定。
**采集间隔上限**:理论最低要求是窗口内采到 N 次(开关打开时 24h ÷ N),但这样一次失败或限流,当天就写不进去,T13 告警会常态触发。上限按 N+2 次算,留 2 次冗余。
间隔还决定每天第一次写入的时间:开关打开时,当天第一次写入在第一次采集之后 (N−1) × 间隔(N=3、间隔 4.8 小时,约为当天第一次采集后 9.6 小时)。这之前的用量(含前一天最后一段)都在这次写入时一起扣,停机也在这时才判断。需要更及时的扣费和停机时,应缩短间隔,而不是只满足上限。
**采集间隔校验**:只校验**启用中**的轮询配置,停用的不管。保存(启用状态)或启用轮询配置时,流量采集间隔超过当前开关下的上限直接报错;`traffic.queue.size` 或开关修改时,同样校验现有启用中的轮询配置,不满足的拒绝保存。测试库启用中的只有 id 29(间隔 60 秒,满足上限);id 25(86400 秒)是停用的,不拦,以后要启用时再按上限调小。
---
## 5. 写入判断
同时满足以下条件,写**最后一个点**(即本次采集的点):
1. 新点在队尾(4.2 乱序);
2. 队列满 N 个点;
3. 相邻两点都不下降:后一个读数 ≥ 前一个读数 − `drop_tolerance_mb`。
- 只比较先后读数,与采集间隔是否均匀无关。
- 持平也写:有套餐但没用流量的卡能写入 0 用量。
- **容差**:日快照取当天最大值、增量 = 最大值 − 已结算,小幅回退的点写进去也不产生增量,所以容差不会造成多扣;它只是避免读数在几 KB 内来回跳时整窗不写。重置时的大幅下降远超容差,照样被拦下。
N=3、容差 1 MB 的例子:
| 队列 | 结果 |
|---|---|
| 100, 150, 200 | 写 200 |
| 100, 100, 100 | 写 100 |
| 100, 150, 120 | 不写 |
| 100, 99999, 120 | 不写(中间点暴涨被拦下) |
| 100, 110, 99999 | 写 99999(最后一个点暴涨挡不住,见第 14 节) |
| 100.002, 100.001, 100.003 | 回退 0.001 在容差内,写 100.003 |
| 3010, 20, 25(重置) | 不写 |
| 20, 25, 30(新周期) | 写 30 |
不写时,原因记进采集记录(第 7.1 节)。
---
## 6. 写入:日快照与结算
沿用灰度路径(`internal/application/cardobservation/traffic_flow.go`),改动见下。
**读数校验放宽**:`cardflow.ValidReading` 现在要求读数 > 0,`writeSnapshot` 与 `writeUnswitchedSnapshot` 都以它为前提,读数为 0 的点写不进快照,与 T8 冲突。改为"有限且 ≥ 0"(与第 3 节的入队条件一致);没套餐的卡读数为 0 不写快照,由 6.1 的写入范围判断,不再由读数校验拦。读数 0 在重置日不触发防旧值(`isStale` 对 ≤ 0 直接返回否),换算结果不变。
1. **换算**:`cardflow.Convert`,按卡所属通道的重置日把**被写入那个点**的读数换算成自然月累计;重置日当天读数超过比较基准 × 0.9 按 0 计(防旧值)。日期取被写入那个点的采集日期。
2. **写日快照** `tb_card_flow_day`:upsert,`month_used_mb` 取当天最大(`GREATEST`)。
3. **结算**:`Δ = 本月最大快照 − 本月已结算`,Δ > 0 记入当天 `settled_mb`。
4. **事件**:采集时刻卡有套餐(6.1.1)且 Δ > 0,同事务写流量事件(outbox),事件带采集时间和采集 ID。
5. **停复机**:通道阈值只在这里判断(被队列拦下的点不判断)。
### 6.1 写入范围
| | 有套餐 | 没套餐 |
|---|---|---|
| 被写入的点读数为 0 | 写日快照 | 不写 |
| 被写入的点读数 > 0 | 写日快照 | 写日快照 |
| 套餐日用量 | 每个有效套餐(6.1.1)每天一行,没扣也写一行 0 | — |
| Δ | 发事件扣费 | 只记已结算(T9) |
**套餐日用量 0 行在观测事务里写**(T25):扣减消费者只在 Δ > 0 时才运行,没用流量的卡走不到那里。所以在观测事务里,当天第一次写入时,为采集时刻的每个有效套餐 `INSERT ... ON CONFLICT (package_usage_id, date) DO NOTHING` 一行 0;之后扣减按 8.2 累加。由 `usage_service` 提供方法,观测事务调用,不在 cardobservation 里直接写套餐表。
#### 6.1.1 "有套餐"口径(T18)
采集侧(写入范围、是否发事件、是否写采集记录)与扣费侧(8.1 查套餐)用**同一个函数**判断"采集时刻有效套餐":
| 条件 | 说明 |
|---|---|
| 状态 | `status IN (生效中, 已用完, 已过期)`;待生效、已失效不算 |
| 已生效 | `activated_at ≤ observed_at`;`activated_at` 为空的历史数据按已生效处理(与现有口径一致) |
| 未到期 | `expires_at` 为空,或 `expires_at > observed_at` |
| 未退款 | `refund_id IS NULL` |
| 载体 | 卡自己的套餐;卡自己一个都没有时,看卡当前绑定设备的套餐 |
- **已用完但仍在有效期内的套餐算有套餐**:套餐用完到真正停机之间的用量照常发事件,扣费时进最后一个套餐的超出部分(T10)。现在 `countActivePackages`(`traffic_calc_policy.go:148`)只认"生效中"、不看 `expires_at`,这段用量被记为已结算、不发事件,`recordOverflowToDepletedPackage` 实际收不到流量,需要改。
- 已过期状态之所以纳入,是因为状态可能在采集之后才变为已过期;是否在采集时刻有效由 `expires_at` 判断。
- 待实名激活、沉默期等待中的套餐处于待生效状态,不算有套餐,期间用量按 T9 记为已结算。
### 6.2 路径统一
测试环境**一次性全量切换**,不再灰度(T7):
- 删掉 `TrafficCalcPolicy`(灰度名单)、卡表基线算法的扣费作用、第一次切换逻辑(`firstSwitch`)和切换水位(`tb_card_flow_switch` 不再读写)。
- **切换时所有卡打"待建起点"标记**(随迁移执行,与换卡同一个标记,第 10 节)。原因:切换前扣费走的是卡表基线算法,日快照上没有本月已结算(测试库 `tb_card_flow_day` 为 0 行);不打标记,切换后第一次写入的 Δ 会是运营商读数对应的整月用量,而这些用量老路径已经扣过。打了标记,每张卡切换后第一次写入只建起点、不扣费。
- 切换过程中老代码还在跑也安全:老代码不认标记,照常按老路径扣;新代码第一次写入只建起点,不会重复扣。
- 代价:老路径最后一次扣费到新路径建起点之间的用量不扣(约 N−1 个采集周期),测试环境可接受。
- 卡表上的 `current_month_usage_mb` 等展示字段由日快照覆盖(与现灰度路径一致);`last_gateway_reading_mb` 不再使用。
---
## 7. 留痕
### 7.1 采集记录(新表)
只记**有套餐的卡**,每次采集一条,包括接口失败和读数为 0。
| 字段 | 说明 |
|---|---|
| `id`、`iot_card_id`、`observed_at` | |
| `observation_id` | 采集 ID,与流量事件、扣费审计、对接日志串联 |
| `source`、`scene` | 轮询、人工刷新、激活前结算 |
| `result` | 读数是否有效:`ok` / `failed` / `rate_limited` / `invalid_payload` / `no_reading` / `invalid_reading` |
| `vendor_used_mb` | 运营商原始读数,可为空 |
| `queue_points` | 入队后的队列(jsonb:采集时间 + 读数) |
| `decision` | 读数有效之后怎么判断的,`result ≠ ok` 时为空:`written` / `baseline`(建起点)/ `queue_not_full` / `slope_negative` / `out_of_order` / `settled_existing`(激活前结算没写入,用已有本月最大快照结算) |
| `month_used_mb`、`stale_guarded` | 写入时才有:换算后月累计、是否触发防旧值 |
| `delta_mb`、`event_id` | 写入时才有:本次 Δ、流量事件 ID |
| `created_at` | |
- 按 `observed_at` 按月分区,保留 90 天,过期直接删分区(不用 `DELETE ... LIMIT`)。整月都过期才能删,所以同时存在 4 个分区(约 120 天的数据)。
- 数据量:每天行数 ≈ 有套餐的卡数 × 每天采集次数。例:10 万张卡、间隔 4.8 小时(每天 5 次),每天 50 万行,4 个分区约 6000 万行。
- 索引:`(iot_card_id, observed_at DESC)`、`observation_id`。
### 7.2 排查路径
```
扣费审计(package_usage.deduct_traffic:扣了哪个套餐、多少、前后值)
→ parent_event_id → 流量事件(卡、增量、采集时间、采集 ID)
→ observation_id → 采集记录(当时的原始读数、队列、判断结果)
→ 卡 + 日期 → 日快照(当天最大值、已结算)
```
---
## 8. 扣减
扣减逻辑沿用 `internal/service/package/usage_service.go` 的 `DeductDataUsage`:小数部分攒在 Redis;加油包优先,再按 `priority`、`id`;每个套餐容量 = 阈值 − 已用(启用虚流量时阈值为虚总量,否则为真总量);最后一个套餐承接超出部分;全部用完触发停机。
改动两点:
### 8.1 按采集时刻确定套餐
- 流量事件已带 `observed_at`,消费者把它传给扣减。
- 查套餐由"当前 `status = 生效中`"改为"**采集时刻处于生效期内**":已激活、`activated_at ≤ observed_at`、未到期(`expires_at > observed_at`)、未退款、未作废;设备回退口径不变。
- 查询条件与 6.1.1 共用同一个函数,采集侧和扣费侧不会出现"采集说有、扣费说没有"(采集后退款、作废的情况除外,见下)。
- 采集时刻生效、但扣费时已用完或已到期的套餐**照扣**(T10)。
- 排序、容量、超出承接规则不变。
**状态迁移只作用于当前"生效中"的套餐**(T19)。现有循环(`usage_service.go:124-166`)在余量 ≤ 0 或扣到阈值时把套餐改成"已用完"。查询范围扩大后,集合里会有已过期、已用完的套餐,按现有写法会把"已过期"改回"已用完"。改为:
| 套餐当前状态 | 累加 `data_usage_mb`、写日用量 | 改状态 |
|---|---|---|
| 生效中 | 是 | 扣到阈值改为已用完(同现在) |
| 已用完 | 是(只会作为最后一个套餐承接超出) | 否 |
| 已过期 | 是 | 否 |
- 余量 ≤ 0 且不是最后一个套餐的,跳过;只有当前是"生效中"的才顺带改为已用完。
- 停机判断 `checkAndTriggerSuspension` 不变,仍按**当前**状态判断是否还有可用套餐。
**扣费时一个套餐都查不到**:采集时刻有套餐(所以发了事件),扣费前该套餐被退款或作废。现在 `DeductDataUsage` 返回"没有可用套餐"错误,消费者会一直重试。改为:记告警日志和扣费审计(标明跳过原因),事件按成功处理,不重试;该笔用量需要时走人工冲正。
**采集之后套餐流量被重置**:日/月重置任务(`reset_service.go`)把 `data_usage_mb` 清零。采集时刻早于套餐 `last_reset_at`、扣费在重置之后的,**扣在重置后的周期**,不跳过(跳过就是少扣)。审计里记录 `observed_at` 与 `last_reset_at`,排查时能看出这笔是跨重置扣的。列入第 14 节已知限制。
### 8.2 套餐日用量
- `updateDailyRecord` 由"先查后插"改为 upsert(`(package_usage_id, date)` 唯一),`daily_usage_mb` 累加、`cumulative_usage_mb` 覆盖。
- `date` 取采集日期(`cardflow.ShanghaiDate(observed_at)`),不取扣费时间。
- 消费者的日流量缓冲 key(`event.go` 里 `RedisCardDailyTrafficKey(..., event.ObservedAt.Format(...))`)同样改为上海日期;现在直接 `Format`,时间值是 UTC 时上海 0~8 点会落到前一天(T24)。
---
## 9. 其他读数来源
| 来源 | 处理 |
|---|---|
| 轮询 | 第 2~6 节 |
| 人工刷新 | 同一个队列、同一套规则;接口返回本次读数、是否写入、不写原因 |
| 激活前结算 | 读数同样过队列;本次没写入时,用卡上已有的本月最大快照做结算(把它全部记为已结算),不新写快照,`decision = settled_existing` |
激活前结算没写入时,"最近一次写入到激活"之间的用量会算给新套餐,精度到写入粒度(第 14 节)。
---
## 10. 换卡
套餐使用记录随换卡整条迁到新卡(`internal/service/exchange/migration.go:103`),已用量跟着走,所以不需要老系统那套"旧卡流量补算"(`tbl_new_card_add_flow`)。需要处理的是新卡的起点:
| 问题 | 例子 | 处理 |
|---|---|---|
| 新卡换卡前已有读数 | 出库前测试用了 50 MB,库存期间没轮询 | 新卡**建起点**(下文),这 50 MB 不扣 |
| 旧卡最后一段 | 最后一次采集 10:00,换卡完成 10:30 | 不处理:旧卡之后的采集按没套餐处理,记为已结算(与 T9 一致) |
**建起点**:
1. 换卡事务内,在新卡上打"待建起点"标记(`tb_iot_card` 新增标记字段),不在换卡流程里调用运营商接口。
2. 新卡的读数照常过队列。**第一次满足写入条件**时,看到标记:写日快照,把新卡本月最大快照全部记为已结算,不发事件,清掉标记,写采集记录(`decision = baseline`)。
3. 之后按正常规则处理。
建起点也过队列,是为了不让一个异常读数当起点:第一个读数是 99999 时如果直接当起点,本月最大值一直是 99999,之后整月增量为 0、不扣。
N=3、间隔 1 小时的例子:
| 时间 | 事件 | 读数 | 队列 | 处理 |
|---|---|---|---|---|
| 10-20 10:00 | 换卡完成 | — | — | 打标记 |
| 10-20 11:00 | 采集 | 52 | 52 | 不满,不写 |
| 10-20 12:00 | 采集 | 53 | 52, 53 | 不满,不写 |
| 10-20 13:00 | 采集 | 55 | 52, 53, 55 | 写 55,建起点:55 记为已结算,不扣,清标记 |
| 10-20 14:00 | 采集 | 58 | 53, 55, 58 | 写 58,扣 3 |
代价:换卡完成到建起点之间的用量不扣(比直接用第一个读数多 N−1 个采集周期),与"旧卡最后一段不补扣"口径一致。
设备换货时,设备套餐迁到新设备,新设备绑定的每张卡同样打标记。
---
## 11. 人工冲正
新增冲正流水表,冲正只通过它进行,不直接改套餐字段。
| 字段 | 说明 |
|---|---|
| `id`、`package_usage_id`、`iot_card_id` / `device_id` | |
| `delta_mb` | 正数补扣,负数退回 |
| `before_usage_mb`、`after_usage_mb` | 套餐已用量前后值 |
| `reason` | 必填 |
| `ref_observation_id`、`ref_event_id` | 关联的采集记录、流量事件,可选 |
| `operator_id`、`created_at` | |
处理:
1. 事务内先 `SELECT ... FOR UPDATE` 锁套餐行(与扣减同一把锁),按**锁住后的当前值** + `delta_mb` 计算新值,`before_usage_mb` 取锁住后的值。避免与扣减并发时互相覆盖。
2. 退回后已用量小于 0 直接报错,不截成 0。
3. 同一事务内写流水、更新套餐 `data_usage_mb`,按阈值重新计算状态(已用完 ↔ 生效中);已过期、已失效的套餐只改已用量,不改状态(与 8.1 一致)。
4. 写审计(新动作码,例如 `package_usage.adjust_traffic`)。
5. 提交后调用现有停复机判断(`evaluator.EvaluateAndAct`),**不直接复机或停机**:卡可能同时因为实名、沉默期、人工等原因停着,冲正只改变了"套餐用完"这一个条件,由停复机判断综合决定。
6. 卡上的日快照与已结算不改,冲正只作用在套餐上。
---
## 12. 监控与告警
| 指标 | 告警条件 |
|---|---|
| 有套餐的卡一整天没写入 | 开启轮询的卡,当天采集记录里没有 `decision = written`(T13) |
| 队列拦截率 | 某张卡当天 `slope_negative` 占比过高,或全局拦截率突增 |
| 待建起点 | 开启轮询的卡,"待建起点"标记超过 1 天未清除(一直没写入);不轮询的卡不告警 |
| 乱序 | `out_of_order` 数量突增(排查并发采集或时钟问题) |
| 防旧值命中 | 重置日命中数,便于核对重置日配置 |
---
## 13. 改动清单
| 位置 | 改动 |
|---|---|
| `internal/gateway/models.go` | 流量读数区分"没值"和 0,零值表示没值 |
| `internal/task/polling_carddata_handler.go` | 无效读数分类;调用队列;写采集记录 |
| 新增队列组件(Redis + Lua)、配置项注册 | 第 4 节;返回新点是否在队尾;写入判断(不下降 + 容差、写最后一个点) |
| `internal/application/cardobservation/traffic.go`、`traffic_flow.go` | 按 6.1 的写入范围;确保套餐日用量 0 行;通道阈值只在写入点判断;删除未切换分支、`firstSwitch` 与切换水位 |
| `internal/domain/cardobservation/traffic.go` | 基线算法下线 |
| `internal/application/cardobservation/traffic_calc_policy.go` | `hasActivePackageForCard` 改为调用 6.1.1 的共用判断(含已用完、按 `expires_at`、排除退款);删除灰度名单 |
| `internal/domain/cardflow/rules.go` | `ValidReading` 放宽为"有限且 ≥ 0";`isStale` 去掉 `ThisMonthBeforeResetMax > 0` 条件,比较基准为 0 时不论配置的重置日是几号都照常防旧值;调用方注释同步 |
| `internal/infrastructure/cardobservation/event.go` | 把 `observed_at` 传给扣减;日流量缓冲 key 用上海日期 |
| `internal/service/package/usage_service.go` | 按采集时刻查套餐(与采集侧共用判断);状态迁移只作用于当前生效中的套餐;查不到套餐按成功跳过;`updateDailyRecord` upsert,日期按采集日期(上海);提供"确保当天日用量行"方法供观测事务调用 |
| 人工刷新、激活前结算入口 | 过队列;人工刷新返回写入结果 |
| `internal/service/exchange` | 换卡完成时给新卡打"待建起点"标记 |
| 观测写入 | 有"待建起点"标记时,第一次满足写入条件时建起点并清标记 |
| 轮询配置保存/启用、队列配置保存 | 采集间隔上限校验(24h 或 `max_point_age_hours` ÷ (N+2)),只校验启用中的配置 |
| 新表 + 迁移 | 采集记录(按月分区)、冲正流水;`tb_iot_card` 待建起点标记,迁移时**所有卡置为待建起点**(6.2);`(package_usage_id, date)` 唯一索引 |
| 后台接口 | 采集记录查询、冲正(锁套餐行、不允许负数、提交后走停复机判断) |
---
## 14. 已知限制
| 限制 | 说明 |
|---|---|
| 最后一个点暴涨 | 队列挡不住,靠人工冲正 |
| 运营商连续返回错误且递增 | 队列挡不住,靠人工冲正 |
| 运营商整体抬高、事后下调 | 队列挡不住;初期靠人工,后期可接运营商 T+1 数据对账 |
| 月末尾巴 | 最后一次**写入**的旧周期读数到运营商归零之间的用量丢失,与老系统一致 |
| 防旧值比较基准为 0 | 没有快照可比(新卡、之前没写入)时,配置的重置日当天任何正读数都按 0 计,次日如实写入;只会晚一天扣,不会多扣 |
| 运营商跨天才归零 | 防旧值只管重置日当天(T6,不设警惕期);运营商晚一天才清零、重置日当天没取到数后第二天拿到旧数、清零后又返回缓存旧数,这些读数都会被当成新周期用量,造成多扣 |
| 写入粒度 | 激活前结算、换卡、没套餐 → 有套餐的切换点,精度都到"最近一次写入" |
| 晚写 | 队列需要攒满 N 个点,读数回退超过容差时会一直不写,扣费与停机相应延后 |
| 切换和换卡的起点 | 切换、换卡后到建起点之间的用量不扣(约 N−1 个采集周期) |
| 跨套餐流量重置 | 采集在套餐重置前、扣费在重置后的,扣在重置后的周期 |
| 采集后退款、作废 | 扣费时查不到套餐,跳过不扣,需要时人工冲正 |
---
## 15. 后续
- **对账**:接入运营商 T+1 日用量或月账单后,定期从日快照(或运营商数据)重算,与套餐已用量比对;多扣自动退回、少扣告警,调整同样走冲正流水。
- **月末尾巴**:重置日把旧周期读数的增量补上(旧周期读数 ≥ 上次旧周期读数时,差额计入本月),可选。

View File

@@ -1,150 +0,0 @@
# 测试环境导入生产数据验证流量计量技术方案
| 项目 | 内容 |
|---|---|
| 日期 | 2026-10-29 |
| 状态 | 方案已确认(2026-10-29),代码未修改 |
| 范围 | 测试环境 |
| 关联 | `docs/卡流量采集写入与扣减技术方案.md`、Change `add-card-traffic-queue-and-observed-deduction`、`docs/测试环境生产卡只读模式清理顺序.md` |
## 1. 背景与目标
生产(迁移版本 240,仍是卡表基线算法)大量卡"卡住":卡表基线 `last_gateway_reading_mb` 高于运营商读数,读数被当成下降拒绝,套餐长期扣 0。2026-10-29 核对:
- 生产有生效套餐、开启轮询、本月用量为 0、基线大于 0 的卡:1632 张(独立卡 1233 张)。
- 其中 916 张在 flowcollector 10-26 起的读数里持续上涨、一直未扣,合计约 1635 GB。
- 单卡导入测试环境已验证新算法可解冻并按增量扣费(测试卡 12244、12245),但单卡无法说明全量差距。
目标:把生产的卡、设备与套餐数据导入测试环境,用新计量逻辑(采集队列、日快照、建起点、按采集时刻扣减)跑真实读数,算出全量差距;**全过程不得对真实卡、设备发起任何写操作(停复机、限速、实名认证、设备指令)**。
不做:订单、钱包、分佣、退款、审批、个人客户、通知等业务流程的验证;生产发布与回滚。
## 2. 数据规模(2026-10-29)
| 项 | 生产 | 测试 |
|---|---|---|
| 卡 | 98,567(开启轮询 90,073,独立卡 59,900) | 33 |
| 设备 | 20,144(有效绑定 38,667) | 6 |
| 生效中套餐 | 22,426(其中设备套餐 10,799) | — |
| 待生效 / 生效中 / 已用完套餐 | 29,175 | 128(全部状态) |
| 使用中的运营商通道 | 34 | 13 |
| 迁移版本 | 240 | 268 |
## 3. 方案总览
```
① 建只读表 + 开关上线(测试环境)
② 生产数据 → 只读表(全部 ICCID 与设备 IMEI)
③ 确认部署生效、只读表已写入
④ 导入卡、设备、绑定、套餐(测试库),全部卡打"待建起点"
⑤ 轮询按新逻辑采集 → 采集记录、日快照、套餐扣减
⑥ 差距报表:新逻辑应扣 vs 生产已扣
⑦ 验证结束后按清理文档撤除
```
必须先 ①②③ 再 ④:导入的卡在测试环境一开始没有套餐或状态不一致,会立即触发停复机判断,此时只读保护必须已生效。
## 4. 只读表与开关
### 4.1 表 `tb_gateway_readonly_card`
| 字段 | 说明 |
|---|---|
| `id` | 主键 |
| `identifier` | 网关请求里的卡号 `cardNo`:卡为 ICCID,设备指令为设备 IMEI;大写;唯一索引 |
| `identifier_type` | `iccid` / `imei` |
| `source` | 来源,如 `prod_import_20261029` |
| `remark` | 备注 |
| `created_at` | 写入时间 |
- 新的成对迁移建表;不写入数据(数据是测试环境专用,由导入脚本写入)。
- 不使用数据库外键。
### 4.2 开关 `JUNHONG_GATEWAY_READONLY_ENABLED`
只读表里的卡与设备**只允许查询类调用**,其余一律不允许:
| 允许 | 接口 |
|---|---|
| 查卡状态 | `/flow-card/status` |
| 查实名 | `/flow-card/realName` |
| 查流量 | `/flow-card/flow` |
| 查设备信息 | `/device/info` |
| 查设备槽位 | `/device/slot-info` |
| 同步设备状态 | `/device/sync-info` |
- `true`:网关客户端每次调用前,接口不在上表时,按请求卡号(`cardNo`)查表,命中则本地拦截、不发请求,返回明确失败(不是结果未知),记日志 `gateway.readonly_card.blocked`。上表接口不查表、直接放行。
- 被拦截的包括但不限于:停机 `/flow-card/cardStop`、复机 `/flow-card/cardStart`、限速 `/flow-card/speedLimit`、实名认证链接 `/flow-card/RealNameVerification`,以及设备切卡、恢复出厂、重启、WiFi 配置、模式切换。
- 查表失败(含超时、表不存在)按拦截处理:宁可调用失败,不放过真卡。
- 不做内存缓存:非查询类调用本身不频繁,按唯一索引查表约 1 ms;表一写入立即生效,没有刷新空窗。
- 未配置或为 `false`:不查表。生产不配置。
- 替换现有 `JUNHONG_GATEWAY_READONLY_ICCIDS`;名单内现有两张卡(`89861590172420410612`、`89861590172420420169`)迁入表中。
### 4.3 拦截覆盖面
所有网关调用都经过 `internal/gateway/client.go` 的 `doRequest` / `doRequestWithoutPayloadLog`,在这里判断即可覆盖所有入口:自动停复机(轮询、流量扣减、通道阈值、套餐激活与到期、订单、退款)、人工停复机按钮、限速档位、实名认证链接、设备各类接口等。业务代码不做任何改动。
## 5. 数据导入
### 5.1 范围(2026-10-29 已确认:全量)
| 数据 | 导入内容 |
|---|---|
| 运营商通道 | 不导入;按 `carrier_code` 映射到测试库已有通道,测试库缺的通道按生产配置补建(含重置日) |
| 卡 | 生产全部卡(98,567):ICCID、ICCID19/20、MSISDN、虚拟号、通道、卡类别、实名状态、网络状态、实名策略、轮询开关;店铺统一挂测试代理 9「测试采购部」 |
| 资产标识 | 每张卡的 ICCID、虚拟号写入 `tb_asset_identifier` |
| 设备与绑定 | 生产全部设备(20,144):设备号、IMEI、SN、槽位与有效绑定关系;店铺统一挂测试代理 9「测试采购部」 |
| 套餐使用记录 | 卡套餐与设备套餐中待生效、生效中、已用完的记录(约 29,175 条),保留生产的开始(生效)时间、结束(到期)时间、真总量、虚总量、重置周期与下次重置时间、优先级与主套餐 / 加油包关系;**只保存真实已用量**(`data_usage_mb`,导入时刻的生产当前值;待生效套餐本来为 0)。虚流量已用量不存储,由真实已用量 × 展示倍率 `display_gain_ratio_snapshot` 现算(上限为真总量);虚总量、展示倍率、是否启用虚流量是套餐条款快照(停机线 = 启用虚流量时的虚总量,否则真总量),照原样复制;订单号用 `PROD-<原订单号>` 标记,订单 ID 用不存在的占位值;`package_id` 指向一个测试占位套餐 |
| 不导入 | 订单、钱包、分佣、退款、个人客户、审批、历史日用量与审计 |
### 5.2 方式
用导入脚本直接写测试库(后台导卡接口一次只能导卡,不能导设备与套餐,也不适合 10 万级数据):
1. 从生产只读导出(dbhub / 只读连接),生成 CSV。
2. 写只读表:全部 ICCID 与设备 IMEI。
3. 确认开关已部署(以 Gitea Actions 部署成功为准)且只读表行数与导出一致。
4. 按通道映射写卡、资产标识、设备、绑定、套餐;同 ICCID / IMEI 已存在则跳过并记录。
5. 全部导入的卡置"待建起点"(`traffic_baseline_pending = true`)。
6. 触发轮询重新加载(重启 Worker 或调用轮询初始化),使导入的卡进入调度。
导入脚本放在 `cmd/zz_tmp_*`,不提交;只允许连接 `junhong_cmp_test`。
### 5.3 建起点
每张卡在测试环境第一次通过队列写入时只建起点:把当时运营商读数对应的本月累计全部记为已结算,不扣费。之后按增量扣到导入的套餐上。因此测试环境算出的是"导入之后"的应扣,导入之前的差距由第 7 节用 flowcollector 读数补充。
## 6. 轮询
- **完全按测试环境现有的轮询配置与代码逻辑运行**,不为导入的卡、设备单独配置,也不改业务流程:查卡状态、查实名、查流量、查套餐照常执行;流量照常进采集队列、写日快照、建起点、按增量扣套餐;套餐照常激活、到期、重置;停复机判断照常执行并记录。
- 唯一不同:导入资产的网关调用只放行查询类(查卡状态、查实名、查流量、查设备信息、查设备槽位、同步设备状态),停机、复机等其他调用在网关出口拦截(第 4 节)。被拦截的停复机记为失败并按退避重试,本地卡状态不变;这些失败记录是预期的,也正好说明"按新逻辑该停/该复"的卡有哪些。
- 查卡状态、查实名放行后,测试环境的网络状态、实名状态跟随真实情况。
- 轮询频率不作为本方案的约束。
## 7. 差距报表
按套餐(设备套餐按设备合并)输出:
| 列 | 来源 |
|---|---|
| 卡 / 设备、套餐、生效与到期时间、真总量、虚总量 | 导入数据 |
| 导入时已用量 | 导入时刻生产套餐已用量(两边共同起点) |
| 测试环境新增扣减 | 测试环境套餐已用量 − 导入时已用量(建起点之后按新逻辑扣) |
| 导入前差距上限 | 本月运营商读数(重置日按通道配置换算)对生效后的上限,来自 flowcollector |
| 生产同期新增扣减 | 生产套餐已用量(同一时间点只读查询)− 导入时已用量 |
| 差额、是否已到停机线 | 计算 |
汇总:卡住卡数、总差额(GB)、按新逻辑已到停机线但生产未停的套餐数。
## 8. 清理
按 `docs/测试环境生产卡只读模式清理顺序.md` 执行:先处理测试环境里的生产卡 → 关开关并重新部署 → 新迁移删表 → 可选清理代码。导入的卡、设备、套餐与采集数据在第一步删除。
## 9. 已确认事项
已确认(2026-10-29):
1. 导入全量:卡、设备、绑定,以及卡套餐与设备套餐(含开始、结束时间)。
2. 套餐只保存真实已用量(导入时刻生产当前值),虚流量已用量不存储、由真实已用量 × 展示倍率现算。差距报表因此按"导入后测试环境新增扣减 vs 生产同期新增扣减"对比,两边起点一致。
3. 卡和设备全部挂测试代理 9「测试采购部」。之前单独导入的两张卡(测试卡 12244、12245,目前未挂店铺)一并挂过去。
4. 生产卡的 MSISDN、虚拟号等按原值导入,不脱敏。

View File

@@ -1,47 +0,0 @@
# 测试环境生产卡只读模式清理顺序
| 项目 | 内容 |
|---|---|
| 日期 | 2026-10-29 |
| 范围 | 测试环境 |
| 对应机制 | 网关只读卡表 `tb_gateway_readonly_card` + 环境变量 `JUNHONG_GATEWAY_READONLY_ENABLED` |
## 背景
为验证卡流量采集与扣减,把生产上"卡住"的卡导入测试环境。这些是真实用户的卡,测试环境只能查询,不能停复机。
- 只读卡表 `tb_gateway_readonly_card`:存放导入卡的 ICCID 与导入设备的 IMEI(`identifier` 唯一索引)。
- 环境变量 `JUNHONG_GATEWAY_READONLY_ENABLED=true` 时,网关每次写操作(停机、复机、限速、实名认证、设备指令)前按请求卡号查表:在表里就本地拦截、不发请求;查流量、卡状态、实名这三类查询不查表、直接放行。
- 查表失败按拦截处理(宁可停复机失败,不放过真卡)。
- 生产不配置该环境变量,代码不查表。
## 清理顺序(必须按顺序执行)
### 1. 先处理测试环境里的生产卡(保护仍在,安全)
- 把表里这些卡在测试环境的 `enable_polling` 设为 `false`,或删除卡及其测试套餐、采集记录、日快照。
- 确认这些卡已不在轮询队列中,也没有待执行的停复机指令。
> 顺序错误的后果:保护撤掉后这些卡仍在测试环境,一旦触发无套餐、套餐用完、套餐到期等停复机,会**真实停掉用户的卡**。
### 2. 关闭开关并重新部署
- 部署脚本去掉 `JUNHONG_GATEWAY_READONLY_ENABLED`(或设为 `false`),重新部署 API 与 Worker。
- 以 Gitea Actions 部署成功为准确认生效(不要用数据库连接建立时间判断,连接池每 5 分钟会重建连接)。
> 顺序错误的后果:开关开着就删表,查表报"表不存在"按失败处理,测试环境**所有卡**的写操作都会被拦截,直到关闭开关。
### 3. 删表
- 新建一对迁移:up 执行 `DROP TABLE tb_gateway_readonly_card`,down 重建该表。
- 不修改原建表迁移。
### 4. 清理代码(可选)
- 删除写操作前的查表逻辑与开关配置。开关关闭时这段代码不生效,不删也不影响。
## 其他说明
- 生产:不配置开关,不查表。若该版本发布到生产,建表迁移会建出空表,不影响功能,后续删表迁移随之执行即可。
- 数据量:几万行(ICCID + 备注),删表瞬间完成,无锁表风险。
- 留痕:拦截记录在日志、停复机记录与审计中,与该表无关,删表不影响已有记录。

2
go.mod
View File

@@ -48,7 +48,6 @@ require (
github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/go-sql-driver/mysql v1.8.1 // indirect
github.com/go-viper/mapstructure/v2 v2.4.0 // indirect
github.com/golang/snappy v0.0.1 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/jackc/puddle/v2 v2.2.2 // indirect
@@ -62,7 +61,6 @@ require (
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-runewidth v0.0.16 // indirect
github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 // indirect
github.com/nsqio/go-nsq v1.1.0 // indirect
github.com/patrickmn/go-cache v2.1.0+incompatible // indirect
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
github.com/philhofer/fwd v1.1.3-0.20240916144458-20a13a1f6b7c // indirect

4
go.sum
View File

@@ -106,8 +106,6 @@ github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9 h1:au07oEsX2xN0kt
github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9/go.mod h1:8vg3r2VgvsThLBIFL93Qb5yWzgyZWhEmBwUJWevAkK0=
github.com/golang-sql/sqlexp v0.1.0 h1:ZCD6MBpcuOVfGVqsEmY5/4FtYiKz6tSyUv9LPEDei6A=
github.com/golang-sql/sqlexp v0.1.0/go.mod h1:J4ad9Vo8ZCWQ2GMrC4UCQy1JpCbwU9m3EOqtpKwwwHI=
github.com/golang/snappy v0.0.1 h1:Qgr9rKW7uDUkrbSmQeiDsGa8SjGyCOGtuasMWwvp2P4=
github.com/golang/snappy v0.0.1/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
@@ -177,8 +175,6 @@ github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 h1:RWengNIwukTxcDr9
github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826/go.mod h1:TaXosZuwdSHYgviHp1DAtfrULt5eUgsSMsZf+YrPgl8=
github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A=
github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc=
github.com/nsqio/go-nsq v1.1.0 h1:PQg+xxiUjA7V+TLdXw7nVrJ5Jbl3sN86EhGCQj4+FYE=
github.com/nsqio/go-nsq v1.1.0/go.mod h1:vKq36oyeVXgsS5Q8YEO7WghqidAVXQlcFxzQbQTuDEY=
github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U=
github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM=
github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040=

View File

@@ -34,8 +34,6 @@ const (
OnlinePaymentStateClosed = "closed"
// OnlinePaymentStateUnknown 表示渠道状态暂时无法确定。
OnlinePaymentStateUnknown = "unknown"
// OnlinePaymentStateNotFound 表示渠道明确没有这笔订单;恢复任务在支付单过期后按关闭处理。
OnlinePaymentStateNotFound = "not_found"
)
// OnlinePaymentRequest 描述生成支付链接与主动查单所需的最小事实。

View File

@@ -139,12 +139,6 @@ func (s *RecoverOnlinePaymentService) queryPayment(ctx context.Context, adapter
return err
case OnlinePaymentStateClosed:
return s.closePending(ctx, payment, recharge)
case OnlinePaymentStateNotFound:
// 渠道没有这笔订单:过期后已不可能再支付,按关闭处理;未过期时可能尚未同步到渠道,继续等待。
if payment.ExpireAt != nil && time.Now().After(*payment.ExpireAt) {
return s.closePending(ctx, payment, recharge)
}
return nil
default:
return nil
}

View File

@@ -319,14 +319,6 @@ func (s *Service) writeRenewalFacts(ctx context.Context, tx *gorm.DB, plan *exec
if err != nil {
return err
}
if s.silence != nil {
if err := s.silence.ApplyOnUsageCreatedInTx(ctx, tx, usage, plan.pkg, now, constants.SilenceTriggerAutoPurchase); err != nil {
return err
}
if err := s.silence.RecordForOrderInTx(ctx, tx, order); err != nil {
return err
}
}
if err := commissiondelivery.AppendCommissionCalculate(ctx, tx, s.outbox, order.ID); err != nil {
return err
}

View File

@@ -92,18 +92,6 @@ type Service struct {
priorityEvents priorityapp.PriorityEventWriter
logger *zap.Logger
now func() time.Time
silence SilenceRecorder
}
// SilenceRecorder 在续购创建套餐记录的同一事务内设置沉默期与首次购买套餐时间(无人参与路径只用默认值)。
type SilenceRecorder interface {
ApplyOnUsageCreatedInTx(ctx context.Context, tx *gorm.DB, usage *model.PackageUsage, pkg *model.Package, paidAt time.Time, trigger string) error
RecordForOrderInTx(ctx context.Context, tx *gorm.DB, order *model.Order) error
}
// SetSilenceRecorder 注入沉默期与首次购买套餐时间写入接缝;未注入时不记录。
func (s *Service) SetSilenceRecorder(recorder SilenceRecorder) {
s.silence = recorder
}
// NewService 创建资产钱包自动续费用例。

View File

@@ -13,7 +13,6 @@ import (
"github.com/break/junhong_cmp_fiber/pkg/constants"
"github.com/break/junhong_cmp_fiber/pkg/errors"
"github.com/break/junhong_cmp_fiber/pkg/outboxid"
"go.uber.org/zap"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
@@ -74,53 +73,6 @@ type Service struct {
auditWriter StateAuditWriter
// channelThreshold 为可选的通道阈值判定能力;未注入时流量观测不做阈值判定。
channelThreshold ChannelThresholdEvaluator
// trafficQueue 是卡流量采集队列;未注入时流量观测报错。
trafficQueue TrafficQueuePort
// trafficQueueSettings 返回采集队列配置;未注入时使用代码默认值。
trafficQueueSettings TrafficQueueSettingsSource
// logger 记录采集记录写入失败等不影响观测结果的告警;未注入时不输出。
logger *zap.Logger
// commandGrace 返回停复机成功后的状态保护窗口;未注入时不保护。
commandGrace func(ctx context.Context) time.Duration
// collectionSink 把采集记录发布到批量写入消息;未注入或发布失败时同步写库。
collectionSink CollectionSink
}
// CollectionSink 发布一条采集记录(成功表或失败表二选一),由批量消费者写库。
type CollectionSink interface {
PublishCollection(ctx context.Context, success *model.CardTrafficCollection, failure *model.CardTrafficCollectionFailure) error
}
// SetCollectionSink 注入采集记录发布器(Worker 启用 card.collection 批量写入时使用)。
func (s *Service) SetCollectionSink(sink CollectionSink) {
s.collectionSink = sink
}
// SetCommandGrace 注入停复机成功后的状态保护窗口(受控系统配置 polling.stop_resume.status_grace_seconds)。
func (s *Service) SetCommandGrace(grace func(ctx context.Context) time.Duration) {
s.commandGrace = grace
}
func (s *Service) currentCommandGrace(ctx context.Context) time.Duration {
if s == nil || s.commandGrace == nil {
return 0
}
return s.commandGrace(ctx)
}
// SetTrafficQueue 注入卡流量采集队列、队列配置与告警日志。
func (s *Service) SetTrafficQueue(queue TrafficQueuePort, settings TrafficQueueSettingsSource, logger *zap.Logger) {
s.trafficQueue = queue
s.trafficQueueSettings = settings
s.logger = logger
}
// warnLogger 返回可用的告警日志器。
func (s *Service) warnLogger() *zap.Logger {
if s.logger == nil {
return zap.NewNop()
}
return s.logger
}
// NewService 创建卡实名观测应用服务。
@@ -156,7 +108,6 @@ func (s *Service) ApplyCardObservation(ctx context.Context, observation domain.R
}
var decision domain.RealnameDecision
var auditedCard *model.IotCard
stale := false
err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
var card model.IotCard
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where("id = ?", observation.CardID).First(&card).Error; err != nil {
@@ -166,15 +117,6 @@ func (s *Service) ApplyCardObservation(ctx context.Context, observation domain.R
return errors.Wrap(errors.CodeDatabaseError, err, "锁定IoT卡失败")
}
auditedCard = &card
if staleObservation(card.LastRealNameCheckAt, observation.Metadata.ObservedAt) {
// 异步入库可能乱序:早于已应用观测的实名结果不改卡事实,只记录。
s.warnLogger().Info("实名观测早于卡上最近一次已应用的检查,忽略",
zap.Uint("card_id", card.ID), zap.String("observation_id", observation.Metadata.ObservationID),
zap.Time("observed_at", observation.Metadata.ObservedAt), zap.Timep("last_checked_at", card.LastRealNameCheckAt),
zap.String("metric", "card_observation.realname_stale"))
stale = true
return nil
}
nextDecision, decisionErr := domain.ApplyRealname(domain.CardRealnameSnapshot{
CardID: card.ID, Status: card.RealNameStatus, FirstRealnameAt: card.FirstRealnameAt,
ReversalCount: card.RealnameReversalCount, ReversalStartedAt: card.RealnameReversalStartedAt,
@@ -297,9 +239,6 @@ func (s *Service) ApplyCardObservation(ctx context.Context, observation domain.R
}
return domain.RealnameDecision{}, err
}
if stale {
return domain.RealnameDecision{}, nil
}
if s.cache != nil {
s.cache.Invalidate(ctx, observation.CardID)
}

View File

@@ -2,8 +2,6 @@ package cardobservation
import (
"context"
"github.com/break/junhong_cmp_fiber/internal/infrastructure/pollingpause"
"go.uber.org/zap"
"strconv"
"time"
@@ -37,7 +35,6 @@ func (s *Service) ApplyNetworkObservation(ctx context.Context, observation domai
}
var decision domain.NetworkDecision
var auditedCard *model.IotCard
stale := false
err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
var card model.IotCard
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where("id = ?", observation.CardID).First(&card).Error; err != nil {
@@ -47,28 +44,13 @@ func (s *Service) ApplyNetworkObservation(ctx context.Context, observation domai
return errors.Wrap(errors.CodeDatabaseError, err, "锁定IoT卡网络事实失败")
}
auditedCard = &card
if staleObservation(card.LastCardStatusCheckAt, observation.Metadata.ObservedAt) {
// 异步入库可能乱序:早于已应用观测的网络状态不改卡事实,只记录。
s.warnLogger().Info("网络状态观测早于卡上最近一次已应用的检查,忽略",
zap.Uint("card_id", card.ID), zap.String("observation_id", observation.Metadata.ObservationID),
zap.Time("observed_at", observation.Metadata.ObservedAt), zap.Timep("last_checked_at", card.LastCardStatusCheckAt),
zap.String("metric", "card_observation.network_stale"))
stale = true
return nil
}
nextDecision, decisionErr := domain.ApplyNetwork(domain.CardNetworkSnapshot{
CardID: card.ID, NetworkStatus: card.NetworkStatus, StopReason: card.StopReason,
EnablePolling: card.EnablePolling, StoppedAt: card.StoppedAt, ResumedAt: card.ResumedAt,
CommandGrace: s.currentCommandGrace(ctx),
IsStandalone: card.IsStandalone, EnablePolling: card.EnablePolling,
}, observation)
if decisionErr != nil {
return decisionErr
}
if nextDecision.StatusSuppressed {
s.warnLogger().Info("停复机成功后保护窗口内观测到相反状态,判定为上游状态滞后,不回写网络状态",
zap.Uint("card_id", card.ID), zap.Int("network_status", card.NetworkStatus),
zap.String("gateway_status", observation.GatewayStatus), zap.String("metric", "card_observation.network_suppressed"))
}
decision = nextDecision
updates := map[string]any{
"last_card_status_check_at": observation.Metadata.ObservedAt,
@@ -96,12 +78,6 @@ func (s *Service) ApplyNetworkObservation(ctx context.Context, observation domai
if result.RowsAffected != 1 {
return errors.New(errors.CodeConflict, "卡网络事实已被其他请求更新")
}
if decision.StopPolling && card.EnablePolling {
// 风险停机或已销户自动关闭轮询:同事务写不轮询清单(只关该卡自身,不级联设备)。
if err := pollingpause.Record(ctx, tx, pollingpause.CardChange(&card, false), constants.PollingToggleSourceRiskAuto); err != nil {
return err
}
}
if decision.StatusChanged {
eventID := outboxid.Stable("card-network:", strconv.FormatUint(uint64(card.ID), 10)+":"+observation.Metadata.ObservationID+":changed")
if err := s.eventWriter.AppendNetwork(ctx, tx, NetworkChangedEvent{
@@ -186,16 +162,8 @@ func (s *Service) ApplyNetworkObservation(ctx context.Context, observation domai
}
return domain.NetworkDecision{}, err
}
if stale {
return domain.NetworkDecision{}, nil
}
if s.cache != nil {
s.cache.Invalidate(ctx, observation.CardID)
}
return decision, nil
}
// staleObservation 判断观测时间是否早于卡上最近一次已应用的同类检查时间(异步入库乱序保护)。
func staleObservation(lastChecked *time.Time, observedAt time.Time) bool {
return lastChecked != nil && !observedAt.IsZero() && observedAt.Before(*lastChecked)
}

View File

@@ -5,15 +5,12 @@ import (
"strconv"
"time"
"go.uber.org/zap"
"gorm.io/gorm"
"gorm.io/gorm/clause"
cardflow "github.com/break/junhong_cmp_fiber/internal/domain/cardflow"
domain "github.com/break/junhong_cmp_fiber/internal/domain/cardobservation"
carrierthresholddomain "github.com/break/junhong_cmp_fiber/internal/domain/carrierthreshold"
"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"
"github.com/break/junhong_cmp_fiber/pkg/outboxid"
@@ -21,56 +18,24 @@ import (
// TrafficIncrementedEvent 是卡流量正增量的可靠领域事实。
type TrafficIncrementedEvent struct {
EventID string `json:"event_id"`
CardID uint `json:"card_id"`
IncrementMB float64 `json:"increment_mb"`
ObservedAt time.Time `json:"observed_at"`
// ObservationID 是采集 ID,扣费审计据此追溯到采集记录。
ObservationID string `json:"observation_id,omitempty"`
Source string `json:"source"`
Scene string `json:"scene"`
RequestID string `json:"request_id,omitempty"`
CorrelationID string `json:"correlation_id,omitempty"`
EventID string `json:"event_id"`
CardID uint `json:"card_id"`
IncrementMB float64 `json:"increment_mb"`
ObservedAt time.Time `json:"observed_at"`
Source string `json:"source"`
Scene string `json:"scene"`
RequestID string `json:"request_id,omitempty"`
CorrelationID string `json:"correlation_id,omitempty"`
}
// TrafficQueuePort 是卡流量采集队列:原子入队并返回入队后的队列与新点是否在队尾。
type TrafficQueuePort interface {
Push(ctx context.Context, cardID uint, point cardflow.QueuePoint, settings TrafficQueueSettings) ([]cardflow.QueuePoint, bool, error)
}
// TrafficQueueSettingsSource 返回当前采集队列配置。
type TrafficQueueSettingsSource interface {
Current(ctx context.Context) TrafficQueueSettings
}
// ApplyTrafficObservation 应用一次流量采集:读数先进采集队列,满足写入条件才写日快照、按月对账、
// 在采集时刻有套餐时同事务写流量事件;有套餐的卡每次采集写一条采集记录。
// ApplyTrafficObservation 串行应用流量读数,并在正增量时同事务写 Outbox。
func (s *Service) ApplyTrafficObservation(ctx context.Context, observation domain.TrafficObservation) (domain.TrafficDecision, error) {
if s == nil || s.db == nil || s.eventWriter == nil || s.trafficQueue == nil {
if s == nil || s.db == nil || s.eventWriter == nil {
return domain.TrafficDecision{}, errors.New(errors.CodeInternalError, "卡流量观测能力未完整配置")
}
if err := domain.ValidateTrafficObservation(observation); err != nil {
return domain.TrafficDecision{}, err
}
record := newCollectionRecord(observation)
decision := domain.TrafficDecision{Result: record.Result}
if record.Result != constants.TrafficCollectionResultOK {
s.writeCollectionRecord(ctx, record, observation.Metadata.ObservedAt)
return decision, nil
}
reading := observation.GatewayReadingMB
decision.ReadingMB = &reading
settings := s.queueSettings()
points, tail, err := s.trafficQueue.Push(ctx, observation.CardID,
cardflow.QueuePoint{ObservedAt: observation.Metadata.ObservedAt, ReadingMB: reading}, settings)
if err != nil {
return decision, err
}
record.setQueue(points)
judge := cardflow.JudgeQueue(points, tail, settings.Size, settings.DropToleranceMB)
record.Decision = judge
var outcome *flowOutcome
err = s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
var decision domain.TrafficDecision
var auditedCard *model.IotCard
err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
var card model.IotCard
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where("id = ?", observation.CardID).First(&card).Error; err != nil {
if err == gorm.ErrRecordNotFound {
@@ -78,182 +43,114 @@ func (s *Service) ApplyTrafficObservation(ctx context.Context, observation domai
}
return errors.Wrap(errors.CodeDatabaseError, err, "锁定IoT卡流量事实失败")
}
decision.CurrentMonthUsageMB, decision.LastGatewayReadingMB = card.CurrentMonthUsageMB, card.LastGatewayReadingMB
packages, err := postgres.FindEffectivePackagesAt(ctx, tx, card.ID, observation.Metadata.ObservedAt, false)
if err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "查询卡采集时刻有效套餐失败")
auditedCard = &card
nextDecision, decisionErr := domain.ApplyTraffic(domain.CardTrafficSnapshot{
CardID: card.ID, DataUsageMB: card.DataUsageMB, CurrentMonthUsageMB: card.CurrentMonthUsageMB,
CurrentMonthStartDate: card.CurrentMonthStartDate, LastMonthTotalMB: card.LastMonthTotalMB,
LastGatewayReadingMB: card.LastGatewayReadingMB,
}, observation)
if decisionErr != nil {
return decisionErr
}
record.HasPackage = len(packages.Packages) > 0
record.CarrierID = card.CarrierID
if judge != cardflow.QueueWrite {
if observation.Metadata.Scene == constants.CardObservationScenePreActivationSettlement {
// 激活前结算没写入:用已有本月最大快照结算,不新写快照。
if err := s.settleExisting(ctx, tx, &card, observation.Metadata.ObservedAt); err != nil {
return err
}
record.Decision = constants.TrafficDecisionSettledExisting
decision = nextDecision
updates := map[string]any{
"last_data_check_at": observation.Metadata.ObservedAt, "last_sync_time": observation.Metadata.ObservedAt,
"current_month_start_date": decision.CurrentMonthStartDate, "last_month_total_mb": decision.LastMonthTotalMB,
"current_month_usage_mb": decision.CurrentMonthUsageMB, "data_usage_mb": decision.DataUsageMB,
}
if decision.ReadingAccepted {
updates["last_gateway_reading_mb"] = decision.LastGatewayReadingMB
}
result := tx.Model(&model.IotCard{}).
Where("id = ? AND last_gateway_reading_mb = ?", card.ID, card.LastGatewayReadingMB).
Updates(updates)
if result.Error != nil {
return errors.Wrap(errors.CodeDatabaseError, result.Error, "更新卡流量事实失败")
}
if result.RowsAffected != 1 {
return errors.New(errors.CodeConflict, "卡流量基线已被其他请求更新")
}
if decision.IncrementMB > 0 {
eventID := outboxid.Stable("card-traffic:", strconv.FormatUint(uint64(card.ID), 10)+":"+observation.Metadata.ObservationID+":incremented")
if err := s.eventWriter.AppendTraffic(ctx, tx, TrafficIncrementedEvent{
EventID: eventID, CardID: card.ID, IncrementMB: decision.IncrementMB,
ObservedAt: observation.Metadata.ObservedAt, Source: observation.Metadata.Source,
Scene: observation.Metadata.Scene, RequestID: observation.Metadata.RequestID,
CorrelationID: observation.Metadata.CorrelationID,
}); err != nil {
return err
}
return touchTrafficCheck(tx, card.ID, observation.Metadata.ObservedAt)
}
next, err := s.applyWrittenPoint(ctx, tx, &card, observation, packages)
if err != nil {
return err
if decision.ReadingAccepted && s.channelThreshold != nil {
// 达量判定只使用本次已接受的网关累计读数,异常下降保护命中的观测不参与判定;
// 判定失败必须回滚整个流量事务,与既有流量事实保持同事务语义。
if err := s.channelThreshold.EvaluateInTx(ctx, tx, carrierthresholddomain.Evaluation{
CardID: card.ID, CarrierID: card.CarrierID,
ReadingMB: decision.LastGatewayReadingMB, ObservedAt: observation.Metadata.ObservedAt,
// 观测来源是判定端获取触发来源的唯一位置:命中留痕据此区分自动判定、人工同步与回调。
Source: observation.Metadata.Source,
}); err != nil {
return err
}
}
stateChanged := decision.IncrementMB != 0 || decision.CrossMonth || decision.LastGatewayReadingMB != card.LastGatewayReadingMB
if actionCode, audited := manualRefreshAuditAction(ctx); observation.Metadata.Source == constants.CardObservationSourceManualSync && stateChanged && audited {
if s.auditWriter == nil {
return errors.New(errors.CodeInternalError, "卡状态统一审计能力未配置")
}
if err := s.auditWriter.WriteCardStateAudit(ctx, tx, StateAudit{
ActionCode: actionCode,
Summary: "人工刷新 IoT 卡流量",
Card: &card,
BeforeData: map[string]any{
"data_usage_mb": card.DataUsageMB, "current_month_usage_mb": card.CurrentMonthUsageMB,
"current_month_start_date": card.CurrentMonthStartDate, "last_month_total_mb": card.LastMonthTotalMB,
"last_gateway_reading_mb": card.LastGatewayReadingMB,
},
AfterData: map[string]any{
"data_usage_mb": decision.DataUsageMB, "current_month_usage_mb": decision.CurrentMonthUsageMB,
"current_month_start_date": decision.CurrentMonthStartDate, "last_month_total_mb": decision.LastMonthTotalMB,
"last_gateway_reading_mb": decision.LastGatewayReadingMB, "increment_mb": decision.IncrementMB,
},
}); err != nil {
return err
}
} else if workerObservationAudited(ctx) && stateChanged {
if s.auditWriter == nil {
return errors.New(errors.CodeInternalError, "卡状态统一审计能力未配置")
}
if err := s.auditWriter.WriteCardStateAudit(ctx, tx, StateAudit{
ActionCode: constants.AuditActionIotCardWorkerTrafficSynced,
Summary: "Worker 同步 IoT 卡流量事实", Card: &card,
IntegrationID: observation.Metadata.ObservationID,
BeforeData: map[string]any{
"data_usage_mb": card.DataUsageMB, "current_month_usage_mb": card.CurrentMonthUsageMB,
"current_month_start_date": card.CurrentMonthStartDate, "last_month_total_mb": card.LastMonthTotalMB,
"last_gateway_reading_mb": card.LastGatewayReadingMB,
},
AfterData: map[string]any{
"data_usage_mb": decision.DataUsageMB, "current_month_usage_mb": decision.CurrentMonthUsageMB,
"current_month_start_date": decision.CurrentMonthStartDate, "last_month_total_mb": decision.LastMonthTotalMB,
"last_gateway_reading_mb": decision.LastGatewayReadingMB, "increment_mb": decision.IncrementMB,
},
}); err != nil {
return err
}
}
outcome = next
return nil
})
if err != nil {
s.writeTrafficFailureAudit(ctx, observation, err)
if workerObservationAudited(ctx) && auditedCard != nil && s.auditWriter != nil {
s.auditWriter.WriteCardStateFailure(ctx, StateAudit{
ActionCode: constants.AuditActionIotCardWorkerTrafficSynced,
Summary: "Worker 同步 IoT 卡流量事实失败", Card: auditedCard,
IntegrationID: observation.Metadata.ObservationID,
}, err)
}
return domain.TrafficDecision{}, err
}
if outcome != nil {
record.applyOutcome(outcome)
decision.ReadingAccepted = outcome.Written
decision.IncrementMB = outcome.EmitMB
decision.CrossMonth = outcome.CrossMonth
decision.CurrentMonthUsageMB = outcome.MonthMaxMB
}
decision.Decision = record.Decision
s.writeCollectionRecord(ctx, record, observation.Metadata.ObservedAt)
if s.cache != nil {
s.cache.Invalidate(ctx, observation.CardID)
}
return decision, nil
}
// applyWrittenPoint 在观测事务内处理一个通过队列判断的写入点。
func (s *Service) applyWrittenPoint(ctx context.Context, tx *gorm.DB, card *model.IotCard, observation domain.TrafficObservation, packages postgres.EffectivePackages) (*flowOutcome, error) {
hasPackage := len(packages.Packages) > 0
reading := observation.GatewayReadingMB
observedAt := observation.Metadata.ObservedAt
outcome := &flowOutcome{MonthMaxMB: card.CurrentMonthUsageMB, Decision: constants.TrafficDecisionWritten}
if reading == 0 && !hasPackage {
// 没套餐的卡读数为 0 不写快照。
return outcome, touchTrafficCheck(tx, card.ID, observedAt)
}
flow, err := s.writeFlow(ctx, tx, card, reading, observedAt, hasPackage)
if err != nil {
return nil, err
}
outcome.Written, outcome.MonthMaxMB, outcome.StaleGuarded = true, flow.monthMax, flow.conversion.StaleGuarded
outcome.MonthUsedMB, outcome.DeltaMB = flow.conversion.MonthUsedMB, flow.delta
if card.TrafficBaselinePending {
outcome.Decision = constants.TrafficDecisionBaseline
} else if hasPackage && flow.delta > 0 {
outcome.EmitMB = flow.delta
}
if hasPackage {
if err := ensureDailyUsageRows(ctx, tx, packages.Packages, flow.flowDate); err != nil {
return nil, err
}
}
monthStart := cardflow.MonthStart(flow.flowDate)
outcome.CrossMonth = card.CurrentMonthStartDate == nil || card.CurrentMonthStartDate.Before(monthStart)
updates := map[string]any{
"last_data_check_at": observedAt, "last_sync_time": observedAt,
"current_month_usage_mb": flow.monthMax, "current_month_start_date": monthStart,
"data_usage_mb": card.DataUsageMB + int64(flow.delta), "traffic_baseline_pending": false,
}
if outcome.CrossMonth {
updates["last_month_total_mb"] = card.CurrentMonthUsageMB
}
if err := tx.Model(&model.IotCard{}).Where("id = ?", card.ID).Updates(updates).Error; err != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "更新卡流量事实失败")
}
if outcome.EmitMB > 0 {
outcome.EventID = outboxid.Stable("card-traffic:", strconv.FormatUint(uint64(card.ID), 10)+":"+observation.Metadata.ObservationID+":incremented")
if err := s.eventWriter.AppendTraffic(ctx, tx, TrafficIncrementedEvent{
EventID: outcome.EventID, CardID: card.ID, IncrementMB: outcome.EmitMB,
ObservedAt: observedAt, ObservationID: observation.Metadata.ObservationID,
Source: observation.Metadata.Source, Scene: observation.Metadata.Scene,
RequestID: observation.Metadata.RequestID, CorrelationID: observation.Metadata.CorrelationID,
}); err != nil {
return nil, err
}
}
// 通道阈值只在写入点判断:读数大于 0 且未被防旧值按 0 处理;激活前结算不触发停复机评估。
if reading > 0 && !outcome.StaleGuarded && s.channelThreshold != nil &&
observation.Metadata.Scene != constants.CardObservationScenePreActivationSettlement {
if err := s.channelThreshold.EvaluateInTx(ctx, tx, carrierthresholddomain.Evaluation{
CardID: card.ID, CarrierID: card.CarrierID, ReadingMB: reading, ObservedAt: observedAt,
// 观测来源是判定端获取触发来源的唯一位置:命中留痕据此区分自动判定、人工同步与回调。
Source: observation.Metadata.Source,
}); err != nil {
return nil, err
}
}
if err := s.writeTrafficStateAudit(ctx, tx, card, observation, outcome); err != nil {
return nil, err
}
return outcome, nil
}
// touchTrafficCheck 只刷新卡的最近流量检查时间(本次没有写入快照)。
func touchTrafficCheck(tx *gorm.DB, cardID uint, observedAt time.Time) error {
if err := tx.Model(&model.IotCard{}).Where("id = ?", cardID).
Updates(map[string]any{"last_data_check_at": observedAt, "last_sync_time": observedAt}).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "更新卡流量检查时间失败")
}
return nil
}
// writeTrafficStateAudit 在本次写入改变了卡流量事实时写统一审计(人工刷新或 Worker 观测)。
func (s *Service) writeTrafficStateAudit(ctx context.Context, tx *gorm.DB, card *model.IotCard, observation domain.TrafficObservation, outcome *flowOutcome) error {
if outcome.DeltaMB <= 0 && !outcome.CrossMonth {
return nil
}
before := map[string]any{
"data_usage_mb": card.DataUsageMB, "current_month_usage_mb": card.CurrentMonthUsageMB,
"current_month_start_date": card.CurrentMonthStartDate, "last_month_total_mb": card.LastMonthTotalMB,
}
after := map[string]any{
"data_usage_mb": card.DataUsageMB + int64(outcome.DeltaMB), "current_month_usage_mb": outcome.MonthMaxMB,
"increment_mb": outcome.EmitMB, "delta_mb": outcome.DeltaMB, "decision": outcome.Decision,
"observation_id": observation.Metadata.ObservationID,
}
input := StateAudit{Card: card, BeforeData: before, AfterData: after}
if actionCode, audited := manualRefreshAuditAction(ctx); observation.Metadata.Source == constants.CardObservationSourceManualSync && audited {
input.ActionCode, input.Summary = actionCode, "人工刷新 IoT 卡流量"
} else if workerObservationAudited(ctx) {
input.ActionCode, input.Summary = constants.AuditActionIotCardWorkerTrafficSynced, "Worker 同步 IoT 卡流量事实"
input.IntegrationID = observation.Metadata.ObservationID
} else {
return nil
}
if s.auditWriter == nil {
return errors.New(errors.CodeInternalError, "卡状态统一审计能力未配置")
}
return s.auditWriter.WriteCardStateAudit(ctx, tx, input)
}
// writeTrafficFailureAudit 记录 Worker 观测事务失败的审计。
func (s *Service) writeTrafficFailureAudit(ctx context.Context, observation domain.TrafficObservation, err error) {
if !workerObservationAudited(ctx) || s.auditWriter == nil {
return
}
// 观测失败常因上下文已超时,失败审计改用独立短超时上下文,避免连带报错。
auditCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 5*time.Second)
defer cancel()
ctx = auditCtx
var card model.IotCard
if loadErr := s.db.WithContext(ctx).Where("id = ?", observation.CardID).First(&card).Error; loadErr != nil {
return
}
s.auditWriter.WriteCardStateFailure(ctx, StateAudit{
ActionCode: constants.AuditActionIotCardWorkerTrafficSynced,
Summary: "Worker 同步 IoT 卡流量事实失败", Card: &card,
IntegrationID: observation.Metadata.ObservationID,
}, err)
}
func (s *Service) queueSettings() TrafficQueueSettings {
if s.trafficQueueSettings == nil {
return DefaultTrafficQueueSettings()
}
return s.trafficQueueSettings.Current(context.Background())
}
// logTrafficWarn 记录不影响观测结果的告警。
func (s *Service) logTrafficWarn(message string, fields ...zap.Field) {
s.warnLogger().Warn(message, fields...)
}

View File

@@ -1,167 +0,0 @@
package cardobservation
import (
"context"
"encoding/json"
"time"
"unicode/utf8"
"go.uber.org/zap"
"gorm.io/datatypes"
cardflow "github.com/break/junhong_cmp_fiber/internal/domain/cardflow"
domain "github.com/break/junhong_cmp_fiber/internal/domain/cardobservation"
"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"
)
// rawResponseMaxBytes 是原始响应体保存上限,超过时截断并标记。
const rawResponseMaxBytes = 4096
// collectionRecord 是一次采集的留痕内容;HasPackage 在观测事务内判断(只对成功读数有意义)。
type collectionRecord struct {
model.CardTrafficCollection
HasPackage bool
failure *domain.CollectionFailure
iccid string
}
func newCollectionRecord(observation domain.TrafficObservation) *collectionRecord {
record := &collectionRecord{CardTrafficCollection: model.CardTrafficCollection{
IotCardID: observation.CardID, ObservedAt: observation.Metadata.ObservedAt,
ObservationID: observation.Metadata.ObservationID, Source: observation.Metadata.Source,
Scene: observation.Metadata.Scene, Result: domain.ResolveReadingResult(observation),
CollectionRaw: buildCollectionRaw(observation.Raw), CarrierID: observation.CarrierID,
}, failure: observation.Failure, iccid: observation.ICCID}
if record.Result == constants.TrafficCollectionResultOK {
reading := observation.GatewayReadingMB
record.VendorUsedMB = &reading
}
return record
}
func (r *collectionRecord) setQueue(points []cardflow.QueuePoint) {
if encoded, err := json.Marshal(points); err == nil {
r.QueuePoints = encoded
}
}
func (r *collectionRecord) applyOutcome(outcome *flowOutcome) {
r.Decision = outcome.Decision
if !outcome.Written {
return
}
monthUsed, delta, stale := outcome.MonthUsedMB, outcome.DeltaMB, outcome.StaleGuarded
r.MonthUsedMB, r.DeltaMB, r.StaleGuarded, r.EventID = &monthUsed, &delta, &stale, outcome.EventID
}
// writeCollectionRecord 在观测事务之外写采集记录:成功读数只为采集时刻有套餐的卡写成功表;
// 失败读数不判断有无套餐,写失败表。注入发布器时发布到批量写入消息,由消费者攒批写库并失败重投;
// 未注入或发布失败时同步写库。
func (s *Service) writeCollectionRecord(ctx context.Context, record *collectionRecord, _ time.Time) {
if record.Result != constants.TrafficCollectionResultOK {
s.writeCollectionFailure(ctx, record)
return
}
if !record.HasPackage {
return
}
row := &record.CardTrafficCollection
if s.collectionSink != nil {
err := s.collectionSink.PublishCollection(ctx, row, nil)
if err == nil {
return
}
s.logTrafficWarn("发布卡流量采集记录消息失败,改为同步写库", zap.Uint("card_id", record.IotCardID),
zap.String("observation_id", record.ObservationID), zap.Error(err))
}
writeCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), constants.PollingIngestSyncCollectionTimeout)
defer cancel()
if err := postgres.NewCardTrafficCollectionStore(s.db).Create(writeCtx, row); err != nil {
s.logTrafficWarn("写卡流量采集记录失败", zap.Uint("card_id", record.IotCardID),
zap.String("observation_id", record.ObservationID), zap.Error(err))
}
}
func (s *Service) writeCollectionFailure(ctx context.Context, record *collectionRecord) {
row := &model.CardTrafficCollectionFailure{
IotCardID: record.IotCardID, ObservedAt: record.ObservedAt, ObservationID: record.ObservationID,
Source: record.Source, Scene: record.Scene, Result: record.Result, CollectionRaw: record.CollectionRaw,
ICCID: record.iccid, CarrierID: record.CarrierID,
}
if failure := record.failure; failure != nil {
row.ErrorCategory, row.ProviderCode = failure.ErrorCategory, failure.ProviderCode
row.ProviderMessage, row.DurationMS = truncateText(failure.ProviderMessage, 500), failure.DurationMS
}
writeCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), constants.PollingIngestSyncCollectionTimeout)
defer cancel()
if row.ICCID == "" || row.CarrierID == 0 {
var card model.IotCard
if err := s.db.WithContext(writeCtx).Unscoped().Select("iccid", "carrier_id").Where("id = ?", record.IotCardID).Take(&card).Error; err == nil {
row.ICCID, row.CarrierID = card.ICCID, card.CarrierID
}
}
if s.collectionSink != nil {
err := s.collectionSink.PublishCollection(ctx, nil, row)
if err == nil {
return
}
s.logTrafficWarn("发布卡流量采集失败记录消息失败,改为同步写库", zap.Uint("card_id", record.IotCardID),
zap.String("observation_id", record.ObservationID), zap.Error(err))
}
if err := postgres.NewCardTrafficCollectionFailureStore(s.db).Create(writeCtx, row); err != nil {
s.logTrafficWarn("写卡流量采集失败记录失败", zap.Uint("card_id", record.IotCardID),
zap.String("observation_id", record.ObservationID), zap.Error(err))
}
}
// buildCollectionRaw 把网关原始数据转为留存字段:响应可解析为 JSON 且不超过 4KB 时存 JSON,否则存文本并按 4KB 截断。
func buildCollectionRaw(raw *domain.GatewayRaw) model.CollectionRaw {
if raw == nil {
return model.CollectionRaw{}
}
out := model.CollectionRaw{
GatewayCode: raw.GatewayCode, GatewayMsg: truncateText(raw.GatewayMsg, 500),
GatewayTraceID: truncateText(raw.TraceID, 128), GatewayAttempts: raw.Attempts,
}
if raw.HTTPStatus > 0 {
status := raw.HTTPStatus
out.HTTPStatus = &status
}
if len(raw.Request) > 0 && json.Valid(raw.Request) {
out.RawRequest = datatypes.JSON(raw.Request)
}
switch {
case len(raw.Response) == 0:
case len(raw.Response) > rawResponseMaxBytes:
out.RawResponseText, out.RawTruncated = validUTF8Prefix(raw.Response, rawResponseMaxBytes), true
case json.Valid(raw.Response):
out.RawResponse = datatypes.JSON(raw.Response)
default:
out.RawResponseText = validUTF8Prefix(raw.Response, rawResponseMaxBytes)
}
return out
}
// validUTF8Prefix 返回不超过 limit 字节且不截断多字节字符的前缀,并替换非法 UTF-8(PostgreSQL text 不接受)。
func validUTF8Prefix(data []byte, limit int) string {
if len(data) > limit {
data = data[:limit]
for len(data) > 0 && !utf8.Valid(data) {
data = data[:len(data)-1]
}
}
if !utf8.Valid(data) {
return string([]rune(string(data)))
}
return string(data)
}
func truncateText(value string, limit int) string {
runes := []rune(value)
if len(runes) <= limit {
return value
}
return string(runes[:limit])
}

View File

@@ -1,118 +0,0 @@
package cardobservation
import (
"context"
"time"
"go.uber.org/zap"
"gorm.io/gorm"
cardflow "github.com/break/junhong_cmp_fiber/internal/domain/cardflow"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/internal/store/postgres"
"github.com/break/junhong_cmp_fiber/pkg/errors"
)
// flowOutcome 是一个写入点的日快照计量结果。
type flowOutcome struct {
// Written 为 true 表示本次写入了日快照(没套餐且读数为 0 时为 false)。
Written bool
// Decision 是写入判断:已写入或建起点。
Decision string
// EmitMB 是本次写入流量事件的增量,0 表示不写事件。
EmitMB float64
// DeltaMB 是本次按月对账计入已结算的增量(含没套餐、建起点时只记已结算的部分)。
DeltaMB float64
// MonthUsedMB 是本次读数换算后的本月累计;MonthMaxMB 是写入后的本月最大快照。
MonthUsedMB float64
MonthMaxMB float64
// StaleGuarded 为 true 表示本次读数在重置日触发防旧值、按 0 计。
StaleGuarded bool
// CrossMonth 为 true 表示卡表本月用量跨入新的自然月。
CrossMonth bool
// EventID 是本次写入的流量事件 ID。
EventID string
}
// flowWrite 是一次快照写入与按月对账的结果。
type flowWrite struct {
flowDate time.Time
conversion cardflow.Conversion
monthMax float64
delta float64
}
// writeFlow 按卡所属通道的重置日换算并原子写入当天快照,再按月对账把增量计入当天已结算。
func (s *Service) writeFlow(ctx context.Context, tx *gorm.DB, card *model.IotCard, readingMB float64, observedAt time.Time, hasPackage bool) (flowWrite, error) {
resetDay := s.strictResetDay(ctx, tx, card)
flowDate := cardflow.ShanghaiDate(observedAt)
days := postgres.NewCardFlowDayStore(tx)
monthCtx, err := days.MonthContext(ctx, card.ID, flowDate, resetDay)
if err != nil {
return flowWrite{}, errors.Wrap(errors.CodeDatabaseError, err, "查询卡日流量快照失败")
}
conversion := cardflow.Convert(readingMB, flowDate.Day(), resetDay, monthCtx)
if err := days.Upsert(ctx, postgres.CardFlowSnapshotWrite{
CardID: card.ID, FlowDate: flowDate, MonthUsedMB: conversion.MonthUsedMB,
VendorUsedMB: readingMB, StaleGuarded: conversion.StaleGuarded,
}); err != nil {
return flowWrite{}, errors.Wrap(errors.CodeDatabaseError, err, "写入卡日流量快照失败")
}
monthMax, settled, err := days.MonthTotals(ctx, card.ID, flowDate)
if err != nil {
return flowWrite{}, errors.Wrap(errors.CodeDatabaseError, err, "查询卡本月流量结算失败")
}
delta := cardflow.MonthlyDelta(monthMax, settled)
if err := days.AddSettled(ctx, card.ID, flowDate, delta); err != nil {
return flowWrite{}, errors.Wrap(errors.CodeDatabaseError, err, "结算卡流量增量失败")
}
return flowWrite{flowDate: flowDate, conversion: conversion, monthMax: monthMax, delta: delta}, nil
}
// settleExisting 用卡上已有的本月最大快照结算(激活前结算本次没写入时):差额全部记为已结算,不新写快照。
func (s *Service) settleExisting(ctx context.Context, tx *gorm.DB, card *model.IotCard, observedAt time.Time) error {
flowDate := cardflow.ShanghaiDate(observedAt)
days := postgres.NewCardFlowDayStore(tx)
monthMax, settled, err := days.MonthTotals(ctx, card.ID, flowDate)
if err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "查询卡本月流量结算失败")
}
if err := days.AddSettledToLatest(ctx, card.ID, flowDate, cardflow.MonthlyDelta(monthMax, settled)); err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "激活前结算已有快照失败")
}
return nil
}
// ensureDailyUsageRows 为采集时刻每个有效套餐确保当天一行套餐日用量(没扣也写 0)。
func ensureDailyUsageRows(ctx context.Context, tx *gorm.DB, packages []*model.PackageUsage, flowDate time.Time) error {
ids := make([]uint, 0, len(packages))
cumulative := make(map[uint]int64, len(packages))
for _, pkg := range packages {
ids = append(ids, pkg.ID)
cumulative[pkg.ID] = pkg.DataUsageMB
}
if err := postgres.EnsureDailyUsageRows(ctx, tx, ids, flowDate, cumulative); err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "写入套餐当天日用量失败")
}
return nil
}
// strictResetDay 在当前连接内按卡的 carrier_id 读取上游流量重置日,不使用调用方预先计算的值;
// 查询失败、为空或非法时按卡的运营商类型兜底(联通 27,其余 1)并记录日志。
func (s *Service) strictResetDay(ctx context.Context, db *gorm.DB, card *model.IotCard) int {
var days []int
queryErr := db.WithContext(ctx).Model(&model.Carrier{}).
Where("id = ?", card.CarrierID).Limit(1).Pluck("data_reset_day", &days).Error
queried := 0
if len(days) > 0 {
queried = days[0]
}
resetDay, fallback := cardflow.ResolveResetDay(queried, queryErr == nil && len(days) > 0, card.CarrierType)
if fallback {
s.warnLogger().Warn("通道上游流量重置日不可用,按运营商类型兜底",
zap.Uint("card_id", card.ID), zap.Uint("carrier_id", card.CarrierID),
zap.String("carrier_type", card.CarrierType), zap.Int("queried", queried),
zap.Int("reset_day", resetDay), zap.Error(queryErr))
}
return resetDay
}

View File

@@ -1,193 +0,0 @@
package cardobservation
import (
"context"
stderrors "errors"
"math"
"strconv"
"sync"
"time"
"github.com/redis/go-redis/v9"
"go.uber.org/zap"
"gorm.io/gorm"
"github.com/break/junhong_cmp_fiber/internal/infrastructure/systemconfig"
"github.com/break/junhong_cmp_fiber/pkg/constants"
)
// trafficQueueSettingsTTL 是队列配置在进程内的缓存时长;后台修改数秒内生效。
const trafficQueueSettingsTTL = 5 * time.Second
// TrafficQueueSettings 是卡流量采集队列的运行参数。
type TrafficQueueSettings struct {
Size int
ClearOnNewDay bool
MaxPointAgeHours int
DropToleranceMB float64
}
// DefaultTrafficQueueSettings 返回代码默认值。
func DefaultTrafficQueueSettings() TrafficQueueSettings {
return TrafficQueueSettings{
Size: constants.TrafficQueueDefaultSize, ClearOnNewDay: true,
MaxPointAgeHours: constants.TrafficQueueDefaultMaxPointAgeHours, DropToleranceMB: constants.TrafficQueueDefaultDropToleranceMB,
}
}
// TrafficIntervalChecker 校验队列配置变更后启用中轮询配置的流量采集间隔是否仍在上限内。
type TrafficIntervalChecker func(settings TrafficQueueSettings) error
// RegisterTrafficQueueConfigDefinitions 注册采集队列系统配置。checker 非空时,队列长度、跨天清空、最长点龄
// 修改前用它校验启用中的轮询配置(读取其余三项的当前值组合成修改后的配置)。
func RegisterTrafficQueueConfigDefinitions(registry *systemconfig.Registry, logger *zap.Logger, current func() TrafficQueueSettings, checker TrafficIntervalChecker) {
if registry == nil {
return
}
minSize, maxSize := int64(constants.TrafficQueueMinSize), int64(constants.TrafficQueueMaxSize)
minAge, maxAge := int64(0), int64(24*30)
withCheck := func(apply func(*TrafficQueueSettings, string) error) func(string) error {
return func(value string) error {
next := DefaultTrafficQueueSettings()
if current != nil {
next = current()
}
if err := apply(&next, value); err != nil {
return err
}
if checker == nil {
return nil
}
return checker(next)
}
}
definitions := []systemconfig.Definition{
{Key: constants.SystemConfigTrafficQueueSize, Module: constants.SystemConfigModuleTraffic, ValueType: constants.SystemConfigTypeInt,
DefaultValue: strconv.Itoa(constants.TrafficQueueDefaultSize), Min: &minSize, Max: &maxSize,
Description: "卡流量采集队列长度 N:攒满 N 个不下降的读数才写最后一个点",
Validator: withCheck(func(s *TrafficQueueSettings, v string) error {
n, err := strconv.Atoi(v)
s.Size = n
return err
})},
{Key: constants.SystemConfigTrafficQueueClearOnNewDay, Module: constants.SystemConfigModuleTraffic, ValueType: constants.SystemConfigTypeBool,
DefaultValue: "true", Description: "卡流量采集队列跨天清空:新点与队列中的点不在同一上海日期时先清空",
Validator: withCheck(func(s *TrafficQueueSettings, v string) error {
s.ClearOnNewDay = v == "true"
return nil
})},
{Key: constants.SystemConfigTrafficQueueMaxPointAgeHours, Module: constants.SystemConfigModuleTraffic, ValueType: constants.SystemConfigTypeInt,
DefaultValue: strconv.Itoa(constants.TrafficQueueDefaultMaxPointAgeHours), Min: &minAge, Max: &maxAge,
Description: "卡流量采集队列最长点龄(小时):入队时丢弃与新点相差超过该时长的点,0 表示不限",
Validator: withCheck(func(s *TrafficQueueSettings, v string) error {
n, err := strconv.Atoi(v)
s.MaxPointAgeHours = n
return err
})},
{Key: constants.SystemConfigTrafficQueueDropToleranceMB, Module: constants.SystemConfigModuleTraffic, ValueType: constants.SystemConfigTypeString,
DefaultValue: "1", Description: "卡流量采集队列回退容差(MB):相邻两点回退不超过该值算持平,0 表示不允许回退",
Validator: func(v string) error {
_, err := parseDropTolerance(v)
return err
}},
}
for _, definition := range definitions {
if _, exists := registry.Get(definition.Key); exists {
continue
}
if err := registry.Register(definition); err != nil && logger != nil {
logger.Error("注册卡流量采集队列系统配置失败,将使用代码默认值", zap.String("config_key", definition.Key), zap.Error(err))
}
}
}
func parseDropTolerance(value string) (float64, error) {
tolerance, err := strconv.ParseFloat(value, 64)
if err != nil || math.IsNaN(tolerance) || math.IsInf(tolerance, 0) || tolerance < 0 || tolerance > 1024 {
return 0, stderrors.New("回退容差必须是 0~1024 之间的数字(MB)")
}
return tolerance, nil
}
// trafficConfigReader 读取系统配置的最小接缝。
type trafficConfigReader interface {
Get(ctx context.Context, key string) (string, error)
}
// TrafficQueueSettingsProvider 读取采集队列配置,带短缓存;读取失败时使用代码默认值。
type TrafficQueueSettingsProvider struct {
reader trafficConfigReader
logger *zap.Logger
mu sync.Mutex
cached TrafficQueueSettings
expiresAt time.Time
}
// NewTrafficQueueSettingsProvider 创建采集队列配置读取器。
func NewTrafficQueueSettingsProvider(reader trafficConfigReader, logger *zap.Logger) *TrafficQueueSettingsProvider {
if logger == nil {
logger = zap.NewNop()
}
return &TrafficQueueSettingsProvider{reader: reader, logger: logger}
}
// NewDefaultTrafficQueueSettingsProvider 用独立注册表与读取器创建采集队列配置读取器(API 与 Worker 共用同一份数据)。
func NewDefaultTrafficQueueSettingsProvider(db *gorm.DB, redisClient *redis.Client, logger *zap.Logger) *TrafficQueueSettingsProvider {
registry := systemconfig.NewRegistry()
RegisterTrafficQueueConfigDefinitions(registry, logger, nil, nil)
var cache systemconfig.Cache
if redisClient != nil {
cache = systemconfig.NewRedisCache(redisClient)
}
reader := systemconfig.NewReader(db, registry, cache, systemconfig.NewLogAlertSink(logger))
return NewTrafficQueueSettingsProvider(reader, logger)
}
// Current 返回当前采集队列配置。
func (p *TrafficQueueSettingsProvider) Current(ctx context.Context) TrafficQueueSettings {
if p == nil || p.reader == nil {
return DefaultTrafficQueueSettings()
}
p.mu.Lock()
defer p.mu.Unlock()
now := time.Now()
if now.Before(p.expiresAt) {
return p.cached
}
settings, err := p.load(ctx)
if err != nil {
p.logger.Warn("读取卡流量采集队列配置失败,使用代码默认值", zap.Error(err))
settings = DefaultTrafficQueueSettings()
}
p.cached, p.expiresAt = settings, now.Add(trafficQueueSettingsTTL)
return settings
}
func (p *TrafficQueueSettingsProvider) load(ctx context.Context) (TrafficQueueSettings, error) {
settings := DefaultTrafficQueueSettings()
raw, err := p.reader.Get(ctx, constants.SystemConfigTrafficQueueSize)
if err != nil {
return settings, err
}
if settings.Size, err = strconv.Atoi(raw); err != nil || settings.Size < constants.TrafficQueueMinSize {
return DefaultTrafficQueueSettings(), stderrors.New("采集队列长度配置非法")
}
if raw, err = p.reader.Get(ctx, constants.SystemConfigTrafficQueueClearOnNewDay); err != nil {
return DefaultTrafficQueueSettings(), err
}
settings.ClearOnNewDay = raw == "true"
if raw, err = p.reader.Get(ctx, constants.SystemConfigTrafficQueueMaxPointAgeHours); err != nil {
return DefaultTrafficQueueSettings(), err
}
if settings.MaxPointAgeHours, err = strconv.Atoi(raw); err != nil || settings.MaxPointAgeHours < 0 {
return DefaultTrafficQueueSettings(), stderrors.New("采集队列最长点龄配置非法")
}
if raw, err = p.reader.Get(ctx, constants.SystemConfigTrafficQueueDropToleranceMB); err != nil {
return DefaultTrafficQueueSettings(), err
}
if settings.DropToleranceMB, err = parseDropTolerance(raw); err != nil {
return DefaultTrafficQueueSettings(), err
}
return settings, nil
}

View File

@@ -14,8 +14,8 @@ import (
// EvaluateInTx 在流量观测事务内判定通道阈值,达量时写周期锁并同事务写停机事件。
//
// 判定前提由调用方保证:未切换日快照算法的卡只有「读数被接受」(ReadingAccepted)的观测才进入本方法,
// 异常下降保护命中的观测不参与判定;已切换的卡读数有效且未被重置日防旧值处理即进入本方法。重复达量观测由部分唯一索引的 23505 识别为
// 判定前提由调用方保证:只有「读数被接受」(ReadingAccepted)的观测才进入本方法,
// 异常下降保护命中的观测不参与判定。重复达量观测由部分唯一索引的 23505 识别为
// 「该周期已处理」并幂等跳过:不重复建锁、不重复写停机事件,该冲突与流量基线 CAS 的
// CodeConflict 语义不同,绝不能混流。返回错误表示必须整体回滚(与既有流量事实同事务)。
func (s *Service) EvaluateInTx(ctx context.Context, tx *gorm.DB, evaluation domain.Evaluation) error {

View File

@@ -1,155 +0,0 @@
package packagesilence
import (
"context"
"crypto/rand"
"fmt"
"math/big"
"time"
"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"
)
// 沉默期审批模板"申请类型"控件取值(技术方案 §11.3)。
const (
ApplyTypePurchaseAdjust = "购买时调整沉默期"
ApplyTypeSpecial = "特殊延期"
ApplyTypeOfflinePaid = "线下付费延期"
)
// 沉默期审批业务字段(企微场景配置把模板控件映射到这些字段)。
const (
ApprovalFieldApplyType = constants.ApprovalFieldSilenceApplyType
ApprovalFieldBatchNo = constants.ApprovalFieldSilenceBatchNo
ApprovalFieldAssetIdentifier = constants.ApprovalFieldSilenceAssetIdentifier
ApprovalFieldPackageName = constants.ApprovalFieldSilencePackageName
ApprovalFieldOrderNo = constants.ApprovalFieldSilenceOrderNo
ApprovalFieldDefaultDays = constants.ApprovalFieldSilenceDefaultDays
ApprovalFieldRequestedDays = constants.ApprovalFieldSilenceRequestedDays
ApprovalFieldOriginalDeadline = constants.ApprovalFieldSilenceOriginalDeadline
ApprovalFieldInputDays = constants.ApprovalFieldSilenceInputDays
ApprovalFieldBilledDays = constants.ApprovalFieldSilenceBilledDays
ApprovalFieldNewDeadline = constants.ApprovalFieldSilenceNewDeadline
ApprovalFieldExtensionType = constants.ApprovalFieldSilenceExtensionType
ApprovalFieldItemCount = constants.ApprovalFieldSilenceItemCount
ApprovalFieldAmount = constants.ApprovalFieldSilenceAmount
ApprovalFieldAmountCent = constants.ApprovalFieldSilenceAmountCent
ApprovalFieldReason = constants.ApprovalFieldSilenceReason
ApprovalFieldBasis = constants.ApprovalFieldSilenceBasis
ApprovalFieldEvidenceKeys = constants.ApprovalFieldSilenceEvidenceKeys
ApprovalFieldPaymentMethod = constants.ApprovalFieldSilencePaymentMethod
ApprovalFieldTradeNo = constants.ApprovalFieldSilenceTradeNo
ApprovalFieldVoucherKeys = constants.ApprovalFieldSilenceVoucherKeys
ApprovalFieldSubmitterID = constants.ApprovalFieldSubmitterID
ApprovalFieldSubmitterName = constants.ApprovalFieldSubmitterName
)
// ApprovalFields 返回沉默期审批场景允许映射的业务字段(企微场景配置校验用)。
func ApprovalFields() []string {
return []string{
ApprovalFieldApplyType, ApprovalFieldBatchNo, ApprovalFieldAssetIdentifier, ApprovalFieldPackageName,
ApprovalFieldOrderNo, ApprovalFieldDefaultDays, ApprovalFieldRequestedDays, ApprovalFieldOriginalDeadline,
ApprovalFieldInputDays, ApprovalFieldBilledDays, ApprovalFieldNewDeadline, ApprovalFieldExtensionType,
ApprovalFieldItemCount, ApprovalFieldAmount, ApprovalFieldAmountCent, ApprovalFieldReason, ApprovalFieldBasis,
ApprovalFieldEvidenceKeys, ApprovalFieldPaymentMethod, ApprovalFieldTradeNo, ApprovalFieldVoucherKeys,
ApprovalFieldSubmitterID, ApprovalFieldSubmitterName,
}
}
// approvalSubmitter 是审批发起人快照。
type approvalSubmitter struct {
ID uint
Name string
UserType int
}
func loadSubmitter(ctx context.Context, db *gorm.DB, accountID uint) (approvalSubmitter, error) {
var account model.Account
if err := db.WithContext(ctx).Select("id", "username", "user_type").Where("id = ?", accountID).First(&account).Error; err != nil {
if err == gorm.ErrRecordNotFound {
return approvalSubmitter{}, errors.New(errors.CodeForbidden, "审批发起人账号不存在")
}
return approvalSubmitter{}, errors.Wrap(errors.CodeDatabaseError, err, "查询审批发起人失败")
}
return approvalSubmitter{ID: account.ID, Name: account.Username, UserType: account.UserType}, nil
}
// prepareApproval 在业务事务前确认企微审批场景与发起人企微成员绑定可用;不可用时拒绝提交。
func prepareApproval(ctx context.Context, port approvalapp.Port, submitterID uint, correlationID string) (approvalapp.Preparation, error) {
if port == nil {
return approvalapp.Preparation{}, errors.New(errors.CodeServiceUnavailable, "沉默期审批能力未配置")
}
return port.Prepare(ctx, approvalapp.PrepareRequest{
BusinessType: constants.ApprovalBusinessTypePackageSilence, SubmitterAccountID: submitterID,
CorrelationID: correlationID,
})
}
// createApprovalInTx 在业务事务内创建审批实例并关联到批次。
func createApprovalInTx(ctx context.Context, tx *gorm.DB, port approvalapp.Port, preparation approvalapp.Preparation,
batch *model.PackageSilenceExtensionBatch, submitter approvalSubmitter, applyType string, fields map[string]any) (uint, error) {
submitterSnapshot, err := sonic.Marshal(map[string]any{
"account_id": submitter.ID, "account_name": submitter.Name, "user_type": submitter.UserType,
})
if err != nil {
return 0, errors.Wrap(errors.CodeInternalError, err, "编码沉默期审批发起人快照失败")
}
request := map[string]any{
ApprovalFieldApplyType: applyType, ApprovalFieldBatchNo: batch.BatchNo,
ApprovalFieldSubmitterID: submitter.ID, ApprovalFieldSubmitterName: submitter.Name,
}
for key, value := range fields {
request[key] = value
}
requestSnapshot, err := sonic.Marshal(request)
if err != nil {
return 0, errors.Wrap(errors.CodeInternalError, err, "编码沉默期审批业务快照失败")
}
reference, err := port.CreateInTx(ctx, tx, approvalapp.CreateRequest{
Preparation: preparation, BusinessType: constants.ApprovalBusinessTypePackageSilence,
BusinessID: batch.ID, SubmitterAccountID: submitter.ID,
SubmitterSnapshot: submitterSnapshot, RequestSnapshot: requestSnapshot, CorrelationID: batch.BatchNo,
})
if err != nil {
return 0, err
}
result := tx.WithContext(ctx).Model(&model.PackageSilenceExtensionBatch{}).
Where("id = ? AND approval_instance_id IS NULL", batch.ID).
Updates(map[string]any{"approval_instance_id": reference.InstanceID, "approval_status": constants.SilenceApprovalStatusPending})
if result.Error != nil {
return 0, errors.Wrap(errors.CodeDatabaseError, result.Error, "关联沉默期审批实例失败")
}
if result.RowsAffected != 1 {
return 0, errors.New(errors.CodeConflict, "沉默期审批实例关联已变化")
}
batch.ApprovalInstanceID = &reference.InstanceID
batch.ApprovalStatus = constants.SilenceApprovalStatusPending
return reference.InstanceID, nil
}
// generateNo 生成带前缀的单号:前缀 + 年月日时分秒 + 6 位随机数。
func generateNo(prefix string, now time.Time) string {
value, err := rand.Int(rand.Reader, big.NewInt(1000000))
random := int64(0)
if err == nil {
random = value.Int64()
}
return fmt.Sprintf("%s%s%06d", prefix, now.In(time.FixedZone("CST", 8*3600)).Format("20060102150405"), random)
}
func formatAmount(cents int64) string {
return fmt.Sprintf("%d.%02d", cents/100, cents%100)
}
func formatDate(t *time.Time) string {
if t == nil {
return ""
}
return t.In(time.FixedZone("CST", 8*3600)).Format("2006-01-02")
}

View File

@@ -1,290 +0,0 @@
package packagesilence
import (
"context"
"sort"
"time"
"gorm.io/gorm"
packagedomain "github.com/break/junhong_cmp_fiber/internal/domain/package"
"github.com/break/junhong_cmp_fiber/internal/infrastructure/audit"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/pkg/constants"
"github.com/break/junhong_cmp_fiber/pkg/errors"
)
// Backfill 是历史数据回填用例(技术方案 §6、§17.4):沉默期回填与首次购买套餐时间回填,均先出清单、只处理空值、可重复执行。
// 可在总开关关闭时运行;不给上线宽限,截止早于上线日且期内未实名的,打开总开关后过期。
type Backfill struct {
db *gorm.DB
audit *audit.Writer
firstPurchase *FirstPurchaseRecorder
now func() time.Time
}
// NewBackfill 创建回填用例。
func NewBackfill(db *gorm.DB, writer *audit.Writer) *Backfill {
return &Backfill{db: db, audit: writer, firstPurchase: NewFirstPurchaseRecorder(writer), now: time.Now}
}
// SilenceBackfillReport 是沉默期回填影响清单。
type SilenceBackfillReport struct {
Candidates int `json:"candidates"` // 待回填的套餐记录数(待实名、实名即生效主套餐、沉默期为空)
UnconfiguredPackages map[string]int `json:"unconfigured_packages"` // 套餐未配置默认沉默期、不回填的记录数(按套餐)
UnconfiguredCount int `json:"unconfigured_count"` // 未配置套餐的记录合计
Backfillable int `json:"backfillable"` // 可回填记录数
EndedInPeriod int `json:"ended_in_period"` // 截止前已实名,记为结束
RealnamedWithoutTime int `json:"realnamed_without_time"` // 已实名但实名时间为空(视为期内实名并补写)
WillExpire int `json:"will_expire"` // 打开总开关后将过期
WillExpireNoPaidExtend int `json:"will_expire_no_paid_extend"` // 将过期且无法付费延期(只能特殊延期)
Waiting int `json:"waiting"` // 回填后仍在期内等待实名
ByShop map[string]int `json:"by_shop"` // 按代理(资产当前店铺)分布
ByPackage map[string]int `json:"by_package"` // 按套餐分布
Written int `json:"written"` // 执行模式:实际写入数
SkippedConcurrentUpdate int `json:"skipped_concurrent_update"` // 执行模式:已被其他入口写入而跳过
}
type silenceCandidate struct {
model.PackageUsage
OrderPaidAt *time.Time `gorm:"column:order_paid_at"`
PkgDays *int `gorm:"column:pkg_days"`
PkgName string `gorm:"column:pkg_name"`
PkgSeries uint `gorm:"column:pkg_series"`
ShopName string `gorm:"column:shop_name"`
}
// SilenceBackfill 生成影响清单;execute 为 true 时按批写入(只处理沉默期为空的记录,可重复执行)。
func (b *Backfill) SilenceBackfill(ctx context.Context, execute bool) (*SilenceBackfillReport, error) {
report := &SilenceBackfillReport{UnconfiguredPackages: map[string]int{}, ByShop: map[string]int{}, ByPackage: map[string]int{}}
now := b.now()
lastID := uint(0)
policyCache := map[uint]PolicyMatch{}
for {
var rows []silenceCandidate
err := b.db.WithContext(ctx).Table("tb_package_usage AS u").
Joins("LEFT JOIN tb_order AS o ON o.id = u.order_id").
Joins("LEFT JOIN tb_package AS p ON p.id = u.package_id").
Joins("LEFT JOIN tb_iot_card AS c ON c.id = u.iot_card_id AND COALESCE(u.device_id, 0) = 0").
Joins("LEFT JOIN tb_device AS d ON d.id = u.device_id AND COALESCE(u.device_id, 0) > 0").
Joins("LEFT JOIN tb_shop AS s ON s.id = COALESCE(d.shop_id, c.shop_id)").
Select("u.*, o.paid_at AS order_paid_at, p.silence_period_days AS pkg_days, COALESCE(p.package_name, u.package_name) AS pkg_name, COALESCE(p.series_id, 0) AS pkg_series, COALESCE(s.shop_name, '未分配店铺') AS shop_name").
Where("u.deleted_at IS NULL AND u.status = ? AND u.pending_realname_activation = ? AND u.master_usage_id IS NULL AND u.expiry_base_snapshot = ? AND u.silence_status IS NULL AND u.id > ?",
constants.PackageUsageStatusPending, true, constants.PackageExpiryBaseFromActivation, lastID).
Order("u.id ASC").Limit(constants.SilenceBackfillBatchSize).Scan(&rows).Error
if err != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询待回填沉默期的套餐记录失败")
}
if len(rows) == 0 {
break
}
for index := range rows {
row := &rows[index]
lastID = row.ID
report.Candidates++
if row.PkgDays == nil {
report.UnconfiguredPackages[row.PkgName]++
report.UnconfiguredCount++
continue
}
report.Backfillable++
report.ByShop[row.ShopName]++
report.ByPackage[row.PkgName]++
if err := b.backfillOne(ctx, row, now, execute, report, policyCache); err != nil {
return nil, err
}
}
}
return report, nil
}
func (b *Backfill) backfillOne(ctx context.Context, row *silenceCandidate, now time.Time, execute bool, report *SilenceBackfillReport, policyCache map[uint]PolicyMatch) error {
start := row.CreatedAt
if row.OrderPaidAt != nil {
start = *row.OrderPaidAt
}
days := *row.PkgDays
deadline := packagedomain.SilenceDeadline(start, days)
facts, err := loadRealnameFacts(ctx, b.db, &row.PackageUsage)
if err != nil {
return err
}
waiting := constants.SilenceStatusWaiting
judgement := packagedomain.JudgeSilence(packagedomain.SilenceFacts{
Status: waiting, DeadlineAt: &deadline, Realnamed: facts.realnamed, RealnameAt: facts.realnameAt, Now: now,
})
status := constants.SilenceStatusWaiting
switch judgement.Decision {
case packagedomain.SilenceDecisionEnd:
status = constants.SilenceStatusEnded
report.EndedInPeriod++
if judgement.BackfillRealnameAt || len(facts.backfillCardIDs) > 0 {
report.RealnamedWithoutTime++
}
case packagedomain.SilenceDecisionExpire:
report.WillExpire++
match, ok := policyCache[row.PackageID]
if !ok {
match, err = matchPolicy(ctx, b.db, row.PackageID, row.PkgSeries, now)
if err != nil {
return err
}
policyCache[row.PackageID] = match
}
quote := packagedomain.QuoteSilenceExtension(packagedomain.SilenceQuoteInput{
DeadlineAt: deadline, Now: now, Policy: match.Snapshot, Kind: packagedomain.SilenceQuotePaid,
})
if !quote.CanExtend {
report.WillExpireNoPaidExtend++
}
default:
report.Waiting++
}
if !execute {
return nil
}
return b.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
result := tx.Model(&model.PackageUsage{}).Where("id = ? AND silence_status IS NULL AND status = ?", row.ID, constants.PackageUsageStatusPending).
Updates(map[string]any{
"silence_status": status, "silence_start_at": start, "silence_period_days": days,
"silence_deadline_at": deadline, "updated_at": now,
})
if result.Error != nil {
return errors.Wrap(errors.CodeDatabaseError, result.Error, "回填套餐沉默期失败")
}
if result.RowsAffected != 1 {
report.SkippedConcurrentUpdate++
return nil
}
if status == constants.SilenceStatusEnded {
if _, err := backfillRealnameAt(ctx, tx, facts, now); err != nil {
return err
}
}
usage := row.PackageUsage
usage.SilenceStatus, usage.SilenceStartAt, usage.SilencePeriodDays, usage.SilenceDeadlineAt = &status, &start, &days, &deadline
report.Written++
return writeRecord(ctx, tx, b.audit, recordInput{
Usage: &usage, EventType: constants.SilenceRecordEventBackfill, Trigger: constants.SilenceTriggerBackfill,
DefaultDays: &days, RealnameAt: facts.realnameAt, Reason: "历史数据回填:按购买时间起算,不给上线宽限",
Extra: map[string]any{"decision": string(judgement.Decision)},
AuditAction: constants.AuditActionUsageSilenceBackfilled, AuditSummary: "回填套餐沉默期",
})
})
}
// FirstPurchaseBackfillReport 是首次购买套餐时间回填清单。
type FirstPurchaseBackfillReport struct {
CardBackfillable int `json:"card_backfillable"` // 可回填的卡(当前世代有已支付或已退款的单卡订单,未绑定设备)
CardWithoutOrder int `json:"card_without_order"` // 无订单、保持为空的卡
CardBoundToDevice int `json:"card_bound_to_device"` // 绑定在设备上、不记录的卡
DeviceBackfillable int `json:"device_backfillable"` // 可回填的设备
DeviceWithoutOrder int `json:"device_without_order"` // 无订单、保持为空的设备
CardWritten int `json:"card_written"` // 执行模式:写入的卡
DeviceWritten int `json:"device_written"` // 执行模式:写入的设备
}
type firstOrderRow struct {
AssetID uint `gorm:"column:asset_id"`
Generation int `gorm:"column:generation"`
PaidAt time.Time `gorm:"column:paid_at"`
OrderID uint `gorm:"column:order_id"`
OrderNo string `gorm:"column:order_no"`
}
// FirstPurchaseBackfill 按资产当前世代最早一笔已支付或已退款的套餐订单回填首次购买套餐时间(排除绑定在设备上的卡),
// 插入 backfill 首次购买套餐记录;只更新为空的,可重复执行。
func (b *Backfill) FirstPurchaseBackfill(ctx context.Context, execute bool) (*FirstPurchaseBackfillReport, error) {
report := &FirstPurchaseBackfillReport{}
db := b.db.WithContext(ctx)
countWithout := func(table, orderType, column string, extra string) (int64, error) {
var count int64
err := db.Table(table+" AS a").Where("a.deleted_at IS NULL AND a.first_purchase_at IS NULL "+extra).
Where("NOT EXISTS (SELECT 1 FROM tb_order o WHERE o.order_type = ? AND o."+column+" = a.id AND o.generation = a.generation AND o.payment_status IN ? AND o.paid_at IS NOT NULL AND o.deleted_at IS NULL)",
orderType, []int{model.PaymentStatusPaid, model.PaymentStatusRefunded}).Count(&count).Error
return count, err
}
notBound := "AND NOT EXISTS (SELECT 1 FROM tb_device_sim_binding bd WHERE bd.iot_card_id = a.id AND bd.bind_status = 1 AND bd.deleted_at IS NULL)"
cardWithout, err := countWithout("tb_iot_card", model.OrderTypeSingleCard, "iot_card_id", notBound)
if err != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "统计无订单的卡失败")
}
deviceWithout, err := countWithout("tb_device", model.OrderTypeDevice, "device_id", "")
if err != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "统计无订单的设备失败")
}
var bound int64
if err := db.Table("tb_iot_card AS a").Where("a.deleted_at IS NULL AND a.first_purchase_at IS NULL AND EXISTS (SELECT 1 FROM tb_device_sim_binding bd WHERE bd.iot_card_id = a.id AND bd.bind_status = 1 AND bd.deleted_at IS NULL)").Count(&bound).Error; err != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "统计绑定在设备上的卡失败")
}
report.CardWithoutOrder, report.DeviceWithoutOrder, report.CardBoundToDevice = int(cardWithout), int(deviceWithout), int(bound)
if err := b.firstPurchaseBatches(ctx, "tb_iot_card", model.OrderTypeSingleCard, "iot_card_id", notBound, constants.FirstPurchaseAssetTypeIotCard, execute, &report.CardBackfillable, &report.CardWritten); err != nil {
return nil, err
}
if err := b.firstPurchaseBatches(ctx, "tb_device", model.OrderTypeDevice, "device_id", "", constants.FirstPurchaseAssetTypeDevice, execute, &report.DeviceBackfillable, &report.DeviceWritten); err != nil {
return nil, err
}
return report, nil
}
func (b *Backfill) firstPurchaseBatches(ctx context.Context, table, orderType, column, extra, assetType string, execute bool, backfillable, written *int) error {
lastID := uint(0)
for {
var rows []firstOrderRow
err := b.db.WithContext(ctx).Table(table+" AS a").
Joins(`JOIN LATERAL (
SELECT o.id AS order_id, o.order_no, o.paid_at FROM tb_order o
WHERE o.order_type = ? AND o.`+column+` = a.id AND o.generation = a.generation
AND o.payment_status IN ? AND o.paid_at IS NOT NULL AND o.deleted_at IS NULL
ORDER BY o.paid_at ASC, o.id ASC LIMIT 1
) AS f ON TRUE`, orderType, []int{model.PaymentStatusPaid, model.PaymentStatusRefunded}).
Select("a.id AS asset_id, a.generation, f.paid_at, f.order_id, f.order_no").
Where("a.deleted_at IS NULL AND a.first_purchase_at IS NULL AND a.id > ? "+extra, lastID).
Order("a.id ASC").Limit(constants.SilenceFirstPurchaseBackfillBatchSize).Scan(&rows).Error
if err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "查询待回填首次购买套餐时间的资产失败")
}
if len(rows) == 0 {
return nil
}
for _, row := range rows {
lastID = row.AssetID
*backfillable++
if !execute {
continue
}
err := b.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
inserted, err := b.firstPurchase.RecordBackfillInTx(ctx, tx, assetType, row.AssetID, row.Generation, row.PaidAt, row.OrderID, row.OrderNo)
if inserted {
*written++
}
return err
})
if err != nil {
return err
}
}
if len(rows) < constants.SilenceFirstPurchaseBackfillBatchSize {
return nil
}
}
}
// SortedCounts 把分布按数量倒序输出,便于清单阅读。
func SortedCounts(values map[string]int) []struct {
Name string
Count int
} {
result := make([]struct {
Name string
Count int
}, 0, len(values))
for name, count := range values {
result = append(result, struct {
Name string
Count int
}{name, count})
}
sort.Slice(result, func(i, j int) bool { return result[i].Count > result[j].Count })
return result
}

View File

@@ -1,470 +0,0 @@
package packagesilence
import (
"context"
"strings"
"time"
"gorm.io/gorm"
"gorm.io/gorm/clause"
approvalapp "github.com/break/junhong_cmp_fiber/internal/application/approval"
packagedomain "github.com/break/junhong_cmp_fiber/internal/domain/package"
"github.com/break/junhong_cmp_fiber/internal/infrastructure/audit"
"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/auditcontext"
"github.com/break/junhong_cmp_fiber/pkg/constants"
"github.com/break/junhong_cmp_fiber/pkg/errors"
"github.com/break/junhong_cmp_fiber/pkg/middleware"
)
// DecisionHandler 消费沉默期审批场景 package_silence_approval 的标准终态(技术方案 §11.3)。
// 审批实例的业务 ID 为批次 ID,按批次路径分发:adjust 购买时调整、offline 线下付费延期、special 特殊延期。
// 回调不依赖表单文本;以批次与明细状态条件更新保证重复事件只处理一次。总开关关闭时照常处理。
type DecisionHandler struct {
extensions *ExtensionService
}
// NewDecisionHandler 创建沉默期审批终态消费者。
func NewDecisionHandler(extensions *ExtensionService) *DecisionHandler {
return &DecisionHandler{extensions: extensions}
}
var _ approvalapp.BusinessDecisionHandler = (*DecisionHandler)(nil)
// Handle 幂等消费标准审批终态。
func (h *DecisionHandler) Handle(ctx context.Context, event approvalapp.TerminalDecisionEvent) error {
if h == nil || h.extensions == nil {
return errors.New(errors.CodeInternalError, "沉默期审批终态能力未配置")
}
if event.BusinessType != constants.ApprovalBusinessTypePackageSilence || event.BusinessID == 0 || event.InstanceID == 0 {
return errors.New(errors.CodeInvalidParam, "沉默期审批终态参数无效")
}
ctx = auditcontext.With(ctx, auditcontext.Context{
ActorKind: constants.AuditActorExternalSystem, ActorID: "wecom_approval", ActorName: "企业微信审批",
Source: constants.AuditSourceCallback, CorrelationID: event.CorrelationID, ParentEventID: event.EventID,
})
approved := event.Decision == constants.ApprovalDecisionApproved
if event.Decision == constants.ApprovalDecisionRevokedAfterApproved {
// 通过后撤销:延期已按通过执行,不回滚。
return nil
}
s := h.extensions
var batch *model.PackageSilenceExtensionBatch
var postUsageIDs []uint
executeSpecial := false
err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
var err error
batch, err = lockBatchForDecision(ctx, tx, event.BusinessID)
if err != nil {
return err
}
if batch.ApprovalInstanceID == nil || *batch.ApprovalInstanceID != event.InstanceID {
return errors.New(errors.CodeConflict, "沉默期批次关联的审批实例不一致")
}
if batch.Status != constants.SilenceBatchStatusPendingApproval {
return nil
}
now := s.now()
approvalStatus := constants.SilenceApprovalStatusRejected
if approved {
approvalStatus = constants.SilenceApprovalStatusApproved
} else if event.Decision == constants.ApprovalDecisionCancelled || event.Decision == constants.ApprovalDecisionDeleted {
approvalStatus = constants.SilenceApprovalStatusCanceled
}
switch batch.Path {
case constants.SilencePathAdjust:
usageID, err := s.applyAdjustDecisionInTx(ctx, tx, batch, approved, approvalStatus, now)
if usageID > 0 {
postUsageIDs = append(postUsageIDs, usageID)
}
return err
case constants.SilencePathOffline:
usageID, err := s.applyOfflineDecisionInTx(ctx, tx, batch, approved, approvalStatus, now)
if usageID > 0 {
postUsageIDs = append(postUsageIDs, usageID)
}
return err
case constants.SilencePathSpecial:
executeSpecial, err = s.applySpecialDecisionInTx(ctx, tx, batch, approved, approvalStatus, now)
return err
default:
return errors.New(errors.CodeInvalidStatus, "沉默期批次路径不支持审批")
}
})
if err != nil {
return err
}
if executeSpecial {
return s.executeBatch(ctx, batch.ID)
}
if batch != nil && batch.Path == constants.SilencePathAdjust {
for _, id := range postUsageIDs {
if _, err := s.resolver.ResolveAndActivate(ctx, id, ResolveOptions{Trigger: constants.SilenceTriggerAdjustApproval, AllowExpire: s.sw.Enabled(ctx)}); err != nil {
return err
}
}
return nil
}
s.afterExecuted(ctx, postUsageIDs)
return nil
}
// applyAdjustDecisionInTx 处理购买时调整审批终态(技术方案 §4.3):通过按 起算日期 + 审批天数 重算截止日;
// 驳回或撤回保持默认值;两种情况都清除审批中标记,提交后立即重新判定。
func (s *ExtensionService) applyAdjustDecisionInTx(ctx context.Context, tx *gorm.DB, batch *model.PackageSilenceExtensionBatch, approved bool, approvalStatus string, now time.Time) (uint, error) {
if batch.AdjustPackageUsageID == nil {
return 0, errors.New(errors.CodeInvalidStatus, "调整申请缺少套餐记录")
}
var usage model.PackageUsage
if err := tx.WithContext(ctx).Clauses(clause.Locking{Strength: "UPDATE"}).Where("id = ?", *batch.AdjustPackageUsageID).First(&usage).Error; err != nil {
return 0, errors.Wrap(errors.CodeDatabaseError, err, "锁定套餐记录失败")
}
batchStatus := constants.SilenceBatchStatusRejected
values := map[string]any{"silence_adjust_pending": false, "updated_at": now}
beforeDeadline := copyTimePtr(usage.SilenceDeadlineAt)
days := usage.SilencePeriodDays
if approved && batch.AdjustRequestedDays != nil && usage.SilenceStartAt != nil {
deadline := packagedomain.SilenceDeadline(*usage.SilenceStartAt, *batch.AdjustRequestedDays)
values["silence_period_days"] = *batch.AdjustRequestedDays
values["silence_deadline_at"] = deadline
usage.SilenceDeadlineAt = &deadline
days = batch.AdjustRequestedDays
batchStatus = constants.SilenceBatchStatusCompleted
}
if err := tx.WithContext(ctx).Model(&model.PackageUsage{}).Where("id = ? AND silence_adjust_pending = ?", usage.ID, true).Updates(values).Error; err != nil {
return 0, errors.Wrap(errors.CodeDatabaseError, err, "更新套餐沉默期调整结果失败")
}
usage.SilenceAdjustPending = false
usage.SilencePeriodDays = days
if _, err := postgres.NewPackageSilenceBatchStore(tx).TransitionStatus(ctx, batch.ID, []string{constants.SilenceBatchStatusPendingApproval}, batchStatus, map[string]any{
"approval_status": approvalStatus, "approval_finished_at": now, "completed_at": now,
"succeeded_count": boolToInt(approved), "failed_count": boolToInt(!approved),
}); err != nil {
return 0, errors.Wrap(errors.CodeDatabaseError, err, "更新调整申请状态失败")
}
action, summary := constants.AuditActionUsageSilenceAdjustRejected, "购买时调整沉默期审批未通过,保持默认值"
if approved {
action, summary = constants.AuditActionUsageSilenceAdjustApproved, "购买时调整沉默期审批通过,重算截止日"
}
approvalID, batchID := *batch.ApprovalInstanceID, batch.ID
changeDays := 0
if batch.AdjustRequestedDays != nil && batch.AdjustDefaultDays != nil {
changeDays = *batch.AdjustRequestedDays - *batch.AdjustDefaultDays
}
err := writeRecord(ctx, tx, s.audit, recordInput{
Usage: &usage, EventType: constants.SilenceRecordEventAdjust, Trigger: constants.SilenceTriggerAdjustApproval,
BeforeStatus: copyStringPtr(usage.SilenceStatus), BeforeDeadline: beforeDeadline, DefaultDays: batch.AdjustDefaultDays,
IsAdjusted: approved, ChangeDays: changeDays, Reason: batch.Reason, ApprovalID: &approvalID, BatchID: &batchID,
Extra: map[string]any{"stage": approvalStatus, "requested_days": batch.AdjustRequestedDays},
AuditAction: action, AuditSummary: summary,
})
return usage.ID, err
}
// applyOfflineDecisionInTx 处理线下付费延期审批:通过执行延期,驳回或撤回订单 rejected、不延期、系统不退款。
func (s *ExtensionService) applyOfflineDecisionInTx(ctx context.Context, tx *gorm.DB, batch *model.PackageSilenceExtensionBatch, approved bool, approvalStatus string, now time.Time) (uint, error) {
var order model.PackageSilenceExtensionOrder
if err := tx.WithContext(ctx).Clauses(clause.Locking{Strength: "UPDATE"}).Where("batch_id = ?", batch.ID).First(&order).Error; err != nil {
return 0, errors.Wrap(errors.CodeDatabaseError, err, "锁定线下延期订单失败")
}
if _, err := postgres.NewPackageSilenceBatchStore(tx).TransitionStatus(ctx, batch.ID, []string{constants.SilenceBatchStatusPendingApproval},
map[bool]string{true: constants.SilenceBatchStatusExecuting, false: constants.SilenceBatchStatusRejected}[approved],
map[string]any{"approval_status": approvalStatus, "approval_finished_at": now}); err != nil {
return 0, errors.Wrap(errors.CodeDatabaseError, err, "更新线下延期批次失败")
}
var usage model.PackageUsage
_ = tx.WithContext(ctx).Where("id = ?", order.PackageUsageID).First(&usage).Error
if !approved {
if _, err := postgres.NewPackageSilenceOrderStore(tx).TransitionStatus(ctx, order.ID, []string{constants.SilenceOrderStatusPendingApproval}, constants.SilenceOrderStatusRejected, nil); err != nil {
return 0, errors.Wrap(errors.CodeDatabaseError, err, "更新线下延期订单为已驳回失败")
}
if err := tx.Model(&model.PackageSilenceExtension{}).Where("batch_id = ? AND status = ?", batch.ID, constants.SilenceExtensionStatusPendingApproval).
Updates(map[string]any{"status": constants.SilenceExtensionStatusRejected, "updated_at": now}).Error; err != nil {
return 0, errors.Wrap(errors.CodeDatabaseError, err, "更新线下延期明细为已驳回失败")
}
if err := tx.Model(&model.PackageSilenceExtensionBatch{}).Where("id = ?", batch.ID).Updates(map[string]any{"failed_count": 1, "completed_at": now}).Error; err != nil {
return 0, errors.Wrap(errors.CodeDatabaseError, err, "更新线下延期批次计数失败")
}
before := order.Status
order.Status = constants.SilenceOrderStatusRejected
if usage.ID > 0 && s.audit != nil {
_ = s.audit.AppendSilenceUsage(ctx, tx, extensionAuditInput(&usage, batch, constants.AuditActionUsageSilenceExtensionRejected, "线下付费延期审批未通过"))
}
return 0, s.writeOrderAudit(ctx, tx, &order, before, "线下付费延期审批未通过,订单已驳回")
}
if usage.ID > 0 && s.audit != nil {
_ = s.audit.AppendSilenceUsage(ctx, tx, extensionAuditInput(&usage, batch, constants.AuditActionUsageSilenceExtensionApproved, "线下付费延期审批通过"))
}
ok, err := s.executeOrderInTx(ctx, tx, &order)
if err != nil || !ok {
return 0, err
}
return order.PackageUsageID, nil
}
// applySpecialDecisionInTx 处理特殊延期审批:通过时明细置为已批准、提交后逐条执行;驳回或撤回不产生任何变化。
func (s *ExtensionService) applySpecialDecisionInTx(ctx context.Context, tx *gorm.DB, batch *model.PackageSilenceExtensionBatch, approved bool, approvalStatus string, now time.Time) (bool, error) {
batchStatus, detailStatus := constants.SilenceBatchStatusRejected, constants.SilenceExtensionStatusRejected
action, summary := constants.AuditActionUsageSilenceExtensionRejected, "特殊延期审批未通过"
if approved {
batchStatus, detailStatus = constants.SilenceBatchStatusExecuting, constants.SilenceExtensionStatusApproved
action, summary = constants.AuditActionUsageSilenceExtensionApproved, "特殊延期审批通过"
}
if _, err := postgres.NewPackageSilenceBatchStore(tx).TransitionStatus(ctx, batch.ID, []string{constants.SilenceBatchStatusPendingApproval}, batchStatus,
map[string]any{"approval_status": approvalStatus, "approval_finished_at": now}); err != nil {
return false, errors.Wrap(errors.CodeDatabaseError, err, "更新特殊延期批次失败")
}
var extensions []model.PackageSilenceExtension
if err := tx.WithContext(ctx).Where("batch_id = ? AND status = ?", batch.ID, constants.SilenceExtensionStatusPendingApproval).Find(&extensions).Error; err != nil {
return false, errors.Wrap(errors.CodeDatabaseError, err, "查询特殊延期明细失败")
}
if err := tx.Model(&model.PackageSilenceExtension{}).Where("batch_id = ? AND status = ?", batch.ID, constants.SilenceExtensionStatusPendingApproval).
Updates(map[string]any{"status": detailStatus, "updated_at": now}).Error; err != nil {
return false, errors.Wrap(errors.CodeDatabaseError, err, "更新特殊延期明细失败")
}
if !approved {
if err := tx.Model(&model.PackageSilenceExtensionBatch{}).Where("id = ?", batch.ID).
Updates(map[string]any{"failed_count": len(extensions), "completed_at": now}).Error; err != nil {
return false, errors.Wrap(errors.CodeDatabaseError, err, "更新特殊延期批次计数失败")
}
}
for index := range extensions {
var usage model.PackageUsage
if err := tx.WithContext(ctx).Where("id = ?", extensions[index].PackageUsageID).First(&usage).Error; err == nil && s.audit != nil {
_ = s.audit.AppendSilenceUsage(ctx, tx, extensionAuditInput(&usage, batch, action, summary))
}
}
return approved, nil
}
// HandleException 后台处理异常明细(权限 package_silence:exception):重试执行或关闭;不得直接改库时间。
// 在线订单关闭时按冻结商户原路退款。
func (s *ExtensionService) HandleException(ctx context.Context, extensionID uint, req dto.SilenceExceptionHandleRequest) (*dto.SilenceExtensionResponse, error) {
if err := s.requirePermission(ctx, constants.PermissionPackageSilenceException); err != nil {
return nil, err
}
reason := strings.TrimSpace(req.Reason)
if reason == "" {
return nil, errors.New(errors.CodeInvalidParam, "处理原因不能为空")
}
extension, err := postgres.NewPackageSilenceExtensionStore(s.db).GetByID(ctx, extensionID)
if err != nil {
if err == gorm.ErrRecordNotFound {
return nil, errors.New(errors.CodeNotFound, "延期明细不存在")
}
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询延期明细失败")
}
if _, err := s.loadUsage(ctx, s.db, extension.PackageUsageID, Scope{}); err != nil {
return nil, err
}
if extension.Status != constants.SilenceExtensionStatusException {
return nil, errors.New(errors.CodeInvalidStatus, "只能处理执行异常的明细")
}
operatorID := middleware.GetUserIDFromContext(ctx)
now := s.now()
executed, refundOrderID := false, uint(0)
err = s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
locked, err := postgres.NewPackageSilenceExtensionStore(tx).LockByID(ctx, extensionID)
if err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "锁定延期明细失败")
}
if locked.Status != constants.SilenceExtensionStatusException {
return errors.New(errors.CodeInvalidStatus, "只能处理执行异常的明细")
}
var order *model.PackageSilenceExtensionOrder
if locked.ExtensionOrderID != nil {
if order, err = postgres.NewPackageSilenceOrderStore(tx).LockByID(ctx, *locked.ExtensionOrderID); err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "锁定延期订单失败")
}
}
if err := tx.Model(&model.PackageSilenceExtension{}).Where("id = ?", locked.ID).
Updates(map[string]any{"handled_by": operatorID, "handled_at": now}).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "记录异常处理人失败")
}
if req.Action == "close" {
if _, err := postgres.NewPackageSilenceExtensionStore(tx).TransitionStatus(ctx, locked.ID, []string{constants.SilenceExtensionStatusException},
constants.SilenceExtensionStatusClosed, map[string]any{"failure_summary": truncate("人工关闭:"+reason, 500)}); err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "关闭异常明细失败")
}
if order != nil && order.PaymentMethod != model.PaymentByOffline && order.Status == constants.SilenceOrderStatusException {
refundOrderID = order.ID
}
if order != nil && order.PaymentMethod == model.PaymentByOffline && order.Status == constants.SilenceOrderStatusException {
if _, err := postgres.NewPackageSilenceOrderStore(tx).TransitionStatus(ctx, order.ID, []string{constants.SilenceOrderStatusException},
constants.SilenceOrderStatusClosed, map[string]any{"closed_at": now, "close_reason": truncate("人工关闭:"+reason, 200)}); err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "关闭线下延期订单失败")
}
}
return refreshBatchCountsInTx(ctx, tx, locked.BatchID, now)
}
// 重试:按原明细与快照重新执行
batch, err := postgres.NewPackageSilenceBatchStore(tx).GetByID(ctx, locked.BatchID)
if err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "查询延期批次失败")
}
if order != nil {
if order.Status != constants.SilenceOrderStatusException {
return errors.New(errors.CodeInvalidStatus, "延期订单不是异常状态,不能重试")
}
if order.RefundNo != nil {
return errors.New(errors.CodeInvalidStatus, "延期订单已发起原路退款,不能重试")
}
ok, err := s.executeOrderInTx(ctx, tx, order)
executed = ok
return err
}
from := []string{constants.SilenceExtensionStatusException}
outcome, err := s.executeExtensionInTx(ctx, tx, locked, executeKind{free: batch.ExtensionType == constants.SilenceExtensionTypeFree},
paidSnapshot{freeLimit: batch.FreeExtensionLimitSnapshot}, from)
if err != nil {
return err
}
if !outcome.executed {
return errors.NewWithData(errors.CodeInvalidStatus, map[string]any{"failure_code": outcome.failureCode}, "重试仍不满足延期条件:"+outcome.failureText)
}
executed = true
return refreshBatchCountsInTx(ctx, tx, locked.BatchID, now)
})
if err != nil {
return nil, err
}
if executed {
s.afterExecuted(ctx, []uint{extension.PackageUsageID})
}
if refundOrderID > 0 {
if err := s.refundOrder(ctx, refundOrderID, "异常明细人工关闭,原路退款:"+reason); err != nil {
return nil, err
}
}
current, err := postgres.NewPackageSilenceExtensionStore(s.db).GetByID(ctx, extensionID)
if err != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询延期明细失败")
}
resp := extensionDTO(current)
return &resp, nil
}
// BatchDetail 查询延期批次与明细。
func (s *ExtensionService) BatchDetail(ctx context.Context, batchID uint) (*dto.SilenceBatchResponse, error) {
batch, err := postgres.NewPackageSilenceBatchStore(s.db).GetByID(ctx, batchID)
if err != nil {
if err == gorm.ErrRecordNotFound {
return nil, errors.New(errors.CodeNotFound, "延期批次不存在")
}
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询延期批次失败")
}
extensions, err := postgres.NewPackageSilenceExtensionStore(s.db).ListByBatch(ctx, batchID)
if err != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询延期明细失败")
}
if len(extensions) > 0 {
if _, err := s.loadUsage(ctx, s.db, extensions[0].PackageUsageID, Scope{}); err != nil {
return nil, err
}
} else if batch.AdjustPackageUsageID != nil {
if _, err := s.loadUsage(ctx, s.db, *batch.AdjustPackageUsageID, Scope{}); err != nil {
return nil, err
}
}
resp := BatchDTO(batch)
for index := range extensions {
resp.Items = append(resp.Items, extensionDTO(&extensions[index]))
}
return resp, nil
}
// Records 按套餐记录查询沉默期流水(后台与 H5)。
func (s *ExtensionService) Records(ctx context.Context, usageID uint, scope Scope) ([]dto.SilenceRecordResponse, error) {
if _, err := s.loadUsage(ctx, s.db, usageID, scope); err != nil {
return nil, err
}
records, err := postgres.NewPackageSilenceRecordStore(s.db).ListByUsage(ctx, usageID)
if err != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询沉默期流水失败")
}
items := make([]dto.SilenceRecordResponse, 0, len(records))
for index := range records {
record := &records[index]
items = append(items, dto.SilenceRecordResponse{
ID: record.ID, PackageUsageID: record.PackageUsageID, EventType: record.EventType,
EventName: RecordEventName(record.EventType), TriggerSource: record.TriggerSource, AssetType: record.AssetType,
AssetID: record.AssetID, BeforeStatus: record.BeforeStatus, AfterStatus: record.AfterStatus,
BeforeDeadlineAt: record.BeforeDeadlineAt, AfterDeadlineAt: record.AfterDeadlineAt, PeriodDays: record.PeriodDays,
DefaultDays: record.DefaultDaysSnapshot, IsAdjusted: record.IsAdjusted, ChangeDays: record.ChangeDays,
OperatorID: record.OperatorID, OperatorType: record.OperatorType, Reason: record.Reason,
ApprovalInstanceID: record.ApprovalInstanceID, BatchID: record.BatchID, ExtensionID: record.ExtensionID,
ExchangeNo: record.ExchangeNo, CreatedAt: record.CreatedAt,
})
}
return items, nil
}
// RecordEventName 返回流水事件名称。
func RecordEventName(event string) string {
switch event {
case constants.SilenceRecordEventSet:
return "设置沉默期"
case constants.SilenceRecordEventAdjust:
return "购买时调整"
case constants.SilenceRecordEventExpired:
return "沉默期过期"
case constants.SilenceRecordEventEnded:
return "期内实名结束"
case constants.SilenceRecordEventExtend:
return "延期"
case constants.SilenceRecordEventExchange:
return "换货迁移"
case constants.SilenceRecordEventBackfill:
return "历史回填"
case constants.SilenceRecordEventRealnameBackfill:
return "补写实名时间"
default:
return event
}
}
// BatchDTO 构造批次响应(不含明细)。
func BatchDTO(batch *model.PackageSilenceExtensionBatch) *dto.SilenceBatchResponse {
return &dto.SilenceBatchResponse{
ID: batch.ID, BatchNo: batch.BatchNo, Path: batch.Path, ExtensionType: batch.ExtensionType,
ApprovalRequired: batch.ApprovalRequired, ApprovalInstanceID: batch.ApprovalInstanceID,
ApprovalStatus: batch.ApprovalStatus, Status: batch.Status, Reason: batch.Reason, Basis: batch.Basis,
EvidenceRefs: nonNil([]string(batch.EvidenceRefs)), Amount: batch.Amount, RequestedBy: batch.RequestedBy,
RequestedAt: batch.RequestedAt, RequestedCount: batch.RequestedCount, SucceededCount: batch.SucceededCount,
FailedCount: batch.FailedCount, CompletedAt: batch.CompletedAt,
}
}
func extensionDTO(extension *model.PackageSilenceExtension) dto.SilenceExtensionResponse {
return dto.SilenceExtensionResponse{
ID: extension.ID, BatchID: extension.BatchID, RowNo: extension.RowNo, PackageUsageID: extension.PackageUsageID,
PackageName: extension.PackageNameSnapshot, AssetType: extension.AssetType, AssetIdentifier: extension.AssetIdentifierSnapshot,
ExtensionType: extension.ExtensionType, InputDays: extension.InputDays, Months: extension.Months,
ExtensionDays: extension.ExtensionDays, Amount: extension.Amount, ExtensionOrderID: extension.ExtensionOrderID,
BeforeDeadlineAt: extension.BeforeDeadlineAt, AfterDeadlineAt: extension.AfterDeadlineAt, Status: extension.Status,
FailureCode: extension.FailureCode, FailureSummary: extension.FailureSummary, ExecutedAt: extension.ExecutedAt,
}
}
func extensionAuditInput(usage *model.PackageUsage, batch *model.PackageSilenceExtensionBatch, action, summary string) audit.SilenceAuditInput {
return audit.SilenceAuditInput{
ActionCode: action, Summary: summary, Usage: usage,
AfterData: map[string]any{"batch_status": batch.Status},
Metadata: map[string]any{
"batch_no": batch.BatchNo, "batch_id": batch.ID, "path": batch.Path, "extension_type": batch.ExtensionType,
"approval_required": batch.ApprovalRequired, "approval_instance_id": batch.ApprovalInstanceID,
"amount": batch.Amount, "reason": truncate(batch.Reason, 200), "evidence_refs": []string(batch.EvidenceRefs),
"requested_by": batch.RequestedBy,
},
}
}
func boolToInt(value bool) int {
if value {
return 1
}
return 0
}

View File

@@ -1,70 +0,0 @@
package packagesilence
import (
"context"
"time"
"gorm.io/gorm"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/pkg/constants"
"github.com/break/junhong_cmp_fiber/pkg/errors"
)
// ExchangeHook 是换货流程中的沉默期与首次购买套餐时间接缝(技术方案 §9、§17.3.2)。
type ExchangeHook struct {
setter *Setter
firstPurchase *FirstPurchaseRecorder
}
// NewExchangeHook 创建换货接缝。
func NewExchangeHook(setter *Setter, firstPurchase *FirstPurchaseRecorder) *ExchangeHook {
return &ExchangeHook{setter: setter, firstPurchase: firstPurchase}
}
// OnUsagesMigratedInTx 换货迁移套餐记录后写沉默期流水(exchange):沉默期字段随记录迁移,不重新起算,截止时间不变;
// 过期记录仍为过期,可在新资产上延期。
func (h *ExchangeHook) OnUsagesMigratedInTx(ctx context.Context, tx *gorm.DB, order *model.ExchangeOrder, usageIDs []uint) error {
if h == nil || len(usageIDs) == 0 {
return nil
}
var usages []model.PackageUsage
if err := tx.WithContext(ctx).Where("id IN ? AND silence_status IS NOT NULL", usageIDs).Find(&usages).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "查询迁移的沉默期套餐记录失败")
}
var writer = h.setter.audit
for index := range usages {
usage := &usages[index]
exchangeID := order.ID
if err := writeRecord(ctx, tx, writer, recordInput{
Usage: usage, EventType: constants.SilenceRecordEventExchange, Trigger: constants.SilenceTriggerExchange,
BeforeStatus: copyStringPtr(usage.SilenceStatus), BeforeDeadline: copyTimePtr(usage.SilenceDeadlineAt),
ExchangeID: &exchangeID, ExchangeNo: order.ExchangeNo, Reason: "换货迁移套餐记录,沉默期随套餐迁移、不重新起算",
Extra: map[string]any{
"old_asset_type": order.OldAssetType, "old_asset_id": order.OldAssetID,
"new_asset_type": order.NewAssetType, "new_asset_id": order.NewAssetID,
},
AuditAction: constants.AuditActionUsageSilenceMigratedOnExchange, AuditSummary: "换货迁移套餐沉默期",
}); err != nil {
return err
}
}
return nil
}
// OnOldAssetRenewedInTx 旧资产换货回库、世代 +1 的同一事务内清空首次购买套餐时间并结束旧世代首次购买套餐记录。
func (h *ExchangeHook) OnOldAssetRenewedInTx(ctx context.Context, tx *gorm.DB, assetType string, assetID uint, oldGeneration int, at time.Time) error {
if h == nil || h.firstPurchase == nil {
return nil
}
return h.firstPurchase.EndGenerationInTx(ctx, tx, normalizeFirstPurchaseAssetType(assetType), assetID, oldGeneration, at)
}
func normalizeFirstPurchaseAssetType(assetType string) string {
switch assetType {
case "card", constants.FirstPurchaseAssetTypeIotCard:
return constants.FirstPurchaseAssetTypeIotCard
default:
return constants.FirstPurchaseAssetTypeDevice
}
}

View File

@@ -1,231 +0,0 @@
package packagesilence
import (
"context"
"time"
"gorm.io/gorm"
"gorm.io/gorm/clause"
packagedomain "github.com/break/junhong_cmp_fiber/internal/domain/package"
"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"
)
// executeKind 描述延期执行时的计数口径。
type executeKind struct {
paid bool // 付费延期(在线与线下):受政策快照上限约束并计入付费累计天数
free bool // 免费延期:受免费次数约束并计数
}
// executeOutcome 是一次延期执行结果。
type executeOutcome struct {
executed bool
failureCode string
failureText string
afterAt time.Time
}
// paidSnapshot 是付费延期执行时使用的政策快照上限。
type paidSnapshot struct {
paidMaxMonths int
singleMaxMonths *int
freeLimit int
}
// executeExtensionInTx 执行一条延期明细(技术方案 §8.6),调用方在同一事务内:
// 1. 锁载体、锁套餐记录并重读;2. 校验仍为过期、新截止晚于当天,付费另校验上限、免费校验次数;
// 3. 更新套餐记录沉默期字段;4. 写明细状态、流水与审计。
//
// 校验不通过时不改套餐记录,返回失败码由调用方把明细置为 exception(付费)或 failed(批量特殊延期)。
// 事务内不调用支付渠道、Gateway 或企微接口;以明细状态条件更新保证幂等。
func (s *ExtensionService) executeExtensionInTx(ctx context.Context, tx *gorm.DB, extension *model.PackageSilenceExtension, kind executeKind, snapshot paidSnapshot, fromStatuses []string) (executeOutcome, error) {
now := s.now()
var usage model.PackageUsage
if err := tx.WithContext(ctx).Clauses(clause.Locking{Strength: "UPDATE"}).Where("id = ?", extension.PackageUsageID).First(&usage).Error; err != nil {
if err == gorm.ErrRecordNotFound {
return executeOutcome{failureCode: constants.SilenceFailureUsageInvalid, failureText: "套餐记录不存在"}, nil
}
return executeOutcome{}, errors.Wrap(errors.CodeDatabaseError, err, "锁定套餐记录失败")
}
if err := lockCarrier(ctx, tx, &usage); err != nil {
return executeOutcome{}, err
}
status := ""
if usage.SilenceStatus != nil {
status = *usage.SilenceStatus
}
if usage.Status != constants.PackageUsageStatusPending || usage.SilenceDeadlineAt == nil ||
!packagedomain.IsSilenceEffectivelyExpired(status, usage.SilenceDeadlineAt, usage.SilenceAdjustPending, now) {
return executeOutcome{failureCode: constants.SilenceFailureNotExpired, failureText: "套餐不是沉默期已过期状态(已激活、已退款或未到期)"}, nil
}
before := *usage.SilenceDeadlineAt
after := packagedomain.SilenceDeadline(before, extension.ExtensionDays)
if !after.After(packagedomain.SilenceDeadline(now, 0)) {
return executeOutcome{failureCode: constants.SilenceFailureDeadlineNotAfter, failureText: "延期后的截止日期不晚于今天"}, nil
}
if kind.paid {
if snapshot.singleMaxMonths != nil && extension.Months > *snapshot.singleMaxMonths {
return executeOutcome{failureCode: constants.SilenceFailureExceedsLimit, failureText: "超过单次付费延期月数上限"}, nil
}
if usage.SilencePaidExtendedDays/constants.SilenceDaysPerMonth+extension.Months > snapshot.paidMaxMonths {
return executeOutcome{failureCode: constants.SilenceFailureExceedsLimit, failureText: "超过付费延期累计月数上限"}, nil
}
}
if kind.free && usage.SilenceFreeExtensionUsed >= snapshot.freeLimit {
return executeOutcome{failureCode: constants.SilenceFailureFreeUsedUp, failureText: "免费延期次数已用完"}, nil
}
updates := map[string]any{
"silence_status": constants.SilenceStatusWaiting, "silence_deadline_at": after,
"silence_extended_days": gorm.Expr("silence_extended_days + ?", extension.ExtensionDays),
"silence_extension_count": gorm.Expr("silence_extension_count + 1"),
"updated_at": now,
}
if kind.paid {
updates["silence_paid_extended_days"] = gorm.Expr("silence_paid_extended_days + ?", extension.ExtensionDays)
}
if kind.free {
updates["silence_free_extension_used"] = gorm.Expr("silence_free_extension_used + 1")
}
result := tx.WithContext(ctx).Model(&model.PackageUsage{}).
Where("id = ? AND status = ? AND silence_deadline_at = ?", usage.ID, constants.PackageUsageStatusPending, before).
Updates(updates)
if result.Error != nil {
return executeOutcome{}, errors.Wrap(errors.CodeDatabaseError, result.Error, "更新套餐沉默期失败")
}
if result.RowsAffected != 1 {
return executeOutcome{failureCode: constants.SilenceFailureUsageInvalid, failureText: "套餐沉默期已变化"}, nil
}
affected, err := postgres.NewPackageSilenceExtensionStore(tx).TransitionStatus(ctx, extension.ID, fromStatuses, constants.SilenceExtensionStatusCompleted, map[string]any{
"before_deadline_at": before, "after_deadline_at": after, "executed_at": now, "failure_code": "", "failure_summary": "",
})
if err != nil {
return executeOutcome{}, errors.Wrap(errors.CodeDatabaseError, err, "更新延期明细失败")
}
if affected != 1 {
// 明细已被其他事件执行:回滚本次对套餐记录的更新,保证只执行一次。
return executeOutcome{}, errAlreadyExecuted
}
extension.Status = constants.SilenceExtensionStatusCompleted
extension.BeforeDeadlineAt, extension.AfterDeadlineAt, extension.ExecutedAt = &before, &after, &now
beforeStatus := stringPtr(status)
usage.SilenceStatus = stringPtr(constants.SilenceStatusWaiting)
usage.SilenceDeadlineAt = &after
usage.SilenceExtendedDays += extension.ExtensionDays
usage.SilenceExtensionCount++
if kind.paid {
usage.SilencePaidExtendedDays += extension.ExtensionDays
}
if kind.free {
usage.SilenceFreeExtensionUsed++
}
batchID, extensionID := extension.BatchID, extension.ID
if err := writeRecord(ctx, tx, s.audit, recordInput{
Usage: &usage, EventType: constants.SilenceRecordEventExtend, Trigger: constants.SilenceTriggerExtension,
BeforeStatus: beforeStatus, BeforeDeadline: &before, ChangeDays: extension.ExtensionDays,
BatchID: &batchID, ExtensionID: &extensionID, Reason: "沉默期延期生效",
Extra: map[string]any{
"extension_type": extension.ExtensionType, "months": extension.Months, "amount": extension.Amount,
"extension_order_id": extension.ExtensionOrderID, "paid": kind.paid, "free": kind.free,
},
AuditAction: constants.AuditActionUsageSilenceExtensionApplied, AuditSummary: "沉默期延期生效",
}); err != nil {
return executeOutcome{}, err
}
return executeOutcome{executed: true, afterAt: after}, nil
}
// errAlreadyExecuted 表示明细已被并发事件执行,调用方按幂等成功处理。
var errAlreadyExecuted = errors.New(errors.CodeConflict, "延期明细已执行")
func isAlreadyExecuted(err error) bool {
return err == errAlreadyExecuted
}
// markExtensionFailedInTx 把明细置为 exception / failed 并写审计(不改套餐记录)。
func (s *ExtensionService) markExtensionFailedInTx(ctx context.Context, tx *gorm.DB, extension *model.PackageSilenceExtension, to string, fromStatuses []string, code, text string) error {
affected, err := postgres.NewPackageSilenceExtensionStore(tx).TransitionStatus(ctx, extension.ID, fromStatuses, to, map[string]any{
"failure_code": code, "failure_summary": truncate(text, 500),
})
if err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "更新延期明细失败状态失败")
}
if affected != 1 {
return nil
}
extension.Status, extension.FailureCode, extension.FailureSummary = to, code, text
if s.audit == nil {
return nil
}
var usage model.PackageUsage
if err := tx.WithContext(ctx).Where("id = ?", extension.PackageUsageID).First(&usage).Error; err != nil {
return nil
}
return s.audit.AppendSilenceUsage(ctx, tx, auditInputForException(&usage, extension, code, text))
}
// afterExecuted 在事务提交后按延期结果判定沉默期:资产在新截止内已实名则立即激活(或排队)。
func (s *ExtensionService) afterExecuted(ctx context.Context, usageIDs []uint) {
for _, id := range usageIDs {
if _, err := s.resolver.ResolveAndActivate(ctx, id, ResolveOptions{Trigger: constants.SilenceTriggerExtension, AllowExpire: s.sw.Enabled(ctx)}); err != nil {
s.logger.Warn("延期生效后判定沉默期失败,等待扫描或实名接续")
}
}
}
// lockCarrier 锁载体行,避免与换货、实名、激活并发改变载体关系。
func lockCarrier(ctx context.Context, tx *gorm.DB, usage *model.PackageUsage) error {
assetType, assetID := usageAsset(usage)
if assetID == 0 {
return nil
}
var id uint
if err := tx.WithContext(ctx).Table(firstPurchaseTable(assetType)).Clauses(clause.Locking{Strength: "UPDATE"}).
Select("id").Where("id = ?", assetID).Scan(&id).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "锁定套餐载体失败")
}
return nil
}
// refreshBatchCountsInTx 按明细结果刷新批次计数与状态(部分失败不影响其他明细)。
func refreshBatchCountsInTx(ctx context.Context, tx *gorm.DB, batchID uint, now time.Time) error {
type countRow struct {
Status string
Count int
}
var rows []countRow
if err := tx.WithContext(ctx).Model(&model.PackageSilenceExtension{}).Select("status, COUNT(*) AS count").
Where("batch_id = ?", batchID).Group("status").Scan(&rows).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "统计延期明细失败")
}
total, succeeded, failed, open := 0, 0, 0, 0
for _, row := range rows {
total += row.Count
switch row.Status {
case constants.SilenceExtensionStatusCompleted:
succeeded += row.Count
case constants.SilenceExtensionStatusFailed, constants.SilenceExtensionStatusException,
constants.SilenceExtensionStatusClosed, constants.SilenceExtensionStatusRejected:
failed += row.Count
default:
open += row.Count
}
}
values := map[string]any{"requested_count": total, "succeeded_count": succeeded, "failed_count": failed, "updated_at": now}
if open == 0 {
switch {
case succeeded == total && total > 0:
values["status"] = constants.SilenceBatchStatusCompleted
case succeeded > 0:
values["status"] = constants.SilenceBatchStatusPartiallyCompleted
default:
values["status"] = constants.SilenceBatchStatusFailed
}
values["completed_at"] = now
}
return tx.WithContext(ctx).Model(&model.PackageSilenceExtensionBatch{}).
Where("id = ? AND status NOT IN ?", batchID, []string{constants.SilenceBatchStatusRejected, constants.SilenceBatchStatusClosed}).
Updates(values).Error
}

View File

@@ -1,359 +0,0 @@
package packagesilence
import (
"context"
"time"
"go.uber.org/zap"
"gorm.io/gorm"
agentrechargeApp "github.com/break/junhong_cmp_fiber/internal/application/agentrecharge"
approvalapp "github.com/break/junhong_cmp_fiber/internal/application/approval"
"github.com/break/junhong_cmp_fiber/internal/application/merchantpayment"
"github.com/break/junhong_cmp_fiber/internal/application/refundchannel"
packagedomain "github.com/break/junhong_cmp_fiber/internal/domain/package"
"github.com/break/junhong_cmp_fiber/internal/infrastructure/audit"
"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"
)
// OwnershipChecker 校验 H5 客户是否拥有资产(由客户绑定服务实现)。
type OwnershipChecker interface {
OwnsAsset(ctx context.Context, customerID uint, assetType string, assetID uint) (bool, error)
}
// PaymentPorts 是在线支付渠道 Adapter(与代理在线充值同一套)。
type PaymentPorts struct {
Wechat agentrechargeApp.OnlinePaymentPort
Alipay agentrechargeApp.OnlinePaymentPort
Fuiou agentrechargeApp.OnlinePaymentPort
}
// ExtensionService 是沉默期延期用例集合(技术方案 §7、§8):资格、在线下单、支付确认、线下登记、特殊延期、执行与异常。
type ExtensionService struct {
db *gorm.DB
sw *Switch
resolver *Resolver
audit *audit.Writer
approval approvalapp.Port
perms PermissionChecker
ownership OwnershipChecker
runtime *merchantpayment.RuntimeLoader
payments PaymentPorts
refunder refundchannel.Refunder
jsapi JSAPIPayer
logger *zap.Logger
now func() time.Time
}
// NewExtensionService 创建延期用例。
func NewExtensionService(db *gorm.DB, resolver *Resolver, writer *audit.Writer, logger *zap.Logger) *ExtensionService {
if logger == nil {
logger = zap.NewNop()
}
return &ExtensionService{db: db, sw: resolver.Switch(), resolver: resolver, audit: writer, logger: logger, now: time.Now}
}
// SetApprovalPort 注入审批创建接缝。
func (s *ExtensionService) SetApprovalPort(port approvalapp.Port) { s.approval = port }
// SetPermissionChecker 注入权限校验。
func (s *ExtensionService) SetPermissionChecker(checker PermissionChecker) { s.perms = checker }
// SetOwnershipChecker 注入 H5 资产归属校验。
func (s *ExtensionService) SetOwnershipChecker(checker OwnershipChecker) { s.ownership = checker }
// SetPayment 注入商户池路由、在线支付渠道与原路退款渠道。
func (s *ExtensionService) SetPayment(runtime *merchantpayment.RuntimeLoader, ports PaymentPorts, refunder refundchannel.Refunder) {
s.runtime = runtime
s.payments = ports
s.refunder = refunder
}
// Scope 描述调用方身份:CustomerID 为 0 表示后台账号(叠加店铺数据范围),否则为 H5 客户(校验资产归属)。
type Scope struct {
CustomerID uint
}
// requireEnabled 在总开关关闭时拒绝新的延期申请(silence_feature_disabled);已发生的回调不调用它。
func (s *ExtensionService) requireEnabled(ctx context.Context) error {
if !s.sw.Enabled(ctx) {
return errors.New(errors.CodeSilenceFeatureDisabled)
}
return nil
}
func (s *ExtensionService) requirePermission(ctx context.Context, code string) error {
return requirePermission(ctx, s.perms, code)
}
// requirePermission 校验后台账号权限点;超级管理员不受限。
func requirePermission(ctx context.Context, perms PermissionChecker, code string) error {
if middleware.GetUserTypeFromContext(ctx) == constants.UserTypeSuperAdmin {
return nil
}
if perms == nil {
return errors.New(errors.CodeForbidden)
}
allowed, err := perms.CheckPermission(ctx, middleware.GetUserIDFromContext(ctx), code, constants.PlatformWeb)
if err != nil {
return err
}
if !allowed {
return errors.New(errors.CodeForbidden, "缺少权限:"+code)
}
return nil
}
// loadUsage 读取套餐记录并校验调用方可见:后台叠加店铺数据范围,H5 校验资产归属。
func (s *ExtensionService) loadUsage(ctx context.Context, db *gorm.DB, usageID uint, scope Scope) (*model.PackageUsage, error) {
var usage model.PackageUsage
if err := db.WithContext(ctx).Where("id = ?", usageID).First(&usage).Error; err != nil {
if err == gorm.ErrRecordNotFound {
return nil, errors.New(errors.CodeNotFound, "套餐记录不存在")
}
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询套餐记录失败")
}
assetType, assetID := usageAsset(&usage)
if scope.CustomerID > 0 {
if s.ownership == nil {
return nil, errors.New(errors.CodeServiceUnavailable, "资产归属校验未配置")
}
owned, err := s.ownership.OwnsAsset(ctx, scope.CustomerID, assetType, assetID)
if err != nil {
return nil, err
}
if !owned {
return nil, errors.New(errors.CodeForbidden, "无权限操作该资产或资源不存在")
}
return &usage, nil
}
var count int64
query := db.WithContext(ctx).Table(firstPurchaseTable(assetType)).Where("id = ? AND deleted_at IS NULL", assetID)
if err := middleware.ApplyStrictShopFilter(ctx, query).Count(&count).Error; err != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "校验资产数据范围失败")
}
if count == 0 {
return nil, errors.New(errors.CodeForbidden, "无权限操作该资源或资源不存在")
}
return &usage, nil
}
// eligibility 是资格判定的中间事实。
type eligibility struct {
usage *model.PackageUsage
pkg *model.Package
match PolicyMatch
paid packagedomain.SilenceQuote
special packagedomain.SilenceQuote
effectiveStatus string
expired bool
reason string
specialAvailable bool
freeAvailable bool
openOrder *model.PackageSilenceExtensionOrder
openDetail bool
}
// computeEligibility 计算付费与特殊延期资格(技术方案 §8.1、§8.2)。
func (s *ExtensionService) computeEligibility(ctx context.Context, db *gorm.DB, usage *model.PackageUsage, now time.Time) (*eligibility, error) {
result := &eligibility{usage: usage}
if usage.SilenceStatus == nil || *usage.SilenceStatus == "" {
result.reason = constants.SilenceReasonNoSilence
return result, nil
}
result.effectiveStatus = *usage.SilenceStatus
if usage.Status == constants.PackageUsageStatusInvalidated {
result.reason = constants.SilenceReasonRefunded
return result, nil
}
if usage.Status != constants.PackageUsageStatusPending || result.effectiveStatus == constants.SilenceStatusEnded {
result.reason = constants.SilenceReasonActivated
return result, nil
}
result.expired = packagedomain.IsSilenceEffectivelyExpired(result.effectiveStatus, usage.SilenceDeadlineAt, usage.SilenceAdjustPending, now)
if result.expired {
result.effectiveStatus = constants.SilenceStatusExpired
}
if !result.expired {
result.reason = constants.SilenceReasonNotExpired
if usage.SilenceAdjustPending {
result.reason = constants.SilenceReasonAdjustPending
}
return result, nil
}
match, pkg, err := matchPolicyForPackage(ctx, db, usage.PackageID, now)
if err != nil {
return nil, err
}
result.match, result.pkg = match, pkg
order, err := postgres.NewPackageSilenceOrderStore(db).FindOpenByUsage(ctx, usage.ID)
if err != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询未结束延期订单失败")
}
result.openOrder = order
openDetail, err := postgres.NewPackageSilenceExtensionStore(db).HasOpenByUsage(ctx, usage.ID)
if err != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询未结束延期明细失败")
}
result.openDetail = openDetail
deadline := *usage.SilenceDeadlineAt
result.paid = packagedomain.QuoteSilenceExtension(packagedomain.SilenceQuoteInput{
DeadlineAt: deadline, Now: now, Policy: match.Snapshot, PaidExtendedDays: usage.SilencePaidExtendedDays,
Kind: packagedomain.SilenceQuotePaid,
})
result.special = packagedomain.QuoteSilenceExtension(packagedomain.SilenceQuoteInput{
DeadlineAt: deadline, Now: now, Policy: match.Snapshot, Kind: packagedomain.SilenceQuoteSpecial,
})
switch {
case openDetail || order != nil:
result.reason = constants.SilenceReasonOrderPending
case !pkg.SilenceExtensionEnabled:
result.reason = constants.SilenceReasonExtensionDisabled
case match.Snapshot == nil:
result.reason = constants.SilenceReasonNoPolicy
case !result.paid.CanExtend:
result.reason = constants.SilenceReasonExceedsLimit
}
result.specialAvailable = !openDetail && order == nil
result.freeAvailable = result.specialAvailable && usage.SilenceFreeExtensionUsed < match.FreeExtensionLimit()
return result, nil
}
// canPay 表示可以办理付费延期。
func (e *eligibility) canPay() bool {
return e.expired && e.reason == ""
}
// paidError 把不能付费延期的原因转换为错误码。
func (e *eligibility) paidError() error {
switch e.reason {
case "":
return nil
case constants.SilenceReasonNotExpired, constants.SilenceReasonNoSilence, constants.SilenceReasonActivated,
constants.SilenceReasonRefunded, constants.SilenceReasonAdjustPending:
return errors.NewWithData(errors.CodeSilenceExtensionNotExpired, map[string]any{"reason": e.reason})
case constants.SilenceReasonOrderPending:
return errors.New(errors.CodeSilenceExtensionOrderPending)
case constants.SilenceReasonExtensionDisabled:
return errors.New(errors.CodeSilenceExtensionDisabled)
case constants.SilenceReasonNoPolicy:
return errors.New(errors.CodeSilenceExtensionNoPolicy)
case constants.SilenceReasonExceedsLimit:
return errors.NewWithData(errors.CodeSilenceExtensionExceedsLimit, map[string]any{"min_months": e.paid.MinMonths, "max_months": e.paid.MaxMonths})
default:
return errors.New(errors.CodeSilenceExtensionNotExpired)
}
}
// Eligibility 查询延期资格(后台与 H5 同语义);waiting 但已到期的先判定为过期。
func (s *ExtensionService) Eligibility(ctx context.Context, usageID uint, scope Scope) (*dto.SilenceEligibilityResponse, error) {
if err := s.requireEnabled(ctx); err != nil {
return nil, err
}
if _, err := s.loadUsage(ctx, s.db, usageID, scope); err != nil {
return nil, err
}
if _, err := s.resolver.Resolve(ctx, usageID, ResolveOptions{Trigger: constants.SilenceTriggerEligibilityRead, AllowExpire: true}); err != nil {
return nil, err
}
usage, err := s.loadUsage(ctx, s.db, usageID, Scope{CustomerID: scope.CustomerID})
if err != nil {
return nil, err
}
now := s.now()
facts, err := s.computeEligibility(ctx, s.db, usage, now)
if err != nil {
return nil, err
}
resp := eligibilityResponse(facts)
// 办理延期时一并展示套餐订购时间、到期时间与沉默期截止时间
resp.PackageName = usage.PackageName
resp.PurchasedAt = usage.SilenceStartAt
if resp.PurchasedAt == nil {
createdAt := usage.CreatedAt
resp.PurchasedAt = &createdAt
}
resp.ActivatedAt = usage.ActivatedAt
resp.ExpiresAt = usage.ExpiresAt
resp.SilenceDeadlineAt = usage.SilenceDeadlineAt
if identifier, idErr := assetIdentifier(ctx, s.db, usage); idErr == nil {
resp.AssetIdentifier = identifier
}
return resp, nil
}
// Quote 按输入天数试算付费延期。
func (s *ExtensionService) Quote(ctx context.Context, usageID uint, days int, scope Scope) (*dto.SilenceQuoteResponse, error) {
if err := s.requireEnabled(ctx); err != nil {
return nil, err
}
usage, err := s.loadUsage(ctx, s.db, usageID, scope)
if err != nil {
return nil, err
}
now := s.now()
facts, err := s.computeEligibility(ctx, s.db, usage, now)
if err != nil {
return nil, err
}
if !facts.expired || usage.SilenceDeadlineAt == nil {
return nil, facts.paidError()
}
quote := packagedomain.QuoteSilenceExtension(packagedomain.SilenceQuoteInput{
DeadlineAt: *usage.SilenceDeadlineAt, Now: now, InputDays: days, Policy: facts.match.Snapshot,
PaidExtendedDays: usage.SilencePaidExtendedDays, Kind: packagedomain.SilenceQuotePaid,
})
deadline := quote.NewDeadline
return &dto.SilenceQuoteResponse{
InputDays: days, Months: quote.Months, BilledDays: quote.BilledDays, Amount: quote.Amount,
NewDeadline: formatDate(&deadline), Valid: quote.InputValid && facts.canPay(),
}, nil
}
func eligibilityResponse(facts *eligibility) *dto.SilenceEligibilityResponse {
usage := facts.usage
resp := &dto.SilenceEligibilityResponse{
PackageUsageID: usage.ID, SilenceStatus: facts.effectiveStatus, CanExtend: facts.canPay(),
SpecialExtensionAvailable: facts.expired && facts.specialAvailable, Reason: facts.reason,
ExtendedDays: usage.SilenceExtendedDays, PaidExtendedDays: usage.SilencePaidExtendedDays,
FreeExtensionUsed: usage.SilenceFreeExtensionUsed, FreeExtensionLimit: facts.match.FreeExtensionLimit(),
FreeExtensionAvailable: facts.expired && facts.freeAvailable, Options: []dto.SilenceExtensionOption{},
}
if usage.SilenceDeadlineAt != nil {
resp.OriginalDeadline = formatDate(usage.SilenceDeadlineAt)
}
if facts.pkg != nil {
resp.ExtensionEnabled = facts.pkg.SilenceExtensionEnabled
}
if facts.match.Policy != nil {
id := facts.match.Policy.ID
resp.PolicyID = &id
resp.PolicyName = facts.match.Policy.Name
resp.UnitPrice = facts.match.Policy.UnitPrice
}
if facts.openOrder != nil {
resp.PendingOrderNo = facts.openOrder.OrderNo
}
if !facts.expired {
return resp
}
resp.OverdueDays = facts.paid.OverdueDays
resp.MinDays = facts.paid.MinDays
resp.MinMonths = facts.paid.MinMonths
resp.MaxMonths = facts.paid.MaxMonths
resp.MaxDays = facts.paid.MaxDays
if facts.canPay() && usage.SilenceDeadlineAt != nil {
for _, option := range packagedomain.SilenceQuoteOptions(facts.paid, *usage.SilenceDeadlineAt, constants.SilenceExtensionOptionsMax) {
deadline := option.NewDeadline
resp.Options = append(resp.Options, dto.SilenceExtensionOption{
Months: option.Months, BilledDays: option.BilledDays, Amount: option.Amount, NewDeadline: formatDate(&deadline),
})
}
}
return resp
}

View File

@@ -1,548 +0,0 @@
package packagesilence
import (
"context"
"fmt"
"slices"
"strings"
"time"
"gorm.io/gorm"
"gorm.io/gorm/clause"
approvalapp "github.com/break/junhong_cmp_fiber/internal/application/approval"
packagedomain "github.com/break/junhong_cmp_fiber/internal/domain/package"
"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"
)
// RegisterOffline 登记线下付费延期(技术方案 §8.4a):实收金额必须等于计算金额,同一收款方式下交易单号唯一;
// 政策快照未勾选 offline_paid 时提交企微审批,勾选时登记即执行。
func (s *ExtensionService) RegisterOffline(ctx context.Context, req dto.SilenceOfflineOrderRequest) (*dto.SilenceExtensionOrderResponse, error) {
if err := s.requireEnabled(ctx); err != nil {
return nil, err
}
if err := s.requirePermission(ctx, constants.PermissionPackageSilenceOffline); err != nil {
return nil, err
}
operatorID := middleware.GetUserIDFromContext(ctx)
requestKey := scopedRequestKey(constants.SilenceOrderSourceOffline, 0, &operatorID, req.RequestID)
if existing, err := postgres.NewPackageSilenceOrderStore(s.db).FindByRequestID(ctx, requestKey); err != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询延期订单幂等记录失败")
} else if existing != nil {
return s.orderResponse(ctx, existing, false)
}
tradeNo := strings.TrimSpace(req.OfflineTradeNo)
if tradeNo == "" {
return nil, errors.New(errors.CodeInvalidParam, "线下交易单号不能为空")
}
if len(req.PaymentVoucherKeys) == 0 || len(req.PaymentVoucherKeys) > constants.SilenceExtensionVoucherMaxCount {
return nil, errors.New(errors.CodeInvalidParam, "线下付费延期必须上传 1 至 5 个收款凭证")
}
usage, err := s.loadUsage(ctx, s.db, req.PackageUsageID, Scope{})
if err != nil {
return nil, err
}
if _, err := s.resolver.Resolve(ctx, usage.ID, ResolveOptions{Trigger: constants.SilenceTriggerEligibilityRead, AllowExpire: true}); err != nil {
return nil, err
}
if replaced, err := s.settleOpenOrder(ctx, usage.ID); err != nil {
return nil, err
} else if replaced != nil {
return nil, errors.New(errors.CodeSilenceExtensionOrderPending, "该套餐已有已支付的在线延期订单,不能再登记线下付款")
}
if usage, err = s.loadUsage(ctx, s.db, req.PackageUsageID, Scope{}); err != nil {
return nil, err
}
now := s.now()
facts, err := s.computeEligibility(ctx, s.db, usage, now)
if err != nil {
return nil, err
}
if !facts.canPay() {
return nil, facts.paidError()
}
quote := packagedomain.QuoteSilenceExtension(packagedomain.SilenceQuoteInput{
DeadlineAt: *usage.SilenceDeadlineAt, Now: now, InputDays: req.Days, Policy: facts.match.Snapshot,
PaidExtendedDays: usage.SilencePaidExtendedDays, Kind: packagedomain.SilenceQuotePaid,
})
if !quote.InputValid {
return nil, errors.NewWithData(errors.CodeSilenceExtensionMonthsInvalid, map[string]any{"min_days": quote.MinDays, "max_days": quote.MaxDays})
}
if req.PaidAmount != quote.Amount {
return nil, errors.NewWithData(errors.CodeSilenceOfflineAmountMismatch, map[string]any{"expected_amount": quote.Amount})
}
method, err := loadOfflinePaymentMethod(ctx, s.db, req.OfflinePaymentMethodID)
if err != nil {
return nil, err
}
if err := ensureTradeNoUnused(ctx, s.db, method.ID, tradeNo); err != nil {
return nil, err
}
snapshot := facts.match.Snapshot
approvalRequired := !packagedomain.IsSilenceApprovalExempt(snapshot.ApprovalExemptTypes, constants.SilenceExtensionTypeOfflinePaid)
var preparation approvalapp.Preparation
var submitter approvalSubmitter
if approvalRequired {
if submitter, err = loadSubmitter(ctx, s.db, operatorID); err != nil {
return nil, err
}
if preparation, err = prepareApproval(ctx, s.approval, operatorID, "silence-offline-"+requestKey[:16]); err != nil {
return nil, err
}
}
identifier, err := assetIdentifier(ctx, s.db, usage)
if err != nil {
return nil, err
}
shopID := assetShopID(ctx, s.db, usage)
var order *model.PackageSilenceExtensionOrder
executed := false
err = s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
if err := lockCarrier(ctx, tx, usage); err != nil {
return err
}
batchStatus, orderStatus, detailStatus := constants.SilenceBatchStatusExecuting, constants.SilenceOrderStatusPaid, constants.SilenceExtensionStatusExecuting
if approvalRequired {
batchStatus, orderStatus, detailStatus = constants.SilenceBatchStatusPendingApproval, constants.SilenceOrderStatusPendingApproval, constants.SilenceExtensionStatusPendingApproval
}
paidAt, paidAmount := req.PaidAt, req.PaidAmount
batch := &model.PackageSilenceExtensionBatch{
BatchNo: generateNo(constants.SilenceExtensionBatchNoPrefix, now), Path: constants.SilencePathOffline,
ExtensionType: constants.SilenceExtensionTypeOfflinePaid, ApprovalRequired: approvalRequired,
EvidenceRefs: model.StringJSONBArray(nonNil(req.EvidenceRefs)), PolicyID: &snapshot.PolicyID,
PolicyNameSnapshot: snapshot.Name, UnitPriceSnapshot: snapshot.UnitPrice,
ApprovalExemptTypesSnapshot: model.StringJSONBArray(nonNil(snapshot.ApprovalExemptTypes)),
FreeExtensionLimitSnapshot: snapshot.FreeExtensionLimit, PaidMaxMonthsSnapshot: snapshot.PaidMaxMonths,
SingleMaxMonthsSnapshot: snapshot.SingleMaxMonths, Amount: quote.Amount,
PaymentVoucherKeys: model.StringJSONBArray(req.PaymentVoucherKeys), RequestedBy: operatorID,
RequestedByType: constants.SilenceOperatorAccount, RequestedAt: now, Status: batchStatus, RequestedCount: 1, ShopIDTag: shopID,
}
if err := postgres.NewPackageSilenceBatchStore(tx).Create(ctx, batch); err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "创建延期批次失败")
}
order = &model.PackageSilenceExtensionOrder{
OrderNo: generateNo(constants.SilenceExtensionOrderNoPrefix, now), BatchID: batch.ID,
PackageUsageID: usage.ID, PackageID: usage.PackageID, PackageNameSnapshot: usage.PackageName,
AssetIdentifierSnapshot: identifier, ShopID: shopID, Source: constants.SilenceOrderSourceOffline,
CreatedBy: &operatorID, EvidenceRefs: model.StringJSONBArray(nonNil(req.EvidenceRefs)), InputDays: req.Days,
Months: quote.Months, ExtensionDays: quote.BilledDays, PolicyID: &snapshot.PolicyID,
PolicyNameSnapshot: snapshot.Name, UnitPrice: snapshot.UnitPrice,
ApprovalExemptTypesSnapshot: model.StringJSONBArray(nonNil(snapshot.ApprovalExemptTypes)),
FreeExtensionLimitSnapshot: snapshot.FreeExtensionLimit, PaidMaxMonthsSnapshot: snapshot.PaidMaxMonths,
SingleMaxMonthsSnapshot: snapshot.SingleMaxMonths, Amount: quote.Amount,
BeforeDeadlineAt: *usage.SilenceDeadlineAt, ExpectedDeadlineAt: quote.NewDeadline, Status: orderStatus,
PaymentMethod: model.PaymentByOffline, OfflinePaymentMethodID: &method.ID, OfflinePaymentMethodCode: &method.Code,
OfflinePaymentMethodName: &method.Name, OfflineTradeNo: &tradeNo,
PaymentVoucherKeys: model.StringJSONBArray(req.PaymentVoucherKeys), ApprovalRequired: approvalRequired,
PaidAmount: &paidAmount, PaidAt: &paidAt, RequestID: &requestKey, ShopIDTag: shopID,
}
order.AssetType, order.AssetID = usageAsset(usage)
if err := postgres.NewPackageSilenceOrderStore(tx).Create(ctx, order); err != nil {
if isUniqueViolation(err) {
return errors.New(errors.CodeSilenceOfflineTradeNoDuplicate, "线下交易单号已登记或该套餐已有未结束的延期订单")
}
return errors.Wrap(errors.CodeDatabaseError, err, "创建线下延期订单失败")
}
extension := &model.PackageSilenceExtension{
BatchID: batch.ID, PackageUsageID: usage.ID, PackageID: usage.PackageID, PackageNameSnapshot: usage.PackageName,
AssetType: order.AssetType, AssetID: order.AssetID, AssetIdentifierSnapshot: identifier,
ExtensionType: constants.SilenceExtensionTypeOfflinePaid, InputDays: req.Days, Months: quote.Months,
ExtensionDays: quote.BilledDays, UnitPriceSnapshot: snapshot.UnitPrice, Amount: quote.Amount,
ExtensionOrderID: &order.ID, BeforeDeadlineAt: usage.SilenceDeadlineAt, Status: detailStatus,
}
if err := postgres.NewPackageSilenceExtensionStore(tx).Create(ctx, extension); err != nil {
if isUniqueViolation(err) {
return errors.New(errors.CodeSilenceExtensionOrderPending)
}
return errors.Wrap(errors.CodeDatabaseError, err, "创建延期明细失败")
}
if approvalRequired {
instanceID, err := createApprovalInTx(ctx, tx, s.approval, preparation, batch, submitter, ApplyTypeOfflinePaid, map[string]any{
ApprovalFieldAssetIdentifier: identifier, ApprovalFieldPackageName: usage.PackageName,
ApprovalFieldOrderNo: order.OrderNo, ApprovalFieldOriginalDeadline: formatDate(usage.SilenceDeadlineAt),
ApprovalFieldInputDays: req.Days, ApprovalFieldBilledDays: quote.BilledDays,
ApprovalFieldNewDeadline: formatDate(&quote.NewDeadline), ApprovalFieldAmount: formatAmount(quote.Amount),
ApprovalFieldAmountCent: quote.Amount, ApprovalFieldPaymentMethod: method.Name, ApprovalFieldTradeNo: tradeNo,
ApprovalFieldVoucherKeys: req.PaymentVoucherKeys, ApprovalFieldEvidenceKeys: nonNil(req.EvidenceRefs),
})
if err != nil {
return err
}
if err := tx.Model(&model.PackageSilenceExtensionOrder{}).Where("id = ?", order.ID).Update("approval_instance_id", instanceID).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "关联线下延期审批实例失败")
}
order.ApprovalInstanceID = &instanceID
if err := s.writeRequestAudit(ctx, tx, usage, batch, "提交线下付费延期审批"); err != nil {
return err
}
return s.writeOrderAudit(ctx, tx, order, "", "登记线下付费延期,待审批")
}
if err := s.writeOrderAudit(ctx, tx, order, "", "登记线下付费延期(免审批)"); err != nil {
return err
}
ok, err := s.executeOrderInTx(ctx, tx, order)
executed = ok
return err
})
if err != nil {
return nil, err
}
if executed {
s.afterExecuted(ctx, []uint{usage.ID})
}
current, err := postgres.NewPackageSilenceOrderStore(s.db).GetByID(ctx, order.ID)
if err != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询延期订单失败")
}
return s.orderResponse(ctx, current, false)
}
// SubmitSpecial 提交特殊延期(技术方案 §8.5):单个或批量;免费次数受政策约束;不受付费上限与"是否支持延期"约束。
// 政策快照未勾选对应免审批场景(批量看 batch,否则看延期类型)时提交企微审批,通过后自动执行;勾选时提交即逐条执行。
func (s *ExtensionService) SubmitSpecial(ctx context.Context, req dto.SilenceSpecialRequest) (*dto.SilenceBatchResponse, error) {
if err := s.requireEnabled(ctx); err != nil {
return nil, err
}
if !slices.Contains(constants.SilenceSpecialExtensionTypes, req.ExtensionType) {
return nil, errors.New(errors.CodeInvalidParam, "特殊延期类型非法")
}
isBatch := len(req.Items) > 1 || strings.TrimSpace(req.ImportFileKey) != ""
if err := s.requirePermission(ctx, constants.PermissionPackageSilenceExtend); err != nil {
return nil, err
}
if isBatch {
if err := s.requirePermission(ctx, constants.PermissionPackageSilenceExtendBatch); err != nil {
return nil, err
}
}
if len(req.Items) == 0 || len(req.Items) > constants.SilenceBatchImportMaxRows {
return nil, errors.New(errors.CodeInvalidParam, "特殊延期明细数量需在 1~1000 之间")
}
reason := strings.TrimSpace(req.Reason)
if reason == "" {
return nil, errors.New(errors.CodeInvalidParam, "特殊延期必须填写原因")
}
free := req.ExtensionType == constants.SilenceExtensionTypeFree
if free && req.Amount != 0 {
return nil, errors.New(errors.CodeInvalidParam, "免费延期费用必须为 0")
}
operatorID := middleware.GetUserIDFromContext(ctx)
now := s.now()
rows, firstMatch, exempt, err := s.validateSpecialItems(ctx, req, free, isBatch, now)
if err != nil {
return nil, err
}
var method *model.EmployeeCollectionPaymentMethod
tradeNo := strings.TrimSpace(req.OfflineTradeNo)
if tradeNo != "" {
if req.OfflinePaymentMethodID == nil || len(req.PaymentVoucherKeys) == 0 {
return nil, errors.New(errors.CodeInvalidParam, "优惠价有收款时需填写收款方式与收款凭证")
}
if method, err = loadOfflinePaymentMethod(ctx, s.db, *req.OfflinePaymentMethodID); err != nil {
return nil, err
}
if err := ensureTradeNoUnused(ctx, s.db, method.ID, tradeNo); err != nil {
return nil, err
}
}
approvalRequired := !exempt
var preparation approvalapp.Preparation
var submitter approvalSubmitter
if approvalRequired {
if submitter, err = loadSubmitter(ctx, s.db, operatorID); err != nil {
return nil, err
}
if preparation, err = prepareApproval(ctx, s.approval, operatorID, fmt.Sprintf("silence-special-%d-%d", operatorID, now.UnixNano())); err != nil {
return nil, err
}
}
snapshot := firstMatch.Snapshot
var batch *model.PackageSilenceExtensionBatch
err = s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
batchStatus, detailStatus := constants.SilenceBatchStatusExecuting, constants.SilenceExtensionStatusApproved
if approvalRequired {
batchStatus, detailStatus = constants.SilenceBatchStatusPendingApproval, constants.SilenceExtensionStatusPendingApproval
}
extensionType := req.ExtensionType
batch = &model.PackageSilenceExtensionBatch{
BatchNo: generateNo(constants.SilenceExtensionBatchNoPrefix, now), Path: constants.SilencePathSpecial,
ExtensionType: extensionType, ApprovalRequired: approvalRequired, Reason: truncate(reason, 1000),
EvidenceRefs: model.StringJSONBArray(nonNil(req.EvidenceRefs)), Basis: truncate(strings.TrimSpace(req.Basis), 1000),
ImportFileKey: strings.TrimSpace(req.ImportFileKey), FreeExtensionLimitSnapshot: firstMatch.FreeExtensionLimit(),
ApprovalExemptTypesSnapshot: model.StringJSONBArray(nonNil(firstMatch.ExemptTypes())), Amount: req.Amount,
PaymentVoucherKeys: model.StringJSONBArray(nonNil(req.PaymentVoucherKeys)), RequestedBy: operatorID,
RequestedByType: constants.SilenceOperatorAccount, RequestedAt: now, Status: batchStatus, RequestedCount: len(rows),
}
if snapshot != nil {
batch.PolicyID, batch.PolicyNameSnapshot, batch.UnitPriceSnapshot = &snapshot.PolicyID, snapshot.Name, snapshot.UnitPrice
batch.PaidMaxMonthsSnapshot, batch.SingleMaxMonthsSnapshot = snapshot.PaidMaxMonths, snapshot.SingleMaxMonths
}
if method != nil {
paidAmount := req.Amount
batch.OfflinePaymentMethodID, batch.OfflinePaymentMethodCode, batch.OfflinePaymentMethodName = &method.ID, &method.Code, &method.Name
batch.OfflineTradeNo, batch.OfflinePaidAmount, batch.OfflinePaidAt = &tradeNo, &paidAmount, req.OfflinePaidAt
}
if len(rows) == 1 {
batch.ShopIDTag = rows[0].shopID
}
if err := postgres.NewPackageSilenceBatchStore(tx).Create(ctx, batch); err != nil {
if isUniqueViolation(err) {
return errors.New(errors.CodeSilenceOfflineTradeNoDuplicate)
}
return errors.Wrap(errors.CodeDatabaseError, err, "创建特殊延期批次失败")
}
perRowAmount := int64(0)
if !free && len(rows) > 0 {
perRowAmount = req.Amount / int64(len(rows))
}
for index, row := range rows {
extension := &model.PackageSilenceExtension{
BatchID: batch.ID, RowNo: index + 1, PackageUsageID: row.usage.ID, PackageID: row.usage.PackageID,
PackageNameSnapshot: row.usage.PackageName, AssetType: row.assetType, AssetID: row.assetID,
AssetIdentifierSnapshot: row.identifier, ExtensionType: extensionType, InputDays: row.days,
Months: row.months, ExtensionDays: row.months * constants.SilenceDaysPerMonth, Amount: perRowAmount,
BeforeDeadlineAt: row.usage.SilenceDeadlineAt, Status: detailStatus,
}
if err := postgres.NewPackageSilenceExtensionStore(tx).Create(ctx, extension); err != nil {
if isUniqueViolation(err) {
return errors.NewWithData(errors.CodeSilenceExtensionOrderPending, map[string]any{"row_no": index + 1, "package_usage_id": row.usage.ID})
}
return errors.Wrap(errors.CodeDatabaseError, err, "创建特殊延期明细失败")
}
if err := s.writeRequestAudit(ctx, tx, row.usage, batch, "提交特殊延期"); err != nil {
return err
}
}
if !approvalRequired {
return nil
}
first := rows[0]
_, err := createApprovalInTx(ctx, tx, s.approval, preparation, batch, submitter, ApplyTypeSpecial, map[string]any{
ApprovalFieldAssetIdentifier: first.identifier, ApprovalFieldPackageName: first.usage.PackageName,
ApprovalFieldOrderNo: first.usage.OrderNo, ApprovalFieldOriginalDeadline: formatDate(first.usage.SilenceDeadlineAt),
ApprovalFieldInputDays: first.days, ApprovalFieldBilledDays: first.months * constants.SilenceDaysPerMonth,
ApprovalFieldExtensionType: specialTypeName(extensionType), ApprovalFieldItemCount: len(rows),
ApprovalFieldAmount: formatAmount(req.Amount), ApprovalFieldAmountCent: req.Amount,
ApprovalFieldReason: reason, ApprovalFieldBasis: strings.TrimSpace(req.Basis),
ApprovalFieldEvidenceKeys: nonNil(req.EvidenceRefs), ApprovalFieldTradeNo: tradeNo,
ApprovalFieldVoucherKeys: nonNil(req.PaymentVoucherKeys),
})
return err
})
if err != nil {
return nil, err
}
if !approvalRequired {
if err := s.executeBatch(ctx, batch.ID); err != nil {
return nil, err
}
}
return s.BatchDetail(ctx, batch.ID)
}
// specialRow 是一条已校验的特殊延期明细。
type specialRow struct {
usage *model.PackageUsage
days int
months int
identifier string
assetType string
assetID uint
shopID uint
}
// validateSpecialItems 逐行校验特殊延期明细,任一行不满足时拒绝并返回行号与原因。
// 返回首行匹配的政策(批次快照)以及是否全部明细的政策都勾选了对应免审批场景。
func (s *ExtensionService) validateSpecialItems(ctx context.Context, req dto.SilenceSpecialRequest, free, isBatch bool, now time.Time) ([]specialRow, PolicyMatch, bool, error) {
scene := packagedomain.SilenceSpecialApprovalScene(req.ExtensionType, isBatch)
rows := make([]specialRow, 0, len(req.Items))
seen := map[uint]bool{}
var first PolicyMatch
exempt := true
rowErrors := make([]map[string]any, 0)
for index, item := range req.Items {
rowNo := index + 1
fail := func(reason string) {
rowErrors = append(rowErrors, map[string]any{"row_no": rowNo, "package_usage_id": item.PackageUsageID, "reason": reason})
}
if seen[item.PackageUsageID] {
fail("同一套餐记录重复")
continue
}
seen[item.PackageUsageID] = true
if _, err := s.resolver.Resolve(ctx, item.PackageUsageID, ResolveOptions{Trigger: constants.SilenceTriggerEligibilityRead, AllowExpire: true}); err != nil {
return nil, first, false, err
}
usage, err := s.loadUsage(ctx, s.db, item.PackageUsageID, Scope{})
if err != nil {
fail("套餐记录不存在或无权限")
continue
}
facts, err := s.computeEligibility(ctx, s.db, usage, now)
if err != nil {
return nil, first, false, err
}
if !facts.expired {
fail("套餐不是沉默期已过期状态")
continue
}
if !facts.specialAvailable {
fail("该套餐已有未结束的延期订单或申请")
continue
}
months, _ := packagedomain.SilenceMonthsForDays(item.Days)
if months < facts.special.MinMonths {
fail(fmt.Sprintf("延期天数不足,至少 %d 天", facts.special.MinDays))
continue
}
if free && !facts.freeAvailable {
if !isBatch && len(req.Items) == 1 {
return nil, first, false, errors.New(errors.CodeSilenceFreeExtensionUsedUp)
}
fail("免费延期次数已用完")
continue
}
if index == 0 || first.Policy == nil {
first = facts.match
}
if !packagedomain.IsSilenceApprovalExempt(facts.match.ExemptTypes(), scene) {
exempt = false
}
identifier, err := assetIdentifier(ctx, s.db, usage)
if err != nil {
return nil, first, false, err
}
assetType, assetID := usageAsset(usage)
rows = append(rows, specialRow{usage: usage, days: item.Days, months: months, identifier: identifier,
assetType: assetType, assetID: assetID, shopID: assetShopID(ctx, s.db, usage)})
}
if len(rowErrors) > 0 {
if len(req.Items) == 1 {
return nil, first, false, errors.NewWithData(errors.CodeSilenceExtensionNotExpired, map[string]any{"rows": rowErrors}, fmt.Sprint(rowErrors[0]["reason"]))
}
return nil, first, false, errors.NewWithData(errors.CodeInvalidParam, map[string]any{"rows": rowErrors}, "批量特殊延期明细校验未通过")
}
return rows, first, exempt, nil
}
// executeBatch 逐条执行已批准的特殊延期明细:每条独立事务,部分失败不影响其他行。
func (s *ExtensionService) executeBatch(ctx context.Context, batchID uint) error {
batch, err := postgres.NewPackageSilenceBatchStore(s.db).GetByID(ctx, batchID)
if err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "查询延期批次失败")
}
extensions, err := postgres.NewPackageSilenceExtensionStore(s.db).ListByBatch(ctx, batchID)
if err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "查询延期明细失败")
}
free := batch.ExtensionType == constants.SilenceExtensionTypeFree
succeeded := make([]uint, 0, len(extensions))
for index := range extensions {
extension := &extensions[index]
if extension.Status != constants.SilenceExtensionStatusApproved {
continue
}
executed := false
err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
from := []string{constants.SilenceExtensionStatusApproved}
outcome, err := s.executeExtensionInTx(ctx, tx, extension, executeKind{free: free}, paidSnapshot{freeLimit: batch.FreeExtensionLimitSnapshot}, from)
if err != nil {
if isAlreadyExecuted(err) {
return nil
}
return err
}
if !outcome.executed {
return s.markExtensionFailedInTx(ctx, tx, extension, constants.SilenceExtensionStatusFailed, from, outcome.failureCode, outcome.failureText)
}
executed = true
return nil
})
if err != nil {
return err
}
if executed {
succeeded = append(succeeded, extension.PackageUsageID)
}
}
if err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
return refreshBatchCountsInTx(ctx, tx, batchID, s.now())
}); err != nil {
return err
}
s.afterExecuted(ctx, succeeded)
return nil
}
func (s *ExtensionService) writeRequestAudit(ctx context.Context, tx *gorm.DB, usage *model.PackageUsage, batch *model.PackageSilenceExtensionBatch, summary string) error {
if s.audit == nil {
return nil
}
return s.audit.AppendSilenceUsage(ctx, tx, extensionAuditInput(usage, batch, constants.AuditActionUsageSilenceExtensionRequested, summary))
}
func loadOfflinePaymentMethod(ctx context.Context, db *gorm.DB, id uint) (*model.EmployeeCollectionPaymentMethod, error) {
var method model.EmployeeCollectionPaymentMethod
if err := db.WithContext(ctx).Where("id = ?", id).First(&method).Error; err != nil {
if err == gorm.ErrRecordNotFound {
return nil, errors.New(errors.CodeEmployeeCollectionPaymentMethodNotFound)
}
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询线下收款方式失败")
}
if method.Status != constants.StatusEnabled {
return nil, errors.New(errors.CodeEmployeeCollectionPaymentMethodDisabled)
}
return &method, nil
}
// ensureTradeNoUnused 同一收款方式下交易单号只能登记一次(延期订单与特殊延期的优惠收款共同校验)。
func ensureTradeNoUnused(ctx context.Context, db *gorm.DB, methodID uint, tradeNo string) error {
var orders, batches int64
if err := db.WithContext(ctx).Model(&model.PackageSilenceExtensionOrder{}).
Where("offline_payment_method_id = ? AND offline_trade_no = ?", methodID, tradeNo).Count(&orders).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "校验线下交易单号失败")
}
if err := db.WithContext(ctx).Model(&model.PackageSilenceExtensionBatch{}).
Where("offline_payment_method_id = ? AND offline_trade_no = ? AND status NOT IN ?", methodID, tradeNo,
[]string{constants.SilenceBatchStatusRejected, constants.SilenceBatchStatusClosed}).Count(&batches).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "校验线下交易单号失败")
}
if orders+batches > 0 {
return errors.New(errors.CodeSilenceOfflineTradeNoDuplicate)
}
return nil
}
func specialTypeName(extensionType string) string {
switch extensionType {
case constants.SilenceExtensionTypeFree:
return "免费延期"
case constants.SilenceExtensionTypeDiscount:
return "优惠价延期"
case constants.SilenceExtensionTypeComplaintCompensation:
return "客诉补偿"
case constants.SilenceExtensionTypeSystemCompensation:
return "系统异常补偿"
case constants.SilenceExtensionTypeSpecialCustomer:
return "特殊客户政策"
default:
return extensionType
}
}
// lockBatchForDecision 锁定审批关联的批次。
func lockBatchForDecision(ctx context.Context, tx *gorm.DB, batchID uint) (*model.PackageSilenceExtensionBatch, error) {
var batch model.PackageSilenceExtensionBatch
if err := tx.WithContext(ctx).Clauses(clause.Locking{Strength: "UPDATE"}).Where("id = ?", batchID).First(&batch).Error; err != nil {
if err == gorm.ErrRecordNotFound {
return nil, errors.New(errors.CodeNotFound, "沉默期审批关联的批次不存在")
}
return nil, errors.Wrap(errors.CodeDatabaseError, err, "锁定沉默期批次失败")
}
return &batch, nil
}

View File

@@ -1,202 +0,0 @@
package packagesilence
import (
"context"
"time"
"gorm.io/gorm"
"github.com/break/junhong_cmp_fiber/internal/infrastructure/audit"
"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"
)
// FirstPurchaseRecorder 写资产首次购买套餐时间与首次购买套餐记录(技术方案 §17)。
// 首次购买套餐时间 = 资产当前世代第 1 次购买套餐的支付时间;只做记录展示,不受沉默期总开关影响。
type FirstPurchaseRecorder struct {
audit *audit.Writer
}
// NewFirstPurchaseRecorder 创建首次购买套餐时间写入用例。
func NewFirstPurchaseRecorder(writer *audit.Writer) *FirstPurchaseRecorder {
return &FirstPurchaseRecorder{audit: writer}
}
// RecordForOrderInTx 在订单发放套餐的事务内写首次购买套餐时间:只在为空且世代与订单一致时写入,
// 影响行数为 1 时插入首次购买套餐记录;当前绑定在设备上的卡不记录(套餐在设备上)。
func (r *FirstPurchaseRecorder) RecordForOrderInTx(ctx context.Context, tx *gorm.DB, order *model.Order) error {
if r == nil || order == nil {
return nil
}
paidAt := time.Now()
if order.PaidAt != nil {
paidAt = *order.PaidAt
}
generation := order.Generation
if generation <= 0 {
generation = 1
}
orderID := order.ID
switch {
case order.OrderType == model.OrderTypeSingleCard && order.IotCardID != nil:
bound, err := cardBoundToDevice(ctx, tx, *order.IotCardID)
if err != nil || bound {
return err
}
return r.write(ctx, tx, constants.FirstPurchaseAssetTypeIotCard, *order.IotCardID, generation, paidAt, &model.AssetFirstPurchaseRecord{
Source: constants.FirstPurchaseSourceOrder, OrderID: &orderID, OrderNo: order.OrderNo,
})
case order.OrderType == model.OrderTypeDevice && order.DeviceID != nil:
return r.write(ctx, tx, constants.FirstPurchaseAssetTypeDevice, *order.DeviceID, generation, paidAt, &model.AssetFirstPurchaseRecord{
Source: constants.FirstPurchaseSourceOrder, OrderID: &orderID, OrderNo: order.OrderNo,
})
default:
return nil
}
}
// EndGenerationInTx 在换货旧资产世代 +1 的同一事务内清空首次购买套餐时间并结束旧世代记录。
// oldGeneration 为换货前的世代。
func (r *FirstPurchaseRecorder) EndGenerationInTx(ctx context.Context, tx *gorm.DB, assetType string, assetID uint, oldGeneration int, endedAt time.Time) error {
if r == nil {
return nil
}
table := firstPurchaseTable(assetType)
if table == "" {
return nil
}
if err := tx.WithContext(ctx).Table(table).Where("id = ?", assetID).Update("first_purchase_at", nil).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "清空旧资产首次购买套餐时间失败")
}
affected, err := postgres.NewAssetFirstPurchaseRecordStore(tx).EndGeneration(ctx, assetType, assetID, oldGeneration, endedAt, constants.FirstPurchaseEndReasonExchange)
if err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "结束旧世代首次购买套餐记录失败")
}
if affected > 0 && r.audit != nil {
record := &model.AssetFirstPurchaseRecord{AssetType: assetType, AssetID: assetID, Generation: oldGeneration}
return r.audit.AppendSilenceResource(ctx, tx, constants.AuditActionAssetFirstPurchaseEnded, "换货回库结束首次购买套餐记录",
audit.AssetFirstPurchaseResource(record, map[string]any{"ended_at": endedAt, "end_reason": constants.FirstPurchaseEndReasonExchange}), nil)
}
return nil
}
// RecordBackfillInTx 历史回填:只更新为空的首次购买套餐时间并插入 backfill 记录,返回是否写入。
func (r *FirstPurchaseRecorder) RecordBackfillInTx(ctx context.Context, tx *gorm.DB, assetType string, assetID uint, generation int, firstPurchaseAt time.Time, orderID uint, orderNo string) (bool, error) {
id := orderID
before, err := r.countRecords(ctx, tx, assetType, assetID, generation)
if err != nil {
return false, err
}
if err := r.write(ctx, tx, assetType, assetID, generation, firstPurchaseAt, &model.AssetFirstPurchaseRecord{
Source: constants.FirstPurchaseSourceBackfill, OrderID: &id, OrderNo: orderNo,
}); err != nil {
return false, err
}
after, err := r.countRecords(ctx, tx, assetType, assetID, generation)
return after > before, err
}
func (r *FirstPurchaseRecorder) countRecords(ctx context.Context, tx *gorm.DB, assetType string, assetID uint, generation int) (int64, error) {
var count int64
err := tx.WithContext(ctx).Model(&model.AssetFirstPurchaseRecord{}).
Where("asset_type = ? AND asset_id = ? AND generation = ?", assetType, assetID, generation).Count(&count).Error
return count, err
}
func (r *FirstPurchaseRecorder) write(ctx context.Context, tx *gorm.DB, assetType string, assetID uint, generation int, firstPurchaseAt time.Time, record *model.AssetFirstPurchaseRecord) error {
table := firstPurchaseTable(assetType)
result := tx.WithContext(ctx).Table(table).
Where("id = ? AND generation = ? AND first_purchase_at IS NULL AND deleted_at IS NULL", assetID, generation).
Update("first_purchase_at", firstPurchaseAt)
if result.Error != nil {
return errors.Wrap(errors.CodeDatabaseError, result.Error, "写入资产首次购买套餐时间失败")
}
if result.RowsAffected != 1 {
return nil
}
identifier, shopID, err := firstPurchaseAssetSnapshot(ctx, tx, assetType, assetID)
if err != nil {
return err
}
record.AssetType = assetType
record.AssetID = assetID
record.AssetIdentifierSnapshot = identifier
record.Generation = generation
record.FirstPurchaseAt = firstPurchaseAt
record.ShopID = shopID
record.CreatedAt = time.Now()
inserted, err := postgres.NewAssetFirstPurchaseRecordStore(tx).CreateIfAbsent(ctx, record)
if err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "写入资产首次购买套餐记录失败")
}
if inserted && r.audit != nil {
return r.audit.AppendSilenceResource(ctx, tx, constants.AuditActionAssetFirstPurchaseRecorded, "记录资产首次购买套餐时间",
audit.AssetFirstPurchaseResource(record, map[string]any{"first_purchase_at": firstPurchaseAt, "source": record.Source, "order_no": record.OrderNo}), nil)
}
return nil
}
func firstPurchaseTable(assetType string) string {
switch assetType {
case constants.FirstPurchaseAssetTypeIotCard:
return "tb_iot_card"
case constants.FirstPurchaseAssetTypeDevice:
return "tb_device"
default:
return ""
}
}
func cardBoundToDevice(ctx context.Context, tx *gorm.DB, cardID uint) (bool, error) {
var count int64
if err := tx.WithContext(ctx).Model(&model.DeviceSimBinding{}).
Where("iot_card_id = ? AND bind_status = ?", cardID, constants.BindStatusBound).Count(&count).Error; err != nil {
return false, errors.Wrap(errors.CodeDatabaseError, err, "查询卡绑定设备失败")
}
return count > 0, nil
}
func currentGeneration(ctx context.Context, tx *gorm.DB, assetType string, assetID uint) (int, error) {
var generation int
if err := tx.WithContext(ctx).Table(firstPurchaseTable(assetType)).Select("generation").Where("id = ?", assetID).Scan(&generation).Error; err != nil {
return 0, errors.Wrap(errors.CodeDatabaseError, err, "查询资产世代失败")
}
return generation, nil
}
func firstPurchaseAssetSnapshot(ctx context.Context, tx *gorm.DB, assetType string, assetID uint) (string, *uint, error) {
if assetType == constants.FirstPurchaseAssetTypeDevice {
var device model.Device
if err := tx.WithContext(ctx).Select("id", "virtual_no", "imei", "shop_id").Where("id = ?", assetID).First(&device).Error; err != nil {
return "", nil, errors.Wrap(errors.CodeDatabaseError, err, "查询设备快照失败")
}
identifier := device.VirtualNo
if identifier == "" {
identifier = device.IMEI
}
return identifier, device.ShopID, nil
}
var card model.IotCard
if err := tx.WithContext(ctx).Select("id", "iccid", "shop_id").Where("id = ?", assetID).First(&card).Error; err != nil {
return "", nil, errors.Wrap(errors.CodeDatabaseError, err, "查询卡快照失败")
}
return card.ICCID, card.ShopID, nil
}
// PurchaseRecorder 组合沉默期设置与首次购买套餐时间写入,供续费等其他发放套餐路径注入。
type PurchaseRecorder struct {
Setter *Setter
FirstPurchase *FirstPurchaseRecorder
}
// ApplyOnUsageCreatedInTx 设置沉默期。
func (r PurchaseRecorder) ApplyOnUsageCreatedInTx(ctx context.Context, tx *gorm.DB, usage *model.PackageUsage, pkg *model.Package, paidAt time.Time, trigger string) error {
return r.Setter.ApplyOnUsageCreatedInTx(ctx, tx, usage, pkg, paidAt, trigger)
}
// RecordForOrderInTx 写首次购买套餐时间。
func (r PurchaseRecorder) RecordForOrderInTx(ctx context.Context, tx *gorm.DB, order *model.Order) error {
return r.FirstPurchase.RecordForOrderInTx(ctx, tx, order)
}

View File

@@ -1,64 +0,0 @@
package packagesilence
import (
"context"
"encoding/json"
"strings"
"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"
)
// jsapiContentPrefix 标记支付单 qr_content 里保存的是微信 JSAPI 调起参数(JSON),而不是二维码 / 支付链接。
const jsapiContentPrefix = "jsapi:"
// JSAPIPayer 为 C 端客户按已冻结商户创建微信 JSAPI 预下单(复用套餐购买的公众号 / 小程序支付)。
type JSAPIPayer interface {
CreateJSAPIPayConfig(ctx context.Context, customerID uint, appType string, config *model.WechatConfig,
paymentNo, description string, amount int64) (*dto.ClientPayConfig, error)
}
// SetJSAPIPayer 注入微信 JSAPI 预下单能力;未注入时 H5 微信延期仍走 H5 支付链接。
func (s *ExtensionService) SetJSAPIPayer(payer JSAPIPayer) { s.jsapi = payer }
// useJSAPI 判断本次下单是否走微信内直接支付:H5 客户、微信支付、带应用类型、商户为微信直连(v3 / v2)。
// 富友商户仍走扫码,避免查单与下单协议不一致。
func (s *ExtensionService) useJSAPI(source string, req dto.SilenceOnlineOrderRequest, config *model.WechatConfig) bool {
return s.jsapi != nil && source == constants.SilenceOrderSourceClient && req.PaymentMethod == model.PaymentByWechat &&
strings.TrimSpace(req.AppType) != "" && config != nil &&
(config.ProviderType == model.ProviderTypeWechat || config.ProviderType == model.ProviderTypeWechatV2)
}
// createJSAPIContent 预下单并把调起参数编码成支付单内容,便于"继续支付"复用(预支付有效期 2 小时,长于支付截止)。
func (s *ExtensionService) createJSAPIContent(ctx context.Context, customerID uint, appType string, config *model.WechatConfig, payment *model.Payment) (string, error) {
payConfig, err := s.jsapi.CreateJSAPIPayConfig(ctx, customerID, appType, config, payment.PaymentNo, "沉默期延期", payment.Amount)
if err != nil {
return "", err
}
if payConfig == nil || strings.TrimSpace(payConfig.PackageVal) == "" {
return "", errors.New(errors.CodeWechatPayFailed, "微信支付未返回调起参数")
}
raw, err := json.Marshal(payConfig)
if err != nil {
return "", errors.Wrap(errors.CodeInternalError, err, "编码微信支付参数失败")
}
return jsapiContentPrefix + string(raw), nil
}
// ApplyPaymentContent 把支付单内容写到响应:JSAPI 参数放 pay_config,其余(二维码 / 支付链接)放 qr_content。
func ApplyPaymentContent(resp *dto.SilenceExtensionOrderResponse, content string) {
if resp == nil || content == "" {
return
}
if strings.HasPrefix(content, jsapiContentPrefix) {
var payConfig dto.ClientPayConfig
if err := json.Unmarshal([]byte(strings.TrimPrefix(content, jsapiContentPrefix)), &payConfig); err == nil {
resp.PayConfig = &payConfig
resp.QRContent = ""
}
return
}
resp.QRContent = content
}

View File

@@ -1,57 +0,0 @@
package packagesilence
import (
"context"
"testing"
"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"
)
type fakeJSAPIPayer struct{}
func (fakeJSAPIPayer) CreateJSAPIPayConfig(context.Context, uint, string, *model.WechatConfig, string, string, int64) (*dto.ClientPayConfig, error) {
return &dto.ClientPayConfig{PackageVal: "prepay_id=wx1"}, nil
}
func TestUseJSAPI(t *testing.T) {
s := &ExtensionService{jsapi: fakeJSAPIPayer{}}
wechatReq := dto.SilenceOnlineOrderRequest{PaymentMethod: model.PaymentByWechat, AppType: "official_account"}
tests := []struct {
name string
svc *ExtensionService
source string
req dto.SilenceOnlineOrderRequest
config *model.WechatConfig
want bool
}{
{"H5 微信直连 v2", s, constants.SilenceOrderSourceClient, wechatReq, &model.WechatConfig{ProviderType: model.ProviderTypeWechatV2}, true},
{"H5 微信直连 v3", s, constants.SilenceOrderSourceClient, wechatReq, &model.WechatConfig{ProviderType: model.ProviderTypeWechat}, true},
{"富友仍走扫码", s, constants.SilenceOrderSourceClient, wechatReq, &model.WechatConfig{ProviderType: model.ProviderTypeFuiou}, false},
{"后台二维码", s, constants.SilenceOrderSourceAdmin, wechatReq, &model.WechatConfig{ProviderType: model.ProviderTypeWechatV2}, false},
{"未带应用类型", s, constants.SilenceOrderSourceClient, dto.SilenceOnlineOrderRequest{PaymentMethod: model.PaymentByWechat}, &model.WechatConfig{ProviderType: model.ProviderTypeWechatV2}, false},
{"支付宝", s, constants.SilenceOrderSourceClient, dto.SilenceOnlineOrderRequest{PaymentMethod: model.PaymentByAlipay, AppType: "official_account"}, &model.WechatConfig{ProviderType: "alipay"}, false},
{"未注入", &ExtensionService{}, constants.SilenceOrderSourceClient, wechatReq, &model.WechatConfig{ProviderType: model.ProviderTypeWechatV2}, false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := tt.svc.useJSAPI(tt.source, tt.req, tt.config); got != tt.want {
t.Fatalf("useJSAPI() = %v, want %v", got, tt.want)
}
})
}
}
func TestApplyPaymentContent(t *testing.T) {
resp := &dto.SilenceExtensionOrderResponse{}
ApplyPaymentContent(resp, `jsapi:{"app_id":"wxA","package":"prepay_id=wx1","pay_sign":"S"}`)
if resp.PayConfig == nil || resp.PayConfig.PackageVal != "prepay_id=wx1" || resp.PayConfig.AppID != "wxA" || resp.QRContent != "" {
t.Fatalf("jsapi content not mapped: %+v", resp)
}
link := &dto.SilenceExtensionOrderResponse{}
ApplyPaymentContent(link, "https://wx.tenpay.com/checkmweb?x=1")
if link.QRContent != "https://wx.tenpay.com/checkmweb?x=1" || link.PayConfig != nil {
t.Fatalf("link content not mapped: %+v", link)
}
}

View File

@@ -1,693 +0,0 @@
package packagesilence
import (
"context"
"crypto/sha256"
"encoding/hex"
stderrors "errors"
"fmt"
"strings"
"time"
"go.uber.org/zap"
"gorm.io/gorm"
"gorm.io/gorm/clause"
agentrechargeApp "github.com/break/junhong_cmp_fiber/internal/application/agentrecharge"
"github.com/break/junhong_cmp_fiber/internal/application/merchantpayment"
packagedomain "github.com/break/junhong_cmp_fiber/internal/domain/package"
"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"
)
// OnlineOrderCommand 是在线付费延期下单命令(H5 客户自助或运营后台生成二维码)。
type OnlineOrderCommand struct {
Request dto.SilenceOnlineOrderRequest
Scope Scope
PayerClientIP string
}
// CreateOnlineOrder 创建在线付费延期订单与支付单(技术方案 §8.4):
// 冻结月数、单价、金额与政策快照;商户按支付方式经商户池路由并冻结;事务外调用渠道生成支付内容。
// 同一套餐已有待支付订单时先查单:未支付则关闭旧单再新建,已支付则按已支付处理并返回旧单。
func (s *ExtensionService) CreateOnlineOrder(ctx context.Context, command OnlineOrderCommand) (*dto.SilenceExtensionOrderResponse, error) {
if err := s.requireEnabled(ctx); err != nil {
return nil, err
}
if s.runtime == nil || s.payments.Wechat == nil || s.payments.Alipay == nil || s.payments.Fuiou == nil {
return nil, errors.New(errors.CodeServiceUnavailable, "沉默期在线支付能力未配置")
}
req := command.Request
source := constants.SilenceOrderSourceClient
var createdBy *uint
if command.Scope.CustomerID == 0 {
if err := s.requirePermission(ctx, constants.PermissionPackageSilenceExtend); err != nil {
return nil, err
}
source = constants.SilenceOrderSourceAdmin
operatorID := middleware.GetUserIDFromContext(ctx)
createdBy = &operatorID
}
requestKey := scopedRequestKey(source, command.Scope.CustomerID, createdBy, req.RequestID)
if existing, err := postgres.NewPackageSilenceOrderStore(s.db).FindByRequestID(ctx, requestKey); err != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询延期订单幂等记录失败")
} else if existing != nil {
if existing.PackageUsageID != req.PackageUsageID || existing.InputDays != req.Days || existing.PaymentMethod != req.PaymentMethod {
return nil, errors.New(errors.CodeConflict, "同一请求标识对应的延期内容不一致")
}
return s.orderResponse(ctx, existing, true)
}
usage, err := s.loadUsage(ctx, s.db, req.PackageUsageID, command.Scope)
if err != nil {
return nil, err
}
if _, err := s.resolver.Resolve(ctx, usage.ID, ResolveOptions{Trigger: constants.SilenceTriggerEligibilityRead, AllowExpire: true}); err != nil {
return nil, err
}
if replaced, err := s.settleOpenOrder(ctx, usage.ID); err != nil {
return nil, err
} else if replaced != nil {
return s.orderResponse(ctx, replaced, false)
}
usage, err = s.loadUsage(ctx, s.db, req.PackageUsageID, command.Scope)
if err != nil {
return nil, err
}
now := s.now()
facts, err := s.computeEligibility(ctx, s.db, usage, now)
if err != nil {
return nil, err
}
if !facts.canPay() {
return nil, facts.paidError()
}
quote := packagedomain.QuoteSilenceExtension(packagedomain.SilenceQuoteInput{
DeadlineAt: *usage.SilenceDeadlineAt, Now: now, InputDays: req.Days, Policy: facts.match.Snapshot,
PaidExtendedDays: usage.SilencePaidExtendedDays, Kind: packagedomain.SilenceQuotePaid,
})
if !quote.InputValid {
return nil, errors.NewWithData(errors.CodeSilenceExtensionMonthsInvalid, map[string]any{
"min_days": quote.MinDays, "max_days": quote.MaxDays, "months": quote.Months,
})
}
order, payment, config, adapter, err := s.createOnlineFacts(ctx, usage, facts, quote, req, source, command.Scope.CustomerID, createdBy, requestKey, now)
if err != nil {
if existing, findErr := postgres.NewPackageSilenceOrderStore(s.db).FindByRequestID(ctx, requestKey); findErr == nil && existing != nil {
return s.orderResponse(ctx, existing, true)
}
return nil, err
}
if s.useJSAPI(source, req, config) {
// H5 客户在微信内用微信支付:走 JSAPI 直接拉起,不生成 H5 支付链接(H5 链接在微信内无法支付)
content, jsapiErr := s.createJSAPIContent(ctx, command.Scope.CustomerID, req.AppType, config, payment)
if jsapiErr != nil {
_ = s.closeOrder(ctx, order.ID, "微信支付参数生成失败")
return nil, jsapiErr
}
if err := s.db.WithContext(ctx).Model(&model.Payment{}).
Where("id = ? AND status = ? AND (qr_content IS NULL OR qr_content = '')", payment.ID, model.PaymentRecordStatusPending).
Update("qr_content", content).Error; err != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "保存支付内容失败")
}
return s.orderResponse(ctx, order, true)
}
paymentResult, err := adapter.CreatePaymentURL(ctx, agentrechargeApp.OnlinePaymentRequest{
PaymentID: payment.ID, PaymentNo: payment.PaymentNo, CorrelationID: payment.PaymentNo,
Description: "沉默期延期", Amount: payment.Amount, ExpireAt: *payment.ExpireAt,
PayerClientIP: command.PayerClientIP, Config: config,
})
if err != nil {
if merchantpayment.ShouldDisableMerchant(err) {
if disableErr := merchantpayment.DisableMerchantAfterExplicitFailure(ctx, s.db, payment, "payment_precreate_failed", time.Now()); disableErr != nil {
return nil, disableErr
}
}
if !isUnknownPayment(err) {
_ = s.closeOrder(ctx, order.ID, "支付内容生成失败")
}
return nil, err
}
if strings.TrimSpace(paymentResult.QRContent) == "" {
_ = s.closeOrder(ctx, order.ID, "支付渠道未返回支付内容")
return nil, errors.New(errors.CodeServiceUnavailable, "支付渠道未返回支付内容")
}
if err := s.db.WithContext(ctx).Model(&model.Payment{}).
Where("id = ? AND status = ? AND (qr_content IS NULL OR qr_content = '')", payment.ID, model.PaymentRecordStatusPending).
Update("qr_content", paymentResult.QRContent).Error; err != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "保存支付内容失败")
}
return s.orderResponse(ctx, order, true)
}
func (s *ExtensionService) createOnlineFacts(ctx context.Context, usage *model.PackageUsage, facts *eligibility, quote packagedomain.SilenceQuote,
req dto.SilenceOnlineOrderRequest, source string, customerID uint, createdBy *uint, requestKey string, now time.Time,
) (*model.PackageSilenceExtensionOrder, *model.Payment, *model.WechatConfig, agentrechargeApp.OnlinePaymentPort, error) {
identifier, err := assetIdentifier(ctx, s.db, usage)
if err != nil {
return nil, nil, nil, nil, err
}
shopID := assetShopID(ctx, s.db, usage)
var order *model.PackageSilenceExtensionOrder
var payment *model.Payment
var config *model.WechatConfig
var adapter agentrechargeApp.OnlinePaymentPort
err = s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
if err := lockCarrier(ctx, tx, usage); err != nil {
return err
}
route, err := s.runtime.SelectForNewPaymentWithTx(ctx, tx, req.PaymentMethod, now)
if err != nil {
return err
}
config, err = s.runtime.MerchantConfigWithAuthorization(ctx, route.Merchant)
if err != nil {
return err
}
adapter = s.adapter(req.PaymentMethod, config)
if adapter == nil || !adapter.Available(config) {
return errors.New(errors.CodeNoPaymentConfig)
}
expireAt := onlineExpireAt(config, now)
snapshot := facts.match.Snapshot
batch := &model.PackageSilenceExtensionBatch{
BatchNo: generateNo(constants.SilenceExtensionBatchNoPrefix, now), Path: constants.SilencePathOnline,
ExtensionType: constants.SilenceExtensionTypeStandardPaid, EvidenceRefs: model.StringJSONBArray(nonNil(req.EvidenceRefs)),
PolicyID: &snapshot.PolicyID, PolicyNameSnapshot: snapshot.Name, UnitPriceSnapshot: snapshot.UnitPrice,
ApprovalExemptTypesSnapshot: model.StringJSONBArray(nonNil(snapshot.ApprovalExemptTypes)),
FreeExtensionLimitSnapshot: snapshot.FreeExtensionLimit, PaidMaxMonthsSnapshot: snapshot.PaidMaxMonths,
SingleMaxMonthsSnapshot: snapshot.SingleMaxMonths, Amount: quote.Amount,
RequestedBy: requesterID(customerID, createdBy), RequestedByType: requesterType(customerID),
RequestedAt: now, Status: constants.SilenceBatchStatusPendingPayment, RequestedCount: 1,
PaymentVoucherKeys: model.StringJSONBArray{}, ShopIDTag: shopID,
}
if err := postgres.NewPackageSilenceBatchStore(tx).Create(ctx, batch); err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "创建延期批次失败")
}
order = &model.PackageSilenceExtensionOrder{
OrderNo: generateNo(constants.SilenceExtensionOrderNoPrefix, now), BatchID: batch.ID,
PackageUsageID: usage.ID, PackageID: usage.PackageID, PackageNameSnapshot: usage.PackageName,
AssetIdentifierSnapshot: identifier, ShopID: shopID, Source: source, CreatedBy: createdBy,
EvidenceRefs: model.StringJSONBArray(nonNil(req.EvidenceRefs)), InputDays: req.Days,
Months: quote.Months, ExtensionDays: quote.BilledDays, PolicyID: &snapshot.PolicyID,
PolicyNameSnapshot: snapshot.Name, UnitPrice: snapshot.UnitPrice,
ApprovalExemptTypesSnapshot: model.StringJSONBArray(nonNil(snapshot.ApprovalExemptTypes)),
FreeExtensionLimitSnapshot: snapshot.FreeExtensionLimit, PaidMaxMonthsSnapshot: snapshot.PaidMaxMonths,
SingleMaxMonthsSnapshot: snapshot.SingleMaxMonths, Amount: quote.Amount,
BeforeDeadlineAt: *usage.SilenceDeadlineAt, ExpectedDeadlineAt: quote.NewDeadline,
Status: constants.SilenceOrderStatusPendingPayment, PaymentMethod: req.PaymentMethod,
PaymentVoucherKeys: model.StringJSONBArray{}, ExpireAt: &expireAt, RequestID: &requestKey, ShopIDTag: shopID,
}
order.AssetType, order.AssetID = usageAsset(usage)
if customerID > 0 {
order.CustomerID = &customerID
}
if err := postgres.NewPackageSilenceOrderStore(tx).Create(ctx, order); err != nil {
if isUniqueViolation(err) {
return errors.New(errors.CodeSilenceExtensionOrderPending)
}
return errors.Wrap(errors.CodeDatabaseError, err, "创建延期订单失败")
}
extension := &model.PackageSilenceExtension{
BatchID: batch.ID, PackageUsageID: usage.ID, PackageID: usage.PackageID, PackageNameSnapshot: usage.PackageName,
AssetType: order.AssetType, AssetID: order.AssetID, AssetIdentifierSnapshot: identifier,
ExtensionType: constants.SilenceExtensionTypeStandardPaid, InputDays: req.Days, Months: quote.Months,
ExtensionDays: quote.BilledDays, UnitPriceSnapshot: snapshot.UnitPrice, Amount: quote.Amount,
ExtensionOrderID: &order.ID, BeforeDeadlineAt: usage.SilenceDeadlineAt,
Status: constants.SilenceExtensionStatusPendingPayment,
}
if err := postgres.NewPackageSilenceExtensionStore(tx).Create(ctx, extension); err != nil {
if isUniqueViolation(err) {
return errors.New(errors.CodeSilenceExtensionOrderPending)
}
return errors.Wrap(errors.CodeDatabaseError, err, "创建延期明细失败")
}
paymentNo := generateNo("PAY", now)
payment = &model.Payment{
PaymentNo: paymentNo, OrderID: order.ID, OrderType: constants.PaymentOrderTypeSilenceExtension,
PaymentMethod: req.PaymentMethod, Amount: quote.Amount, Status: model.PaymentRecordStatusPending, ExpireAt: &expireAt,
}
merchantpayment.FreezeRoute(payment, route)
if err := tx.Create(payment).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "创建延期支付单失败")
}
if err := merchantpayment.RecordRoutingAttempt(ctx, tx, payment); err != nil {
return err
}
return s.writeOrderAudit(ctx, tx, order, "", "创建在线付费延期订单")
})
if err != nil {
return nil, nil, nil, nil, err
}
return order, payment, config, adapter, nil
}
// settleOpenOrder 处理同一套餐的未结束订单:待支付订单先查单,未支付关闭、已支付按已支付处理并返回该订单。
func (s *ExtensionService) settleOpenOrder(ctx context.Context, usageID uint) (*model.PackageSilenceExtensionOrder, error) {
open, err := postgres.NewPackageSilenceOrderStore(s.db).FindOpenByUsage(ctx, usageID)
if err != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询未结束延期订单失败")
}
if open == nil {
return nil, nil
}
if open.Status == constants.SilenceOrderStatusPendingApproval {
return nil, errors.New(errors.CodeSilenceExtensionOrderPending, "该套餐有待审批的线下延期订单")
}
paid, err := s.queryAndConfirm(ctx, open)
if err != nil {
return nil, err
}
if paid {
current, getErr := postgres.NewPackageSilenceOrderStore(s.db).GetByID(ctx, open.ID)
if getErr != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, getErr, "查询延期订单失败")
}
return current, nil
}
if err := s.closeOrder(ctx, open.ID, constants.SilenceExtensionOrderCloseReasonSuperseded); err != nil {
return nil, err
}
return nil, nil
}
// queryAndConfirm 对订单的待支付支付单查单;已支付时走支付确认,返回是否已支付。
// 渠道无法确认时保守返回错误,避免旧单已付又新建。
func (s *ExtensionService) queryAndConfirm(ctx context.Context, order *model.PackageSilenceExtensionOrder) (bool, error) {
var payments []model.Payment
if err := s.db.WithContext(ctx).Where("order_type = ? AND order_id = ?", constants.PaymentOrderTypeSilenceExtension, order.ID).
Order("id DESC").Find(&payments).Error; err != nil {
return false, errors.Wrap(errors.CodeDatabaseError, err, "查询延期支付单失败")
}
for index := range payments {
payment := &payments[index]
if payment.Status == model.PaymentRecordStatusPaid {
return true, nil
}
if payment.Status != model.PaymentRecordStatusPending {
continue
}
state, result, err := s.queryPayment(ctx, payment)
if err != nil {
return false, err
}
switch state {
case agentrechargeApp.OnlinePaymentStatePaid:
paidAt := s.now()
if result.PaidAt != nil {
paidAt = *result.PaidAt
}
if err := s.ConfirmPayment(ctx, PaymentConfirmCommand{
PaymentNo: payment.PaymentNo, ThirdPartyTradeNo: result.ThirdPartyTradeNo, Amount: result.Amount, PaidAt: paidAt,
}); err != nil {
return false, err
}
return true, nil
case agentrechargeApp.OnlinePaymentStatePending, agentrechargeApp.OnlinePaymentStateUnknown:
if payment.QRContent == "" && state == agentrechargeApp.OnlinePaymentStateUnknown {
// 支付内容从未生成成功:客户无法付款,可以安全关闭。
continue
}
if state == agentrechargeApp.OnlinePaymentStateUnknown {
return false, errors.New(errors.CodeServiceUnavailable, "原待支付订单状态暂时无法确认,请稍后重试")
}
}
}
return false, nil
}
func (s *ExtensionService) queryPayment(ctx context.Context, payment *model.Payment) (string, agentrechargeApp.OnlinePaymentQueryResult, error) {
config, err := s.paymentConfig(ctx, payment)
if err != nil {
return "", agentrechargeApp.OnlinePaymentQueryResult{}, err
}
adapter := s.adapter(payment.PaymentMethod, config)
if adapter == nil {
return "", agentrechargeApp.OnlinePaymentQueryResult{}, errors.New(errors.CodeNoPaymentConfig)
}
result, err := adapter.Query(ctx, agentrechargeApp.OnlinePaymentRequest{
PaymentID: payment.ID, PaymentNo: payment.PaymentNo, CorrelationID: payment.PaymentNo, Config: config,
})
if err != nil {
return agentrechargeApp.OnlinePaymentStateUnknown, result, nil
}
return result.State, result, nil
}
// paymentConfig 按支付单冻结的商户加载凭证,不按当前商户池推断。
func (s *ExtensionService) paymentConfig(ctx context.Context, payment *model.Payment) (*model.WechatConfig, error) {
if s.runtime == nil {
return nil, errors.New(errors.CodeServiceUnavailable, "商户池运行时未配置")
}
if payment.MerchantID == nil {
return nil, errors.New(errors.CodeNoPaymentConfig, "延期支付单缺少冻结商户")
}
merchant, err := s.runtime.LoadMerchant(ctx, *payment.MerchantID)
if err != nil {
return nil, err
}
return s.runtime.MerchantConfigWithAuthorization(ctx, merchant)
}
func (s *ExtensionService) adapter(paymentMethod string, config *model.WechatConfig) agentrechargeApp.OnlinePaymentPort {
switch paymentMethod {
case model.PaymentByWechat:
if config != nil && config.ProviderType == model.ProviderTypeFuiou {
return s.payments.Fuiou
}
return s.payments.Wechat
case model.PaymentByAlipay:
return s.payments.Alipay
default:
return nil
}
}
// PaymentConfirmCommand 是支付回调或查单确认的支付事实(已验签、已按冻结商户核对)。
type PaymentConfirmCommand struct {
PaymentNo string
ThirdPartyTradeNo string
Amount int64
PaidAt time.Time
}
// ConfirmPayment 处理延期支付成功(回调或查单):pending_payment → paid → completed,重复回调幂等。
// 金额不一致或执行校验不通过时进入 exception,并在提交后按冻结商户原路退款。
// 总开关关闭时照常处理(避免收钱不延期)。
func (s *ExtensionService) ConfirmPayment(ctx context.Context, command PaymentConfirmCommand) error {
var orderID uint
var usageID uint
completed, needRefund := false, false
err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
var payment model.Payment
if err := tx.WithContext(ctx).Clauses(clause.Locking{Strength: "UPDATE"}).
Where("payment_no = ? AND order_type = ?", command.PaymentNo, constants.PaymentOrderTypeSilenceExtension).
First(&payment).Error; err != nil {
if err == gorm.ErrRecordNotFound {
return errors.New(errors.CodeNotFound, "延期支付单不存在")
}
return errors.Wrap(errors.CodeDatabaseError, err, "锁定延期支付单失败")
}
if payment.Status == model.PaymentRecordStatusPaid || payment.Status == model.PaymentRecordStatusRefunded {
return nil
}
paidAt := command.PaidAt
if paidAt.IsZero() {
paidAt = s.now()
}
if err := tx.Model(&model.Payment{}).Where("id = ? AND status IN ?", payment.ID, []int{model.PaymentRecordStatusPending, model.PaymentRecordStatusFailed}).
Updates(map[string]any{"status": model.PaymentRecordStatusPaid, "third_party_trade_no": command.ThirdPartyTradeNo, "paid_at": paidAt, "updated_at": s.now()}).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "更新延期支付单失败")
}
order, err := postgres.NewPackageSilenceOrderStore(tx).LockByID(ctx, payment.OrderID)
if err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "锁定延期订单失败")
}
orderID, usageID = order.ID, order.PackageUsageID
paidValues := map[string]any{"paid_payment_id": payment.ID, "paid_amount": command.Amount, "paid_at": paidAt}
if order.Status != constants.SilenceOrderStatusPendingPayment {
// 订单已关闭(超时或被新订单替换)后才到达的成功支付:收到钱但不能延期,进入异常并原路退款。
needRefund = true
return s.orderToException(ctx, tx, order, []string{order.Status}, paidValues, constants.SilenceFailureUsageInvalid, "订单已关闭后收到支付")
}
if command.Amount != order.Amount {
needRefund = true
return s.orderToException(ctx, tx, order, []string{constants.SilenceOrderStatusPendingPayment}, paidValues, constants.SilenceFailureAmountMismatch, "回调金额与订单金额不一致")
}
if _, err := postgres.NewPackageSilenceOrderStore(tx).TransitionStatus(ctx, order.ID, []string{constants.SilenceOrderStatusPendingPayment}, constants.SilenceOrderStatusPaid, paidValues); err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "更新延期订单为已支付失败")
}
before := order.Status
order.Status = constants.SilenceOrderStatusPaid
if err := s.writeOrderAudit(ctx, tx, order, before, "延期订单支付成功"); err != nil {
return err
}
ok, err := s.executeOrderInTx(ctx, tx, order)
if err != nil {
return err
}
completed, needRefund = ok, !ok
return nil
})
if err != nil {
return err
}
if completed {
s.afterExecuted(ctx, []uint{usageID})
}
if needRefund && orderID > 0 {
if refundErr := s.refundOrder(ctx, orderID, "延期执行异常原路退款"); refundErr != nil {
s.logger.Warn("延期异常订单原路退款失败,等待恢复任务重试", zap.Uint("order_id", orderID), zap.Error(refundErr))
}
}
return nil
}
// executeOrderInTx 执行已支付订单对应的明细;成功订单 completed,失败订单与明细进入 exception。
func (s *ExtensionService) executeOrderInTx(ctx context.Context, tx *gorm.DB, order *model.PackageSilenceExtensionOrder) (bool, error) {
var extension model.PackageSilenceExtension
if err := tx.WithContext(ctx).Clauses(clause.Locking{Strength: "UPDATE"}).Where("extension_order_id = ?", order.ID).First(&extension).Error; err != nil {
return false, errors.Wrap(errors.CodeDatabaseError, err, "查询延期明细失败")
}
if extension.Status == constants.SilenceExtensionStatusCompleted {
return true, nil
}
from := []string{constants.SilenceExtensionStatusPendingPayment, constants.SilenceExtensionStatusPendingApproval,
constants.SilenceExtensionStatusApproved, constants.SilenceExtensionStatusExecuting, constants.SilenceExtensionStatusException}
outcome, err := s.executeExtensionInTx(ctx, tx, &extension, executeKind{paid: true}, paidSnapshot{
paidMaxMonths: order.PaidMaxMonthsSnapshot, singleMaxMonths: order.SingleMaxMonthsSnapshot, freeLimit: order.FreeExtensionLimitSnapshot,
}, from)
if err != nil {
if isAlreadyExecuted(err) {
return true, nil
}
return false, err
}
now := s.now()
if !outcome.executed {
if err := s.markExtensionFailedInTx(ctx, tx, &extension, constants.SilenceExtensionStatusException, from, outcome.failureCode, outcome.failureText); err != nil {
return false, err
}
if err := s.orderToException(ctx, tx, order, []string{constants.SilenceOrderStatusPaid, constants.SilenceOrderStatusPendingApproval}, nil, outcome.failureCode, outcome.failureText); err != nil {
return false, err
}
return false, refreshBatchCountsInTx(ctx, tx, order.BatchID, now)
}
if _, err := postgres.NewPackageSilenceOrderStore(tx).TransitionStatus(ctx, order.ID,
[]string{constants.SilenceOrderStatusPaid, constants.SilenceOrderStatusPendingApproval, constants.SilenceOrderStatusException},
constants.SilenceOrderStatusCompleted, map[string]any{"extension_id": extension.ID, "completed_at": now, "exception_code": "", "exception_summary": ""}); err != nil {
return false, errors.Wrap(errors.CodeDatabaseError, err, "更新延期订单为已完成失败")
}
before := order.Status
order.Status = constants.SilenceOrderStatusCompleted
if err := s.writeOrderAudit(ctx, tx, order, before, "延期订单执行完成"); err != nil {
return false, err
}
return true, refreshBatchCountsInTx(ctx, tx, order.BatchID, now)
}
func (s *ExtensionService) orderToException(ctx context.Context, tx *gorm.DB, order *model.PackageSilenceExtensionOrder, from []string, values map[string]any, code, text string) error {
updates := map[string]any{"exception_code": code, "exception_summary": truncate(text, 500)}
for key, value := range values {
updates[key] = value
}
if _, err := postgres.NewPackageSilenceOrderStore(tx).TransitionStatus(ctx, order.ID, from, constants.SilenceOrderStatusException, updates); err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "更新延期订单为异常失败")
}
before := order.Status
order.Status = constants.SilenceOrderStatusException
order.ExceptionCode, order.ExceptionSummary = code, text
return s.writeOrderAudit(ctx, tx, order, before, "延期订单执行异常:"+text)
}
// closeOrder 关闭待支付订单(超时、被替换或支付内容生成失败):订单 closed、支付单失败、明细与批次关闭。
func (s *ExtensionService) closeOrder(ctx context.Context, orderID uint, reason string) error {
return s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
order, err := postgres.NewPackageSilenceOrderStore(tx).LockByID(ctx, orderID)
if err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "锁定延期订单失败")
}
if order.Status != constants.SilenceOrderStatusPendingPayment {
return nil
}
now := s.now()
if _, err := postgres.NewPackageSilenceOrderStore(tx).TransitionStatus(ctx, order.ID, []string{constants.SilenceOrderStatusPendingPayment},
constants.SilenceOrderStatusClosed, map[string]any{"closed_at": now, "close_reason": truncate(reason, 200)}); err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "关闭延期订单失败")
}
if err := tx.Model(&model.Payment{}).Where("order_type = ? AND order_id = ? AND status = ?", constants.PaymentOrderTypeSilenceExtension, order.ID, model.PaymentRecordStatusPending).
Update("status", model.PaymentRecordStatusFailed).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "关闭延期支付单失败")
}
if err := tx.Model(&model.PackageSilenceExtension{}).Where("extension_order_id = ? AND status = ?", order.ID, constants.SilenceExtensionStatusPendingPayment).
Updates(map[string]any{"status": constants.SilenceExtensionStatusClosed, "failure_summary": truncate(reason, 500), "updated_at": now}).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "关闭延期明细失败")
}
if _, err := postgres.NewPackageSilenceBatchStore(tx).TransitionStatus(ctx, order.BatchID, []string{constants.SilenceBatchStatusPendingPayment},
constants.SilenceBatchStatusClosed, map[string]any{"completed_at": now}); err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "关闭延期批次失败")
}
before := order.Status
order.Status = constants.SilenceOrderStatusClosed
return s.writeOrderAudit(ctx, tx, order, before, "关闭延期订单:"+reason)
})
}
// refundOrder 对异常订单按支付单冻结商户原路退款(延期费正常不退,只在执行异常时退)。
func (s *ExtensionService) refundOrder(ctx context.Context, orderID uint, reason string) error {
if s.refunder == nil || s.runtime == nil {
return errors.New(errors.CodeServiceUnavailable, "原路退款能力未配置")
}
order, err := postgres.NewPackageSilenceOrderStore(s.db).GetByID(ctx, orderID)
if err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "查询延期订单失败")
}
if order.Status != constants.SilenceOrderStatusException || order.PaidPaymentID == nil || order.PaymentMethod == model.PaymentByOffline {
return nil
}
refundNo := ""
if order.RefundNo != nil {
refundNo = *order.RefundNo
} else {
refundNo = generateNo("SR", s.now())
result := s.db.WithContext(ctx).Model(&model.PackageSilenceExtensionOrder{}).Where("id = ? AND refund_no IS NULL", order.ID).
Updates(map[string]any{"refund_no": refundNo, "refund_reason": truncate(reason, 500), "updated_at": s.now()})
if result.Error != nil {
return errors.Wrap(errors.CodeDatabaseError, result.Error, "登记延期订单退款单号失败")
}
if result.RowsAffected == 0 {
return nil
}
}
var payment model.Payment
if err := s.db.WithContext(ctx).Where("id = ?", *order.PaidPaymentID).First(&payment).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "查询延期支付单失败")
}
config, err := s.paymentConfig(ctx, &payment)
if err != nil {
return err
}
merchant, err := s.runtime.LoadMerchant(ctx, *payment.MerchantID)
if err != nil {
return err
}
amount := payment.Amount
if order.PaidAmount != nil && *order.PaidAmount > 0 {
amount = *order.PaidAmount
}
target := refundTarget(order, &payment, config, merchant.ProviderType, refundNo, amount)
result, err := s.refunder.Refund(ctx, target)
if err != nil {
return err
}
if result.State != "success" {
return errors.New(errors.CodeServiceUnavailable, "原路退款未确认成功:"+result.FailureMessage)
}
return s.markRefunded(ctx, order.ID, payment.ID, amount)
}
func (s *ExtensionService) markRefunded(ctx context.Context, orderID, paymentID uint, amount int64) error {
return s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
order, err := postgres.NewPackageSilenceOrderStore(tx).LockByID(ctx, orderID)
if err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "锁定延期订单失败")
}
now := s.now()
affected, err := postgres.NewPackageSilenceOrderStore(tx).TransitionStatus(ctx, orderID, []string{constants.SilenceOrderStatusException},
constants.SilenceOrderStatusRefunded, map[string]any{"refund_amount": amount, "refunded_at": now})
if err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "更新延期订单为已退款失败")
}
if affected == 0 {
return nil
}
if err := tx.Model(&model.Payment{}).Where("id = ? AND status = ?", paymentID, model.PaymentRecordStatusPaid).
Update("status", model.PaymentRecordStatusRefunded).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "更新延期支付单为已退款失败")
}
before := order.Status
order.Status = constants.SilenceOrderStatusRefunded
return s.writeOrderAudit(ctx, tx, order, before, "延期异常订单已原路退款")
})
}
// writeOrderAudit 写延期订单状态变化审计(主资源为套餐记录)。
func (s *ExtensionService) writeOrderAudit(ctx context.Context, tx *gorm.DB, order *model.PackageSilenceExtensionOrder, before, summary string) error {
if s.audit == nil {
return nil
}
var usage model.PackageUsage
if err := tx.WithContext(ctx).Where("id = ?", order.PackageUsageID).First(&usage).Error; err != nil {
return nil
}
return s.audit.AppendSilenceUsage(ctx, tx, orderAuditInput(&usage, order, before, summary))
}
func onlineExpireAt(config *model.WechatConfig, now time.Time) time.Time {
minutes := model.DefaultAliPayExpireMinutes
if config != nil && config.AliPayExpireMinutes > 0 {
minutes = config.AliPayExpireMinutes
}
expireAt := now.Add(time.Duration(minutes) * time.Minute)
// 支付截止不跨天:降低跨天支付导致新截止不再晚于当天的概率(技术方案 Risks)。
if endOfDay := packagedomain.SilenceDeadline(now, 0); expireAt.After(endOfDay) {
expireAt = endOfDay
}
return expireAt
}
func scopedRequestKey(source string, customerID uint, createdBy *uint, requestID string) string {
actor := uint(0)
if createdBy != nil {
actor = *createdBy
}
digest := sha256.Sum256([]byte(fmt.Sprintf("%s:%d:%d:%s", source, customerID, actor, strings.TrimSpace(requestID))))
return hex.EncodeToString(digest[:])
}
func requesterID(customerID uint, createdBy *uint) uint {
if customerID > 0 {
return customerID
}
if createdBy != nil {
return *createdBy
}
return 0
}
func requesterType(customerID uint) string {
if customerID > 0 {
return constants.SilenceOperatorCustomer
}
return constants.SilenceOperatorAccount
}
func nonNil(values []string) []string {
if values == nil {
return []string{}
}
return values
}
func isUnknownPayment(err error) bool {
var appErr *errors.AppError
return stderrors.As(err, &appErr) && appErr.Code == errors.CodeTimeout
}
func isUniqueViolation(err error) bool {
return err != nil && (strings.Contains(err.Error(), "23505") || strings.Contains(err.Error(), "duplicate key"))
}
// assetShopID 返回套餐载体当前所属店铺(数据范围与对账用)。
func assetShopID(ctx context.Context, db *gorm.DB, usage *model.PackageUsage) uint {
assetType, assetID := usageAsset(usage)
var shopID *uint
_ = db.WithContext(ctx).Table(firstPurchaseTable(assetType)).Select("shop_id").Where("id = ?", assetID).Scan(&shopID).Error
if shopID == nil {
return 0
}
return *shopID
}

View File

@@ -1,101 +0,0 @@
package packagesilence
import (
"context"
"strings"
"gorm.io/gorm"
agentrechargeApp "github.com/break/junhong_cmp_fiber/internal/application/agentrecharge"
"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"
)
// CancelOrderCommand 是作废待支付延期订单的命令:Scope.CustomerID 非 0 为 H5 客户作废自己的订单,为 0 为后台运营作废。
type CancelOrderCommand struct {
OrderID uint
Scope Scope
Reason string
}
// CancelPendingOrder 作废未支付的在线延期订单,作废后同一套餐可以重新下单。
//
// 作废前先按冻结商户查单:渠道已支付则按支付成功处理(照常延期)并拒绝作废;
// 其余情况(未支付、渠道无此订单、查单失败)直接关闭。关闭后才到达的支付会进入异常并由恢复任务原路退款,不会吞款。
func (s *ExtensionService) CancelPendingOrder(ctx context.Context, command CancelOrderCommand) (*dto.SilenceExtensionOrderResponse, error) {
if command.Scope.CustomerID == 0 {
if err := s.requirePermission(ctx, constants.PermissionPackageSilenceExtend); err != nil {
return nil, err
}
}
order, err := postgres.NewPackageSilenceOrderStore(s.db).GetByID(ctx, command.OrderID)
if err != nil {
if err == gorm.ErrRecordNotFound {
return nil, errors.New(errors.CodeNotFound, "延期订单不存在")
}
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询延期订单失败")
}
if command.Scope.CustomerID != 0 && (order.CustomerID == nil || *order.CustomerID != command.Scope.CustomerID) {
return nil, errors.New(errors.CodeNotFound, "延期订单不存在")
}
if order.Status != constants.SilenceOrderStatusPendingPayment {
return nil, errors.New(errors.CodeInvalidStatus, "只有待支付的延期订单可以作废")
}
paid, err := s.confirmIfPaid(ctx, order)
if err != nil {
return nil, err
}
if paid {
return nil, errors.New(errors.CodeInvalidStatus, "该订单已支付,已按支付成功办理延期,不能作废")
}
reason := "手动作废"
if command.Scope.CustomerID != 0 {
reason = "客户作废"
}
if extra := strings.TrimSpace(command.Reason); extra != "" {
reason += ":" + extra
}
if err := s.closeOrder(ctx, order.ID, reason); err != nil {
return nil, err
}
current, err := postgres.NewPackageSilenceOrderStore(s.db).GetByID(ctx, order.ID)
if err != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询延期订单失败")
}
return s.orderResponse(ctx, current, false)
}
// confirmIfPaid 查询订单下待支付的支付单;渠道已支付时走支付确认并返回 true。查单失败不阻止作废。
func (s *ExtensionService) confirmIfPaid(ctx context.Context, order *model.PackageSilenceExtensionOrder) (bool, error) {
if order.PaymentMethod == model.PaymentByOffline {
return false, nil
}
var payments []model.Payment
if err := s.db.WithContext(ctx).Where("order_type = ? AND order_id = ? AND status = ?", constants.PaymentOrderTypeSilenceExtension, order.ID, model.PaymentRecordStatusPending).
Order("id DESC").Find(&payments).Error; err != nil {
return false, errors.Wrap(errors.CodeDatabaseError, err, "查询延期支付单失败")
}
for index := range payments {
payment := &payments[index]
state, query, err := s.queryPayment(ctx, payment)
if err != nil || state != agentrechargeApp.OnlinePaymentStatePaid {
continue
}
paidAt := s.now()
if query.PaidAt != nil {
paidAt = *query.PaidAt
}
if err := s.ConfirmPayment(ctx, PaymentConfirmCommand{
PaymentNo: payment.PaymentNo, ThirdPartyTradeNo: query.ThirdPartyTradeNo, Amount: query.Amount, PaidAt: paidAt,
}); err != nil {
return false, err
}
return true, nil
}
return false, nil
}

View File

@@ -1,146 +0,0 @@
package packagesilence
import (
"context"
"github.com/break/junhong_cmp_fiber/internal/application/refundchannel"
"github.com/break/junhong_cmp_fiber/internal/infrastructure/audit"
"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"
)
func refundTarget(order *model.PackageSilenceExtensionOrder, payment *model.Payment, config *model.WechatConfig, providerType, refundNo string, amount int64) refundchannel.Target {
target := refundchannel.Target{
RefundNo: refundNo, OrderID: order.ID, OrderNo: order.OrderNo, ProviderType: providerType, Config: config,
PaymentNo: payment.PaymentNo, ChannelTradeNo: payment.ThirdPartyTradeNo, PaidAt: payment.PaidAt,
PaidAmount: payment.Amount, RefundAmount: amount, FrozenActualReceivedAmount: amount, ChannelRefundRequestNo: refundNo,
}
if providerType == model.ProviderTypeFuiou {
target.ChannelOrderType = "WECHAT"
}
return target
}
func orderAuditInput(usage *model.PackageUsage, order *model.PackageSilenceExtensionOrder, before, summary string) audit.SilenceAuditInput {
return audit.SilenceAuditInput{
ActionCode: constants.AuditActionUsageSilenceExtensionOrderState, Summary: summary, Usage: usage,
BeforeData: map[string]any{"order_status": before},
AfterData: map[string]any{"order_status": order.Status},
Metadata: map[string]any{
"order_no": order.OrderNo, "batch_id": order.BatchID, "source": order.Source, "payment_method": order.PaymentMethod,
"months": order.Months, "extension_days": order.ExtensionDays, "amount": order.Amount,
"paid_payment_id": order.PaidPaymentID, "exception_code": order.ExceptionCode,
"offline_trade_no": order.OfflineTradeNo, "approval_instance_id": order.ApprovalInstanceID,
"before_deadline_at": order.BeforeDeadlineAt, "expected_deadline_at": order.ExpectedDeadlineAt,
},
}
}
func auditInputForException(usage *model.PackageUsage, extension *model.PackageSilenceExtension, code, text string) audit.SilenceAuditInput {
return audit.SilenceAuditInput{
ActionCode: constants.AuditActionUsageSilenceExtensionException, Summary: "沉默期延期执行异常:" + text, Usage: usage,
AfterData: map[string]any{"extension_status": extension.Status},
Metadata: map[string]any{
"batch_id": extension.BatchID, "extension_id": extension.ID, "extension_type": extension.ExtensionType,
"months": extension.Months, "extension_days": extension.ExtensionDays, "amount": extension.Amount,
"extension_order_id": extension.ExtensionOrderID, "failure_code": code,
},
}
}
// orderResponse 构造延期订单响应:支付方式、收款商户、交易流水号取已支付或已退款支付单快照;待支付时返回支付内容。
func (s *ExtensionService) orderResponse(ctx context.Context, order *model.PackageSilenceExtensionOrder, includeQR bool) (*dto.SilenceExtensionOrderResponse, error) {
resp := OrderDTO(order)
var payments []model.Payment
if order.PaymentMethod != model.PaymentByOffline {
if err := s.db.WithContext(ctx).Where("order_type = ? AND order_id = ?", constants.PaymentOrderTypeSilenceExtension, order.ID).
Order("id DESC").Find(&payments).Error; err != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询延期支付单失败")
}
}
for index := range payments {
payment := &payments[index]
if order.PaidPaymentID != nil && payment.ID == *order.PaidPaymentID {
resp.PaymentMerchantName = payment.MerchantNameSnapshot
resp.ThirdPartyTradeNo = payment.ThirdPartyTradeNo
resp.PaymentNo = payment.PaymentNo
}
if includeQR && order.Status == constants.SilenceOrderStatusPendingPayment && payment.Status == model.PaymentRecordStatusPending &&
resp.QRContent == "" && resp.PayConfig == nil {
ApplyPaymentContent(resp, payment.QRContent)
resp.PaymentNo = payment.PaymentNo
}
}
var batch model.PackageSilenceExtensionBatch
if err := s.db.WithContext(ctx).Select("id", "batch_no").Where("id = ?", order.BatchID).First(&batch).Error; err == nil {
resp.BatchNo = batch.BatchNo
}
return resp, nil
}
// OrderDTO 构造延期订单基础响应(不含支付单快照)。
func OrderDTO(order *model.PackageSilenceExtensionOrder) *dto.SilenceExtensionOrderResponse {
resp := &dto.SilenceExtensionOrderResponse{
ID: order.ID, OrderNo: order.OrderNo, PackageUsageID: order.PackageUsageID, PackageID: order.PackageID,
PackageName: order.PackageNameSnapshot, AssetType: order.AssetType, AssetID: order.AssetID,
AssetIdentifier: order.AssetIdentifierSnapshot, ShopID: order.ShopID, CustomerID: order.CustomerID,
Source: order.Source, SourceName: orderSourceName(order.Source), InputDays: order.InputDays, Months: order.Months,
ExtensionDays: order.ExtensionDays, UnitPrice: order.UnitPrice, Amount: order.Amount, PolicyName: order.PolicyNameSnapshot,
BeforeDeadlineAt: order.BeforeDeadlineAt, ExpectedDeadlineAt: order.ExpectedDeadlineAt, Status: order.Status,
StatusName: OrderStatusName(order.Status), PaymentMethod: order.PaymentMethod,
PaymentVoucherKeys: []string(order.PaymentVoucherKeys), EvidenceRefs: []string(order.EvidenceRefs),
ApprovalRequired: order.ApprovalRequired, ApprovalInstanceID: order.ApprovalInstanceID, PaidAmount: order.PaidAmount,
PaidAt: order.PaidAt, ExpireAt: order.ExpireAt, CompletedAt: order.CompletedAt, ExceptionCode: order.ExceptionCode,
ExceptionSummary: order.ExceptionSummary, RefundAmount: order.RefundAmount, RefundedAt: order.RefundedAt,
CreatedBy: order.CreatedBy, CreatedAt: order.CreatedAt,
}
if order.OfflinePaymentMethodName != nil {
resp.OfflinePaymentMethodName = *order.OfflinePaymentMethodName
}
if order.OfflineTradeNo != nil {
resp.OfflineTradeNo = *order.OfflineTradeNo
}
if order.RefundNo != nil {
resp.RefundNo = *order.RefundNo
}
return resp
}
// OrderStatusName 返回延期订单状态名称。
func OrderStatusName(status string) string {
switch status {
case constants.SilenceOrderStatusPendingPayment:
return "待支付"
case constants.SilenceOrderStatusPendingApproval:
return "待审批"
case constants.SilenceOrderStatusRejected:
return "已驳回"
case constants.SilenceOrderStatusPaid:
return "已支付"
case constants.SilenceOrderStatusCompleted:
return "已延期"
case constants.SilenceOrderStatusClosed:
return "已关闭"
case constants.SilenceOrderStatusException:
return "执行异常"
case constants.SilenceOrderStatusRefunded:
return "已退款"
default:
return ""
}
}
func orderSourceName(source string) string {
switch source {
case constants.SilenceOrderSourceClient:
return "客户 H5 下单"
case constants.SilenceOrderSourceAdmin:
return "后台二维码"
case constants.SilenceOrderSourceOffline:
return "线下登记"
default:
return ""
}
}

View File

@@ -1,412 +0,0 @@
package packagesilence
import (
"context"
"slices"
"strings"
"time"
"gorm.io/gorm"
packagedomain "github.com/break/junhong_cmp_fiber/internal/domain/package"
"github.com/break/junhong_cmp_fiber/internal/infrastructure/audit"
"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"
)
// PolicyService 管理沉默期延期政策(技术方案 §3.6)。权限点 package_silence:policy 在路由层校验。
type PolicyService struct {
db *gorm.DB
audit *audit.Writer
perms PermissionChecker
now func() time.Time
}
// SetPermissionChecker 注入权限校验(package_silence:policy)。
func (s *PolicyService) SetPermissionChecker(checker PermissionChecker) {
s.perms = checker
}
// NewPolicyService 创建延期政策用例。
func NewPolicyService(db *gorm.DB, writer *audit.Writer) *PolicyService {
return &PolicyService{db: db, audit: writer, now: time.Now}
}
// List 分页查询延期政策。
func (s *PolicyService) List(ctx context.Context, req *dto.SilencePolicyListRequest) ([]*dto.SilencePolicyResponse, int64, error) {
if err := requirePermission(ctx, s.perms, constants.PermissionPackageSilencePolicy); err != nil {
return nil, 0, err
}
page, pageSize := normalizePage(req.Page, req.PageSize)
query := s.db.WithContext(ctx).Model(&model.PackageSilenceExtensionPolicy{})
if name := strings.TrimSpace(req.Name); name != "" {
query = query.Where("name LIKE ?", "%"+name+"%")
}
if req.ScopeType != "" {
query = query.Where("scope_type = ?", req.ScopeType)
}
if req.Status != nil {
query = query.Where("status = ?", *req.Status)
}
if req.EffectiveAt != "" {
at, err := time.Parse(time.RFC3339, req.EffectiveAt)
if err != nil {
return nil, 0, errors.New(errors.CodeInvalidParam, "生效时间格式应为 RFC3339")
}
query = query.Where("effective_from <= ? AND (effective_to IS NULL OR effective_to > ?)", at, at)
}
var total int64
if err := query.Count(&total).Error; err != nil {
return nil, 0, errors.Wrap(errors.CodeDatabaseError, err, "统计延期政策失败")
}
var policies []model.PackageSilenceExtensionPolicy
if err := query.Order("id DESC").Offset((page - 1) * pageSize).Limit(pageSize).Find(&policies).Error; err != nil {
return nil, 0, errors.Wrap(errors.CodeDatabaseError, err, "查询延期政策失败")
}
items := make([]*dto.SilencePolicyResponse, 0, len(policies))
for index := range policies {
items = append(items, policyResponse(&policies[index]))
}
return items, total, nil
}
// Get 查询政策详情。
func (s *PolicyService) Get(ctx context.Context, id uint) (*dto.SilencePolicyResponse, error) {
if err := requirePermission(ctx, s.perms, constants.PermissionPackageSilencePolicy); err != nil {
return nil, err
}
policy, err := postgres.NewPackageSilencePolicyStore(s.db).GetByID(ctx, id)
if err != nil {
if err == gorm.ErrRecordNotFound {
return nil, errors.New(errors.CodeNotFound, "延期政策不存在")
}
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询延期政策失败")
}
return policyResponse(policy), nil
}
// Create 新增政策:校验字段、同级重叠,写审计。
func (s *PolicyService) Create(ctx context.Context, req *dto.SilencePolicyRequest) (*dto.SilencePolicyResponse, error) {
if err := requirePermission(ctx, s.perms, constants.PermissionPackageSilencePolicy); err != nil {
return nil, err
}
operatorID := middleware.GetUserIDFromContext(ctx)
policy := &model.PackageSilenceExtensionPolicy{Creator: operatorID, Updater: operatorID, Status: constants.SilencePolicyStatusEnabled}
if err := applyPolicyRequest(policy, req); err != nil {
return nil, err
}
err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
store := postgres.NewPackageSilencePolicyStore(tx)
if err := store.LockAll(ctx); err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "锁定延期政策失败")
}
if err := s.validateInTx(ctx, tx, policy); err != nil {
return err
}
now := s.now()
policy.CreatedAt, policy.UpdatedAt = now, now
if err := store.Create(ctx, policy); err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "新增延期政策失败")
}
return s.writeAudit(ctx, tx, constants.AuditActionSilencePolicyCreated, "新增沉默期延期政策", policy, nil, policyAuditData(policy))
})
if err != nil {
return nil, err
}
return policyResponse(policy), nil
}
// Update 编辑政策:只影响之后发起的延期,已创建订单与已提交审批按快照执行。
func (s *PolicyService) Update(ctx context.Context, id uint, req *dto.SilencePolicyRequest) (*dto.SilencePolicyResponse, error) {
if err := requirePermission(ctx, s.perms, constants.PermissionPackageSilencePolicy); err != nil {
return nil, err
}
var policy *model.PackageSilenceExtensionPolicy
err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
store := postgres.NewPackageSilencePolicyStore(tx)
if err := store.LockAll(ctx); err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "锁定延期政策失败")
}
current, err := store.GetByID(ctx, id)
if err != nil {
if err == gorm.ErrRecordNotFound {
return errors.New(errors.CodeNotFound, "延期政策不存在")
}
return errors.Wrap(errors.CodeDatabaseError, err, "查询延期政策失败")
}
before := policyAuditData(current)
if err := applyPolicyRequest(current, req); err != nil {
return err
}
if err := s.validateInTx(ctx, tx, current); err != nil {
return err
}
current.Updater = middleware.GetUserIDFromContext(ctx)
current.UpdatedAt = s.now()
if _, err := store.Update(ctx, current.ID, map[string]any{
"name": current.Name, "scope_type": current.ScopeType, "scope_ids": current.ScopeIDs,
"unit_price": current.UnitPrice, "free_extension_limit": current.FreeExtensionLimit,
"paid_max_months": current.PaidMaxMonths, "single_max_months": current.SingleMaxMonths,
"approval_exempt_types": current.ApprovalExemptTypes, "effective_from": current.EffectiveFrom,
"effective_to": current.EffectiveTo, "status": current.Status, "remark": current.Remark,
"updater": current.Updater, "updated_at": current.UpdatedAt,
}); err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "更新延期政策失败")
}
policy = current
return s.writeAudit(ctx, tx, constants.AuditActionSilencePolicyUpdated, "修改沉默期延期政策", current, before, policyAuditData(current))
})
if err != nil {
return nil, err
}
return policyResponse(policy), nil
}
// SetStatus 启停政策;启用时同样做重叠校验。
func (s *PolicyService) SetStatus(ctx context.Context, id uint, status int) (*dto.SilencePolicyResponse, error) {
if err := requirePermission(ctx, s.perms, constants.PermissionPackageSilencePolicy); err != nil {
return nil, err
}
if status != constants.SilencePolicyStatusEnabled && status != constants.SilencePolicyStatusDisabled {
return nil, errors.New(errors.CodeInvalidParam, "政策状态非法")
}
var policy *model.PackageSilenceExtensionPolicy
err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
store := postgres.NewPackageSilencePolicyStore(tx)
if err := store.LockAll(ctx); err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "锁定延期政策失败")
}
current, err := store.GetByID(ctx, id)
if err != nil {
if err == gorm.ErrRecordNotFound {
return errors.New(errors.CodeNotFound, "延期政策不存在")
}
return errors.Wrap(errors.CodeDatabaseError, err, "查询延期政策失败")
}
before := current.Status
if before == status {
policy = current
return nil
}
current.Status = status
if status == constants.SilencePolicyStatusEnabled {
if err := s.validateOverlapInTx(ctx, tx, current); err != nil {
return err
}
}
current.Updater = middleware.GetUserIDFromContext(ctx)
if _, err := store.Update(ctx, current.ID, map[string]any{"status": status, "updater": current.Updater, "updated_at": s.now()}); err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "更新延期政策状态失败")
}
policy = current
return s.writeAudit(ctx, tx, constants.AuditActionSilencePolicyStatusChanged, "启停沉默期延期政策", current,
map[string]any{"status": before}, map[string]any{"status": status})
})
if err != nil {
return nil, err
}
return policyResponse(policy), nil
}
// MatchForPackage 查询某套餐当前匹配的政策。
func (s *PolicyService) MatchForPackage(ctx context.Context, packageID uint) (*dto.SilencePolicyMatchResponse, error) {
match, _, err := matchPolicyForPackage(ctx, s.db, packageID, s.now())
if err != nil {
return nil, err
}
resp := &dto.SilencePolicyMatchResponse{PackageID: packageID, Matched: match.Policy != nil}
if match.Policy != nil {
resp.Policy = policyResponse(match.Policy)
}
return resp, nil
}
func applyPolicyRequest(policy *model.PackageSilenceExtensionPolicy, req *dto.SilencePolicyRequest) error {
name := strings.TrimSpace(req.Name)
if name == "" || len([]rune(name)) > constants.SilencePolicyNameMaxLen {
return errors.New(errors.CodeInvalidParam, "政策名称不能为空且不超过 100 字")
}
scopeIDs := dedupeUint(req.ScopeIDs)
switch req.ScopeType {
case constants.SilencePolicyScopeAll:
scopeIDs = nil
case constants.SilencePolicyScopeSeries, constants.SilencePolicyScopePackage:
if len(scopeIDs) == 0 {
return errors.New(errors.CodeInvalidParam, "按系列或指定套餐适用时必须选择适用范围")
}
default:
return errors.New(errors.CodeInvalidParam, "适用范围非法")
}
for _, exempt := range req.ApprovalExemptTypes {
if !slices.Contains(constants.SilenceApprovalExemptTypes, exempt) {
return errors.New(errors.CodeInvalidParam, "免审批场景非法")
}
}
if req.EffectiveTo != nil && !req.EffectiveTo.After(req.EffectiveFrom) {
return errors.New(errors.CodeInvalidParam, "失效时间必须晚于生效时间")
}
if req.UnitPrice < 0 || req.FreeExtensionLimit < 0 || req.PaidMaxMonths < 0 {
return errors.New(errors.CodeInvalidParam, "单价、免费次数与付费累计月数不能为负数")
}
policy.Name = name
policy.ScopeType = req.ScopeType
policy.ScopeIDs = model.UintJSONBArray(scopeIDs)
if policy.ScopeIDs == nil {
policy.ScopeIDs = model.UintJSONBArray{}
}
policy.UnitPrice = req.UnitPrice
policy.FreeExtensionLimit = req.FreeExtensionLimit
policy.PaidMaxMonths = req.PaidMaxMonths
policy.SingleMaxMonths = req.SingleMaxMonths
policy.ApprovalExemptTypes = model.StringJSONBArray(dedupeString(req.ApprovalExemptTypes))
if policy.ApprovalExemptTypes == nil {
policy.ApprovalExemptTypes = model.StringJSONBArray{}
}
policy.EffectiveFrom = req.EffectiveFrom
policy.EffectiveTo = req.EffectiveTo
if req.Status != nil {
policy.Status = *req.Status
}
policy.Remark = strings.TrimSpace(req.Remark)
return nil
}
func (s *PolicyService) validateInTx(ctx context.Context, tx *gorm.DB, policy *model.PackageSilenceExtensionPolicy) error {
var sameName int64
if err := tx.WithContext(ctx).Model(&model.PackageSilenceExtensionPolicy{}).
Where("name = ? AND id <> ?", policy.Name, policy.ID).Count(&sameName).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "校验政策名称失败")
}
if sameName > 0 {
return errors.New(errors.CodeConflict, "政策名称已存在")
}
if err := s.validateScopeIDs(ctx, tx, policy); err != nil {
return err
}
return s.validateOverlapInTx(ctx, tx, policy)
}
func (s *PolicyService) validateScopeIDs(ctx context.Context, tx *gorm.DB, policy *model.PackageSilenceExtensionPolicy) error {
if len(policy.ScopeIDs) == 0 {
return nil
}
var count int64
var target any = &model.Package{}
if policy.ScopeType == constants.SilencePolicyScopeSeries {
target = &model.PackageSeries{}
}
if err := tx.WithContext(ctx).Model(target).Where("id IN ?", []uint(policy.ScopeIDs)).Count(&count).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "校验适用范围失败")
}
if int(count) != len(policy.ScopeIDs) {
return errors.New(errors.CodeInvalidParam, "适用范围中存在不存在的系列或套餐")
}
return nil
}
// validateOverlapInTx 校验同一优先级的启用政策适用范围与生效时间段不重叠(silence_policy_overlap)。
func (s *PolicyService) validateOverlapInTx(ctx context.Context, tx *gorm.DB, policy *model.PackageSilenceExtensionPolicy) error {
if policy.Status != constants.SilencePolicyStatusEnabled {
return nil
}
existing, err := postgres.NewPackageSilencePolicyStore(tx).ListEnabled(ctx)
if err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "查询启用中的延期政策失败")
}
candidate := policyCandidate(policy)
for index := range existing {
other := &existing[index]
if other.ID == policy.ID {
continue
}
if packagedomain.SilencePolicyOverlaps(candidate, policyCandidate(other)) {
return errors.NewWithData(errors.CodeSilencePolicyOverlap, map[string]any{"conflict_policy_id": other.ID, "conflict_policy_name": other.Name})
}
}
return nil
}
func (s *PolicyService) writeAudit(ctx context.Context, tx *gorm.DB, action, summary string, policy *model.PackageSilenceExtensionPolicy, before, after map[string]any) error {
if s.audit == nil {
return nil
}
return s.audit.AppendSilenceResource(ctx, tx, action, summary, audit.SilencePolicyResource(policy, before, after), nil)
}
func policyAuditData(policy *model.PackageSilenceExtensionPolicy) map[string]any {
return map[string]any{
"name": policy.Name, "scope_type": policy.ScopeType, "scope_ids": []uint(policy.ScopeIDs),
"unit_price": policy.UnitPrice, "free_extension_limit": policy.FreeExtensionLimit,
"paid_max_months": policy.PaidMaxMonths, "single_max_months": policy.SingleMaxMonths,
"approval_exempt_types": []string(policy.ApprovalExemptTypes), "effective_from": policy.EffectiveFrom,
"effective_to": policy.EffectiveTo, "status": policy.Status,
}
}
func policyResponse(policy *model.PackageSilenceExtensionPolicy) *dto.SilencePolicyResponse {
scopeIDs := []uint(policy.ScopeIDs)
if scopeIDs == nil {
scopeIDs = []uint{}
}
exempt := []string(policy.ApprovalExemptTypes)
if exempt == nil {
exempt = []string{}
}
return &dto.SilencePolicyResponse{
ID: policy.ID, Name: policy.Name, ScopeType: policy.ScopeType, ScopeTypeName: policyScopeName(policy.ScopeType),
ScopeIDs: scopeIDs, UnitPrice: policy.UnitPrice, FreeExtensionLimit: policy.FreeExtensionLimit,
PaidMaxMonths: policy.PaidMaxMonths, SingleMaxMonths: policy.SingleMaxMonths, ApprovalExemptTypes: exempt,
EffectiveFrom: policy.EffectiveFrom, EffectiveTo: policy.EffectiveTo, Status: policy.Status, Remark: policy.Remark,
Creator: policy.Creator, Updater: policy.Updater, CreatedAt: policy.CreatedAt, UpdatedAt: policy.UpdatedAt,
}
}
func policyScopeName(scope string) string {
switch scope {
case constants.SilencePolicyScopeAll:
return "全部套餐"
case constants.SilencePolicyScopeSeries:
return "套餐系列"
case constants.SilencePolicyScopePackage:
return "指定套餐"
default:
return ""
}
}
func normalizePage(page, pageSize int) (int, int) {
if page < 1 {
page = 1
}
if pageSize < 1 {
pageSize = constants.DefaultPageSize
}
if pageSize > constants.MaxPageSize {
pageSize = constants.MaxPageSize
}
return page, pageSize
}
func dedupeUint(values []uint) []uint {
result := make([]uint, 0, len(values))
for _, value := range values {
if value > 0 && !slices.Contains(result, value) {
result = append(result, value)
}
}
return result
}
func dedupeString(values []string) []string {
result := make([]string, 0, len(values))
for _, value := range values {
if value != "" && !slices.Contains(result, value) {
result = append(result, value)
}
}
return result
}

View File

@@ -1,79 +0,0 @@
package packagesilence
import (
"context"
"time"
"gorm.io/gorm"
packagedomain "github.com/break/junhong_cmp_fiber/internal/domain/package"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/internal/store/postgres"
"github.com/break/junhong_cmp_fiber/pkg/errors"
)
// PolicyMatch 是某套餐当前匹配到的延期政策及其快照;Policy 为 nil 表示未匹配到政策。
type PolicyMatch struct {
Policy *model.PackageSilenceExtensionPolicy
Snapshot *packagedomain.SilencePolicySnapshot
}
// FreeExtensionLimit 返回免费延期次数上限;未匹配到政策时按 1 次处理(技术方案 §3.6)。
func (m PolicyMatch) FreeExtensionLimit() int {
if m.Snapshot == nil {
return 1
}
return m.Snapshot.FreeExtensionLimit
}
// ExemptTypes 返回免审批场景;未匹配到政策时全部需要审批。
func (m PolicyMatch) ExemptTypes() []string {
if m.Snapshot == nil {
return nil
}
return m.Snapshot.ApprovalExemptTypes
}
// matchPolicy 按"指定套餐 > 套餐系列 > 全部套餐"匹配当前生效政策(启用且在生效期内)。
func matchPolicy(ctx context.Context, db *gorm.DB, packageID, seriesID uint, now time.Time) (PolicyMatch, error) {
policies, err := postgres.NewPackageSilencePolicyStore(db).ListEnabled(ctx)
if err != nil {
return PolicyMatch{}, errors.Wrap(errors.CodeDatabaseError, err, "查询延期政策失败")
}
candidates := make([]packagedomain.SilencePolicyCandidate, len(policies))
for index := range policies {
candidates[index] = policyCandidate(&policies[index])
}
best := packagedomain.MatchSilencePolicy(candidates, packageID, seriesID, now)
if best < 0 {
return PolicyMatch{}, nil
}
policy := policies[best]
snapshot := policySnapshot(&policy)
return PolicyMatch{Policy: &policy, Snapshot: &snapshot}, nil
}
// matchPolicyForPackage 读取套餐当前系列后匹配政策。
func matchPolicyForPackage(ctx context.Context, db *gorm.DB, packageID uint, now time.Time) (PolicyMatch, *model.Package, error) {
var pkg model.Package
if err := db.WithContext(ctx).Unscoped().Where("id = ?", packageID).First(&pkg).Error; err != nil {
return PolicyMatch{}, nil, errors.Wrap(errors.CodeDatabaseError, err, "查询套餐失败")
}
match, err := matchPolicy(ctx, db, pkg.ID, pkg.SeriesID, now)
return match, &pkg, err
}
func policyCandidate(policy *model.PackageSilenceExtensionPolicy) packagedomain.SilencePolicyCandidate {
return packagedomain.SilencePolicyCandidate{
ID: policy.ID, ScopeType: policy.ScopeType, ScopeIDs: []uint(policy.ScopeIDs),
Enabled: policy.Status == 1, EffectiveFrom: policy.EffectiveFrom, EffectiveTo: policy.EffectiveTo,
}
}
func policySnapshot(policy *model.PackageSilenceExtensionPolicy) packagedomain.SilencePolicySnapshot {
return packagedomain.SilencePolicySnapshot{
PolicyID: policy.ID, Name: policy.Name, UnitPrice: policy.UnitPrice,
FreeExtensionLimit: policy.FreeExtensionLimit, PaidMaxMonths: policy.PaidMaxMonths,
SingleMaxMonths: policy.SingleMaxMonths, ApprovalExemptTypes: append([]string(nil), policy.ApprovalExemptTypes...),
}
}

View File

@@ -1,154 +0,0 @@
package packagesilence
import (
"context"
"time"
"github.com/bytedance/sonic"
"gorm.io/gorm"
"github.com/break/junhong_cmp_fiber/internal/infrastructure/audit"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/internal/store/postgres"
"github.com/break/junhong_cmp_fiber/pkg/auditcontext"
"github.com/break/junhong_cmp_fiber/pkg/constants"
"github.com/break/junhong_cmp_fiber/pkg/errors"
)
// recordInput 是一条沉默期流水与对应审计。
type recordInput struct {
Usage *model.PackageUsage // 变更后的套餐记录
EventType string
Trigger string
BeforeStatus *string
BeforeDeadline *time.Time
ChangeDays int
DefaultDays *int
IsAdjusted bool
Reason string
ApprovalID *uint
BatchID *uint
ExtensionID *uint
ExchangeID *uint
ExchangeNo string
RealnameAt *time.Time
Extra map[string]any
AuditAction string
AuditSummary string
}
// writeRecord 在调用方事务内写不可变流水与审计。审计写入失败不回滚业务(统一审计 Writer 自行留痕)。
func writeRecord(ctx context.Context, tx *gorm.DB, writer *audit.Writer, input recordInput) error {
usage := input.Usage
if usage == nil || usage.ID == 0 {
return errors.New(errors.CodeInvalidParam, "沉默期流水缺少套餐记录")
}
assetType, assetID := usageAsset(usage)
operatorID, operatorType := operatorFromContext(ctx)
extra := []byte("{}")
if len(input.Extra) > 0 {
encoded, err := sonic.Marshal(input.Extra)
if err != nil {
return errors.Wrap(errors.CodeInternalError, err, "序列化沉默期流水扩展信息失败")
}
extra = encoded
}
record := &model.PackageSilenceRecord{
PackageUsageID: usage.ID, EventType: input.EventType, TriggerSource: input.Trigger,
AssetType: assetType, AssetID: assetID,
BeforeStatus: input.BeforeStatus, AfterStatus: usage.SilenceStatus,
BeforeDeadlineAt: input.BeforeDeadline, AfterDeadlineAt: usage.SilenceDeadlineAt,
PeriodDays: usage.SilencePeriodDays, DefaultDaysSnapshot: input.DefaultDays,
IsAdjusted: input.IsAdjusted, ChangeDays: input.ChangeDays,
OperatorID: operatorID, OperatorType: operatorType, Reason: truncate(input.Reason, 500),
ApprovalInstanceID: input.ApprovalID, BatchID: input.BatchID, ExtensionID: input.ExtensionID,
ExchangeOrderID: input.ExchangeID, ExchangeNo: input.ExchangeNo, RealnameAt: input.RealnameAt,
Extra: extra, CreatedAt: time.Now(),
}
if err := postgres.NewPackageSilenceRecordStore(tx).Create(ctx, record); err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "写入沉默期流水失败")
}
if input.AuditAction == "" || writer == nil {
return nil
}
metadata := map[string]any{
"event_type": input.EventType, "trigger": input.Trigger, "record_id": record.ID,
"change_days": input.ChangeDays, "reason": truncate(input.Reason, 200),
}
for key, value := range input.Extra {
metadata[key] = value
}
return writer.AppendSilenceUsage(ctx, tx, audit.SilenceAuditInput{
ActionCode: input.AuditAction, Summary: input.AuditSummary, Usage: usage,
BeforeData: map[string]any{"silence_status": input.BeforeStatus, "silence_deadline_at": input.BeforeDeadline},
AfterData: silenceStateData(usage), Metadata: metadata,
})
}
func silenceStateData(usage *model.PackageUsage) map[string]any {
return map[string]any{
"silence_status": usage.SilenceStatus, "silence_deadline_at": usage.SilenceDeadlineAt,
"silence_period_days": usage.SilencePeriodDays, "silence_extended_days": usage.SilenceExtendedDays,
"silence_paid_extended_days": usage.SilencePaidExtendedDays, "silence_extension_count": usage.SilenceExtensionCount,
"silence_free_extension_used": usage.SilenceFreeExtensionUsed, "silence_expired_at": usage.SilenceExpiredAt,
"silence_adjust_pending": usage.SilenceAdjustPending,
}
}
// usageAsset 返回套餐记录当前载体。
func usageAsset(usage *model.PackageUsage) (string, uint) {
if usage.DeviceID > 0 {
return constants.SilenceAssetTypeDevice, usage.DeviceID
}
return constants.SilenceAssetTypeIotCard, usage.IotCardID
}
func operatorFromContext(ctx context.Context) (uint, string) {
value := auditcontext.From(ctx)
if value.ActorID == "" {
return 0, constants.SilenceOperatorSystem
}
var id uint
for _, r := range value.ActorID {
if r < '0' || r > '9' {
return 0, value.ActorKind
}
id = id*10 + uint(r-'0')
}
switch value.ActorKind {
case constants.AuditActorAccount:
return id, constants.SilenceOperatorAccount
case constants.AuditActorPersonalCustomer:
return id, constants.SilenceOperatorCustomer
default:
return id, value.ActorKind
}
}
func truncate(value string, max int) string {
runes := []rune(value)
if len(runes) <= max {
return value
}
return string(runes[:max])
}
func stringPtr(value string) *string {
return &value
}
func copyStringPtr(value *string) *string {
if value == nil {
return nil
}
copied := *value
return &copied
}
func copyTimePtr(value *time.Time) *time.Time {
if value == nil {
return nil
}
copied := *value
return &copied
}

View File

@@ -1,111 +0,0 @@
package packagesilence
import (
"context"
"time"
"go.uber.org/zap"
agentrechargeApp "github.com/break/junhong_cmp_fiber/internal/application/agentrecharge"
"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"
)
// recoveryMinimumAge 是待支付订单进入查单恢复的最短等待时间,避免与客户正在支付的回调竞争。
const recoveryMinimumAge = time.Minute
// RecoverResult 是一轮延期订单恢复的结果。
type RecoverResult struct {
Confirmed int
Closed int
Refunded int
}
// Recover 收敛延期订单(技术方案 §7.1、§8.4):
// - 待支付订单:按冻结商户查单,已支付走支付确认,渠道明确关闭或订单不存在且已过支付截止的关闭;
// - 执行异常且已支付的在线订单:按冻结商户原路退款(渠道请求号固定,重复提交幂等)。
//
// 不受总开关影响:应急关闭后进行中的支付也要处理完。
func (s *ExtensionService) Recover(ctx context.Context) (RecoverResult, error) {
result := RecoverResult{}
now := s.now()
orders, err := postgres.NewPackageSilenceOrderStore(s.db).ListPendingPayment(ctx, now.Add(-recoveryMinimumAge), constants.SilenceOrderRecoverBatchSize)
if err != nil {
return result, errors.Wrap(errors.CodeDatabaseError, err, "查询待支付延期订单失败")
}
for index := range orders {
order := &orders[index]
state, err := s.recoverPending(ctx, order, now)
if err != nil {
s.logger.Warn("延期订单查单恢复失败,下一轮重试", zap.String("order_no", order.OrderNo), zap.Error(err))
continue
}
switch state {
case agentrechargeApp.OnlinePaymentStatePaid:
result.Confirmed++
case agentrechargeApp.OnlinePaymentStateClosed:
result.Closed++
}
}
var exceptions []model.PackageSilenceExtensionOrder
if err := s.db.WithContext(ctx).Where("status = ? AND paid_payment_id IS NOT NULL AND payment_method <> ?",
constants.SilenceOrderStatusException, model.PaymentByOffline).
Order("id ASC").Limit(constants.SilenceOrderRecoverBatchSize).Find(&exceptions).Error; err != nil {
return result, errors.Wrap(errors.CodeDatabaseError, err, "查询待退款的异常延期订单失败")
}
for index := range exceptions {
if err := s.refundOrder(ctx, exceptions[index].ID, "延期执行异常原路退款"); err != nil {
s.logger.Warn("延期异常订单原路退款失败,下一轮重试", zap.String("order_no", exceptions[index].OrderNo), zap.Error(err))
continue
}
result.Refunded++
}
return result, nil
}
func (s *ExtensionService) recoverPending(ctx context.Context, order *model.PackageSilenceExtensionOrder, now time.Time) (string, error) {
var payments []model.Payment
if err := s.db.WithContext(ctx).Where("order_type = ? AND order_id = ? AND status = ?", constants.PaymentOrderTypeSilenceExtension, order.ID, model.PaymentRecordStatusPending).
Order("id DESC").Find(&payments).Error; err != nil {
return "", errors.Wrap(errors.CodeDatabaseError, err, "查询延期支付单失败")
}
expired := order.ExpireAt != nil && now.After(*order.ExpireAt)
if len(payments) == 0 {
if expired {
return agentrechargeApp.OnlinePaymentStateClosed, s.closeOrder(ctx, order.ID, constants.SilenceExtensionOrderCloseReasonTimeout)
}
return agentrechargeApp.OnlinePaymentStatePending, nil
}
allClosed := true
for index := range payments {
payment := &payments[index]
state, query, err := s.queryPayment(ctx, payment)
if err != nil {
return "", err
}
switch state {
case agentrechargeApp.OnlinePaymentStatePaid:
paidAt := now
if query.PaidAt != nil {
paidAt = *query.PaidAt
}
return state, s.ConfirmPayment(ctx, PaymentConfirmCommand{
PaymentNo: payment.PaymentNo, ThirdPartyTradeNo: query.ThirdPartyTradeNo, Amount: query.Amount, PaidAt: paidAt,
})
case agentrechargeApp.OnlinePaymentStateClosed:
case agentrechargeApp.OnlinePaymentStateNotFound:
// 渠道没有这笔订单:过支付截止后已不可能再支付,按关闭处理;未过期时可能尚未同步,继续等待。
if !expired {
allClosed = false
}
default:
allClosed = false
}
}
if allClosed {
return agentrechargeApp.OnlinePaymentStateClosed, s.closeOrder(ctx, order.ID, constants.SilenceExtensionOrderCloseReasonTimeout)
}
return agentrechargeApp.OnlinePaymentStatePending, nil
}

View File

@@ -1,328 +0,0 @@
package packagesilence
import (
"context"
"time"
"go.uber.org/zap"
"gorm.io/gorm"
"gorm.io/gorm/clause"
packagedomain "github.com/break/junhong_cmp_fiber/internal/domain/package"
"github.com/break/junhong_cmp_fiber/internal/infrastructure/audit"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/pkg/constants"
"github.com/break/junhong_cmp_fiber/pkg/errors"
)
// Activator 是沉默期结束后按现有逻辑激活(或继续排队)套餐的接缝,由旧激活服务实现。
type Activator interface {
ActivateSpecificPackage(ctx context.Context, packageUsageID uint) error
}
// Resolver 是沉默期统一判定用例 ResolveUsageSilence(技术方案 §5.2)。
// 所有入口(实名、绑卡、排队、指定激活、孤儿扫描、延期、调整审批、扫描)只调用它,不自写判定。
type Resolver struct {
db *gorm.DB
sw *Switch
audit *audit.Writer
activator Activator
logger *zap.Logger
now func() time.Time
}
// NewResolver 创建沉默期判定用例。
func NewResolver(db *gorm.DB, sw *Switch, writer *audit.Writer, logger *zap.Logger) *Resolver {
if logger == nil {
logger = zap.NewNop()
}
return &Resolver{db: db, sw: sw, audit: writer, logger: logger, now: time.Now}
}
// SetActivator 注入激活接缝(避免与旧激活服务循环构造)。
func (r *Resolver) SetActivator(activator Activator) {
r.activator = activator
}
// Switch 返回总开关读取器。
func (r *Resolver) Switch() *Switch {
return r.sw
}
// ResolveResult 是一次判定的结果。
type ResolveResult struct {
Decision packagedomain.SilenceDecision
Changed bool // 本次是否写入了状态变化
Status string // 判定后的 silence_status(空表示不适用)
}
// Blocked 表示套餐沉默期已过期,不能激活。
func (r ResolveResult) Blocked() bool {
return r.Status == constants.SilenceStatusExpired
}
// ResolveOptions 控制判定行为。
type ResolveOptions struct {
Trigger string
// AllowExpire 为 false 时只记录"期内实名结束",不判过期(总开关关闭时)。
AllowExpire bool
}
// ResolveInTx 在调用方事务内对一笔套餐记录执行判定:加锁、读实名事实、条件更新、写流水与审计。
func (r *Resolver) ResolveInTx(ctx context.Context, tx *gorm.DB, usageID uint, options ResolveOptions) (ResolveResult, error) {
var usage model.PackageUsage
if err := tx.WithContext(ctx).Clauses(clause.Locking{Strength: "UPDATE"}).
Where("id = ?", usageID).First(&usage).Error; err != nil {
if err == gorm.ErrRecordNotFound {
return ResolveResult{Decision: packagedomain.SilenceDecisionNoop}, nil
}
return ResolveResult{}, errors.Wrap(errors.CodeDatabaseError, err, "锁定套餐记录失败")
}
return r.resolveLocked(ctx, tx, &usage, options)
}
// Resolve 用独立事务执行判定;结束沉默期时提交后按现有逻辑激活或排队。
func (r *Resolver) Resolve(ctx context.Context, usageID uint, options ResolveOptions) (ResolveResult, error) {
var result ResolveResult
err := r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
var resolveErr error
result, resolveErr = r.ResolveInTx(ctx, tx, usageID, options)
return resolveErr
})
if err != nil {
return result, err
}
r.activateIfEnded(ctx, usageID, result)
return result, nil
}
// ResolveAndActivate 判定后,若沉默期已结束(期内实名)则调用激活接缝(排队中的保持排队)。
// 用于扫描、延期生效、调整审批结束等"判定后立即激活"的入口。
func (r *Resolver) ResolveAndActivate(ctx context.Context, usageID uint, options ResolveOptions) (ResolveResult, error) {
result, err := r.Resolve(ctx, usageID, options)
if err != nil {
return result, err
}
if result.Status == constants.SilenceStatusEnded && !result.Changed {
r.activate(ctx, usageID)
}
return result, nil
}
func (r *Resolver) activateIfEnded(ctx context.Context, usageID uint, result ResolveResult) {
if result.Changed && result.Status == constants.SilenceStatusEnded {
r.activate(ctx, usageID)
}
}
func (r *Resolver) activate(ctx context.Context, usageID uint) {
if r.activator == nil {
return
}
if err := r.activator.ActivateSpecificPackage(ctx, usageID); err != nil {
r.logger.Warn("沉默期结束后激活套餐失败,等待后续实名、排队或孤儿扫描接续",
zap.Uint("usage_id", usageID), zap.Error(err))
}
}
func (r *Resolver) resolveLocked(ctx context.Context, tx *gorm.DB, usage *model.PackageUsage, options ResolveOptions) (ResolveResult, error) {
status := ""
if usage.SilenceStatus != nil {
status = *usage.SilenceStatus
}
if status != constants.SilenceStatusWaiting || usage.Status != constants.PackageUsageStatusPending {
return ResolveResult{Decision: packagedomain.SilenceDecisionNoop, Status: status}, nil
}
now := r.now()
facts, err := loadRealnameFacts(ctx, tx, usage)
if err != nil {
return ResolveResult{}, err
}
judgement := packagedomain.JudgeSilence(packagedomain.SilenceFacts{
Status: status, DeadlineAt: usage.SilenceDeadlineAt, Realnamed: facts.realnamed,
RealnameAt: facts.realnameAt, AdjustPending: usage.SilenceAdjustPending, Now: now,
})
switch judgement.Decision {
case packagedomain.SilenceDecisionEnd:
return r.applyEnd(ctx, tx, usage, facts, judgement.BackfillRealnameAt, options.Trigger, now)
case packagedomain.SilenceDecisionExpire:
if !options.AllowExpire {
return ResolveResult{Decision: packagedomain.SilenceDecisionWait, Status: status}, nil
}
return r.applyExpire(ctx, tx, usage, options.Trigger, now)
default:
return ResolveResult{Decision: judgement.Decision, Status: status}, nil
}
}
func (r *Resolver) applyEnd(ctx context.Context, tx *gorm.DB, usage *model.PackageUsage, facts realnameFacts, backfill bool, trigger string, now time.Time) (ResolveResult, error) {
result := tx.WithContext(ctx).Model(&model.PackageUsage{}).
Where("id = ? AND silence_status = ? AND status = ?", usage.ID, constants.SilenceStatusWaiting, constants.PackageUsageStatusPending).
Updates(map[string]any{"silence_status": constants.SilenceStatusEnded, "updated_at": now})
if result.Error != nil {
return ResolveResult{}, errors.Wrap(errors.CodeDatabaseError, result.Error, "结束套餐沉默期失败")
}
if result.RowsAffected == 0 {
return ResolveResult{Decision: packagedomain.SilenceDecisionNoop, Status: constants.SilenceStatusWaiting}, nil
}
before := copyStringPtr(usage.SilenceStatus)
usage.SilenceStatus = stringPtr(constants.SilenceStatusEnded)
realnameAt := facts.realnameAt
if backfill || len(facts.backfillCardIDs) > 0 {
backfilled, err := backfillRealnameAt(ctx, tx, facts, now)
if err != nil {
return ResolveResult{}, err
}
if backfilled > 0 {
if err := writeRecord(ctx, tx, r.audit, recordInput{
Usage: usage, EventType: constants.SilenceRecordEventRealnameBackfill, Trigger: trigger,
BeforeStatus: before, BeforeDeadline: usage.SilenceDeadlineAt, RealnameAt: &now,
Reason: "资产已实名但实名时间为空,视为期内实名并补写实名时间",
Extra: map[string]any{"backfilled_card_count": backfilled},
}); err != nil {
return ResolveResult{}, err
}
}
if realnameAt == nil {
realnameAt = &now
}
}
if err := writeRecord(ctx, tx, r.audit, recordInput{
Usage: usage, EventType: constants.SilenceRecordEventEnded, Trigger: trigger,
BeforeStatus: before, BeforeDeadline: usage.SilenceDeadlineAt, RealnameAt: realnameAt,
Reason: "截止前已实名,沉默期结束", AuditAction: constants.AuditActionUsageSilenceEnded,
AuditSummary: "套餐沉默期结束(期内实名)",
}); err != nil {
return ResolveResult{}, err
}
return ResolveResult{Decision: packagedomain.SilenceDecisionEnd, Changed: true, Status: constants.SilenceStatusEnded}, nil
}
func (r *Resolver) applyExpire(ctx context.Context, tx *gorm.DB, usage *model.PackageUsage, trigger string, now time.Time) (ResolveResult, error) {
result := tx.WithContext(ctx).Model(&model.PackageUsage{}).
Where("id = ? AND silence_status = ? AND status = ? AND silence_adjust_pending = ? AND silence_deadline_at < ?",
usage.ID, constants.SilenceStatusWaiting, constants.PackageUsageStatusPending, false, now).
Updates(map[string]any{"silence_status": constants.SilenceStatusExpired, "silence_expired_at": now, "updated_at": now})
if result.Error != nil {
return ResolveResult{}, errors.Wrap(errors.CodeDatabaseError, result.Error, "标记套餐沉默期过期失败")
}
if result.RowsAffected == 0 {
return ResolveResult{Decision: packagedomain.SilenceDecisionNoop, Status: constants.SilenceStatusWaiting}, nil
}
before := copyStringPtr(usage.SilenceStatus)
usage.SilenceStatus = stringPtr(constants.SilenceStatusExpired)
usage.SilenceExpiredAt = &now
if err := writeRecord(ctx, tx, r.audit, recordInput{
Usage: usage, EventType: constants.SilenceRecordEventExpired, Trigger: trigger,
BeforeStatus: before, BeforeDeadline: usage.SilenceDeadlineAt,
Reason: "沉默期截止仍未实名,套餐失效(沉默期已过期)", AuditAction: constants.AuditActionUsageSilenceExpired,
AuditSummary: "套餐沉默期过期",
}); err != nil {
return ResolveResult{}, err
}
return ResolveResult{Decision: packagedomain.SilenceDecisionExpire, Changed: true, Status: constants.SilenceStatusExpired}, nil
}
// realnameFacts 是判定所需的资产实名事实(技术方案 §5.1)。
type realnameFacts struct {
realnamed bool
realnameAt *time.Time
backfillCardIDs []uint // 已实名但实名时间为空、需要补写的卡
}
type boundCardRow struct {
CardID uint `gorm:"column:card_id"`
RealNameStatus int `gorm:"column:real_name_status"`
FirstRealnameAt *time.Time `gorm:"column:first_realname_at"`
BindTime *time.Time `gorm:"column:bind_time"`
BindCreatedAt time.Time `gorm:"column:bind_created_at"`
}
// loadRealnameFacts 读取实名事实:
// - 卡:first_realname_at;已实名但时间为空时视为期内实名并补写。
// - 设备:所有已绑定且已实名卡中 max(卡实名时间, 绑定时间) 的最小值,使截止后才绑定的已实名卡不能倒推为期内实名;
// 卡实名时间为空时按绑定时间计并补写卡的实名时间。
func loadRealnameFacts(ctx context.Context, tx *gorm.DB, usage *model.PackageUsage) (realnameFacts, error) {
if usage.DeviceID > 0 {
var rows []boundCardRow
err := tx.WithContext(ctx).Table("tb_device_sim_binding AS b").
Select("c.id AS card_id, c.real_name_status, c.first_realname_at, b.bind_time, b.created_at AS bind_created_at").
Joins("JOIN tb_iot_card AS c ON c.id = b.iot_card_id AND c.deleted_at IS NULL").
Where("b.device_id = ? AND b.bind_status = ? AND b.deleted_at IS NULL", usage.DeviceID, constants.BindStatusBound).
Where("c.real_name_status = ?", constants.RealNameStatusVerified).
Scan(&rows).Error
if err != nil {
return realnameFacts{}, errors.Wrap(errors.CodeDatabaseError, err, "查询设备绑定卡实名事实失败")
}
facts := realnameFacts{}
for _, row := range rows {
facts.realnamed = true
bindAt := row.BindCreatedAt
if row.BindTime != nil {
bindAt = *row.BindTime
}
effective := bindAt
if row.FirstRealnameAt == nil {
facts.backfillCardIDs = append(facts.backfillCardIDs, row.CardID)
} else if row.FirstRealnameAt.After(bindAt) {
effective = *row.FirstRealnameAt
}
if facts.realnameAt == nil || effective.Before(*facts.realnameAt) {
value := effective
facts.realnameAt = &value
}
}
return facts, nil
}
if usage.IotCardID == 0 {
return realnameFacts{}, nil
}
var card model.IotCard
if err := tx.WithContext(ctx).Select("id", "real_name_status", "first_realname_at").
Where("id = ?", usage.IotCardID).First(&card).Error; err != nil {
if err == gorm.ErrRecordNotFound {
return realnameFacts{}, nil
}
return realnameFacts{}, errors.Wrap(errors.CodeDatabaseError, err, "查询卡实名事实失败")
}
facts := realnameFacts{realnamed: card.RealNameStatus == constants.RealNameStatusVerified, realnameAt: card.FirstRealnameAt}
if facts.realnamed && card.FirstRealnameAt == nil {
facts.backfillCardIDs = []uint{card.ID}
}
return facts, nil
}
// backfillRealnameAt 为已实名但实名时间为空的卡补写 first_realname_at(只写为空的,返回补写数量)。
func backfillRealnameAt(ctx context.Context, tx *gorm.DB, facts realnameFacts, now time.Time) (int64, error) {
if len(facts.backfillCardIDs) == 0 {
return 0, nil
}
result := tx.WithContext(ctx).Model(&model.IotCard{}).
Where("id IN ? AND first_realname_at IS NULL AND real_name_status = ?", facts.backfillCardIDs, constants.RealNameStatusVerified).
Update("first_realname_at", now)
if result.Error != nil {
return 0, errors.Wrap(errors.CodeDatabaseError, result.Error, "补写卡实名时间失败")
}
return result.RowsAffected, nil
}
// AllowActivationInTx 是激活闸门(技术方案 §5.3):在激活事务内、实名判断前先执行沉默期判定。
// 总开关关闭时只记录"期内实名结束",始终放行(行为与现状一致);打开时沉默期已过期的套餐不能激活。
func (r *Resolver) AllowActivationInTx(ctx context.Context, tx *gorm.DB, usage *model.PackageUsage) (bool, error) {
if r == nil || usage == nil || usage.SilenceStatus == nil {
return true, nil
}
enabled := r.sw.Enabled(ctx)
result, err := r.ResolveInTx(ctx, tx, usage.ID, ResolveOptions{Trigger: constants.SilenceTriggerActivation, AllowExpire: enabled})
if err != nil {
return false, err
}
if result.Status != "" {
status := result.Status
usage.SilenceStatus = &status
}
if !enabled {
return true, nil
}
return !result.Blocked(), nil
}

View File

@@ -1,60 +0,0 @@
package packagesilence
import (
"context"
"go.uber.org/zap"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/pkg/constants"
"github.com/break/junhong_cmp_fiber/pkg/errors"
)
// ScanResult 是一轮沉默期扫描的结果。
type ScanResult struct {
Scanned int
Expired int
Ended int
}
// Scan 执行沉默期扫描 package:silence:scan(技术方案 §5.4):
// 查询 status = 0、待实名、已过截止、无调整审批的套餐记录,逐条执行统一判定(期内已实名则结束并激活,否则过期)。
// 单批 200、最多 20 批;只在总开关打开时运行;条件更新保证与实时判定并发时只有一方生效。
func (r *Resolver) Scan(ctx context.Context) (ScanResult, error) {
result := ScanResult{}
if !r.sw.Enabled(ctx) {
return result, nil
}
lastID := uint(0)
for batch := 0; batch < constants.SilenceScanMaxBatches; batch++ {
var ids []uint
if err := r.db.WithContext(ctx).Model(&model.PackageUsage{}).
Where("status = ? AND silence_status = ? AND silence_deadline_at < ? AND silence_adjust_pending = ? AND id > ?",
constants.PackageUsageStatusPending, constants.SilenceStatusWaiting, r.now(), false, lastID).
Order("id ASC").Limit(constants.SilenceScanBatchSize).Pluck("id", &ids).Error; err != nil {
return result, errors.Wrap(errors.CodeDatabaseError, err, "查询到期沉默期套餐失败")
}
if len(ids) == 0 {
break
}
for _, id := range ids {
lastID = id
resolved, err := r.ResolveAndActivate(ctx, id, ResolveOptions{Trigger: constants.SilenceTriggerScan, AllowExpire: true})
if err != nil {
r.logger.Warn("沉默期扫描判定失败,下一轮重试", zap.Uint("usage_id", id), zap.Error(err))
continue
}
result.Scanned++
if resolved.Changed && resolved.Status == constants.SilenceStatusExpired {
result.Expired++
}
if resolved.Changed && resolved.Status == constants.SilenceStatusEnded {
result.Ended++
}
}
if len(ids) < constants.SilenceScanBatchSize {
break
}
}
return result, nil
}

View File

@@ -1,257 +0,0 @@
package packagesilence
import (
"context"
"strings"
"time"
"github.com/google/uuid"
"gorm.io/gorm"
approvalapp "github.com/break/junhong_cmp_fiber/internal/application/approval"
packagedomain "github.com/break/junhong_cmp_fiber/internal/domain/package"
"github.com/break/junhong_cmp_fiber/internal/infrastructure/audit"
"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"
)
// PermissionChecker 校验后台账号权限点(由权限服务实现)。
type PermissionChecker interface {
CheckPermission(ctx context.Context, userID uint, permCode string, platform string) (bool, error)
}
// Setter 在订单支付成功、创建套餐记录的同一事务内设置沉默期(技术方案 §4),并处理后台购买时调整。
type Setter struct {
db *gorm.DB
audit *audit.Writer
approval approvalapp.Port
perms PermissionChecker
now func() time.Time
}
// NewSetter 创建沉默期设置用例。
func NewSetter(db *gorm.DB, writer *audit.Writer) *Setter {
return &Setter{db: db, audit: writer, now: time.Now}
}
// SetApprovalPort 注入审批创建接缝(购买时调整超默认值需审批时使用)。
func (s *Setter) SetApprovalPort(port approvalapp.Port) {
s.approval = port
}
// SetPermissionChecker 注入权限校验。
func (s *Setter) SetPermissionChecker(checker PermissionChecker) {
s.perms = checker
}
// PurchaseOverride 是后台购买时由有权限运营设置的本次沉默期。
// 无人参与的路径(自动购包、钱包自动续费等)不会携带,只用默认值。
type PurchaseOverride struct {
Days int
Reason string
OperatorID uint
submitter approvalSubmitter
// needApproval 表示本次订单中至少一笔套餐的设置值超过默认值且未免审批,preparation 已在事务前完成。
needApproval bool
preparation approvalapp.Preparation
exempt map[uint]bool // 套餐 ID → 政策是否勾选 purchase_adjust 免审批
defaults map[uint]int // 套餐 ID → 默认沉默期
}
type overrideContextKey struct{}
// WithPurchaseOverride 把购买时调整值放入上下文,由支付成功发放套餐时读取。
func WithPurchaseOverride(ctx context.Context, override *PurchaseOverride) context.Context {
if override == nil {
return ctx
}
return context.WithValue(ctx, overrideContextKey{}, override)
}
func overrideFromContext(ctx context.Context) *PurchaseOverride {
value, _ := ctx.Value(overrideContextKey{}).(*PurchaseOverride)
return value
}
// PrepareOverride 在下单事务前校验购买时调整:权限、天数、原因,并在需要审批时提前完成审批渠道检查。
// days 为空表示不调整。
func (s *Setter) PrepareOverride(ctx context.Context, operatorID uint, days *int, reason string, packages []*model.Package) (*PurchaseOverride, error) {
if days == nil {
return nil, nil
}
if s.perms == nil {
return nil, errors.New(errors.CodeSilenceOverrideForbidden)
}
allowed, err := s.perms.CheckPermission(ctx, operatorID, constants.PermissionPackageSilenceOverride, constants.PlatformWeb)
if err != nil {
return nil, err
}
if !allowed {
return nil, errors.New(errors.CodeSilenceOverrideForbidden)
}
if *days < constants.SilencePackageDaysMin || *days > constants.SilencePurchaseAdjustDaysMax {
return nil, errors.New(errors.CodeSilencePeriodOutOfRange, "本次沉默期至少 1 天")
}
reason = strings.TrimSpace(reason)
if reason == "" {
return nil, errors.New(errors.CodeInvalidParam, "调整沉默期必须填写原因")
}
override := &PurchaseOverride{
Days: *days, Reason: truncate(reason, 500), OperatorID: operatorID,
exempt: map[uint]bool{}, defaults: map[uint]int{},
}
now := s.now()
for _, pkg := range packages {
if pkg == nil || pkg.SilencePeriodDays == nil || pkg.PackageType == constants.PackageTypeAddon {
continue
}
override.defaults[pkg.ID] = *pkg.SilencePeriodDays
if *days <= *pkg.SilencePeriodDays {
continue
}
match, err := matchPolicy(ctx, s.db, pkg.ID, pkg.SeriesID, now)
if err != nil {
return nil, err
}
if packagedomain.IsSilenceApprovalExempt(match.ExemptTypes(), constants.SilenceExtensionTypePurchaseAdjust) {
override.exempt[pkg.ID] = true
continue
}
override.needApproval = true
}
if override.needApproval {
submitter, err := loadSubmitter(ctx, s.db, operatorID)
if err != nil {
return nil, err
}
preparation, err := prepareApproval(ctx, s.approval, operatorID, "silence-adjust-"+uuid.NewString())
if err != nil {
return nil, err
}
override.submitter = submitter
override.preparation = preparation
}
return override, nil
}
// Eligible 判断新建套餐记录是否进入沉默期(技术方案 §1)。
func Eligible(usage *model.PackageUsage, pkg *model.Package) bool {
return usage != nil && pkg != nil && usage.MasterUsageID == nil && usage.PendingRealnameActivation &&
usage.Status == constants.PackageUsageStatusPending &&
usage.ExpiryBaseSnapshot == constants.PackageExpiryBaseFromActivation && pkg.SilencePeriodDays != nil
}
// ApplyOnUsageCreatedInTx 为刚创建的套餐记录设置沉默期并写流水(总开关关闭也写,只记录)。
// paidAt 为订单支付成功时间;trigger 为订单支付 / 自动购包等来源。
func (s *Setter) ApplyOnUsageCreatedInTx(ctx context.Context, tx *gorm.DB, usage *model.PackageUsage, pkg *model.Package, paidAt time.Time, trigger string) error {
if s == nil || !Eligible(usage, pkg) {
return nil
}
defaultDays := *pkg.SilencePeriodDays
days := defaultDays
adjusted := false
needApproval := false
reason := ""
override := overrideFromContext(ctx)
if override != nil {
reason = override.Reason
switch {
case override.Days <= defaultDays || override.exempt[pkg.ID]:
days = override.Days
adjusted = override.Days != defaultDays
case override.needApproval:
needApproval = true
}
}
deadline := packagedomain.SilenceDeadline(paidAt, days)
start := paidAt
values := map[string]any{
"silence_status": constants.SilenceStatusWaiting, "silence_start_at": start,
"silence_period_days": days, "silence_deadline_at": deadline, "silence_adjust_pending": needApproval,
}
if err := tx.WithContext(ctx).Model(&model.PackageUsage{}).Where("id = ? AND silence_status IS NULL", usage.ID).
Updates(values).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "设置套餐沉默期失败")
}
usage.SilenceStatus = stringPtr(constants.SilenceStatusWaiting)
usage.SilenceStartAt = &start
usage.SilencePeriodDays = &days
usage.SilenceDeadlineAt = &deadline
usage.SilenceAdjustPending = needApproval
if err := writeRecord(ctx, tx, s.audit, recordInput{
Usage: usage, EventType: constants.SilenceRecordEventSet, Trigger: trigger,
DefaultDays: &defaultDays, IsAdjusted: adjusted, ChangeDays: days - defaultDays, Reason: reason,
Extra: map[string]any{"requested_days": overrideDays(override), "adjust_pending": needApproval},
AuditAction: constants.AuditActionUsageSilenceSet, AuditSummary: "设置套餐沉默期",
}); err != nil {
return err
}
if needApproval {
return s.submitAdjustInTx(ctx, tx, usage, pkg, override, defaultDays)
}
return nil
}
func overrideDays(override *PurchaseOverride) any {
if override == nil {
return nil
}
return override.Days
}
// submitAdjustInTx 为超默认值的调整创建批次(path = adjust)与企微审批,审批期间套餐不过期。
func (s *Setter) submitAdjustInTx(ctx context.Context, tx *gorm.DB, usage *model.PackageUsage, pkg *model.Package, override *PurchaseOverride, defaultDays int) error {
now := s.now()
requested := override.Days
batch := &model.PackageSilenceExtensionBatch{
BatchNo: generateNo(constants.SilenceExtensionBatchNoPrefix, now), Path: constants.SilencePathAdjust,
ExtensionType: constants.SilenceExtensionTypePurchaseAdjust, ApprovalRequired: true,
Reason: override.Reason, AdjustPackageUsageID: &usage.ID, AdjustDefaultDays: &defaultDays,
AdjustRequestedDays: &requested, RequestedBy: override.OperatorID, RequestedByType: constants.SilenceOperatorAccount,
RequestedAt: now, Status: constants.SilenceBatchStatusPendingApproval, RequestedCount: 1,
}
if err := postgres.NewPackageSilenceBatchStore(tx).Create(ctx, batch); err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "创建沉默期调整申请失败")
}
identifier, err := assetIdentifier(ctx, tx, usage)
if err != nil {
return err
}
instanceID, err := createApprovalInTx(ctx, tx, s.approval, override.preparation, batch, override.submitter, ApplyTypePurchaseAdjust, map[string]any{
ApprovalFieldAssetIdentifier: identifier, ApprovalFieldPackageName: pkg.PackageName,
ApprovalFieldOrderNo: usage.OrderNo, ApprovalFieldDefaultDays: defaultDays,
ApprovalFieldRequestedDays: requested, ApprovalFieldReason: override.Reason,
ApprovalFieldOriginalDeadline: formatDate(usage.SilenceDeadlineAt),
})
if err != nil {
return err
}
return writeRecord(ctx, tx, s.audit, recordInput{
Usage: usage, EventType: constants.SilenceRecordEventAdjust, Trigger: constants.SilenceTriggerOrderPaid,
BeforeStatus: usage.SilenceStatus, BeforeDeadline: usage.SilenceDeadlineAt, DefaultDays: &defaultDays,
ChangeDays: requested - defaultDays, Reason: override.Reason, ApprovalID: &instanceID, BatchID: &batch.ID,
Extra: map[string]any{"requested_days": requested, "stage": "requested"},
AuditAction: constants.AuditActionUsageSilenceAdjustRequested, AuditSummary: "申请购买时调整沉默期",
})
}
// assetIdentifier 返回套餐记录当前载体的标识(卡 ICCID / 设备虚拟号或 IMEI)。
func assetIdentifier(ctx context.Context, db *gorm.DB, usage *model.PackageUsage) (string, error) {
if usage.DeviceID > 0 {
var device model.Device
if err := db.WithContext(ctx).Unscoped().Select("id", "virtual_no", "imei").Where("id = ?", usage.DeviceID).First(&device).Error; err != nil {
return "", errors.Wrap(errors.CodeDatabaseError, err, "查询设备标识失败")
}
if device.VirtualNo != "" {
return device.VirtualNo, nil
}
return device.IMEI, nil
}
var card model.IotCard
if err := db.WithContext(ctx).Unscoped().Select("id", "iccid").Where("id = ?", usage.IotCardID).First(&card).Error; err != nil {
return "", errors.Wrap(errors.CodeDatabaseError, err, "查询卡标识失败")
}
return card.ICCID, nil
}

View File

@@ -1,116 +0,0 @@
// Package packagesilence 实现实名即生效套餐沉默期的设置、判定、扫描、总开关与延期用例(add-package-silence-period)。
package packagesilence
import (
"context"
"strconv"
"sync"
"time"
"github.com/redis/go-redis/v9"
"go.uber.org/zap"
"gorm.io/gorm"
"github.com/break/junhong_cmp_fiber/internal/infrastructure/systemconfig"
"github.com/break/junhong_cmp_fiber/pkg/constants"
)
// switchCacheTTL 是总开关在进程内的缓存时间;应急关闭最多延迟这么久在各进程生效。
const switchCacheTTL = 5 * time.Second
// RegisterConfigDefinitions 注册沉默期相关系统配置。
// 总开关注册为只读:通用系统配置接口不能修改,只能走带打开前检查的专用接口。
func RegisterConfigDefinitions(registry *systemconfig.Registry, logger *zap.Logger) {
if registry == nil {
return
}
minDays, maxDays := int64(constants.SilencePackageDaysMin), int64(constants.SilencePackageDaysMax)
minPrice, maxPrice := int64(0), int64(1000000)
definitions := []systemconfig.Definition{
{Key: constants.SystemConfigSilenceEnabled, Module: constants.SystemConfigModulePackage, ValueType: constants.SystemConfigTypeBool,
DefaultValue: "false", Readonly: true,
Description: "沉默期总开关:关闭时只记录沉默期,不拦截激活、不扫描、不展示、延期申请拒绝;只能通过沉默期总开关接口切换"},
{Key: constants.SystemConfigSilenceDefaultDays, Module: constants.SystemConfigModulePackage, ValueType: constants.SystemConfigTypeInt,
DefaultValue: strconv.Itoa(constants.SilenceDefaultPeriodDays), Description: "新建实名即生效套餐时预填的默认沉默期(天)",
Min: &minDays, Max: &maxDays},
{Key: constants.SystemConfigSilenceExtensionUnitPrice, Module: constants.SystemConfigModulePackage, ValueType: constants.SystemConfigTypeInt,
DefaultValue: strconv.Itoa(constants.SilenceDefaultUnitPrice), Description: "新建延期政策时预填的单价(分/月),实际计价以政策为准",
Min: &minPrice, Max: &maxPrice},
}
for _, definition := range definitions {
if _, exists := registry.Get(definition.Key); exists {
continue
}
if err := registry.Register(definition); err != nil && logger != nil {
logger.Error("注册沉默期系统配置失败,将使用代码默认值", zap.String("config_key", definition.Key), zap.Error(err))
}
}
}
// ConfigReader 读取系统配置的最小接缝。
type ConfigReader interface {
Get(ctx context.Context, key string) (string, error)
}
// Switch 读取沉默期总开关 package.silence.enabled。
// 读取失败时按关闭处理(行为与现状一致),不因配置故障误拦截激活或退款。
type Switch struct {
reader ConfigReader
logger *zap.Logger
mu sync.Mutex
cached bool
expiresAt time.Time
}
// NewSwitch 创建总开关读取器。
func NewSwitch(reader ConfigReader, logger *zap.Logger) *Switch {
if logger == nil {
logger = zap.NewNop()
}
return &Switch{reader: reader, logger: logger}
}
// NewDefaultSwitch 用独立注册表与读取器创建总开关(API 与 Worker 共用同一份数据库与缓存数据)。
func NewDefaultSwitch(db *gorm.DB, redisClient *redis.Client, logger *zap.Logger) *Switch {
registry := systemconfig.NewRegistry()
RegisterConfigDefinitions(registry, logger)
var cache systemconfig.Cache
if redisClient != nil {
cache = systemconfig.NewRedisCache(redisClient)
}
reader := systemconfig.NewReader(db, registry, cache, systemconfig.NewLogAlertSink(logger))
return NewSwitch(reader, logger)
}
// Enabled 返回总开关是否打开。
func (s *Switch) Enabled(ctx context.Context) bool {
if s == nil || s.reader == nil {
return false
}
s.mu.Lock()
defer s.mu.Unlock()
now := time.Now()
if now.Before(s.expiresAt) {
return s.cached
}
value, err := s.reader.Get(ctx, constants.SystemConfigSilenceEnabled)
if err != nil {
s.logger.Warn("读取沉默期总开关失败,按关闭处理", zap.Error(err))
s.cached = false
} else {
s.cached = value == "true"
}
s.expiresAt = now.Add(switchCacheTTL)
return s.cached
}
// Invalidate 清除进程内缓存(本进程切换开关后立即生效)。
func (s *Switch) Invalidate() {
if s == nil {
return
}
s.mu.Lock()
s.expiresAt = time.Time{}
s.mu.Unlock()
}

View File

@@ -1,160 +0,0 @@
package packagesilence
import (
"context"
"time"
"github.com/redis/go-redis/v9"
"gorm.io/gorm"
"gorm.io/gorm/clause"
"github.com/break/junhong_cmp_fiber/internal/infrastructure/audit"
"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"
)
// SwitchService 切换沉默期总开关(技术方案 §3.7):仅超级管理员;打开前做 4 项检查;每次切换写审计。
type SwitchService struct {
db *gorm.DB
redis *redis.Client
sw *Switch
audit *audit.Writer
now func() time.Time
}
// NewSwitchService 创建总开关切换用例。
func NewSwitchService(db *gorm.DB, redisClient *redis.Client, sw *Switch, writer *audit.Writer) *SwitchService {
return &SwitchService{db: db, redis: redisClient, sw: sw, audit: writer, now: time.Now}
}
// Status 返回总开关当前值与打开前检查结果(页面展示用)。
func (s *SwitchService) Status(ctx context.Context) (*dto.SilenceSwitchResponse, error) {
items, err := s.Precheck(ctx)
if err != nil {
return nil, err
}
return &dto.SilenceSwitchResponse{Enabled: s.sw.Enabled(ctx), Precheck: items, PrecheckPassed: precheckPassed(items)}, nil
}
// Precheck 执行打开前检查,返回每一项的结果(未满足项带数量)。
func (s *SwitchService) Precheck(ctx context.Context) ([]dto.SilenceSwitchPrecheckItem, error) {
db := s.db.WithContext(ctx)
var unconfigured int64
if err := db.Model(&model.Package{}).Where(postgres.SilenceUnconfiguredPackageCondition("tb_package")).Count(&unconfigured).Error; err != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "统计未配置默认沉默期的套餐失败")
}
var unbackfilled int64
if err := db.Model(&model.PackageUsage{}).
Where("status = ? AND pending_realname_activation = ? AND master_usage_id IS NULL AND expiry_base_snapshot = ? AND silence_status IS NULL",
constants.PackageUsageStatusPending, true, constants.PackageExpiryBaseFromActivation).
Count(&unbackfilled).Error; err != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "统计未回填沉默期的套餐记录失败")
}
now := s.now()
var defaultPolicies int64
if err := db.Model(&model.PackageSilenceExtensionPolicy{}).
Where("scope_type = ? AND status = ? AND effective_from <= ? AND (effective_to IS NULL OR effective_to > ?)",
constants.SilencePolicyScopeAll, constants.SilencePolicyStatusEnabled, now, now).
Count(&defaultPolicies).Error; err != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询全部套餐延期政策失败")
}
var scenes int64
if err := db.Model(&model.WeComApprovalScene{}).
Where("business_type = ? AND status = ? AND template_id <> ''", constants.ApprovalBusinessTypePackageSilence, constants.StatusEnabled).
Count(&scenes).Error; err != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询沉默期审批场景失败")
}
return []dto.SilenceSwitchPrecheckItem{
{Code: constants.SilenceSwitchPrecheckUnconfiguredPackages, Name: "未配置默认沉默期的套餐", Passed: unconfigured == 0, Count: unconfigured},
{Code: constants.SilenceSwitchPrecheckUnbackfilledUsages, Name: "未回填沉默期的待实名套餐记录", Passed: unbackfilled == 0, Count: unbackfilled},
{Code: constants.SilenceSwitchPrecheckDefaultPolicyMissing, Name: "启用中的全部套餐延期政策", Passed: defaultPolicies > 0, Count: defaultPolicies},
{Code: constants.SilenceSwitchPrecheckApprovalSceneMissing, Name: "沉默期企微审批场景已配置并启用", Passed: scenes > 0, Count: scenes},
}, nil
}
func precheckPassed(items []dto.SilenceSwitchPrecheckItem) bool {
for _, item := range items {
if !item.Passed {
return false
}
}
return true
}
// Toggle 打开或关闭总开关。打开前检查未通过时拒绝,返回 silence_switch_precheck_failed 与未满足项。
// 关闭(应急)不做检查:规则暂停、数据保留,已发生的支付回调与审批回调照常处理。
func (s *SwitchService) Toggle(ctx context.Context, enable bool) (*dto.SilenceSwitchResponse, error) {
if middleware.GetUserTypeFromContext(ctx) != constants.UserTypeSuperAdmin {
return nil, errors.New(errors.CodeForbidden, "只有超级管理员可以切换沉默期总开关")
}
operatorID := middleware.GetUserIDFromContext(ctx)
items, err := s.Precheck(ctx)
if err != nil {
return nil, err
}
if enable && !precheckPassed(items) {
failed := make([]dto.SilenceSwitchPrecheckItem, 0, len(items))
for _, item := range items {
if !item.Passed {
failed = append(failed, item)
}
}
return nil, errors.NewWithData(errors.CodeSilenceSwitchPrecheckFailed, map[string]any{"failed_items": failed},
"沉默期总开关打开前检查未通过")
}
value := "false"
if enable {
value = "true"
}
now := s.now()
var before string
err = s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
if err := tx.Exec("SELECT pg_advisory_xact_lock(hashtext(?))", constants.SystemConfigSilenceEnabled).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "锁定沉默期总开关失败")
}
var existing model.SystemConfig
findErr := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where("config_key = ?", constants.SystemConfigSilenceEnabled).First(&existing).Error
switch {
case findErr == gorm.ErrRecordNotFound:
before = "false"
existing = model.SystemConfig{
ConfigKey: constants.SystemConfigSilenceEnabled, ConfigValue: value, ValueType: constants.SystemConfigTypeBool,
Module: constants.SystemConfigModulePackage, Description: "沉默期总开关", IsReadonly: true,
Creator: operatorID, Updater: operatorID, CreatedAt: now, UpdatedAt: now,
}
if err := tx.Create(&existing).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "写入沉默期总开关失败")
}
case findErr != nil:
return errors.Wrap(errors.CodeDatabaseError, findErr, "查询沉默期总开关失败")
default:
before = existing.ConfigValue
if err := tx.Model(&model.SystemConfig{}).Where("id = ?", existing.ID).
Updates(map[string]any{"config_value": value, "updater": operatorID, "updated_at": now}).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "更新沉默期总开关失败")
}
}
if s.audit == nil {
return nil
}
id := constants.SystemConfigSilenceEnabled
return s.audit.AppendSilenceResource(ctx, tx, constants.AuditActionPackageSilenceSwitchChanged, "切换沉默期总开关",
audit.ResourceInput{
Type: constants.AuditResourceSystemConfig, ID: &id, Key: id, DisplayName: "沉默期总开关",
Role: constants.AuditResourceRoleConfig, IdentitySnapshot: map[string]any{"config_key": id, "module": constants.SystemConfigModulePackage},
BeforeData: map[string]any{"value": before}, AfterData: map[string]any{"value": value},
}, map[string]any{"precheck": items, "operator_id": operatorID})
})
if err != nil {
return nil, err
}
if s.redis != nil {
_ = s.redis.Del(context.WithoutCancel(ctx), constants.RedisSystemConfigKey(constants.SystemConfigSilenceEnabled)).Err()
}
s.sw.Invalidate()
return &dto.SilenceSwitchResponse{Enabled: enable, Precheck: items, PrecheckPassed: precheckPassed(items)}, nil
}

View File

@@ -1,39 +0,0 @@
package packagesilence
import (
"time"
packagedomain "github.com/break/junhong_cmp_fiber/internal/domain/package"
"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"
)
// BuildView 构造套餐记录的沉默期展示信息;不适用沉默期时返回 nil。
// 待实名但已过截止、还没被扫描到的记录直接显示"沉默期已过期"(技术方案 §5.4)。
// 调用方只在总开关打开时调用。
func BuildView(usage *model.PackageUsage, now time.Time) *dto.PackageSilenceView {
if usage == nil || usage.SilenceStatus == nil || *usage.SilenceStatus == "" {
return nil
}
status := *usage.SilenceStatus
effectiveExpired := usage.Status == constants.PackageUsageStatusPending &&
packagedomain.IsSilenceEffectivelyExpired(status, usage.SilenceDeadlineAt, usage.SilenceAdjustPending, now)
if effectiveExpired {
status = constants.SilenceStatusExpired
}
return &dto.PackageSilenceView{
SilenceStatus: status,
SilenceStatusName: constants.SilenceStatusName(status, usage.SilenceExtendedDays > 0),
SilenceStartAt: usage.SilenceStartAt,
SilencePeriodDays: usage.SilencePeriodDays,
SilenceDeadlineAt: usage.SilenceDeadlineAt,
SilenceExtendedDays: usage.SilenceExtendedDays,
SilencePaidExtendedDays: usage.SilencePaidExtendedDays,
SilenceExtensionCount: usage.SilenceExtensionCount,
SilenceFreeExtensionUsed: usage.SilenceFreeExtensionUsed,
SilenceExpiredAt: usage.SilenceExpiredAt,
SilenceAdjustPending: usage.SilenceAdjustPending,
Extendable: effectiveExpired,
}
}

View File

@@ -48,9 +48,9 @@ type pollingPriorityTriggerKey struct{}
// WithPollingPriorityTrigger 标记本次业务评估来自普通套餐轮询。
//
// 「资产无有效套餐」这一场景在停复机评估内部判定,而该评估入口被观测消费者、手动实名、
// 「资产无有效套餐」这一场景在停复机评估内部判定,而该评估入口被观测消费者、手动实名、保护期轮询、
// 既有兼容入口等多条调用链复用;只有普通套餐轮询来源才允许追加优先轮询请求,
// 否则会在观测序列、手动实名与退款等上下文凭空产生加急触发。
// 否则会在观测序列、手动实名、保护期与退款等上下文凭空产生加急触发。
func WithPollingPriorityTrigger(ctx context.Context) context.Context {
return context.WithValue(ctx, pollingPriorityTriggerKey{}, true)
}

View File

@@ -367,7 +367,6 @@ func approvalBusinessTypes() []string {
constants.ApprovalBusinessTypeAgentDistribution,
constants.ApprovalBusinessTypeWithdrawalQualification,
constants.ApprovalBusinessTypeCommissionWithdrawal,
constants.ApprovalBusinessTypePackageSilence,
}
}
@@ -517,32 +516,6 @@ func sceneBusinessFields(businessType string) ([]dto.WeComBusinessFieldResponse,
{Code: constants.ApprovalFieldSubmitterID, Name: "提交人账号 ID", ValueType: constants.ApprovalFieldValueTypeInteger, Description: "本系统真实业务提交人账号 ID"},
{Code: constants.ApprovalFieldSubmitterName, Name: "提交人名称", ValueType: constants.ApprovalFieldValueTypeString, Description: "本系统真实业务提交人名称快照"},
}, true
case constants.ApprovalBusinessTypePackageSilence:
return []dto.WeComBusinessFieldResponse{
{Code: constants.ApprovalFieldSilenceApplyType, Name: "申请类型", ValueType: constants.ApprovalFieldValueTypeString, Description: "购买时调整沉默期 / 特殊延期 / 线下付费延期,建议映射到单选控件并配置选项映射"},
{Code: constants.ApprovalFieldSilenceBatchNo, Name: "申请单号", ValueType: constants.ApprovalFieldValueTypeString, Description: "沉默期申请批次号"},
{Code: constants.ApprovalFieldSilenceAssetIdentifier, Name: "资产", ValueType: constants.ApprovalFieldValueTypeString, Description: "ICCID、虚拟号或 IMEI(批量时为首条明细)"},
{Code: constants.ApprovalFieldSilencePackageName, Name: "套餐", ValueType: constants.ApprovalFieldValueTypeString, Description: "套餐名称(批量时为首条明细)"},
{Code: constants.ApprovalFieldSilenceOrderNo, Name: "订单号", ValueType: constants.ApprovalFieldValueTypeString, Description: "套餐订单号或延期订单号"},
{Code: constants.ApprovalFieldSilenceDefaultDays, Name: "默认沉默期天数", ValueType: constants.ApprovalFieldValueTypeInteger, Description: "购买时调整:套餐默认沉默期"},
{Code: constants.ApprovalFieldSilenceRequestedDays, Name: "申请沉默期天数", ValueType: constants.ApprovalFieldValueTypeInteger, Description: "购买时调整:本次申请天数"},
{Code: constants.ApprovalFieldSilenceOriginalDeadline, Name: "原截止日期", ValueType: constants.ApprovalFieldValueTypeString, Description: "YYYY-MM-DD"},
{Code: constants.ApprovalFieldSilenceInputDays, Name: "延期天数", ValueType: constants.ApprovalFieldValueTypeInteger, Description: "申请输入的延期天数"},
{Code: constants.ApprovalFieldSilenceBilledDays, Name: "计费天数", ValueType: constants.ApprovalFieldValueTypeInteger, Description: "按 30 天向上取整后的计费 / 延长天数"},
{Code: constants.ApprovalFieldSilenceNewDeadline, Name: "新截止日期", ValueType: constants.ApprovalFieldValueTypeString, Description: "YYYY-MM-DD"},
{Code: constants.ApprovalFieldSilenceExtensionType, Name: "延期类型", ValueType: constants.ApprovalFieldValueTypeString, Description: "特殊延期类型名称"},
{Code: constants.ApprovalFieldSilenceItemCount, Name: "明细条数", ValueType: constants.ApprovalFieldValueTypeInteger, Description: "批量特殊延期的明细数"},
{Code: constants.ApprovalFieldSilenceAmount, Name: "费用", ValueType: constants.ApprovalFieldValueTypeMoney, Description: "以元为单位且保留两位小数"},
{Code: constants.ApprovalFieldSilenceAmountCent, Name: "费用(分)", ValueType: constants.ApprovalFieldValueTypeInteger, Description: "以分为单位的整数"},
{Code: constants.ApprovalFieldSilenceReason, Name: "原因", ValueType: constants.ApprovalFieldValueTypeString, Description: "申请原因"},
{Code: constants.ApprovalFieldSilenceBasis, Name: "依据", ValueType: constants.ApprovalFieldValueTypeString, Description: "免费或优惠依据"},
{Code: constants.ApprovalFieldSilenceEvidenceKeys, Name: "申请依据附件", ValueType: constants.ApprovalFieldValueTypeFileList, Description: "客户申请记录或截图对象存储 Key 列表"},
{Code: constants.ApprovalFieldSilencePaymentMethod, Name: "收款方式", ValueType: constants.ApprovalFieldValueTypeString, Description: "线下收款方式名称"},
{Code: constants.ApprovalFieldSilenceTradeNo, Name: "交易单号", ValueType: constants.ApprovalFieldValueTypeString, Description: "线下交易单号"},
{Code: constants.ApprovalFieldSilenceVoucherKeys, Name: "收款凭证", ValueType: constants.ApprovalFieldValueTypeFileList, Description: "收款凭证对象存储 Key 列表"},
{Code: constants.ApprovalFieldSubmitterID, Name: "提交人账号 ID", ValueType: constants.ApprovalFieldValueTypeInteger, Description: "本系统真实业务提交人账号 ID"},
{Code: constants.ApprovalFieldSubmitterName, Name: "提交人名称", ValueType: constants.ApprovalFieldValueTypeString, Description: "本系统真实业务提交人名称快照"},
}, true
default:
return nil, false
}
@@ -585,8 +558,6 @@ func approvalBusinessTypeName(businessType string) string {
return "提现资料资格审批"
case constants.ApprovalBusinessTypeCommissionWithdrawal:
return "佣金提现终审"
case constants.ApprovalBusinessTypePackageSilence:
return "沉默期审批(购买时调整 / 特殊延期 / 线下付费延期)"
default:
return "未知审批业务类型"
}

View File

@@ -1,121 +0,0 @@
package bootstrap
import (
"context"
"strconv"
"strings"
cardObservationApp "github.com/break/junhong_cmp_fiber/internal/application/cardobservation"
"github.com/break/junhong_cmp_fiber/internal/polling"
"github.com/break/junhong_cmp_fiber/pkg/constants"
)
// carrierAwareSeriesDispatcher 是读入口观测分发的模式接缝:按运营商账户调度时把读入口改为按场景请求检查
// (同卡合并、冷却、受账户限流),不再起"立即、3 分钟、5 分钟"观测序列;旧分片调度时沿用原分发器。
type carrierAwareSeriesDispatcher struct {
legacy cardObservationApp.BestEffortSeriesDispatcher
requester *polling.CheckRequester
}
var _ cardObservationApp.BestEffortSeriesDispatcher = (*carrierAwareSeriesDispatcher)(nil)
// ReadEntryCheckScene 把读入口与设备控制的观测场景映射为请求检查场景;不认识的场景返回空串。
func ReadEntryCheckScene(observationScene string) string {
switch observationScene {
case constants.CardObservationSceneClientAssetRead:
return constants.PollingCheckSceneClientAssetDetail
case constants.CardObservationSceneAdminAssetRead:
return constants.PollingCheckSceneAdminAssetRealtime
case constants.CardObservationSceneClientRealnameLink, constants.CardObservationSceneAdminRealnameLink:
return constants.PollingCheckSceneRealnameLink
case constants.CardObservationSceneBusinessStop, constants.CardObservationSceneBusinessResume:
return constants.PollingCheckSceneStopResumeSucceeded
case constants.CardObservationScenePackageChanged:
return constants.PollingCheckScenePackageActivated
case constants.CardObservationSceneDeviceSwitchCard, constants.CardObservationSceneDeviceSwitchMode,
constants.CardObservationSceneDeviceReboot, constants.CardObservationSceneDeviceReset, constants.CardObservationSceneDeviceSetWiFi:
return constants.PollingCheckSceneDeviceOperation
default:
return ""
}
}
func (d *carrierAwareSeriesDispatcher) active(ctx context.Context) bool {
return d.requester != nil && d.requester.Active(ctx)
}
// Dispatch 读入口单卡(或设备)观测。
func (d *carrierAwareSeriesDispatcher) Dispatch(ctx context.Context, request cardObservationApp.SeriesRequest) {
if !d.active(ctx) {
d.legacy.Dispatch(ctx, request)
return
}
scene := ReadEntryCheckScene(request.Scene)
if scene == "" {
return
}
id, err := strconv.ParseUint(request.ResourceID, 10, 64)
if err != nil || id == 0 {
return
}
if request.ResourceType == constants.CardObservationResourceTypeDevice {
d.requester.RequestDevice(ctx, scene, uint(id))
return
}
d.requester.RequestCards(ctx, scene, []uint{uint(id)}, request.ExpectedValue)
}
// DispatchDeviceCards 设备有效绑定卡观测。
func (d *carrierAwareSeriesDispatcher) DispatchDeviceCards(ctx context.Context, request cardObservationApp.DeviceCardsSeriesRequest) {
if !d.active(ctx) {
d.legacy.DispatchDeviceCards(ctx, request)
return
}
if scene := ReadEntryCheckScene(request.Request.Scene); scene != "" {
d.requester.RequestDevice(ctx, scene, request.DeviceID)
}
}
// DispatchDeviceControl 设备控制成功后的观测:按设备操作场景为设备下各卡请求检查。
func (d *carrierAwareSeriesDispatcher) DispatchDeviceControl(ctx context.Context, request cardObservationApp.DeviceControlSeriesRequest) {
if !d.active(ctx) {
d.legacy.DispatchDeviceControl(ctx, request)
return
}
// 设备信息同步(query_device_info,非运营商上游)保持原观测,只把卡检查改为请求检查。
deviceInfo := request.Request
deviceInfo.ResourceType = constants.CardObservationResourceTypeDevice
deviceInfo.ResourceID = strconv.FormatUint(uint64(request.DeviceID), 10)
deviceInfo.SyncType = constants.CardObservationSyncTypeDeviceInfo
deviceInfo.ExpectedValue = strings.TrimSpace(request.TargetICCID)
d.legacy.Dispatch(ctx, deviceInfo)
cardIDs := append([]uint(nil), request.BoundCardIDs...)
for _, id := range []uint{request.SourceCardID, request.TargetCardID} {
if id != 0 && !containsUint(cardIDs, id) {
cardIDs = append(cardIDs, id)
}
}
if len(cardIDs) == 0 && request.DeviceID != 0 {
d.requester.RequestDevice(ctx, constants.PollingCheckSceneDeviceOperation, request.DeviceID)
return
}
d.requester.RequestCards(ctx, constants.PollingCheckSceneDeviceOperation, cardIDs, "")
}
// DispatchRealnameWithCapability 获取实名链接后的实名观测。
func (d *carrierAwareSeriesDispatcher) DispatchRealnameWithCapability(ctx context.Context, request cardObservationApp.RealnameCapabilitySeriesRequest) {
if !d.active(ctx) {
d.legacy.DispatchRealnameWithCapability(ctx, request)
return
}
d.Dispatch(ctx, request.Request)
}
func containsUint(values []uint, target uint) bool {
for _, value := range values {
if value == target {
return true
}
}
return false
}

View File

@@ -3,7 +3,6 @@ package bootstrap
import (
systemConfigApp "github.com/break/junhong_cmp_fiber/internal/application/systemconfig"
"github.com/break/junhong_cmp_fiber/internal/gateway"
"github.com/break/junhong_cmp_fiber/internal/infrastructure/gatewaylimit"
systemConfigInfra "github.com/break/junhong_cmp_fiber/internal/infrastructure/systemconfig"
"github.com/break/junhong_cmp_fiber/internal/service/verification"
"github.com/break/junhong_cmp_fiber/pkg/auth"
@@ -27,7 +26,6 @@ type Dependencies struct {
QueueClient *queue.Client // Asynq 任务队列客户端
StorageService *storage.Service // 对象存储服务(可选,配置缺失时为 nil)
GatewayClient *gateway.Client // Gateway API 客户端(可选,配置缺失时为 nil)
GatewayLimit *gatewaylimit.Runtime // 卡网关运营商账户限流组件(可选)
WechatPayment wechat.PaymentServiceInterface // 微信支付服务(可选)
SystemConfigRegistry *systemConfigInfra.Registry // 业务模块共享的受控配置注册表(可选)
SystemConfigAudit systemConfigApp.AuditWriter // 配置变更审计 Port;生产为空时装配统一 Writer

View File

@@ -1,16 +1,13 @@
package bootstrap
import (
"context"
agentrechargeApp "github.com/break/junhong_cmp_fiber/internal/application/agentrecharge"
businessUserGroupApp "github.com/break/junhong_cmp_fiber/internal/application/businessusergroup"
cardObservationApp "github.com/break/junhong_cmp_fiber/internal/application/cardobservation"
employeecollectionApp "github.com/break/junhong_cmp_fiber/internal/application/employeecollection"
h5PopupApp "github.com/break/junhong_cmp_fiber/internal/application/h5popup"
merchantPaymentApp "github.com/break/junhong_cmp_fiber/internal/application/merchantpayment"
notificationApp "github.com/break/junhong_cmp_fiber/internal/application/notification"
packageExpiryApp "github.com/break/junhong_cmp_fiber/internal/application/packageexpiry"
packagesilenceApp "github.com/break/junhong_cmp_fiber/internal/application/packagesilence"
roleApp "github.com/break/junhong_cmp_fiber/internal/application/role"
shopApp "github.com/break/junhong_cmp_fiber/internal/application/shop"
systemConfigApp "github.com/break/junhong_cmp_fiber/internal/application/systemconfig"
@@ -24,7 +21,6 @@ import (
approvalInfra "github.com/break/junhong_cmp_fiber/internal/infrastructure/approval"
auditInfra "github.com/break/junhong_cmp_fiber/internal/infrastructure/audit"
"github.com/break/junhong_cmp_fiber/internal/infrastructure/carriercallback"
"github.com/break/junhong_cmp_fiber/internal/infrastructure/gatewaylimit"
"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"
@@ -32,7 +28,6 @@ import (
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"
"github.com/break/junhong_cmp_fiber/internal/polling/carrierqueue"
agentRechargeQuery "github.com/break/junhong_cmp_fiber/internal/query/agentrecharge"
assetQuery "github.com/break/junhong_cmp_fiber/internal/query/asset"
auditQuery "github.com/break/junhong_cmp_fiber/internal/query/audit"
@@ -45,11 +40,9 @@ import (
notificationQuery "github.com/break/junhong_cmp_fiber/internal/query/notification"
operationsreportquery "github.com/break/junhong_cmp_fiber/internal/query/operationsreport"
packageExpiryQuery "github.com/break/junhong_cmp_fiber/internal/query/packageexpiry"
packagesilenceQuery "github.com/break/junhong_cmp_fiber/internal/query/packagesilence"
packagetrafficalertquery "github.com/break/junhong_cmp_fiber/internal/query/packagetrafficalert"
priorityPollingQuery "github.com/break/junhong_cmp_fiber/internal/query/prioritypolling"
shopQuery "github.com/break/junhong_cmp_fiber/internal/query/shop"
"github.com/break/junhong_cmp_fiber/internal/query/stopresumerule"
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"
@@ -63,7 +56,6 @@ import (
func initHandlers(svc *services, deps *Dependencies) *Handlers {
validate := validator.New()
assetPollingSvc := newAssetPollingService(svc, deps)
packageExpiry := packageExpiryQuery.NewQuery(deps.DB)
svc.Asset.SetPackageExpiryQuery(packageExpiry)
svc.IotCard.SetPackageExpiryQuery(packageExpiry)
@@ -122,20 +114,12 @@ func initHandlers(svc *services, deps *Dependencies) *Handlers {
deps.Redis,
deps.Logger,
)
// 沉默期延期的 H5 微信支付复用套餐购买的 JSAPI 预下单(微信内直接拉起支付)
if svc.PackageSilence != nil && svc.PackageSilence.Extensions != nil {
svc.PackageSilence.Extensions.SetJSAPIPayer(clientOrderService)
}
systemConfigRegistry := deps.SystemConfigRegistry
if systemConfigRegistry == nil {
systemConfigRegistry = systemConfigInfra.NewRegistry()
}
registerCarrierCallbackConfigDefinitions(systemConfigRegistry, deps.Logger)
registerPaymentMethodConfigDefinitions(systemConfigRegistry, deps.Logger)
gatewaylimit.RegisterConfigDefinitions(systemConfigRegistry, deps.Logger)
carrierqueue.RegisterConfigDefinitions(systemConfigRegistry, deps.Logger)
packagesilenceApp.RegisterConfigDefinitions(systemConfigRegistry, deps.Logger)
registerTrafficQueueConfigDefinitions(systemConfigRegistry, deps)
systemConfigAlerts := systemConfigInfra.NewLogAlertSink(deps.Logger)
var systemConfigCache systemConfigInfra.Cache
if deps.Redis != nil {
@@ -317,22 +301,16 @@ func initHandlers(svc *services, deps *Dependencies) *Handlers {
PackageTrafficAlert: admin.NewPackageTrafficAlertHandler(svc.PackageTrafficAlertRule, packageTrafficAlertQuery, svc.ExportTask, validate),
AssetAutoRenewal: admin.NewAssetAutoRenewalConfigHandler(svc.AssetAutoRenewal, validate),
OperationsReport: admin.NewOperationsReportHandler(operationsReportQuery, svc.ExportTask, validate),
StopResumeRule: admin.NewStopResumeRuleHandler(stopresumerule.NewQuery(deps.DB), svc.ExportTask, validate),
ShopPackageBatchAllocation: admin.NewShopPackageBatchAllocationHandler(svc.ShopPackageBatchAllocation),
ShopPackageBatchPricing: admin.NewShopPackageBatchPricingHandler(svc.ShopPackageBatchPricing),
ShopSeriesGrant: admin.NewShopSeriesGrantHandler(svc.ShopSeriesGrant),
AdminOrder: admin.NewOrderHandler(svc.Order, validate),
AdminExchange: admin.NewExchangeHandler(svc.Exchange, exchangeQuery.NewListQuery(deps.DB), validate),
PaymentCallback: func() *callback.PaymentHandler {
handler := callback.NewPaymentHandler(
svc.Order, svc.Recharge, rechargeOrderService, svc.AgentRecharge,
deps.WechatPayment, svc.WechatConfig, paymentStore,
svc.AgentRechargePaymentConfirm, integrationlog.NewRepository(deps.DB), deps.Logger,
)
// 沉默期延期支付回调:按 order_type = silence_extension 分发
handler.SetSilencePaymentConfirmer(svc.PackageSilence.Extensions)
return handler
}(),
PaymentCallback: callback.NewPaymentHandler(
svc.Order, svc.Recharge, rechargeOrderService, svc.AgentRecharge,
deps.WechatPayment, svc.WechatConfig, paymentStore,
svc.AgentRechargePaymentConfirm, integrationlog.NewRepository(deps.DB), deps.Logger,
),
CTCCRealnameCallback: callback.NewCTCCRealnameHandler(
carriercallback.NewCTCCRealnameTranslator(), carriercallback.NewCTCCCardResolver(deps.DB),
integrationlog.NewRepository(deps.DB), svc.CardObservation, svc.CardObservationSeries, systemConfigReader, deps.Logger,
@@ -351,23 +329,25 @@ func initHandlers(svc *services, deps *Dependencies) *Handlers {
),
WeComApprovalCallback: wecomApprovalCallback,
PollingConfig: admin.NewPollingConfigHandler(svc.PollingConfig),
GatewayRateLimit: admin.NewGatewayRateLimitHandler(pollingSvcPkg.NewGatewayRateLimitService(deps.DB, auditInfra.NewWriter(auditInfra.NewRegistry(), nil), deps.GatewayLimit), deps.Redis, validate),
PollingConcurrency: admin.NewPollingConcurrencyHandler(svc.PollingConcurrency),
PackageSilence: admin.NewPackageSilenceHandler(svc.PackageSilence.Switches, svc.PackageSilence.Policies,
svc.PackageSilence.Extensions, packagesilenceQuery.NewQuery(deps.DB), validate),
ClientPackageSilence: app.NewClientPackageSilenceHandler(svc.PackageSilence.Extensions, packagesilenceQuery.NewQuery(deps.DB), validate),
PollingPause: admin.NewPollingPauseHandler(assetPollingSvc,
pollingSvcPkg.NewPollingStatusImportService(deps.DB, deps.QueueClient, deps.Logger), validate),
CarrierScheduler: admin.NewCarrierSchedulerHandler(pollingSvcPkg.NewCarrierSchedulerService(deps.DB, deps.Redis,
auditInfra.NewWriter(auditInfra.NewRegistry(), nil), carrierSchedulerConfig()), validate),
PollingMonitoring: admin.NewPollingMonitoringHandler(svc.PollingMonitoring),
PollingAlert: admin.NewPollingAlertHandler(svc.PollingAlert),
PollingCleanup: admin.NewPollingCleanupHandler(svc.PollingCleanup),
PollingManualTrigger: admin.NewPollingManualTriggerHandler(svc.PollingManualTrigger),
PollingMonitoring: admin.NewPollingMonitoringHandler(svc.PollingMonitoring),
PollingAlert: admin.NewPollingAlertHandler(svc.PollingAlert),
PollingCleanup: admin.NewPollingCleanupHandler(svc.PollingCleanup),
PollingManualTrigger: admin.NewPollingManualTriggerHandler(svc.PollingManualTrigger),
PollingPriority: admin.NewPriorityPollingHandler(
svc.PriorityPolling, priorityPollingQuery.NewQuery(deps.DB, pollingPriorityItemStore), validate,
),
Asset: func() *admin.AssetHandler {
pollingQueueMgr := pollingPkg.NewPollingQueueManager(deps.Redis, constants.PollingShardCount, deps.Logger)
assetPollingSvc := pollingSvcPkg.NewAssetPollingService(
deps.DB,
deviceStore,
deviceSimBindingStore,
svc.IotCard,
pollingQueueMgr,
deps.Logger,
svc.AccessAudit,
)
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)
@@ -421,42 +401,3 @@ func initHandlers(svc *services, deps *Dependencies) *Handlers {
AgentOpenAPI: openapiHandler.NewHandler(svc.AgentOpenAPI, validate),
}
}
// carrierSchedulerConfig 返回按运营商账户调度的 yaml 参数(供后台校验执行上限与展示默认值)。
func carrierSchedulerConfig() config.CarrierSchedulerConfig {
if cfg := config.Get(); cfg != nil {
return cfg.Polling.Carrier
}
return config.CarrierSchedulerConfig{}
}
// newAssetPollingService 创建资产轮询开关服务,并注入订单套餐生效与改有效期后的自动恢复轮询。
func newAssetPollingService(svc *services, deps *Dependencies) *pollingSvcPkg.AssetPollingService {
pollingQueueMgr := pollingPkg.NewPollingQueueManager(deps.Redis, constants.PollingShardCount, deps.Logger)
assetPollingSvc := pollingSvcPkg.NewAssetPollingService(
deps.DB,
postgres.NewDeviceStore(deps.DB, deps.Redis),
postgres.NewDeviceSimBindingStore(deps.DB, deps.Redis),
svc.IotCard,
pollingQueueMgr,
deps.Logger,
svc.AccessAudit,
)
svc.Order.SetPollingRestorer(assetPollingSvc)
svc.Asset.SetPollingRestorer(assetPollingSvc, svc.StopResumeService, deps.Logger)
svc.Asset.SetTrafficAdjustmentEvaluator(svc.StopResumeService)
return assetPollingSvc
}
// registerTrafficQueueConfigDefinitions 注册卡流量采集队列配置:修改队列长度、跨天清空、最长点龄前
// 校验全部启用中的轮询配置的流量采集间隔仍在上限内,否则拒绝修改。
func registerTrafficQueueConfigDefinitions(registry *systemConfigInfra.Registry, deps *Dependencies) {
settings := cardObservationApp.NewDefaultTrafficQueueSettingsProvider(deps.DB, deps.Redis, deps.Logger)
current := func() cardObservationApp.TrafficQueueSettings { return settings.Current(context.Background()) }
checker := func(next cardObservationApp.TrafficQueueSettings) error {
return pollingSvcPkg.CheckEnabledTrafficIntervals(context.Background(), deps.DB, pollingSvcPkg.TrafficQueueLimits{
Size: next.Size, ClearOnNewDay: next.ClearOnNewDay, MaxPointAgeHours: next.MaxPointAgeHours,
})
}
cardObservationApp.RegisterTrafficQueueConfigDefinitions(registry, deps.Logger, current, checker)
}

View File

@@ -1,56 +0,0 @@
package bootstrap
import (
"github.com/redis/go-redis/v9"
"go.uber.org/zap"
"gorm.io/gorm"
approvalApp "github.com/break/junhong_cmp_fiber/internal/application/approval"
"github.com/break/junhong_cmp_fiber/internal/application/merchantpayment"
packagesilenceApp "github.com/break/junhong_cmp_fiber/internal/application/packagesilence"
refundchannelApp "github.com/break/junhong_cmp_fiber/internal/application/refundchannel"
auditInfra "github.com/break/junhong_cmp_fiber/internal/infrastructure/audit"
packageSvc "github.com/break/junhong_cmp_fiber/internal/service/package"
)
// packageSilenceBundle 是沉默期相关用例的装配结果(API 与 Worker 共用)。
type packageSilenceBundle struct {
Switch *packagesilenceApp.Switch
Resolver *packagesilenceApp.Resolver
Setter *packagesilenceApp.Setter
FirstPurchase *packagesilenceApp.FirstPurchaseRecorder
// 以下仅 API 与 Worker 中延期相关入口装配
Extensions *packagesilenceApp.ExtensionService
Policies *packagesilenceApp.PolicyService
Switches *packagesilenceApp.SwitchService
}
// newPackageSilenceBundle 装配沉默期总开关、判定、设置与首次购买套餐时间用例,并把激活闸门注入激活服务。
func newPackageSilenceBundle(db *gorm.DB, redisClient *redis.Client, logger *zap.Logger, auditWriter *auditInfra.Writer, activation *packageSvc.ActivationService) *packageSilenceBundle {
sw := packagesilenceApp.NewDefaultSwitch(db, redisClient, logger)
resolver := packagesilenceApp.NewResolver(db, sw, auditWriter, logger)
if activation != nil {
resolver.SetActivator(activation)
activation.SetSilenceGate(resolver, sw)
}
return &packageSilenceBundle{
Switch: sw, Resolver: resolver,
Setter: packagesilenceApp.NewSetter(db, auditWriter),
FirstPurchase: packagesilenceApp.NewFirstPurchaseRecorder(auditWriter),
Extensions: packagesilenceApp.NewExtensionService(db, resolver, auditWriter, logger),
Policies: packagesilenceApp.NewPolicyService(db, auditWriter),
Switches: packagesilenceApp.NewSwitchService(db, redisClient, sw, auditWriter),
}
}
// wireExtensions 注入延期用例的审批、权限、资产归属、商户池与支付渠道。
func (b *packageSilenceBundle) wireExtensions(approval approvalApp.Port, perms packagesilenceApp.PermissionChecker, ownership packagesilenceApp.OwnershipChecker,
runtime *merchantpayment.RuntimeLoader, ports packagesilenceApp.PaymentPorts, refunder refundchannelApp.Refunder) {
b.Setter.SetApprovalPort(approval)
b.Setter.SetPermissionChecker(perms)
b.Extensions.SetApprovalPort(approval)
b.Extensions.SetPermissionChecker(perms)
b.Extensions.SetOwnershipChecker(ownership)
b.Extensions.SetPayment(runtime, ports, refunder)
b.Policies.SetPermissionChecker(perms)
}

View File

@@ -2,10 +2,6 @@ package bootstrap
import (
"context"
packagesilenceApp "github.com/break/junhong_cmp_fiber/internal/application/packagesilence"
"github.com/redis/go-redis/v9"
"gorm.io/gorm"
"time"
"go.uber.org/zap"
@@ -33,7 +29,6 @@ import (
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"
"github.com/break/junhong_cmp_fiber/internal/polling/carrierqueue"
assetAutoRenewalQuery "github.com/break/junhong_cmp_fiber/internal/query/assetautorenewal"
accountSvc "github.com/break/junhong_cmp_fiber/internal/service/account"
agentOpenAPISvc "github.com/break/junhong_cmp_fiber/internal/service/agent_open_api"
@@ -98,7 +93,6 @@ type services struct {
AssetAudit *assetAuditSvc.Service
Role *roleSvc.Service
Permission *permissionSvc.Service
PackageSilence *packageSilenceBundle
PersonalCustomer *personalCustomerSvc.Service
ClientAuth *clientAuthSvc.Service
Shop *shopSvc.Service
@@ -207,10 +201,6 @@ func initServices(s *stores, deps *Dependencies) *services {
cardObservationService.SetStateAuditWriter(iotCard)
// 运营商通道流量阈值达量判定嵌入流量观测事务:与卡流量事实同事务写周期锁与停机事件。
cardObservationService.SetChannelThresholdEvaluator(carrierThresholdService)
// 卡流量采集队列:读数先进每卡 Redis 队列,满足写入条件才写日快照;队列配置为受控系统配置。
cardObservationService.SetTrafficQueue(cardObservationInfra.NewTrafficQueue(deps.Redis),
cardObservationApp.NewDefaultTrafficQueueSettingsProvider(deps.DB, deps.Redis, deps.Logger), deps.Logger)
cardObservationService.SetCommandGrace(commandGraceProvider(deps.DB, deps.Redis, deps.Logger))
iotCard.SetCardObservationService(cardObservationService)
iotCard.SetSpeedTierIntegrationLog(integrationlog.NewRepository(deps.DB))
seriesCoordinator := cardObservationInfra.NewSeriesCoordinator(deps.Redis)
@@ -225,26 +215,17 @@ func initServices(s *stores, deps *Dependencies) *services {
cardObservationInfra.NewSeriesRunner(deps.DB, deps.GatewayClient, cardObservationService, seriesIntegration, auditWriter),
cardObservationInfra.NewSeriesAttemptLogger(seriesIntegration),
)
// 读入口观测分发经模式接缝:按运营商账户调度时改为按场景请求检查,旧分片调度时沿用原分发器。
observationSeries := &carrierAwareSeriesDispatcher{legacy: cardObservationInfra.NewBestEffortSeriesDispatcher(seriesTrigger, deps.Logger, s.DeviceSimBinding, s.Carrier)}
observationSeries := cardObservationInfra.NewBestEffortSeriesDispatcher(seriesTrigger, deps.Logger, s.DeviceSimBinding, s.Carrier)
iotCard.SetObservationSeriesDispatcher(observationSeries)
// 使用 PollingLifecycleService 替代 APICallback:通过分片队列准确操作(修复 api_callback.go 遗漏 protect 队列的 Bug3)
pollingConfigStore := postgres.NewPollingConfigStore(deps.DB)
pollingConfigMgr := polling.NewPollingConfigManager(pollingConfigStore, deps.Redis, deps.Logger)
pollingConfigMgr.SetTierResolver(polling.NewTierResolver(postgres.NewPackageUsageStore(deps.DB, deps.Redis),
postgres.NewDeviceSimBindingStore(deps.DB, deps.Redis), postgres.NewCarrierStore(deps.DB), deps.Logger))
if err := pollingConfigMgr.Load(context.Background()); err != nil {
deps.Logger.Warn("API 进程加载轮询配置失败", zap.Error(err))
}
pollingConfigMgr.Start(context.Background())
pollingQueueMgr := polling.NewPollingQueueManager(deps.Redis, constants.PollingShardCount, deps.Logger)
pollingLifecycleSvc := polling.NewPollingLifecycleService(pollingQueueMgr, pollingConfigMgr, s.IotCard, s.DeviceSimBinding, s.Device, deps.Logger)
// 按运营商账户调度:模式为 carrier 时 API 侧生命周期事件写入账户队列与卡到期信息。
carrierSettings := carrierqueue.NewDefaultSettingsProvider(deps.DB, deps.Redis, deps.Logger)
carrierStore := carrierqueue.NewStore(deps.Redis)
carrierScheduling := polling.NewCarrierScheduling(carrierStore, carrierSettings, pollingConfigMgr, deps.Logger)
pollingLifecycleSvc.SetCarrierScheduling(carrierScheduling)
observationSeries.requester = polling.NewCheckRequester(carrierScheduling, deps.DB, deps.Logger)
iotCard.SetPollingCallback(pollingLifecycleSvc)
// 创建支付配置服务(Order 和 Recharge 依赖)
wechatConfig := wechatConfigSvc.New(s.WechatConfig, s.Order, s.RechargeOrder, s.AgentRecharge, s.Payment, auditWriter, deps.Redis, deps.Logger)
@@ -263,10 +244,6 @@ func initServices(s *stores, deps *Dependencies) *services {
packageActivation.SetLifecycleAudit(auditWriter)
packageActivation.SetObservationSeriesEventWriter(observationSeriesEvents)
packageActivation.SetPriorityEventWriter(priorityEvents)
// 激活前流量结算:资产从无生效主套餐变为有生效主套餐前,对已切换日快照计量的卡查一次流量入账。
preActivationSettler := cardObservationInfra.NewPreActivationSettler(deps.DB, deps.GatewayClient, cardObservationService, postgres.NewCarrierStore(deps.DB), deps.Logger)
packageActivation.SetPreActivationSettler(preActivationSettler)
packageSilence := newPackageSilenceBundle(deps.DB, deps.Redis, deps.Logger, auditWriter, packageActivation)
stopResumeService := iotCardSvc.NewStopResumeService(
deps.Redis,
@@ -280,15 +257,8 @@ func initServices(s *stores, deps *Dependencies) *services {
stopResumeService.SetObservationSeriesEventWriter(deps.DB, observationSeriesEvents)
stopResumeService.SetPriorityEventWriter(priorityEvents)
stopResumeService.SetUnifiedAudit(auditWriter, integrationlog.NewRepository(deps.DB))
if deps.GatewayLimit != nil {
stopResumeService.SetStopResumeRecorder(deps.GatewayLimit.Recorder)
deps.GatewayLimit.TerminalPolling.SetAuditWriter(auditWriter)
stopResumeService.SetTerminalPollingDisabler(deps.GatewayLimit.TerminalPolling)
}
// 持通道阈值锁的卡在周期内拒绝一切复机(自动、手动、机卡分离)。
// 持通道阈值锁的卡在周期内拒绝一切复机(自动、手动、保护期强制、机卡分离)。
stopResumeService.SetChannelThresholdLockGuard(carrierThresholdService)
// 停复机指令队列:开关开启时自动入口只请求评估,由 Worker 指令执行器调用网关。
stopResumeService.SetCommandQueue(carrierqueue.NewCommandQueue(carrierStore, carrierSettings))
iotCard.SetRealnameActivator(packageActivation)
iotCard.SetStopResumeService(stopResumeService)
iotCard.SetDeviceSimBindingStore(s.DeviceSimBinding)
@@ -317,10 +287,6 @@ func initServices(s *stores, deps *Dependencies) *services {
device.SetGatewayIntegrationLog(integrationlog.NewRepository(deps.DB))
device.SetObservationSeriesEventWriter(observationSeriesEvents)
device.SetObservationSeriesDispatcher(observationSeries)
if deps.GatewayLimit != nil {
device.SetStopResumeRecorder(deps.GatewayLimit.Recorder)
device.SetTerminalPollingDisabler(deps.GatewayLimit.TerminalPolling)
}
operationPassword := operationPasswordSvc.New(deps.Redis)
shopCommission := shopCommissionSvc.New(s.Shop, s.Account, s.AgentWallet, s.CommissionWithdrawalRequest, s.CommissionWithdrawalSetting, s.CommissionRecord, s.AgentWalletTransaction, deps.DB, deps.Logger)
shopCommission.SetAuditWriter(auditWriter)
@@ -332,14 +298,11 @@ func initServices(s *stores, deps *Dependencies) *services {
// 员工代收款建账用例在订单、充值入账与退款冲销的事务内复用同一实例。
employeeCollectionBillCreation := employeecollectionApp.NewBillCreationService(auditWriter)
orderService.SetEmployeeCollectionBillCreation(employeeCollectionBillCreation)
orderService.SetPreActivationSettler(preActivationSettler)
orderService.SetResumeCallback(stopResumeService)
orderService.SetLifecycleAudit(auditWriter)
orderService.SetPaymentIntegrationLog(integrationlog.NewRepository(deps.DB))
orderService.SetObservationSeriesEventWriter(observationSeriesEvents)
orderService.SetPriorityEventWriter(priorityEvents)
orderService.SetSilenceSetter(packageSilence.Setter)
orderService.SetFirstPurchaseRecorder(packageSilence.FirstPurchase)
walletOutbox := outbox.NewRepository()
walletDebitEvents := walletinfra.NewDebitEventWriter(walletOutbox, auditWriter)
orderService.SetAgentWalletDebitService(walletapp.NewDebitService(walletDebitEvents, nil))
@@ -394,7 +357,6 @@ func initServices(s *stores, deps *Dependencies) *services {
refundService.SetNotificationOutbox(walletOutbox)
refundService.SetPaymentMerchantRuntime(merchantpayment.NewRuntimeLoader(deps.DB, deps.Redis))
refundService.SetLifecycleAudit(auditWriter)
refundService.SetSilenceSwitch(packageSilence.Switch)
// 渠道原路退款的登记与执行共用同一用例:API 侧只登记待执行事实与可靠事件,
// 真正的渠道调用由 Worker 消费该事件执行。
refundService.SetChannelRefundService(
@@ -408,12 +370,10 @@ func initServices(s *stores, deps *Dependencies) *services {
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())))
exchangeService.SetAccessAudit(auditWriter)
exchangeService.SetSilenceHook(packagesilenceApp.NewExchangeHook(packageSilence.Setter, packageSilence.FirstPurchase))
exchangeService.SetPhoneAssetAssociationStore(s.PhoneAssetAssociation)
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)
assetService.SetPhoneAssetAssociationStore(s.PhoneAssetAssociation)
assetService.SetAccessAudit(auditWriter)
assetService.SetSilenceSwitch(packageSilence.Switch)
agentOpenAPI := agentOpenAPISvc.New(assetService, packageService, orderService, shopCommission, stopResumeService, device, s.IotCard, s.PackageUsage, s.Package, s.PackageSeries, s.AgentWallet, s.DeviceSimBinding, s.Device)
wecomApplicationRepository := wecomInfra.NewApplicationRepository(deps.DB)
wecomSceneRepository := wecomInfra.NewSceneRepository(deps.DB)
@@ -458,15 +418,6 @@ func initServices(s *stores, deps *Dependencies) *services {
roleService.SetAccessAudit(deps.DB, deps.Redis, auditWriter)
permissionService := permissionSvc.New(s.Permission, s.AccountRole, s.RolePermission, account, deps.Redis)
permissionService.SetAccessAudit(deps.DB, auditWriter)
packageSilence.wireExtensions(approvalCreationService, permissionService, customerBinding,
merchantpayment.NewRuntimeLoader(deps.DB, deps.Redis),
packagesilenceApp.PaymentPorts{
Wechat: paymentInfra.NewWechatWebAdapter(wechat.NewRedisCache(deps.Redis), paymentIntegration, deps.Logger),
Alipay: paymentInfra.NewAlipayWapAdapter(paymentIntegration, deps.Logger),
Fuiou: paymentInfra.NewFuiouScanAdapter(paymentIntegration, deps.Logger),
},
paymentInfra.NewRefundAdapter(wechat.NewRedisCache(deps.Redis), deps.Logger),
)
shopService := shopSvc.New(s.Shop, s.Account, s.ShopRole, s.Role)
shopService.SetAccessAudit(deps.DB, deps.Redis, auditWriter)
commissionWithdrawal := commissionWithdrawalSvc.New(deps.DB, s.Shop, s.Account, s.AgentWallet, s.AgentWalletTransaction, s.CommissionWithdrawalRequest)
@@ -505,12 +456,6 @@ func initServices(s *stores, deps *Dependencies) *services {
commissionCalculation.SetAuditWriter(auditWriter)
pollingConfigService := pollingSvc.NewConfigService(s.PollingConfig, deps.Redis, deps.Logger)
pollingConfigService.SetAudit(deps.DB, auditWriter)
pollingConfigService.SetTierDeps(pollingConfigMgr, deps.QueueClient)
trafficQueueSettings := cardObservationApp.NewDefaultTrafficQueueSettingsProvider(deps.DB, deps.Redis, deps.Logger)
pollingConfigService.SetTrafficQueueLimits(func(ctx context.Context) pollingSvc.TrafficQueueLimits {
current := trafficQueueSettings.Current(ctx)
return pollingSvc.TrafficQueueLimits{Size: current.Size, ClearOnNewDay: current.ClearOnNewDay, MaxPointAgeHours: current.MaxPointAgeHours}
})
pollingConcurrencyService := pollingSvc.NewConcurrencyService(s.PollingConcurrencyConfig, deps.Redis)
pollingConcurrencyService.SetAudit(deps.DB, auditWriter)
pollingAlertService := pollingSvc.NewAlertService(s.PollingAlertRule, s.PollingAlertHistory, deps.Redis, deps.Logger)
@@ -532,7 +477,6 @@ func initServices(s *stores, deps *Dependencies) *services {
AssetAudit: assetAudit,
Role: roleService,
Permission: permissionService,
PackageSilence: packageSilence,
PersonalCustomer: personalCustomerSvc.NewService(deps.DB, s.PersonalCustomer, s.PersonalCustomerPhone, deps.Logger, auditWriter),
ClientAuth: clientAuthSvc.New(
deps.DB,
@@ -569,7 +513,7 @@ func initServices(s *stores, deps *Dependencies) *services {
Device: device,
DeviceImport: deviceImportSvc.New(deps.DB, s.DeviceImportTask, deps.QueueClient, auditWriter),
AssetAllocationRecord: assetAllocationRecordSvc.New(deps.DB, s.AssetAllocationRecord, s.Shop, s.Account),
Carrier: newCarrierService(s.Carrier, auditWriter, stopResumeService, deps.Logger),
Carrier: carrierSvc.New(s.Carrier, auditWriter),
PackageSeries: packageSeriesService,
Package: packageService,
PackageDailyRecord: packageSvc.NewDailyRecordService(deps.DB, deps.Redis, s.PackageUsageDailyRecord, deps.Logger),
@@ -606,7 +550,7 @@ func initServices(s *stores, deps *Dependencies) *services {
AgentRechargePaymentConfirm: agentRechargePaymentConfirm,
AgentRechargeVoucherOCR: agentRechargeVoucherOCR,
PackageActivation: packageActivation,
TrafficQuery: trafficSvc.NewQueryService(deps.Redis, s.CardDailyUsage).WithFlowSnapshots(func(uint) bool { return true }, postgres.NewCardFlowDayStore(deps.DB)),
TrafficQuery: trafficSvc.NewQueryService(deps.Redis, s.CardDailyUsage),
OperationPassword: operationPassword,
AgentOpenAPI: agentOpenAPI,
Refund: refundService,
@@ -623,18 +567,3 @@ func initServices(s *stores, deps *Dependencies) *services {
CardObservationSeries: cardObservationSeries,
}
}
// newCarrierService 创建运营商配置服务并注入实名管控修改后的批量停复机评估。
func newCarrierService(store *postgres.CarrierStore, auditWriter *auditInfra.Writer, stopResume *iotCardSvc.StopResumeService, logger *zap.Logger) *carrierSvc.Service {
service := carrierSvc.New(store, auditWriter)
if stopResume != nil {
service.SetRealnameControlEvaluator(stopResume, logger)
}
return service
}
// commandGraceProvider 返回停复机成功后的状态保护窗口(受控系统配置,API 与 Worker 共用同一份配置)。
func commandGraceProvider(db *gorm.DB, redisClient *redis.Client, logger *zap.Logger) func(ctx context.Context) time.Duration {
settings := carrierqueue.NewDefaultSettingsProvider(db, redisClient, logger)
return func(ctx context.Context) time.Duration { return settings.Current(ctx).SRStatusGrace }
}

View File

@@ -64,11 +64,6 @@ type Handlers struct {
CUCCRealnameRemovalCallback *callback.CUCCRealnameRemovalHandler
WeComApprovalCallback *callback.WeComApprovalHandler
PollingConfig *admin.PollingConfigHandler
GatewayRateLimit *admin.GatewayRateLimitHandler
CarrierScheduler *admin.CarrierSchedulerHandler
PollingPause *admin.PollingPauseHandler
PackageSilence *admin.PackageSilenceHandler
ClientPackageSilence *app.ClientPackageSilenceHandler
PollingConcurrency *admin.PollingConcurrencyHandler
PollingMonitoring *admin.PollingMonitoringHandler
PollingAlert *admin.PollingAlertHandler
@@ -91,7 +86,6 @@ type Handlers struct {
PackageTrafficAlert *admin.PackageTrafficAlertHandler
AssetAutoRenewal *admin.AssetAutoRenewalConfigHandler
OperationsReport *admin.OperationsReportHandler
StopResumeRule *admin.StopResumeRuleHandler
ClientWechat *app.ClientWechatHandler
SuperAdmin *admin.SuperAdminHandler
SystemConfig *admin.SystemConfigHandler

View File

@@ -7,7 +7,6 @@ import (
"gorm.io/gorm"
"github.com/break/junhong_cmp_fiber/internal/gateway"
"github.com/break/junhong_cmp_fiber/internal/infrastructure/gatewaylimit"
"github.com/break/junhong_cmp_fiber/pkg/queue"
"github.com/break/junhong_cmp_fiber/pkg/storage"
)
@@ -17,11 +16,10 @@ type WorkerDependencies struct {
DB *gorm.DB
Redis *redis.Client
Logger *zap.Logger
AsynqClient *asynq.Client // Worker 特有:用于 Scheduler 提交任务
QueueClient *queue.Client // 统一业务任务客户端,用于 Worker 内产生后续任务
StorageService *storage.Service // 对象存储(可选)
GatewayClient *gateway.Client // Gateway 客户端(可选)
GatewayLimit *gatewaylimit.Runtime // 卡网关运营商账户限流组件(可选)
AsynqClient *asynq.Client // Worker 特有:用于 Scheduler 提交任务
QueueClient *queue.Client // 统一业务任务客户端,用于 Worker 内产生后续任务
StorageService *storage.Service // 对象存储(可选)
GatewayClient *gateway.Client // Gateway 客户端(可选)
}
// WorkerBootstrapResult Worker Bootstrap 初始化结果

View File

@@ -1,16 +1,10 @@
package bootstrap
import (
pollingPkg "github.com/break/junhong_cmp_fiber/internal/polling"
"github.com/break/junhong_cmp_fiber/internal/store/postgres"
"github.com/break/junhong_cmp_fiber/pkg/constants"
assetAutoRenewalApp "github.com/break/junhong_cmp_fiber/internal/application/assetautorenewal"
cardObservationApp "github.com/break/junhong_cmp_fiber/internal/application/cardobservation"
carrierThresholdApp "github.com/break/junhong_cmp_fiber/internal/application/carrierthreshold"
employeecollectionApp "github.com/break/junhong_cmp_fiber/internal/application/employeecollection"
"github.com/break/junhong_cmp_fiber/internal/application/merchantpayment"
packagesilenceApp "github.com/break/junhong_cmp_fiber/internal/application/packagesilence"
walletapp "github.com/break/junhong_cmp_fiber/internal/application/wallet"
assetAutoRenewalInfra "github.com/break/junhong_cmp_fiber/internal/infrastructure/assetautorenewal"
auditInfra "github.com/break/junhong_cmp_fiber/internal/infrastructure/audit"
@@ -18,7 +12,6 @@ import (
carrierThresholdInfra "github.com/break/junhong_cmp_fiber/internal/infrastructure/carrierthreshold"
"github.com/break/junhong_cmp_fiber/internal/infrastructure/integrationlog"
"github.com/break/junhong_cmp_fiber/internal/infrastructure/messaging/outbox"
paymentInfra "github.com/break/junhong_cmp_fiber/internal/infrastructure/payment"
prioritypollingInfra "github.com/break/junhong_cmp_fiber/internal/infrastructure/prioritypolling"
walletinfra "github.com/break/junhong_cmp_fiber/internal/infrastructure/wallet"
assetAutoRenewalQuery "github.com/break/junhong_cmp_fiber/internal/query/assetautorenewal"
@@ -31,7 +24,6 @@ import (
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"
"github.com/break/junhong_cmp_fiber/pkg/wechat"
)
type workerServices struct {
@@ -93,14 +85,6 @@ func initWorkerServices(stores *queue.WorkerStores, deps *WorkerDependencies) *q
deps.Logger,
)
activationService.SetLifecycleAudit(auditWriter)
packageSilence := newPackageSilenceBundle(deps.DB, deps.Redis, deps.Logger, auditWriter, activationService)
// Worker 内延期用例只承担审批通过后的执行、查单恢复与异常原路退款,不创建新申请。
silencePaymentLog := integrationlog.NewRepository(deps.DB)
packageSilence.Extensions.SetPayment(merchantpayment.NewRuntimeLoader(deps.DB, deps.Redis), packagesilenceApp.PaymentPorts{
Wechat: paymentInfra.NewWechatWebAdapter(wechat.NewRedisCache(deps.Redis), silencePaymentLog, deps.Logger),
Alipay: paymentInfra.NewAlipayWapAdapter(silencePaymentLog, deps.Logger),
Fuiou: paymentInfra.NewFuiouScanAdapter(silencePaymentLog, deps.Logger),
}, paymentInfra.NewRefundAdapter(wechat.NewRedisCache(deps.Redis), deps.Logger))
usageService.SetLifecycleAudit(auditWriter)
resetService.SetLifecycleAudit(auditWriter)
@@ -136,13 +120,6 @@ func initWorkerServices(stores *queue.WorkerStores, deps *WorkerDependencies) *q
cardObservationService.SetStateAuditWriter(iotCardAuditService)
// 运营商通道流量阈值达量判定嵌入流量观测事务:与卡流量事实同事务写周期锁与停机事件。
cardObservationService.SetChannelThresholdEvaluator(carrierThresholdService)
cardObservationService.SetCommandGrace(commandGraceProvider(deps.DB, deps.Redis, deps.Logger))
// 卡流量采集队列:读数先进每卡 Redis 队列,满足写入条件才写日快照;队列配置为受控系统配置。
cardObservationService.SetTrafficQueue(cardObservationInfra.NewTrafficQueue(deps.Redis),
cardObservationApp.NewDefaultTrafficQueueSettingsProvider(deps.DB, deps.Redis, deps.Logger), deps.Logger)
// 激活前流量结算:与 API 进程一致,注入激活服务与订单服务。
preActivationSettler := cardObservationInfra.NewPreActivationSettler(deps.DB, deps.GatewayClient, cardObservationService, postgres.NewCarrierStore(deps.DB), deps.Logger)
activationService.SetPreActivationSettler(preActivationSettler)
cardObservationIntegration := integrationlog.NewRepository(deps.DB)
cardObservationSeriesCoordinator := cardObservationInfra.NewSeriesCoordinator(deps.Redis)
cardObservationSeriesService := cardObservationApp.NewSeriesAttemptService(
@@ -180,12 +157,7 @@ func initWorkerServices(stores *queue.WorkerStores, deps *WorkerDependencies) *q
)
// 批量订购线下支付建单需在订单事务内生成员工代收款账单,与 API 进程保持一致。
orderService.SetEmployeeCollectionBillCreation(employeecollectionApp.NewBillCreationService(auditWriter))
orderService.SetPreActivationSettler(preActivationSettler)
orderService.SetLifecycleAudit(auditWriter)
// 资产轮询开关服务:轮询开关文件导入与订单套餐生效后自动恢复轮询(批量下单在 Worker 内执行)。
assetPollingService := pollingSvc.NewAssetPollingService(deps.DB, stores.Device, stores.DeviceSimBinding, iotCardAuditService,
pollingPkg.NewPollingQueueManager(deps.Redis, constants.PollingShardCount, deps.Logger), deps.Logger, auditWriter)
orderService.SetPollingRestorer(assetPollingService)
orderService.SetPaymentIntegrationLog(integrationlog.NewRepository(deps.DB))
walletOutbox := outbox.NewRepository()
walletDebitEvents := walletinfra.NewDebitEventWriter(walletOutbox, auditWriter)
@@ -193,8 +165,6 @@ func initWorkerServices(stores *queue.WorkerStores, deps *WorkerDependencies) *q
orderService.SetAgentWalletDebitService(walletapp.NewDebitService(walletDebitEvents, nil))
orderService.SetObservationSeriesEventWriter(observationSeriesEvents)
orderService.SetPriorityEventWriter(priorityEvents)
orderService.SetSilenceSetter(packageSilence.Setter)
orderService.SetFirstPurchaseRecorder(packageSilence.FirstPurchase)
// 创建停复机服务并注入回调:流量耗尽自动停机、套餐激活/重置/支付后自动复机
stopResumeService := iotCardSvc.NewStopResumeService(
@@ -208,12 +178,7 @@ func initWorkerServices(stores *queue.WorkerStores, deps *WorkerDependencies) *q
stopResumeService.SetObservationSeriesEventWriter(deps.DB, observationSeriesEvents)
stopResumeService.SetPriorityEventWriter(priorityEvents)
stopResumeService.SetUnifiedAudit(auditWriter, integrationlog.NewRepository(deps.DB))
if deps.GatewayLimit != nil {
stopResumeService.SetStopResumeRecorder(deps.GatewayLimit.Recorder)
deps.GatewayLimit.TerminalPolling.SetAuditWriter(auditWriter)
stopResumeService.SetTerminalPollingDisabler(deps.GatewayLimit.TerminalPolling)
}
// 持通道阈值锁的卡在周期内拒绝一切复机(自动、手动、机卡分离)。
// 持通道阈值锁的卡在周期内拒绝一切复机(自动、手动、保护期强制、机卡分离)。
stopResumeService.SetChannelThresholdLockGuard(carrierThresholdService)
// 停复机执行端口复用既有停复机服务作为唯一事实源:消费者与两个计划任务共用同一用例实例。
carrierThresholdService.SetCommander(carrierThresholdInfra.NewCardCommander(stopResumeService))
@@ -227,7 +192,6 @@ func initWorkerServices(stores *queue.WorkerStores, deps *WorkerDependencies) *q
ObservationEvents: observationSeriesEvents,
PriorityEvents: priorityEvents,
})
assetAutoRenewalService.SetSilenceRecorder(packagesilenceApp.PurchaseRecorder{Setter: packageSilence.Setter, FirstPurchase: packageSilence.FirstPurchase})
activationService.SetObservationSeriesEventWriter(observationSeriesEvents)
activationService.SetPriorityEventWriter(priorityEvents)
usageService.SetStopResumeCallback(stopResumeService)
@@ -261,12 +225,5 @@ func initWorkerServices(stores *queue.WorkerStores, deps *WorkerDependencies) *q
OrderExpirer: orderService,
AssetPackageOrderCreator: orderService,
DeviceBatchAllocator: deviceBatchAllocator,
AssetPolling: assetPollingService,
IotCardPolling: iotCardAuditService,
SilenceSwitch: packageSilence.Switch,
SilenceResolver: packageSilence.Resolver,
SilenceSetter: packageSilence.Setter,
SilenceFirstPurchase: packageSilence.FirstPurchase,
SilenceExtensions: packageSilence.Extensions,
}
}

View File

@@ -1,5 +0,0 @@
// Package cardflow 提供卡日流量快照计量的纯领域规则:自然月换算、重置日防旧值、按月对账增量与每天用量。
//
// 规则照搬老卡管:每卡每天一条「本月自然月累计」快照,任意区间用量 = 区间内最大快照 − 区间开始前最大快照。
// 本包不依赖数据库、Redis 或外部 SDK,取值由调用方从快照存储查询后传入。
package cardflow

View File

@@ -1,50 +0,0 @@
package cardflow
import (
"math"
"time"
)
// 队列写入判断结果,与采集记录的写入判断取值一致。
const (
QueueWrite = "written"
QueueNotFull = "queue_not_full"
QueueSlopeNegative = "slope_negative"
QueueOutOfOrder = "out_of_order"
)
// QueuePoint 是采集队列中的一个点:采集时间与运营商原始读数。
type QueuePoint struct {
ObservedAt time.Time `json:"observed_at"`
ReadingMB float64 `json:"reading_mb"`
}
// JudgeQueue 判断本次新点能否写入:新点在队尾、队列满 size 个点、相邻两点后一个 ≥ 前一个 − 回退容差。
// 不满足时返回不写原因;满足时写入的是队列最后一个点(即本次新点)。
func JudgeQueue(points []QueuePoint, newPointIsTail bool, size int, dropToleranceMB float64) string {
if !newPointIsTail {
return QueueOutOfOrder
}
if len(points) < size {
return QueueNotFull
}
for i := 1; i < len(points); i++ {
if points[i].ReadingMB < points[i-1].ReadingMB-dropToleranceMB {
return QueueSlopeNegative
}
}
return QueueWrite
}
// MaxCollectionInterval 返回启用中轮询配置流量采集间隔的上限:留 2 次冗余,
// 跨天清空开启时为 24 小时 ÷ (N+2),关闭时为最长点龄 ÷ (N+2);最长点龄为 0(不限)时不设上限,返回 0。
func MaxCollectionInterval(size int, clearOnNewDay bool, maxPointAgeHours int, spare int) time.Duration {
window := 24 * time.Hour
if !clearOnNewDay {
if maxPointAgeHours <= 0 {
return 0
}
window = time.Duration(maxPointAgeHours) * time.Hour
}
return time.Duration(math.Floor(float64(window) / float64(size+spare)))
}

View File

@@ -1,128 +0,0 @@
package cardflow
import (
"math"
"strings"
"time"
)
// 计量规则常量。
const (
// StaleGuardRatio 是重置日防旧值阈值:重置日读数超过比较基准的该倍数时按 0 计。
StaleGuardRatio = 0.9
// MinResetDay 与 MaxResetDay 是通道上游流量重置日的合法范围。
MinResetDay = 1
MaxResetDay = 28
// FallbackResetDayCUCC 是联通卡重置日查询失败时的兜底值。
FallbackResetDayCUCC = 27
// FallbackResetDayDefault 是非联通卡重置日查询失败时的兜底值。
FallbackResetDayDefault = 1
// carrierTypeCUCC 是联通运营商类型编码。
carrierTypeCUCC = "CUCC"
)
// shanghaiLocation 是快照日期与周期边界的固定时区。
var shanghaiLocation = time.FixedZone("Asia/Shanghai", 8*60*60)
// ShanghaiDate 返回观测时刻对应的上海日期(当天 0 点)。
func ShanghaiDate(t time.Time) time.Time {
local := t.In(shanghaiLocation)
return time.Date(local.Year(), local.Month(), local.Day(), 0, 0, 0, 0, shanghaiLocation)
}
// MonthStart 返回日期所在自然月 1 号(上海时区)。
func MonthStart(date time.Time) time.Time {
local := date.In(shanghaiLocation)
return time.Date(local.Year(), local.Month(), 1, 0, 0, 0, 0, shanghaiLocation)
}
// ResolveResetDay 返回可用的重置日;查询失败或取值非法时按运营商类型兜底,fallback 为 true 表示使用了兜底值。
func ResolveResetDay(queried int, queryOK bool, carrierType string) (resetDay int, fallback bool) {
if queryOK && queried >= MinResetDay && queried <= MaxResetDay {
return queried, false
}
if strings.EqualFold(strings.TrimSpace(carrierType), carrierTypeCUCC) {
return FallbackResetDayCUCC, true
}
return FallbackResetDayDefault, true
}
// MonthContext 是换算与防旧值需要的已有快照取值,均为自然月累计(MB)。
type MonthContext struct {
// ThisMonthBeforeResetMax 是本月 1 号~重置日前一天的最大快照。
ThisMonthBeforeResetMax float64
// PrevMonthMax 是上月最大快照。
PrevMonthMax float64
// PrevMonthBeforeResetMax 是上月 1 号~重置日前一天的最大快照。
PrevMonthBeforeResetMax float64
}
// Conversion 是一次读数换算为自然月累计的结果。
type Conversion struct {
// MonthUsedMB 是换算后的本月自然月累计。
MonthUsedMB float64
// StaleGuarded 为 true 表示本次读数在重置日触发了防旧值、按 0 参与换算。
StaleGuarded bool
}
// Convert 把运营商周期读数换算为自然月累计,并在重置日当天应用防旧值。
//
// 重置日为 1 时运营商周期即自然月;重置日大于 1 时:日号 ≥ 重置日累加本月重置日前的最大快照,
// 日号 < 重置日扣除上月重置日及以后的用量(上月最大 − 上月重置日前最大),结果小于 0 按 0。
func Convert(readingMB float64, day int, resetDay int, ctx MonthContext) Conversion {
reading := readingMB
guarded := isStale(readingMB, day, resetDay, ctx)
if guarded {
reading = 0
}
var month float64
switch {
case resetDay <= 1:
month = reading
case day >= resetDay:
month = reading + ctx.ThisMonthBeforeResetMax
default:
month = reading - (ctx.PrevMonthMax - ctx.PrevMonthBeforeResetMax)
}
return Conversion{MonthUsedMB: nonNegative(round3(month)), StaleGuarded: guarded}
}
// isStale 判断重置日当天的读数是否仍接近上周期总量;只看配置的重置日,当天全天判断。
// 比较基准为 0(没有快照可比)时,不论重置日是几号,当天任何正读数都按旧值处理,次日如实写入。
func isStale(readingMB float64, day int, resetDay int, ctx MonthContext) bool {
if day != resetDay || readingMB <= 0 {
return false
}
if resetDay <= 1 {
return readingMB > ctx.PrevMonthMax*StaleGuardRatio
}
return readingMB > ctx.ThisMonthBeforeResetMax*StaleGuardRatio
}
// MonthlyDelta 返回按月对账的待结算增量:本月最大快照 − 本月已结算合计,不大于 0 时返回 0。
func MonthlyDelta(monthMaxMB float64, settledSumMB float64) float64 {
return nonNegative(round3(monthMaxMB - settledSumMB))
}
// DailyUsage 返回某天用量:当天最大快照 − 本月此前最大快照,小于 0 按 0;本月第一条快照时 priorMax 传 0。
func DailyUsage(dayMaxMB float64, priorMaxMB float64) float64 {
return nonNegative(round3(dayMaxMB - priorMaxMB))
}
// ValidReading 判断运营商读数是否有效:必须为有限且不小于 0 的数。
// 读数为 0 写不写快照由卡在采集时刻有没有套餐决定,不在这里拦。
func ValidReading(readingMB float64) bool {
return !math.IsNaN(readingMB) && !math.IsInf(readingMB, 0) && readingMB >= 0
}
func nonNegative(v float64) float64 {
if v < 0 {
return 0
}
return v
}
// round3 按快照列精度(三位小数)取整,避免浮点误差产生极小的虚假增量。
func round3(v float64) float64 {
return math.Round(v*1000) / 1000
}

View File

@@ -2,7 +2,6 @@ package cardobservation
import (
"strings"
"time"
"github.com/break/junhong_cmp_fiber/pkg/constants"
"github.com/break/junhong_cmp_fiber/pkg/errors"
@@ -22,11 +21,8 @@ type CardNetworkSnapshot struct {
CardID uint
NetworkStatus int
StopReason string
IsStandalone bool
EnablePolling bool
// StoppedAt、ResumedAt 是本系统最近一次停机、复机成功回写的时间;CommandGrace 是停复机成功后的状态保护窗口。
StoppedAt *time.Time
ResumedAt *time.Time
CommandGrace time.Duration
}
// NetworkDecision 描述一次网络观测可安全持久化的最终值。
@@ -41,11 +37,9 @@ type NetworkDecision struct {
StopReason string
StopReasonChanged bool
StopPolling bool
// StatusSuppressed 为 true 表示观测到的状态与保护窗口内刚成功的停复机相反,判定为上游状态滞后,不回写网络状态。
StatusSuppressed bool
}
// ApplyNetwork 应用 Gateway 状态映射、运营商停机原因和风险终止卡关闭轮询规则。
// ApplyNetwork 应用 Gateway 状态映射、运营商停机原因和独立风险卡规则。
func ApplyNetwork(snapshot CardNetworkSnapshot, observation NetworkObservation) (NetworkDecision, error) {
if err := validateObservationMetadata(observation.CardID, observation.Metadata); err != nil {
return NetworkDecision{}, err
@@ -59,16 +53,11 @@ func ApplyNetwork(snapshot CardNetworkSnapshot, observation NetworkObservation)
decision := NetworkDecision{
StatusKnown: known, BeforeStatus: snapshot.NetworkStatus, AfterStatus: snapshot.NetworkStatus,
GatewayExtend: extend, GatewayIMEI: imei, UpdateIMEI: imei != "", StopReason: snapshot.StopReason,
StopPolling: snapshot.EnablePolling && ShouldStopPollingForRisk(extend),
StopPolling: snapshot.EnablePolling && ShouldStopPollingForRisk(snapshot.IsStandalone, extend),
}
if !known {
return decision, nil
}
if status != snapshot.NetworkStatus && withinCommandGrace(snapshot, observation.Metadata.ObservedAt) {
// 停复机成功后上游状态有延迟:保护窗口内观测到操作前的状态不回写,窗口过后的检查以上游为准。
decision.StatusSuppressed = true
return decision, nil
}
decision.AfterStatus = status
decision.StatusChanged = status != snapshot.NetworkStatus
if decision.StatusChanged && status == constants.NetworkStatusOffline && snapshot.StopReason == "" &&
@@ -95,27 +84,7 @@ func MapGatewayNetworkStatus(cardStatus, extend string) (int, bool) {
return constants.NetworkStatusOffline, false
}
// ShouldStopPollingForRisk 判断卡是否命中运营商风险终止状态(风险停机、已销户)。
// 不论是否绑定设备都关闭该卡自身轮询,不级联设备与设备下其他卡。
func ShouldStopPollingForRisk(extend string) bool {
return extend == constants.GatewayCardExtendRiskStop || extend == constants.GatewayCardExtendCancelled
}
// withinCommandGrace 判断观测时间是否落在本系统最近一次停复机成功后的保护窗口内:
// 库里为停机时看停机时间,为开机时看复机时间。
func withinCommandGrace(snapshot CardNetworkSnapshot, observedAt time.Time) bool {
if snapshot.CommandGrace <= 0 {
return false
}
last := snapshot.ResumedAt
if snapshot.NetworkStatus == constants.NetworkStatusOffline {
last = snapshot.StoppedAt
}
if last == nil || last.IsZero() {
return false
}
if observedAt.IsZero() {
observedAt = time.Now()
}
return observedAt.Sub(*last) < snapshot.CommandGrace
// ShouldStopPollingForRisk 判断独立卡是否命中运营商风险终止状态。
func ShouldStopPollingForRisk(isStandalone bool, extend string) bool {
return isStandalone && (extend == constants.GatewayCardExtendRiskStop || extend == constants.GatewayCardExtendCancelled)
}

View File

@@ -2,8 +2,8 @@ package cardobservation
import (
"math"
"time"
"github.com/break/junhong_cmp_fiber/pkg/constants"
"github.com/break/junhong_cmp_fiber/pkg/errors"
)
@@ -11,85 +11,82 @@ import (
type TrafficObservation struct {
CardID uint
GatewayReadingMB float64
// ReadingResult 是读数结果(constants.TrafficCollectionResult*);为空表示运营商给出了读数,
// 是否有效(有限且 ≥ 0)由观测用例判断。非 ok 的观测只写采集记录,不入队、不写快照。
ReadingResult string
ResetDay int
Metadata ObservationMetadata
// Raw 是本次网关调用的原始请求与响应,未收集时为 nil。
Raw *GatewayRaw
// Failure 是读数结果非 ok 时的失败详情,未提供时为 nil。
Failure *CollectionFailure
// ICCID、CarrierID 是调用方已知的卡标识,写采集失败记录时直接使用;为空时由观测用例查库补齐。
ICCID string
CarrierID uint
ResetDay int
Metadata ObservationMetadata
}
// GatewayRaw 是一次网关调用的原始数据(最后一次 HTTP 尝试),只含明文业务请求与网关响应。
type GatewayRaw struct {
Request []byte
HTTPStatus int
Response []byte
GatewayCode *int
GatewayMsg string
TraceID string
Attempts int
// CardTrafficSnapshot 是流量规则需要的最小卡快照。
type CardTrafficSnapshot struct {
CardID uint
DataUsageMB int64
CurrentMonthUsageMB float64
CurrentMonthStartDate *time.Time
LastMonthTotalMB float64
LastGatewayReadingMB float64
}
// CollectionFailure 是一次采集失败的错误详情。
type CollectionFailure struct {
ErrorCategory string
ProviderCode string
ProviderMessage string
DurationMS int
}
// TrafficDecision 描述一次流量观测的处理结果。
// TrafficDecision 描述一次流量观测应持久化的最终值。
type TrafficDecision struct {
// IncrementMB 是本次写入流量事件的增量,0 表示没有写事件。
IncrementMB float64
// ReadingAccepted 为 true 表示本次读数通过采集队列判断并写入了日快照。
ReadingAccepted bool
// Decision 是写入判断(constants.TrafficDecision*);读数无效时为空。
Decision string
// Result 是读数结果(constants.TrafficCollectionResult*)。
Result string
// ReadingMB 是本次运营商读数,没有给值时为 nil。
ReadingMB *float64
// CrossMonth 为 true 表示本次写入使卡表本月用量跨入新的自然月。
CrossMonth bool
// CurrentMonthUsageMB 是写入后的本月最大快照(未写入时为卡表原值)。
CurrentMonthUsageMB float64
// LastGatewayReadingMB 保留卡表原值,卡表基线算法已下线,不再更新。
LastGatewayReadingMB float64
IncrementMB float64
ReadingAccepted bool
CrossMonth bool
DataUsageMB int64
CurrentMonthUsageMB float64
CurrentMonthStartDate time.Time
LastMonthTotalMB float64
LastGatewayReadingMB float64
}
// ValidateTrafficObservation 校验观测元数据与读数结果;给出读数时读数必须是数字(有效性由用例判断)。
func ValidateTrafficObservation(observation TrafficObservation) error {
// ApplyTraffic 应用运营商重置、跨月和异常下降保护规则。
func ApplyTraffic(snapshot CardTrafficSnapshot, observation TrafficObservation) (TrafficDecision, error) {
if err := validateObservationMetadata(observation.CardID, observation.Metadata); err != nil {
return err
return TrafficDecision{}, err
}
switch observation.ReadingResult {
case "", constants.TrafficCollectionResultOK, constants.TrafficCollectionResultFailed, constants.TrafficCollectionResultRateLimited,
constants.TrafficCollectionResultInvalidPayload, constants.TrafficCollectionResultNoReading, constants.TrafficCollectionResultInvalidReading:
return nil
default:
return errors.New(errors.CodeInvalidParam, "流量观测读数结果无效")
if math.IsNaN(observation.GatewayReadingMB) || math.IsInf(observation.GatewayReadingMB, 0) || observation.GatewayReadingMB < 0 {
return TrafficDecision{}, errors.New(errors.CodeInvalidParam, "流量观测读数无效")
}
}
// ResolveReadingResult 返回观测的最终读数结果:给出了读数但不是有限且 ≥ 0 的数时为读数无效。
func ResolveReadingResult(observation TrafficObservation) string {
if observation.ReadingResult != "" && observation.ReadingResult != constants.TrafficCollectionResultOK {
return observation.ReadingResult
if observation.ResetDay < 1 || observation.ResetDay > 31 {
return TrafficDecision{}, errors.New(errors.CodeInvalidParam, "运营商流量重置日无效")
}
reading := observation.GatewayReadingMB
if math.IsNaN(reading) || math.IsInf(reading, 0) || reading < 0 {
return constants.TrafficCollectionResultInvalidReading
now := observation.Metadata.ObservedAt
monthStart := time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, now.Location())
decision := TrafficDecision{
ReadingAccepted: true, DataUsageMB: snapshot.DataUsageMB,
CurrentMonthUsageMB: snapshot.CurrentMonthUsageMB, CurrentMonthStartDate: monthStart,
LastMonthTotalMB: snapshot.LastMonthTotalMB, LastGatewayReadingMB: observation.GatewayReadingMB,
}
return constants.TrafficCollectionResultOK
increment := observation.GatewayReadingMB - snapshot.LastGatewayReadingMB
if increment < 0 {
if isTrafficResetWindow(now, observation.ResetDay) {
increment = observation.GatewayReadingMB
} else {
increment = 0
decision.ReadingAccepted = false
decision.LastGatewayReadingMB = snapshot.LastGatewayReadingMB
}
}
decision.IncrementMB = increment
decision.CrossMonth = snapshot.CurrentMonthStartDate == nil || snapshot.CurrentMonthStartDate.Before(monthStart)
if decision.CrossMonth {
decision.LastMonthTotalMB = snapshot.CurrentMonthUsageMB
decision.CurrentMonthUsageMB = increment
} else if increment > 0 {
decision.CurrentMonthUsageMB += increment
}
if increment > 0 {
decision.DataUsageMB += int64(increment)
}
return decision, nil
}
func validateObservationMetadata(cardID uint, metadata ObservationMetadata) error {
return validateObservation(RealnameObservation{CardID: cardID, Verified: true, Metadata: metadata})
}
func isTrafficResetWindow(now time.Time, resetDay int) bool {
if now.Day() == resetDay {
return true
}
resetDate := time.Date(now.Year(), now.Month(), resetDay, 0, 0, 0, 0, now.Location())
return now.Day() == resetDate.AddDate(0, 0, -1).Day()
}

View File

@@ -1,374 +0,0 @@
package packagedomain
import (
"slices"
"time"
)
// 沉默期领域规则(add-package-silence-period,技术方案 §4.2、§5.2、§8.2、§3.6)。
// 本文件只放纯函数:不读库、不读配置。所有截止时间与延期计费只能由这里计算,禁止各处自写 AddDate 或 SQL interval。
const (
silenceDaysPerMonth = 30
silenceStatusWait = "waiting"
)
var silenceLocation = loadSilenceLocation()
func loadSilenceLocation() *time.Location {
location, err := time.LoadLocation("Asia/Shanghai")
if err != nil {
return time.FixedZone("CST", 8*3600)
}
return location
}
// SilenceLocation 返回沉默期计算使用的时区 Asia/Shanghai。
func SilenceLocation() *time.Location {
return silenceLocation
}
// SilenceDate 返回时间点在 Asia/Shanghai 下的日期(当日 00:00:00)。
func SilenceDate(t time.Time) time.Time {
local := t.In(silenceLocation)
year, month, day := local.Date()
return time.Date(year, month, day, 0, 0, 0, 0, silenceLocation)
}
// SilenceDeadline 返回 (日期 + days 天) 当日 23:59:59(Asia/Shanghai)。
// 例:2026-09-24 10:00 购买、30 天 → 2026-10-24 23:59:59。
func SilenceDeadline(date time.Time, days int) time.Time {
local := date.In(silenceLocation)
year, month, day := local.Date()
return time.Date(year, month, day+days, 23, 59, 59, 0, silenceLocation)
}
// SilenceDaysBetween 返回两个时间点按 Asia/Shanghai 日期相差的天数(to − from)。
func SilenceDaysBetween(from, to time.Time) int {
start := SilenceDate(from)
end := SilenceDate(to)
// 用 UTC 日期差避免夏令时影响(上海无夏令时,这里保持通用)。
startUTC := time.Date(start.Year(), start.Month(), start.Day(), 0, 0, 0, 0, time.UTC)
endUTC := time.Date(end.Year(), end.Month(), end.Day(), 0, 0, 0, 0, time.UTC)
return int(endUTC.Sub(startUTC).Hours() / 24)
}
// SilenceDecision 是一次沉默期判定的结果。
type SilenceDecision string
const (
// SilenceDecisionNoop 状态不变(不适用沉默期、已结束 / 已过期、仍在期内等待实名、调整审批中)。
SilenceDecisionNoop SilenceDecision = "noop"
// SilenceDecisionWait 仍在期内等待实名(与 noop 一样不写库,供展示区分)。
SilenceDecisionWait SilenceDecision = "wait"
// SilenceDecisionEnd 期内已实名,沉默期结束。
SilenceDecisionEnd SilenceDecision = "end"
// SilenceDecisionExpire 已到期且期内未实名,沉默期过期。
SilenceDecisionExpire SilenceDecision = "expire"
)
// SilenceFacts 是判定所需的事实。
type SilenceFacts struct {
Status string // 当前 silence_status(空表示不适用)
DeadlineAt *time.Time // 当前截止时间
Realnamed bool // 资产当前是否已实名
RealnameAt *time.Time // 实名时间(设备为绑定卡 max(实名, 绑定) 的最小值)
AdjustPending bool // 购买时调整审批是否进行中
Now time.Time
}
// SilenceJudgement 是判定结果与补写信息。
type SilenceJudgement struct {
Decision SilenceDecision
// BackfillRealnameAt 为 true 表示资产已实名但实名时间为空,按规则视为期内实名并需补写实名时间。
BackfillRealnameAt bool
}
// JudgeSilence 执行统一判定(技术方案 §5.2):
//
// waiting 且 期内已实名 → end
// waiting 且 已到期 且 期内未实名 且 无调整审批 → expire
// 其他 → 不变
//
// 已实名但实名时间为空的资产视为期内已实名(生产存在 first_realname_at 为空的已实名卡)。
func JudgeSilence(facts SilenceFacts) SilenceJudgement {
if facts.Status != silenceStatusWait || facts.DeadlineAt == nil {
return SilenceJudgement{Decision: SilenceDecisionNoop}
}
deadline := *facts.DeadlineAt
if facts.Realnamed {
if facts.RealnameAt == nil {
return SilenceJudgement{Decision: SilenceDecisionEnd, BackfillRealnameAt: true}
}
if !facts.RealnameAt.After(deadline) {
return SilenceJudgement{Decision: SilenceDecisionEnd}
}
}
if facts.Now.After(deadline) && !facts.AdjustPending {
return SilenceJudgement{Decision: SilenceDecisionExpire}
}
return SilenceJudgement{Decision: SilenceDecisionWait}
}
// IsSilenceEffectivelyExpired 判断展示层是否应显示"沉默期已过期":已过期,或待实名但已过截止且无调整审批。
func IsSilenceEffectivelyExpired(status string, deadlineAt *time.Time, adjustPending bool, now time.Time) bool {
if status == "expired" {
return true
}
return status == silenceStatusWait && deadlineAt != nil && now.After(*deadlineAt) && !adjustPending
}
// SilenceMonthsForDays 按每月 30 天向上取整:返回计费月数与计费 / 延长天数。
// 例:1→(1,30);30→(1,30);31→(2,60);60→(2,60)。
func SilenceMonthsForDays(inputDays int) (months int, billedDays int) {
if inputDays <= 0 {
return 0, 0
}
months = (inputDays + silenceDaysPerMonth - 1) / silenceDaysPerMonth
return months, months * silenceDaysPerMonth
}
// SilencePolicySnapshot 是延期计价与上限使用的政策快照。
type SilencePolicySnapshot struct {
PolicyID uint
Name string
UnitPrice int64
FreeExtensionLimit int
PaidMaxMonths int
SingleMaxMonths *int
ApprovalExemptTypes []string
}
// SilenceQuoteKind 是延期报价类型。
type SilenceQuoteKind string
const (
// SilenceQuotePaid 付费延期(在线与线下):受政策累计与单次上限约束。
SilenceQuotePaid SilenceQuoteKind = "paid"
// SilenceQuoteSpecial 特殊延期(含免费):只要求不少于最少月数。
SilenceQuoteSpecial SilenceQuoteKind = "special"
)
// SilenceQuoteInput 是延期报价输入。
type SilenceQuoteInput struct {
DeadlineAt time.Time
Now time.Time
InputDays int // 0 表示只算资格,不校验输入
Policy *SilencePolicySnapshot
PaidExtendedDays int
Kind SilenceQuoteKind
}
// SilenceQuoteOption 是一个月数档位的展示信息。
type SilenceQuoteOption struct {
Months int
BilledDays int
Amount int64
NewDeadline time.Time
}
// SilenceQuote 是延期报价结果。
type SilenceQuote struct {
OverdueDays int
MinMonths int
MaxMonths int // 付费延期上限;特殊延期无上限时为 0
MinDays int
MaxDays int
UnitPrice int64
// 以下字段仅在 InputDays > 0 时有值
Months int
BilledDays int
Amount int64
NewDeadline time.Time
// CanExtend 表示该类型(付费 / 特殊)能否办理;Reason 为不能办理的原因码
CanExtend bool
Reason string
// InputValid 表示输入天数换算后的月数是否在允许范围内(InputDays > 0 时有意义)
InputValid bool
}
// 报价原因码(与 constants.SilenceReason* 取值一致)。
const (
silenceReasonNoPolicy = "no_policy"
silenceReasonExceedsLimit = "exceeds_limit"
)
// QuoteSilenceExtension 计算延期报价(技术方案 §8.2):
//
// overdue_days = 今天日期 − 截止日期(不小于 0)
// min_months = floor(overdue_days / 30) + 1
// remain = paid_max_months − paid_extended_days / 30
// max_months = min(remain, single_max_months)
// months = ceil(input_days / 30);延长天数 = months × 30
// new_deadline = (截止日期 + 延长天数) 当日 23:59:59
// amount = months × unit_price(特殊延期由调用方按类型决定金额)
func QuoteSilenceExtension(input SilenceQuoteInput) SilenceQuote {
overdue := SilenceDaysBetween(input.DeadlineAt, input.Now)
if overdue < 0 {
overdue = 0
}
quote := SilenceQuote{
OverdueDays: overdue,
MinMonths: overdue/silenceDaysPerMonth + 1,
MinDays: overdue + 1,
}
if input.Policy != nil {
quote.UnitPrice = input.Policy.UnitPrice
}
switch input.Kind {
case SilenceQuoteSpecial:
quote.CanExtend = true
default:
if input.Policy == nil {
quote.Reason = silenceReasonNoPolicy
} else {
remain := input.Policy.PaidMaxMonths - input.PaidExtendedDays/silenceDaysPerMonth
if remain < 0 {
remain = 0
}
maxMonths := remain
if input.Policy.SingleMaxMonths != nil && *input.Policy.SingleMaxMonths < maxMonths {
maxMonths = *input.Policy.SingleMaxMonths
}
quote.MaxMonths = maxMonths
quote.MaxDays = maxMonths * silenceDaysPerMonth
quote.CanExtend = quote.MinMonths <= quote.MaxMonths
if !quote.CanExtend {
quote.Reason = silenceReasonExceedsLimit
}
}
}
if input.InputDays > 0 {
months, billed := SilenceMonthsForDays(input.InputDays)
quote.Months = months
quote.BilledDays = billed
quote.NewDeadline = SilenceDeadline(input.DeadlineAt, billed)
quote.Amount = int64(months) * quote.UnitPrice
quote.InputValid = quote.CanExtend && months >= quote.MinMonths &&
(input.Kind == SilenceQuoteSpecial || months <= quote.MaxMonths) &&
quote.NewDeadline.After(SilenceDeadline(input.Now, 0))
}
return quote
}
// SilenceQuoteOptions 返回付费延期 min~max 每档的计费天数、金额与新截止日;档位数最多 limit 个。
func SilenceQuoteOptions(quote SilenceQuote, deadlineAt time.Time, limit int) []SilenceQuoteOption {
if !quote.CanExtend || quote.MaxMonths < quote.MinMonths {
return nil
}
options := make([]SilenceQuoteOption, 0, quote.MaxMonths-quote.MinMonths+1)
for months := quote.MinMonths; months <= quote.MaxMonths && len(options) < limit; months++ {
billed := months * silenceDaysPerMonth
options = append(options, SilenceQuoteOption{
Months: months, BilledDays: billed, Amount: int64(months) * quote.UnitPrice,
NewDeadline: SilenceDeadline(deadlineAt, billed),
})
}
return options
}
// SilencePolicyCandidate 是政策匹配与重叠校验的输入。
type SilencePolicyCandidate struct {
ID uint
ScopeType string // all / series / package
ScopeIDs []uint
Enabled bool
EffectiveFrom time.Time
EffectiveTo *time.Time
}
func silenceScopePriority(scopeType string) int {
switch scopeType {
case "package":
return 3
case "series":
return 2
case "all":
return 1
default:
return 0
}
}
func (c SilencePolicyCandidate) effectiveAt(now time.Time) bool {
if !c.Enabled || now.Before(c.EffectiveFrom) {
return false
}
return c.EffectiveTo == nil || c.EffectiveTo.After(now)
}
func (c SilencePolicyCandidate) covers(packageID, seriesID uint) bool {
switch c.ScopeType {
case "all":
return true
case "series":
return seriesID != 0 && slices.Contains(c.ScopeIDs, seriesID)
case "package":
return packageID != 0 && slices.Contains(c.ScopeIDs, packageID)
default:
return false
}
}
// MatchSilencePolicy 按"指定套餐 > 套餐系列 > 全部套餐"选出当前生效的政策,返回下标;未匹配返回 -1。
// 同一优先级理论上不重叠(保存时校验),若历史数据重叠则取 ID 较大的一条。
func MatchSilencePolicy(candidates []SilencePolicyCandidate, packageID, seriesID uint, now time.Time) int {
best := -1
for index, candidate := range candidates {
if !candidate.effectiveAt(now) || !candidate.covers(packageID, seriesID) {
continue
}
if best < 0 {
best = index
continue
}
current := candidates[best]
if silenceScopePriority(candidate.ScopeType) > silenceScopePriority(current.ScopeType) ||
(candidate.ScopeType == current.ScopeType && candidate.ID > current.ID) {
best = index
}
}
return best
}
// SilencePolicyOverlaps 判断两条政策是否在同一优先级上适用范围与生效时间段重叠(只比较启用的政策)。
func SilencePolicyOverlaps(a, b SilencePolicyCandidate) bool {
if !a.Enabled || !b.Enabled || a.ScopeType != b.ScopeType {
return false
}
if a.ScopeType != "all" && !silenceIDsIntersect(a.ScopeIDs, b.ScopeIDs) {
return false
}
return silenceRangesOverlap(a.EffectiveFrom, a.EffectiveTo, b.EffectiveFrom, b.EffectiveTo)
}
func silenceIDsIntersect(a, b []uint) bool {
for _, id := range a {
if slices.Contains(b, id) {
return true
}
}
return false
}
// silenceRangesOverlap 判断 [fromA, toA) 与 [fromB, toB) 是否相交,to 为空表示长期。
func silenceRangesOverlap(fromA time.Time, toA *time.Time, fromB time.Time, toB *time.Time) bool {
aBeforeB := toA != nil && !toA.After(fromB)
bBeforeA := toB != nil && !toB.After(fromA)
return !aBeforeB && !bBeforeA
}
// IsSilenceApprovalExempt 判断场景是否在政策快照的免审批场景内;无政策快照时全部需要审批。
func IsSilenceApprovalExempt(exemptTypes []string, scene string) bool {
return scene != "" && slices.Contains(exemptTypes, scene)
}
// SilenceSpecialApprovalScene 返回特殊延期判断免审批时使用的场景:批量看 batch,否则看延期类型。
func SilenceSpecialApprovalScene(extensionType string, isBatch bool) string {
if isBatch {
return "batch"
}
return extensionType
}

View File

@@ -1,137 +0,0 @@
// Package stopresume 定义停复机新规则的纯判断:输入卡与资产的当前事实,输出目标动作,不读写任何外部状态。
//
// 新规则(finalize-unified-stop-resume-rule):
// - 卡或其有效绑定设备暂停轮询时不自动停复机;
// - 开机卡命中任一停机条件即停机:无套餐、流量用完、通道开启实名管控且卡未实名;
// - 停机卡同时满足有套餐、流量未用完、实名条件时复机,不看停机原因;持本周期通道阈值锁的卡拒绝复机;
// - 卡所属通道不存在(无法判断实名管控)时本次不动作。
package stopresume
import "github.com/break/junhong_cmp_fiber/pkg/constants"
// 判断结果。
const (
ResultWillResume = "will_resume" // 将复机
ResultWillStop = "will_stop" // 将停机
ResultNoAction = "no_action" // 不动作
)
// 不动作原因。
const (
NoActionPollingSuspended = "polling_suspended" // 卡或所在设备暂停轮询
NoActionConditionsMet = "conditions_met" // 开机卡满足开机条件
NoActionNoPackage = "no_package" // 停机卡无套餐(与停机原因同值,共用名称)
NoActionTrafficExhausted = "traffic_exhausted" // 停机卡流量已用完
NoActionNotRealname = "not_realname" // 停机卡所属通道开启实名管控且卡未实名
NoActionThresholdLocked = "threshold_locked" // 停机卡持本周期通道阈值锁
NoActionCarrierMissing = "carrier_missing" // 卡所属通道不存在,无法判断实名管控
NoActionUnknownNetwork = "unknown_network" // 卡网络状态既非开机也非停机
)
// Facts 是判断一张卡所需的全部事实;套餐与流量按绑定设备(有有效绑定时)或卡自身计算。
type Facts struct {
NetworkStatus int
PollingSuspended bool // 卡自身或其有效绑定设备暂停轮询
HasValidPackage bool // 存在生效中的套餐
TrafficExhausted bool // 生效或已用完的套餐全部达到流量上限
RealnameVerified bool
CarrierKnown bool // 卡所属通道存在
RealnameControl bool // 卡所属通道开启实名管控(可为假设值)
ThresholdLocked bool // 持本周期通道阈值锁
CurrentStopReason string
}
// Decision 是一张卡的判断结果。
type Decision struct {
Result string // ResultWillResume / ResultWillStop / ResultNoAction
Reason string // 将停机时为目标停机原因;不动作时为不动作原因;将复机时为空
StopReason string // 将停机时的目标停机原因
}
// Decide 按新规则判断卡的目标动作。
func Decide(f Facts) Decision {
if f.PollingSuspended {
return noAction(NoActionPollingSuspended)
}
if !f.CarrierKnown {
return noAction(NoActionCarrierMissing)
}
realnameOK := !f.RealnameControl || f.RealnameVerified
switch f.NetworkStatus {
case constants.NetworkStatusOnline:
// 停机原因优先级:流量用完 > 无套餐(仅在未用完时判断)> 未实名,与评估主流程一致。
switch {
case f.TrafficExhausted:
return stop(constants.StopReasonTrafficExhausted)
case !f.HasValidPackage:
return stop(constants.StopReasonNoPackage)
case !realnameOK:
return stop(constants.StopReasonNotRealname)
default:
return noAction(NoActionConditionsMet)
}
case constants.NetworkStatusOffline:
switch {
case !f.HasValidPackage:
return noAction(NoActionNoPackage)
case f.TrafficExhausted:
return noAction(NoActionTrafficExhausted)
case !realnameOK:
return noAction(NoActionNotRealname)
case f.ThresholdLocked:
return noAction(NoActionThresholdLocked)
default:
return Decision{Result: ResultWillResume}
}
default:
return noAction(NoActionUnknownNetwork)
}
}
func stop(reason string) Decision {
return Decision{Result: ResultWillStop, Reason: reason, StopReason: reason}
}
func noAction(reason string) Decision {
return Decision{Result: ResultNoAction, Reason: reason}
}
// ResultName 返回判断结果中文名称。
func ResultName(result string) string {
switch result {
case ResultWillResume:
return "将复机"
case ResultWillStop:
return "将停机"
case ResultNoAction:
return "不动作"
default:
return result
}
}
// ReasonName 返回停机原因或不动作原因的中文名称。
func ReasonName(reason string) string {
switch reason {
case "":
return ""
case NoActionPollingSuspended:
return "暂停轮询"
case NoActionConditionsMet:
return "满足开机条件"
case NoActionNoPackage:
return "无套餐"
case NoActionTrafficExhausted:
return "流量已用完"
case NoActionNotRealname:
return "未实名(通道开启实名管控)"
case NoActionThresholdLocked:
return "持通道阈值锁"
case NoActionCarrierMissing:
return "所属通道不存在"
case NoActionUnknownNetwork:
return "网络状态未知"
default:
return reason
}
}

View File

@@ -1,41 +0,0 @@
// Package stopresume 提供自动停复机请求评估场景在 ctx 中的传递,供各自动入口与停复机服务共用(无业务依赖)。
package stopresume
import (
"context"
"github.com/break/junhong_cmp_fiber/pkg/constants"
)
type sceneKey struct{}
// WithScene 声明本次自动停复机评估的触发场景(constants.StopResumeScene*);未声明时按套餐步骤(周期评估)处理。
func WithScene(ctx context.Context, scene string) context.Context {
if scene == "" {
return ctx
}
return context.WithValue(ctx, sceneKey{}, scene)
}
// Scene 返回 ctx 中声明的场景;未声明返回套餐步骤场景。
func Scene(ctx context.Context) string {
if scene, ok := ctx.Value(sceneKey{}).(string); ok && scene != "" {
return scene
}
return constants.StopResumeScenePolling
}
// QueueAware 由停复机服务实现:停复机出口开关开启时请求评估只写共享缓存。
type QueueAware interface {
CommandQueueEnabled(ctx context.Context) bool
}
// RunAfterCommit 在业务事务提交后执行自动停复机请求:开关开启时同步执行(只写共享缓存,不起 goroutine);
// 关闭时沿用旧路径在 goroutine 中当场执行(含网关调用与重试,不阻塞业务流程)。
func RunAfterCommit(ctx context.Context, callback any, fn func()) {
if aware, ok := callback.(QueueAware); ok && aware.CommandQueueEnabled(ctx) {
fn()
return
}
go fn()
}

View File

@@ -15,11 +15,7 @@ const (
deviceExportBaseHeaderCount = 6
deviceExportCardGroupSize = 5
// deviceExportTailHeaderCount 是当前尾部固定列数(含「关联手机号」「设备合计(GB)」列)。
deviceExportTailHeaderCount = 9
// deviceExportPollingTailHeaderCount 是追加「首次购买套餐时间」前(以「是否轮询」结尾)的尾部列数
deviceExportPollingTailHeaderCount = 8
// deviceExportGBTailHeaderCount 是新增「是否轮询」之前、带「设备合计(GB)」的尾部列数,供历史任务表头反解。
deviceExportGBTailHeaderCount = 7
deviceExportTailHeaderCount = 7
// deviceExportPhoneTailHeaderCount 是新增「设备合计(GB)」之前、带「关联手机号」的尾部列数,供历史任务表头反解。
deviceExportPhoneTailHeaderCount = 6
// deviceExportLegacyTailHeaderCount 是新增「关联手机号」之前的尾部列数,供历史任务表头反解。
@@ -29,10 +25,6 @@ const (
deviceExportAssociatedPhoneHeader = "关联手机号"
// deviceExportTotalGBHeader 是「设备合计(GB)」列的表头,固定位于导出最后一列。
deviceExportTotalGBHeader = "设备合计(GB)"
// deviceExportPollingHeader 是「是否轮询」列的表头,位于导出最后一列。
deviceExportPollingHeader = "是否轮询"
// deviceExportFirstPurchaseHeader 首次购买套餐时间(当前世代第一笔套餐订单的支付时间)
deviceExportFirstPurchaseHeader = "首次购买套餐时间"
// deviceExportMBPerGB MB 换算 GB 的进制。
deviceExportMBPerGB = 1024
)
@@ -131,12 +123,6 @@ func (s *DeviceDataSource) baseQuery(ctx context.Context) *gorm.DB {
// 导出任务运行在 Worker 中,权限只能使用任务创建时保存的店铺范围快照。
func (s *DeviceDataSource) applyFilters(query *gorm.DB, params ExportParams) *gorm.DB {
query = applyExportShopScope(query, params, "d.shop_id")
if start, ok := filterTime(params.Filters, "first_purchase_start"); ok {
query = query.Where("d.first_purchase_at >= ?", start)
}
if end, ok := filterTime(params.Filters, "first_purchase_end"); ok {
query = query.Where("d.first_purchase_at <= ?", end)
}
if virtualNo, ok := filterString(params.Filters, "virtual_no"); ok {
query = query.Where("d.virtual_no LIKE ?", "%"+virtualNo+"%")
@@ -191,9 +177,6 @@ func (s *DeviceDataSource) applyFilters(query *gorm.DB, params ExportParams) *go
if hasActivePackage, ok := filterOptionalBool(params.Filters, "has_active_package"); ok {
query = s.applyHasActivePackageFilter(query, hasActivePackage)
}
if enablePolling, ok := filterOptionalBool(params.Filters, "enable_polling"); ok {
query = query.Where("d.enable_polling = ?", enablePolling)
}
return query
}
@@ -282,9 +265,7 @@ func (s *DeviceDataSource) fetchDevices(ctx context.Context, params ExportParams
d.device_model,
COALESCE(sh.shop_name, '') AS shop_name,
COALESCE(ps.series_name, '') AS series_name,
COALESCE(aw.balance, 0) AS wallet_balance,
d.enable_polling,
d.first_purchase_at
COALESCE(aw.balance, 0) AS wallet_balance
`).
Order("d.id ASC").
Limit(limit).
@@ -363,16 +344,14 @@ func (s *DeviceDataSource) fetchActivePackages(ctx context.Context, deviceIDs []
}
type deviceExportRow struct {
ID uint `gorm:"column:id"`
VirtualNo string `gorm:"column:virtual_no"`
IMEI string `gorm:"column:imei"`
DeviceName string `gorm:"column:device_name"`
DeviceModel string `gorm:"column:device_model"`
ShopName string `gorm:"column:shop_name"`
SeriesName string `gorm:"column:series_name"`
WalletBalance int64 `gorm:"column:wallet_balance"`
EnablePolling bool `gorm:"column:enable_polling"`
FirstPurchaseAt *time.Time `gorm:"column:first_purchase_at"`
ID uint `gorm:"column:id"`
VirtualNo string `gorm:"column:virtual_no"`
IMEI string `gorm:"column:imei"`
DeviceName string `gorm:"column:device_name"`
DeviceModel string `gorm:"column:device_model"`
ShopName string `gorm:"column:shop_name"`
SeriesName string `gorm:"column:series_name"`
WalletBalance int64 `gorm:"column:wallet_balance"`
}
type deviceExportCardRow struct {
@@ -415,8 +394,6 @@ func buildDeviceExportHeaders(cardGroups int) []string {
"钱包余额",
deviceExportAssociatedPhoneHeader,
deviceExportTotalGBHeader,
deviceExportPollingHeader,
deviceExportFirstPurchaseHeader,
)
return headers
}
@@ -467,14 +444,8 @@ func buildDeviceExportRow(item deviceExportRow, cards []deviceExportCardRow, pkg
if tailCount >= deviceExportPhoneTailHeaderCount {
row = append(row, associatedPhone)
}
if tailCount >= deviceExportGBTailHeaderCount {
row = append(row, formatMB(totalUsage/deviceExportMBPerGB))
}
if tailCount >= deviceExportPollingTailHeaderCount {
row = append(row, yesNo(item.EnablePolling))
}
if tailCount >= deviceExportTailHeaderCount {
row = append(row, formatOptionalTime(item.FirstPurchaseAt))
row = append(row, formatMB(totalUsage/deviceExportMBPerGB))
}
return row
}
@@ -491,12 +462,8 @@ func deviceExportTailCount(headers []string) int {
return deviceExportTailHeaderCount
}
switch headers[len(headers)-1] {
case deviceExportFirstPurchaseHeader:
return deviceExportTailHeaderCount
case deviceExportPollingHeader:
return deviceExportPollingTailHeaderCount
case deviceExportTotalGBHeader:
return deviceExportGBTailHeaderCount
return deviceExportTailHeaderCount
case deviceExportAssociatedPhoneHeader:
return deviceExportPhoneTailHeaderCount
default:

View File

@@ -40,7 +40,7 @@ func (s *IotCardDataSource) Count(ctx context.Context, params ExportParams) (int
// Headers 返回 IoT 卡导出表头。
func (s *IotCardDataSource) Headers(ctx context.Context, params ExportParams) ([]string, error) {
// 「关联手机号」「套餐到期时间」「使用流量(GB)」「剩余流量(GB)」依次固定追加在尾部:导出表头在 dispatch 阶段落库,历史任务重导出沿用同一列序。
return []string{"ICCID", "MSISDN", "绑定设备虚拟号", "运营商", "店铺名称", "绑定设备名称", "是否实名", "实名时间", "网络状态", "套餐名称", "使用流量(MB)", "剩余流量(MB)", iotCardExportAssociatedPhoneHeader, iotCardExportFinalExpiryHeader, iotCardExportUsageGBHeader, iotCardExportRemainingGBHeader, iotCardExportPollingHeader, iotCardExportFirstPurchaseHeader}, nil
return []string{"ICCID", "MSISDN", "绑定设备虚拟号", "运营商", "店铺名称", "绑定设备名称", "是否实名", "实名时间", "网络状态", "套餐名称", "使用流量(MB)", "剩余流量(MB)", iotCardExportAssociatedPhoneHeader, iotCardExportFinalExpiryHeader, iotCardExportUsageGBHeader, iotCardExportRemainingGBHeader}, nil
}
// Fetch 按 offset/limit 查询 IoT 卡导出数据。
@@ -60,9 +60,7 @@ func (s *IotCardDataSource) Fetch(ctx context.Context, params ExportParams, offs
COALESCE(sh.shop_name, '') AS shop_name,
COALESCE(d.device_name, '') AS device_name,
c.real_name_status,
c.enable_polling,
c.first_realname_at,
c.first_purchase_at,
c.network_status,
COALESCE(pkg.package_name, '') AS package_name,
COALESCE(pkg.data_usage_mb, 0) AS data_usage_mb,
@@ -83,7 +81,7 @@ func (s *IotCardDataSource) Fetch(ctx context.Context, params ExportParams, offs
if err != nil {
return nil, err
}
includeExpiry, includeGB, includePolling, includeFirstPurchase := iotCardExportTailColumns(params.ResolvedHeaders)
includeExpiry, includeGB := iotCardExportTailColumns(params.ResolvedHeaders)
var finalExpiries map[uint]string
if includeExpiry {
finalExpiries, err = s.loadFinalExpiries(ctx, cardIDs)
@@ -116,12 +114,6 @@ func (s *IotCardDataSource) Fetch(ctx context.Context, params ExportParams, offs
if includeGB {
row = append(row, formatMB(float64(item.DataUsageMB)/deviceExportMBPerGB), formatMB(float64(remainingMB)/deviceExportMBPerGB))
}
if includePolling {
row = append(row, yesNo(item.EnablePolling))
}
if includeFirstPurchase {
row = append(row, formatOptionalTime(item.FirstPurchaseAt))
}
rows = append(rows, row)
}
return rows, nil
@@ -143,23 +135,19 @@ func (s *IotCardDataSource) loadFinalExpiries(ctx context.Context, cardIDs []uin
return result, nil
}
// iotCardExportTailColumns 按冻结表头末列识别尾部版本:末列「是否轮询」输出到期、GB 与是否轮询列,
// 末列「剩余流量(GB)」输出到期列与 GB 列,末列「套餐到期时间」只输出到期列,更早的表头都不输出;无冻结表头按当前版本输出。
func iotCardExportTailColumns(headers []string) (includeExpiry, includeGB, includePolling, includeFirstPurchase bool) {
// iotCardExportTailColumns 按冻结表头末列识别尾部版本:末列「剩余流量(GB)」输出到期列与 GB 列,
// 末列「套餐到期时间」只输出到期列,更早的表头两者都不输出;无冻结表头按当前版本输出。
func iotCardExportTailColumns(headers []string) (includeExpiry, includeGB bool) {
if len(headers) == 0 {
return true, true, true, true
return true, true
}
switch headers[len(headers)-1] {
case iotCardExportFirstPurchaseHeader:
return true, true, true, true
case iotCardExportPollingHeader:
return true, true, true, false
case iotCardExportRemainingGBHeader:
return true, true, false, false
return true, true
case iotCardExportFinalExpiryHeader:
return true, false, false, false
return true, false
default:
return false, false, false, false
return false, false
}
}
@@ -204,12 +192,6 @@ func (s *IotCardDataSource) baseQuery(ctx context.Context) *gorm.DB {
// 导出任务运行在 Worker 中,权限只能使用任务创建时保存的店铺范围快照。
func (s *IotCardDataSource) applyFilters(ctx context.Context, query *gorm.DB, params ExportParams) *gorm.DB {
query = applyExportShopScope(query, params, "c.shop_id")
if start, ok := filterTime(params.Filters, "first_purchase_start"); ok {
query = query.Where("c.first_purchase_at >= ?", start)
}
if end, ok := filterTime(params.Filters, "first_purchase_end"); ok {
query = query.Where("c.first_purchase_at <= ?", end)
}
if isStandalone, ok := filterOptionalBool(params.Filters, "is_standalone"); ok {
query = query.Where("c.is_standalone = ?", isStandalone)
@@ -286,9 +268,6 @@ func (s *IotCardDataSource) applyFilters(ctx context.Context, query *gorm.DB, pa
if carrierName, ok := filterString(params.Filters, "carrier_name"); ok {
query = query.Where("c.carrier_name LIKE ?", "%"+carrierName+"%")
}
if enablePolling, ok := filterOptionalBool(params.Filters, "enable_polling"); ok {
query = query.Where("c.enable_polling = ?", enablePolling)
}
if hasActivePackage, ok := filterOptionalBool(params.Filters, "has_active_package"); ok {
subQuery := s.db.WithContext(ctx).Table("tb_package_usage AS pu").
Select("1").
@@ -312,10 +291,6 @@ const iotCardExportFinalExpiryHeader = "套餐到期时间"
const (
iotCardExportUsageGBHeader = "使用流量(GB)"
iotCardExportRemainingGBHeader = "剩余流量(GB)"
// iotCardExportPollingHeader 是「是否轮询」列的表头,位于 GB 流量列之后、导出最后一列。
iotCardExportPollingHeader = "是否轮询"
// iotCardExportFirstPurchaseHeader 首次购买套餐时间(当前世代第一笔套餐订单的支付时间)
iotCardExportFirstPurchaseHeader = "首次购买套餐时间"
)
type iotCardExportRow struct {
@@ -328,12 +303,10 @@ type iotCardExportRow struct {
DeviceName string `gorm:"column:device_name"`
RealNameStatus int `gorm:"column:real_name_status"`
FirstRealnameAt *time.Time `gorm:"column:first_realname_at"`
FirstPurchaseAt *time.Time `gorm:"column:first_purchase_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"`
EnablePolling bool `gorm:"column:enable_polling"`
}
func remainingPackageDataMB(limit, used int64) int64 {

View File

@@ -9,7 +9,6 @@ import (
"gorm.io/gorm"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/internal/query/ordercategory"
"github.com/break/junhong_cmp_fiber/pkg/constants"
)
@@ -21,30 +20,19 @@ const (
orderExportOperatorHeader = "操作人"
)
// OrderDataSource 订单导出数据源。category 为空时导出全部订单(场景 order),
// 非空时只导出该类别的订单(场景 order_platform / order_agent / order_online),其余逻辑完全相同。
// OrderDataSource 订单导出数据源。
type OrderDataSource struct {
db *gorm.DB
category string
db *gorm.DB
}
// NewOrderDataSource 创建订单导出数据源;category 为空表示不限类别。
func NewOrderDataSource(db *gorm.DB, category string) *OrderDataSource {
return &OrderDataSource{db: db, category: category}
// NewOrderDataSource 创建订单导出数据源。
func NewOrderDataSource(db *gorm.DB) *OrderDataSource {
return &OrderDataSource{db: db}
}
// Scene 返回导出场景编码。
func (s *OrderDataSource) Scene() string {
switch s.category {
case constants.OrderCategoryPlatform:
return constants.ExportTaskSceneOrderPlatform
case constants.OrderCategoryAgent:
return constants.ExportTaskSceneOrderAgent
case constants.OrderCategoryOnline:
return constants.ExportTaskSceneOrderOnline
default:
return constants.ExportTaskSceneOrder
}
return constants.ExportTaskSceneOrder
}
// Count 统计订单导出行数。
@@ -141,14 +129,6 @@ func (s *OrderDataSource) baseQuery(ctx context.Context) *gorm.DB {
// is_expired 在接口 Service 中未进入 Store 筛选,这里按当前接口行为忽略。
func (s *OrderDataSource) applyFilters(ctx context.Context, query *gorm.DB, params ExportParams) (*gorm.DB, error) {
query = s.applyOrderScope(query, params)
// 订单类别与列表共用同一份条件,导出条数与对应列表一致。
if s.category != "" {
condition, args, ok := ordercategory.Condition("o", s.category)
if !ok {
return nil, fmt.Errorf("不支持的订单类别: %s", s.category)
}
query = query.Where(condition, args...)
}
if params.UserType == constants.UserTypeAgent {
if params.CreatorShopID != nil {

View File

@@ -7,7 +7,6 @@ import (
"gorm.io/gorm"
"github.com/break/junhong_cmp_fiber/internal/store/postgres"
"github.com/break/junhong_cmp_fiber/pkg/constants"
)
@@ -43,7 +42,6 @@ func (s *PackageDataSource) Headers(context.Context, ExportParams) ([]string, er
"套餐时长说明", "套餐周期类型", "套餐天数", "真流量额度(MB)", "虚流量额度(MB)",
"是否启用虚流量", "虚流量比例", "流量重置周期", "到期时间基准", "成本价(元)",
"建议售价(元)", "价格配置状态", "状态", "上架状态", "是否赠送套餐",
"默认沉默期(天)", "是否支持付费延期",
"创建人ID", "更新人ID", "创建时间", "更新时间", "删除时间",
}, nil
}
@@ -113,9 +111,6 @@ func (s *PackageDataSource) applyFilters(query *gorm.DB, params ExportParams) *g
if packageType, ok := filterString(params.Filters, "package_type"); ok {
query = query.Where("p.package_type = ?", packageType)
}
if filterBool(params.Filters, "silence_unconfigured") {
query = query.Where(postgres.SilenceUnconfiguredPackageCondition("p"))
}
return query
}
@@ -146,8 +141,6 @@ func (s *PackageDataSource) selectColumns(params ExportParams) string {
p.status,
` + shelfStatusColumn + ` AS shelf_status,
p.is_gift,
p.silence_period_days,
p.silence_extension_enabled,
p.creator,
p.updater,
p.created_at,
@@ -174,8 +167,6 @@ type packageExportRow struct {
Status int `gorm:"column:status"`
ShelfStatus int `gorm:"column:shelf_status"`
IsGift bool `gorm:"column:is_gift"`
SilencePeriodDays *int `gorm:"column:silence_period_days"`
SilenceExtension bool `gorm:"column:silence_extension_enabled"`
Creator uint `gorm:"column:creator"`
Updater uint `gorm:"column:updater"`
CreatedAt time.Time `gorm:"column:created_at"`
@@ -204,8 +195,6 @@ func buildPackageExportRow(item packageExportRow) []string {
constants.GetStatusName(item.Status),
constants.GetShelfStatusName(item.ShelfStatus),
formatYesNo(item.IsGift),
formatSilencePeriodDays(item.SilencePeriodDays),
formatYesNo(item.SilenceExtension),
strconv.FormatUint(uint64(item.Creator), 10),
strconv.FormatUint(uint64(item.Updater), 10),
item.CreatedAt.Format(exportTimeLayout),
@@ -237,10 +226,3 @@ func formatYesNo(value bool) string {
}
return "否"
}
func formatSilencePeriodDays(days *int) string {
if days == nil {
return ""
}
return strconv.Itoa(*days)
}

View File

@@ -1,218 +0,0 @@
package exporter
import (
"context"
"strconv"
"gorm.io/gorm"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/internal/model/dto"
packagesilencequery "github.com/break/junhong_cmp_fiber/internal/query/packagesilence"
"github.com/break/junhong_cmp_fiber/pkg/constants"
)
// 沉默期延期订单与即将过期 / 已过期套餐导出(add-package-silence-period)。
// 导出在 Worker 中执行,数据范围按任务创建时冻结的账号类型与店铺范围重建上下文。
var silenceOrderHeaders = []string{
"订单号", "批次号", "资产类型", "资产标识", "套餐名称", "来源", "状态", "输入天数", "计费月数", "延长天数",
"单价(元/月)", "金额(元)", "政策", "下单时截止", "预计新截止", "支付方式", "收款商户", "交易流水号",
"线下收款方式", "线下交易单号", "实付金额(元)", "支付时间", "延期生效时间", "退款单号", "退款金额(元)", "退款时间", "异常说明", "创建时间",
}
var silenceUsageHeaders = []string{
"套餐记录ID", "订单号", "资产类型", "资产标识", "店铺", "套餐名称", "购买时间", "沉默期状态", "沉默期天数",
"截止时间", "过期时间", "累计延期天数", "付费延期天数", "延期次数", "免费延期次数", "调整审批中",
}
// silenceExportContext 用任务冻结的账号类型与店铺范围构造查询上下文。
func silenceExportContext(ctx context.Context, params ExportParams) context.Context {
ctx = context.WithValue(ctx, constants.ContextKeyUserType, params.UserType)
if params.CreatorShopID != nil {
ctx = context.WithValue(ctx, constants.ContextKeyShopID, *params.CreatorShopID)
}
if params.UserType == constants.UserTypeAgent {
ctx = context.WithValue(ctx, constants.ContextKeySubordinateShopIDs, append([]uint{}, params.ScopeShopIDs...))
}
return ctx
}
// SilenceOrderDataSource 沉默期延期订单导出数据源(场景 package_silence_order)。
type SilenceOrderDataSource struct {
query *packagesilencequery.Query
}
// NewSilenceOrderDataSource 创建延期订单导出数据源。
func NewSilenceOrderDataSource(db *gorm.DB) *SilenceOrderDataSource {
return &SilenceOrderDataSource{query: packagesilencequery.NewQuery(db)}
}
// Scene 返回导出场景编码。
func (s *SilenceOrderDataSource) Scene() string { return constants.ExportTaskSceneSilenceOrder }
func (s *SilenceOrderDataSource) filter(params ExportParams) packagesilencequery.OrderFilter {
req := dto.SilenceOrderListRequest{}
if id, ok := filterUint(params.Filters, "package_usage_id"); ok && id > 0 {
req.PackageUsageID = &id
}
if id, ok := filterUint(params.Filters, "package_id"); ok && id > 0 {
req.PackageID = &id
}
req.AssetIdentifier, _ = filterString(params.Filters, "asset_identifier")
req.OrderNo, _ = filterString(params.Filters, "order_no")
req.Status, _ = filterString(params.Filters, "status")
req.Source, _ = filterString(params.Filters, "source")
req.PaymentMethod, _ = filterString(params.Filters, "payment_method")
req.MerchantName, _ = filterString(params.Filters, "merchant_name")
req.ThirdPartyTradeNo, _ = filterString(params.Filters, "third_party_trade_no")
req.PaidStart, _ = filterString(params.Filters, "paid_start")
req.PaidEnd, _ = filterString(params.Filters, "paid_end")
return packagesilencequery.OrderFilter{Request: req}
}
// Count 统计导出行数。
func (s *SilenceOrderDataSource) Count(ctx context.Context, params ExportParams) (int, error) {
query, err := s.query.OrderQuery(silenceExportContext(ctx, params), s.filter(params))
if err != nil {
return 0, err
}
var total int64
if err := query.Count(&total).Error; err != nil {
return 0, err
}
return int(total), nil
}
// Headers 返回导出表头。
func (s *SilenceOrderDataSource) Headers(context.Context, ExportParams) ([]string, error) {
return silenceOrderHeaders, nil
}
// Fetch 按 offset/limit 返回导出行。
func (s *SilenceOrderDataSource) Fetch(ctx context.Context, params ExportParams, offset, limit int) ([][]string, error) {
if limit <= 0 {
return [][]string{}, nil
}
ctx = silenceExportContext(ctx, params)
query, err := s.query.OrderQuery(ctx, s.filter(params))
if err != nil {
return nil, err
}
var orders []model.PackageSilenceExtensionOrder
if err := query.Select("o.*").Order("o.id ASC").Offset(offset).Limit(limit).Find(&orders).Error; err != nil {
return nil, err
}
items, err := s.query.OrderItems(ctx, orders)
if err != nil {
return nil, err
}
rows := make([][]string, 0, len(items))
for _, item := range items {
rows = append(rows, []string{
item.OrderNo, item.BatchNo, item.AssetType, item.AssetIdentifier, item.PackageName, item.SourceName, item.StatusName,
strconv.Itoa(item.InputDays), strconv.Itoa(item.Months), strconv.Itoa(item.ExtensionDays),
formatMoneyYuan(item.UnitPrice), formatMoneyYuan(item.Amount), item.PolicyName,
item.BeforeDeadlineAt.Format(exportTimeLayout), item.ExpectedDeadlineAt.Format(exportTimeLayout),
item.PaymentMethod, item.PaymentMerchantName, item.ThirdPartyTradeNo, item.OfflinePaymentMethodName, item.OfflineTradeNo,
formatOptionalMoney(item.PaidAmount), formatOptionalTime(item.PaidAt), formatOptionalTime(item.CompletedAt),
item.RefundNo, formatOptionalMoney(item.RefundAmount), formatOptionalTime(item.RefundedAt), item.ExceptionSummary,
item.CreatedAt.Format(exportTimeLayout),
})
}
return rows, nil
}
// SilenceUsageDataSource 即将过期 / 已过期套餐导出数据源(场景 package_silence_usage)。
type SilenceUsageDataSource struct {
query *packagesilencequery.Query
}
// NewSilenceUsageDataSource 创建沉默期套餐导出数据源。
func NewSilenceUsageDataSource(db *gorm.DB) *SilenceUsageDataSource {
return &SilenceUsageDataSource{query: packagesilencequery.NewQuery(db)}
}
// Scene 返回导出场景编码。
func (s *SilenceUsageDataSource) Scene() string { return constants.ExportTaskSceneSilenceUsage }
func (s *SilenceUsageDataSource) request(params ExportParams) dto.SilenceUsageListRequest {
req := dto.SilenceUsageListRequest{}
req.View, _ = filterString(params.Filters, "view")
if days, ok := filterInt(params.Filters, "within_days"); ok {
req.WithinDays = days
}
req.AssetIdentifier, _ = filterString(params.Filters, "asset_identifier")
if id, ok := filterUint(params.Filters, "package_id"); ok && id > 0 {
req.PackageID = &id
}
if id, ok := filterUint(params.Filters, "shop_id"); ok && id > 0 {
req.ShopID = &id
}
req.DeadlineStart, _ = filterString(params.Filters, "deadline_start")
req.DeadlineEnd, _ = filterString(params.Filters, "deadline_end")
return req
}
// Count 统计导出行数。
func (s *SilenceUsageDataSource) Count(ctx context.Context, params ExportParams) (int, error) {
query, err := s.query.UsageQuery(silenceExportContext(ctx, params), s.request(params))
if err != nil {
return 0, err
}
var total int64
if err := query.Count(&total).Error; err != nil {
return 0, err
}
return int(total), nil
}
// Headers 返回导出表头。
func (s *SilenceUsageDataSource) Headers(context.Context, ExportParams) ([]string, error) {
return silenceUsageHeaders, nil
}
// Fetch 按 offset/limit 返回导出行。
func (s *SilenceUsageDataSource) Fetch(ctx context.Context, params ExportParams, offset, limit int) ([][]string, error) {
if limit <= 0 {
return [][]string{}, nil
}
ctx = silenceExportContext(ctx, params)
query, err := s.query.UsageQuery(ctx, s.request(params))
if err != nil {
return nil, err
}
items, err := s.query.FetchUsages(ctx, query, offset, limit)
if err != nil {
return nil, err
}
rows := make([][]string, 0, len(items))
for _, item := range items {
view := item.Silence
if view == nil {
view = &dto.PackageSilenceView{}
}
rows = append(rows, []string{
strconv.FormatUint(uint64(item.PackageUsageID), 10), item.OrderNo, item.AssetType, item.AssetIdentifier, item.ShopName,
item.PackageName, item.CreatedAt.Format(exportTimeLayout), view.SilenceStatusName, formatOptionalInt(view.SilencePeriodDays),
formatOptionalTime(view.SilenceDeadlineAt), formatOptionalTime(view.SilenceExpiredAt), strconv.Itoa(view.SilenceExtendedDays),
strconv.Itoa(view.SilencePaidExtendedDays), strconv.Itoa(view.SilenceExtensionCount), strconv.Itoa(view.SilenceFreeExtensionUsed),
formatYesNo(view.SilenceAdjustPending),
})
}
return rows, nil
}
func formatOptionalMoney(value *int64) string {
if value == nil {
return ""
}
return formatMoneyYuan(*value)
}
func formatOptionalInt(value *int) string {
if value == nil {
return ""
}
return strconv.Itoa(*value)
}

View File

@@ -1,226 +0,0 @@
package exporter
import (
"context"
"strconv"
"time"
"gorm.io/gorm"
"github.com/break/junhong_cmp_fiber/internal/query/pollingalert"
pausequery "github.com/break/junhong_cmp_fiber/internal/query/pollingpause"
"github.com/break/junhong_cmp_fiber/pkg/constants"
)
// 不轮询清单与停机被拒告警导出(add-polling-pause-list-and-batch-toggle),仅超级管理员与平台账号。
var pollingPauseHeaders = []string{
"资产类型", "资产ID", "ICCID或IMEI", "运营商账户", "当前是否轮询", "网关扩展状态", "状态", "关闭来源", "关闭原因",
"关闭人", "关闭时间", "导入任务ID", "恢复来源", "恢复原因", "恢复人", "恢复时间",
}
// PollingPauseDataSource 不轮询清单导出数据源。
type PollingPauseDataSource struct {
query *pausequery.Query
}
// NewPollingPauseDataSource 创建不轮询清单导出数据源。
func NewPollingPauseDataSource(db *gorm.DB) *PollingPauseDataSource {
return &PollingPauseDataSource{query: pausequery.NewQuery(db)}
}
// Scene 返回导出场景编码。
func (s *PollingPauseDataSource) Scene() string { return constants.ExportTaskScenePollingPause }
func (s *PollingPauseDataSource) filter(params ExportParams) (pausequery.Filter, error) {
if err := pausequery.EnsureAllowed(params.UserType); err != nil {
return pausequery.Filter{}, err
}
filter := pausequery.Filter{}
filter.AssetType, _ = filterString(params.Filters, "asset_type")
filter.Source, _ = filterString(params.Filters, "source")
filter.Identifier, _ = filterString(params.Filters, "identifier")
if id, ok := filterUint(params.Filters, "carrier_id"); ok && id > 0 {
filter.CarrierID = &id
}
if id, ok := filterUint(params.Filters, "operator_id"); ok && id > 0 {
filter.OperatorID = &id
}
if status, ok := filterInt(params.Filters, "status"); ok && status > 0 {
filter.Status = &status
}
if t, ok := filterTime(params.Filters, "start_time"); ok {
filter.StartTime = &t
}
if t, ok := filterTime(params.Filters, "end_time"); ok {
filter.EndTime = &t
}
return filter, nil
}
// Count 统计导出行数。
func (s *PollingPauseDataSource) Count(ctx context.Context, params ExportParams) (int, error) {
filter, err := s.filter(params)
if err != nil {
return 0, err
}
total, err := s.query.Count(ctx, filter)
return int(total), err
}
// Headers 返回导出表头。
func (s *PollingPauseDataSource) Headers(ctx context.Context, params ExportParams) ([]string, error) {
if err := pausequery.EnsureAllowed(params.UserType); err != nil {
return nil, err
}
return pollingPauseHeaders, nil
}
// Fetch 按 offset/limit 返回导出行。
func (s *PollingPauseDataSource) Fetch(ctx context.Context, params ExportParams, offset, limit int) ([][]string, error) {
filter, err := s.filter(params)
if err != nil {
return nil, err
}
rows, err := s.query.Page(ctx, filter, offset, limit)
if err != nil {
return nil, err
}
result := make([][]string, 0, len(rows))
for _, row := range rows {
assetType := "卡"
if row.AssetType == constants.AssetTypeDevice {
assetType = "设备"
}
importID := ""
if row.ImportTaskID > 0 {
importID = strconv.FormatUint(uint64(row.ImportTaskID), 10)
}
result = append(result, []string{
assetType, strconv.FormatUint(uint64(row.AssetID), 10), row.Identifier, row.CarrierName, yesNo(row.EnablePolling), row.GatewayExtend,
constants.PollingPauseStatusName(row.Status), constants.PollingToggleSourceName(row.CloseSource), row.CloseReason,
operatorName(row.ClosedBy, row.ClosedByName), formatExportTime(&row.ClosedAt), importID,
constants.PollingToggleSourceName(row.RestoreSource), row.RestoreReason, operatorName(row.RestoredBy, row.RestoredByName),
formatExportTime(row.RestoredAt),
})
}
return result, nil
}
var stopRefusedHeaders = []string{
"告警ID", "ICCID", "运营商账户", "所属设备ID", "网络状态", "有生效套餐", "停机原因", "错误类型", "上游返回信息",
"首次失败时间", "最近失败时间", "失败次数", "是否轮询", "处理状态", "处理人", "处理时间", "处理备注",
}
// StopRefusedAlertDataSource 停机被拒告警导出数据源。
type StopRefusedAlertDataSource struct {
query *pollingalert.Query
threshold func(ctx context.Context) int
}
// NewStopRefusedAlertDataSource 创建停机被拒告警导出数据源;threshold 为空时取默认阈值。
func NewStopRefusedAlertDataSource(db *gorm.DB, threshold func(ctx context.Context) int) *StopRefusedAlertDataSource {
return &StopRefusedAlertDataSource{query: pollingalert.NewQuery(db), threshold: threshold}
}
// Scene 返回导出场景编码。
func (s *StopRefusedAlertDataSource) Scene() string {
return constants.ExportTaskScenePollingStopRefusedAlert
}
func (s *StopRefusedAlertDataSource) filter(ctx context.Context, params ExportParams) (pollingalert.StopRefusedFilter, int, error) {
if err := pausequery.EnsureAllowed(params.UserType); err != nil {
return pollingalert.StopRefusedFilter{}, 0, err
}
filter := pollingalert.StopRefusedFilter{}
filter.ICCID, _ = filterString(params.Filters, "iccid")
if id, ok := filterUint(params.Filters, "carrier_id"); ok && id > 0 {
filter.CarrierID = &id
}
if status, ok := filterInt(params.Filters, "status"); ok && status > 0 {
filter.Status = &status
}
if t, ok := filterTime(params.Filters, "start_time"); ok {
filter.StartTime = &t
}
if t, ok := filterTime(params.Filters, "end_time"); ok {
filter.EndTime = &t
}
threshold := 0
if s.threshold != nil {
threshold = s.threshold(ctx)
}
return filter, threshold, nil
}
// Count 统计导出行数。
func (s *StopRefusedAlertDataSource) Count(ctx context.Context, params ExportParams) (int, error) {
filter, threshold, err := s.filter(ctx, params)
if err != nil {
return 0, err
}
total, err := s.query.Count(ctx, filter, threshold)
return int(total), err
}
// Headers 返回导出表头。
func (s *StopRefusedAlertDataSource) Headers(ctx context.Context, params ExportParams) ([]string, error) {
if err := pausequery.EnsureAllowed(params.UserType); err != nil {
return nil, err
}
return stopRefusedHeaders, nil
}
// Fetch 按 offset/limit 返回导出行。
func (s *StopRefusedAlertDataSource) Fetch(ctx context.Context, params ExportParams, offset, limit int) ([][]string, error) {
filter, threshold, err := s.filter(ctx, params)
if err != nil {
return nil, err
}
rows, err := s.query.Page(ctx, filter, threshold, offset, limit)
if err != nil {
return nil, err
}
result := make([][]string, 0, len(rows))
for _, row := range rows {
device := ""
if row.DeviceID > 0 {
device = strconv.FormatUint(uint64(row.DeviceID), 10)
}
network := "停机"
if row.NetworkStatus == constants.NetworkStatusOnline {
network = "开机"
}
result = append(result, []string{
strconv.FormatUint(uint64(row.ID), 10), row.ICCID, row.CarrierName, device, network, yesNo(row.HasPackage), row.StopReason,
constants.GatewayErrorClassName(row.ErrorCategory), row.LastError, formatExportTime(row.FirstFailedAt), formatExportTime(row.LastFailedAt),
strconv.Itoa(row.FailCount), yesNo(row.EnablePolling), constants.PollingAlertStatusName(row.Status),
operatorName(row.HandledBy, row.HandledByName), formatExportTime(row.HandledAt), row.HandleRemark,
})
}
return result, nil
}
func yesNo(v bool) string {
if v {
return "是"
}
return "否"
}
func operatorName(id uint, name string) string {
if id == 0 {
return "系统"
}
if name == "" {
return strconv.FormatUint(uint64(id), 10)
}
return name
}
func formatExportTime(t *time.Time) string {
if t == nil || t.IsZero() {
return ""
}
return t.In(time.FixedZone("CST", 8*3600)).Format("2006-01-02 15:04:05")
}

View File

@@ -1,9 +1,6 @@
package exporter
import (
"context"
"github.com/break/junhong_cmp_fiber/internal/polling/carrierqueue"
"sort"
"gorm.io/gorm"
@@ -33,10 +30,7 @@ func NewDefaultRegistry(db *gorm.DB) *Registry {
return NewRegistry(
NewDeviceDataSource(db),
NewIotCardDataSource(db),
NewOrderDataSource(db, ""),
NewOrderDataSource(db, constants.OrderCategoryPlatform),
NewOrderDataSource(db, constants.OrderCategoryAgent),
NewOrderDataSource(db, constants.OrderCategoryOnline),
NewOrderDataSource(db),
NewPackageDataSource(db),
NewAgentWalletTransactionDataSource(db),
NewAgentRechargeDataSource(db),
@@ -47,11 +41,6 @@ func NewDefaultRegistry(db *gorm.DB) *Registry {
NewExpiringAssetDataSource(db),
NewOperationsActivationDataSource(db),
NewOperationsRenewalDataSource(db),
NewStopResumeRuleDataSource(db),
NewPollingPauseDataSource(db),
NewSilenceOrderDataSource(db),
NewSilenceUsageDataSource(db),
NewStopRefusedAlertDataSource(db, stopResumeAlertThreshold(db)),
)
}
@@ -83,9 +72,6 @@ func IsSupportedScene(scene string) bool {
case constants.ExportTaskSceneDevice,
constants.ExportTaskSceneIotCard,
constants.ExportTaskSceneOrder,
constants.ExportTaskSceneOrderPlatform,
constants.ExportTaskSceneOrderAgent,
constants.ExportTaskSceneOrderOnline,
constants.ExportTaskScenePackage,
constants.ExportTaskSceneAgentWalletTransaction,
constants.ExportTaskSceneAgentRecharge,
@@ -95,16 +81,9 @@ func IsSupportedScene(scene string) bool {
constants.ExportTaskScenePackageTrafficAlert,
constants.ExportTaskSceneExpiringAsset,
constants.ExportTaskSceneOperationsActivation,
constants.ExportTaskSceneOperationsRenewal,
constants.ExportTaskSceneStopResumeRuleDryRun:
constants.ExportTaskSceneOperationsRenewal:
return true
default:
return false
}
}
// stopResumeAlertThreshold 读取停复机连续失败告警阈值(停机被拒视图口径)。
func stopResumeAlertThreshold(db *gorm.DB) func(ctx context.Context) int {
settings := carrierqueue.NewDefaultSettingsProvider(db, nil, nil)
return func(ctx context.Context) int { return settings.Current(ctx).SRAlertFailCount }
}

View File

@@ -1,98 +0,0 @@
package exporter
import (
"context"
"strconv"
"gorm.io/gorm"
"github.com/break/junhong_cmp_fiber/internal/query/stopresumerule"
"github.com/break/junhong_cmp_fiber/pkg/constants"
)
// stopResumeRuleHeaders 停复机规则演练导出表头。
var stopResumeRuleHeaders = []string{
"卡ID", "ICCID", "运营商账户ID", "运营商账户", "绑定设备ID", "当前网络状态", "当前停机原因", "卡类别",
"满足实名条件", "判断结果", "原因",
}
// StopResumeRuleDataSource 停复机规则演练明细导出数据源:按卡 ID 升序分页取卡并逐张按新规则判断,只读。
// 仅超级管理员与平台账号可导出:受控入口已做角色门禁,这里再按任务内冻结的账号类型复核一次。
type StopResumeRuleDataSource struct {
query *stopresumerule.Query
}
// NewStopResumeRuleDataSource 创建停复机规则演练导出数据源。
func NewStopResumeRuleDataSource(db *gorm.DB) *StopResumeRuleDataSource {
return &StopResumeRuleDataSource{query: stopresumerule.NewQuery(db)}
}
// Scene 返回导出场景编码。
func (s *StopResumeRuleDataSource) Scene() string {
return constants.ExportTaskSceneStopResumeRuleDryRun
}
// Count 统计参与演练的卡数。
func (s *StopResumeRuleDataSource) Count(ctx context.Context, params ExportParams) (int, error) {
filter, err := s.filter(params)
if err != nil {
return 0, err
}
total, err := s.query.Count(ctx, filter)
return int(total), err
}
// Headers 返回导出表头。
func (s *StopResumeRuleDataSource) Headers(ctx context.Context, params ExportParams) ([]string, error) {
if err := stopresumerule.EnsureAllowed(params.UserType); err != nil {
return nil, err
}
return stopResumeRuleHeaders, nil
}
// Fetch 按 offset/limit 返回演练明细行。
func (s *StopResumeRuleDataSource) Fetch(ctx context.Context, params ExportParams, offset, limit int) ([][]string, error) {
filter, err := s.filter(params)
if err != nil {
return nil, err
}
rows, err := s.query.Page(ctx, filter, offset, limit)
if err != nil {
return nil, err
}
result := make([][]string, 0, len(rows))
for index := range rows {
item := stopresumerule.ToItem(&rows[index])
network := "停机"
if item.NetworkStatus == constants.NetworkStatusOnline {
network = "开机"
}
device := ""
if item.DeviceID != 0 {
device = strconv.FormatUint(uint64(item.DeviceID), 10)
}
realname := "否"
if item.RealnameOK {
realname = "是"
}
result = append(result, []string{
strconv.FormatUint(uint64(item.CardID), 10), item.ICCID, strconv.FormatUint(uint64(item.CarrierID), 10),
item.CarrierName, device, network, item.StopReasonName, item.CardCategory, realname, item.ResultName, item.ReasonName,
})
}
return result, nil
}
func (s *StopResumeRuleDataSource) filter(params ExportParams) (stopresumerule.Filter, error) {
if err := stopresumerule.EnsureAllowed(params.UserType); err != nil {
return stopresumerule.Filter{}, err
}
var carrierID *uint
if id, ok := filterUint(params.Filters, "carrier_id"); ok && id > 0 {
carrierID = &id
}
stopReason, _ := filterString(params.Filters, "stop_reason")
on, _ := filterString(params.Filters, "assume_control_on")
off, _ := filterString(params.Filters, "assume_control_off")
return stopresumerule.BuildFilter(carrierID, stopReason, on, off)
}

View File

@@ -22,9 +22,6 @@ var timeFilterScenes = map[string]struct{}{
constants.ExportTaskSceneExchange: {},
constants.ExportTaskSceneAgentRecharge: {},
constants.ExportTaskSceneOrder: {},
constants.ExportTaskSceneOrderPlatform: {},
constants.ExportTaskSceneOrderAgent: {},
constants.ExportTaskSceneOrderOnline: {},
constants.ExportTaskSceneCommissionRecord: {},
constants.ExportTaskSceneExpiringAsset: {},
constants.ExportTaskScenePackageTrafficAlert: {},

View File

@@ -1,158 +0,0 @@
package gateway
import (
"context"
stderrors "errors"
"time"
"unicode/utf8"
"github.com/break/junhong_cmp_fiber/pkg/constants"
"github.com/break/junhong_cmp_fiber/pkg/errors"
)
// GatewayError 是网关调用失败的结构化错误。
// 它包裹原有的 *errors.AppError:Error() 文本与错误码保持不变,调用方仍可 errors.As 取 AppError;
// 需要类别、上游业务码、上游信息与耗时的调用方用 errors.As 取 *GatewayError。
type GatewayError struct {
Category string // 错误类别,见 constants.GatewayError*
ProviderCode int // 网关业务码(仅上游业务错误时非 0)
ProviderMessage string // 网关返回信息(已截断,仅上游业务错误时非空)
HTTPStatus int // HTTP 状态码(仅 HTTP 异常时非 0)
Duration time.Duration // 本次 HTTP 尝试耗时
app *errors.AppError
}
// Error 返回与原 AppError 一致的文本。
func (e *GatewayError) Error() string {
if e == nil || e.app == nil {
return "Gateway 调用失败"
}
return e.app.Error()
}
// Unwrap 返回原 AppError,保持错误码判断兼容。
func (e *GatewayError) Unwrap() error {
if e == nil || e.app == nil {
return nil
}
return e.app
}
func newGatewayError(category string, app *errors.AppError) *GatewayError {
return &GatewayError{Category: category, app: app}
}
// AsGatewayError 从错误链中取出结构化网关错误。
func AsGatewayError(err error) (*GatewayError, bool) {
var gwErr *GatewayError
if stderrors.As(err, &gwErr) && gwErr != nil {
return gwErr, true
}
return nil, false
}
// IsRateLimited 判断错误是否为本系统运营商账户限流拒绝(未发出请求)。
func IsRateLimited(err error) bool {
gwErr, ok := AsGatewayError(err)
return ok && gwErr.Category == constants.GatewayErrorRateLimited
}
// NewRateLimitedError 创建限流拒绝错误;由限流守卫在拿不到名额时返回。
func NewRateLimitedError() error {
return newGatewayError(constants.GatewayErrorRateLimited,
errors.New(errors.CodeTooManyRequests, "运营商账户网关调用已达限流上限,请稍后重试"))
}
// truncateProviderMessage 按字符截断上游信息,避免超长内容落库或展示。
func truncateProviderMessage(msg string) string {
if utf8.RuneCountInString(msg) <= constants.GatewayProviderMessageMaxLen {
return msg
}
runes := []rune(msg)
return string(runes[:constants.GatewayProviderMessageMaxLen])
}
// CallInfo 描述一次卡网关 HTTP 尝试,供调用守卫限流与统计。
type CallInfo struct {
Operation string // 卡网关操作,见 constants.GatewayOp*
CardNo string // 卡号(ICCID)
CarrierID uint // 调用方已知的运营商账户 ID(本系统 tb_carrier.id),0 表示由守卫按卡号解析
ExemptSource string // 豁免限流来源,空表示受限流约束
Attempt int // 本次方法调用内的尝试序号,从 1 开始
}
// CallGuard 是卡网关调用守卫:每次 HTTP 尝试前取限流名额,尝试结束后记录统计。
// BeforeAttempt 返回错误时本次方法调用立即结束,不再发起后续尝试。
type CallGuard interface {
BeforeAttempt(ctx context.Context, call CallInfo) error
AfterAttempt(ctx context.Context, call CallInfo, callErr error, duration time.Duration)
}
type cardCallKey struct{}
type carrierKey struct{}
type exemptKey struct{}
type rateLimitWaitKey struct{}
type cardCall struct {
operation string
cardNo string
}
// WithCarrier 声明本次调用目标卡所属的运营商账户(本系统 tb_carrier.id),避免守卫按卡号再查一次。
func WithCarrier(ctx context.Context, carrierID uint) context.Context {
if carrierID == 0 {
return ctx
}
return context.WithValue(ctx, carrierKey{}, carrierID)
}
// WithRateLimitExempt 声明本次调用豁免运营商账户限流,只允许后台人工点击、通道阈值、自动续费、手动刷新使用。
func WithRateLimitExempt(ctx context.Context, source string) context.Context {
if source == "" {
return ctx
}
return context.WithValue(ctx, exemptKey{}, source)
}
// WithRateLimitWait 声明本次调用在拿不到限流名额时等待,总等待不超过 maxWait;超时返回限流错误。
// 只用于不占 Asynq 协程的执行方(按运营商账户调度的 NSQ 消费者);未声明时拿不到名额立即返回。
func WithRateLimitWait(ctx context.Context, maxWait time.Duration) context.Context {
if maxWait <= 0 {
return ctx
}
return context.WithValue(ctx, rateLimitWaitKey{}, maxWait)
}
// RateLimitWait 返回 ctx 中声明的限流最长等待;未声明返回 0。
func RateLimitWait(ctx context.Context) time.Duration {
wait, _ := ctx.Value(rateLimitWaitKey{}).(time.Duration)
return wait
}
// RateLimitExemptSource 返回 ctx 中声明的豁免来源,未声明返回空串。
func RateLimitExemptSource(ctx context.Context) string {
source, _ := ctx.Value(exemptKey{}).(string)
return source
}
func withCardCall(ctx context.Context, operation, cardNo string) context.Context {
return context.WithValue(ctx, cardCallKey{}, cardCall{operation: operation, cardNo: cardNo})
}
// cardCallInfo 从 ctx 取出本次卡网关调用信息;非卡操作返回 false。
func cardCallInfo(ctx context.Context) (CallInfo, bool) {
call, ok := ctx.Value(cardCallKey{}).(cardCall)
if !ok {
return CallInfo{}, false
}
carrierID, _ := ctx.Value(carrierKey{}).(uint)
return CallInfo{
Operation: call.operation, CardNo: call.cardNo, CarrierID: carrierID,
ExemptSource: RateLimitExemptSource(ctx),
}, true
}
// SetCallGuard 设置卡网关调用守卫;为 nil 时不限流、不统计。
func (c *Client) SetCallGuard(guard CallGuard) {
c.guard = guard
}

View File

@@ -11,7 +11,6 @@ import (
"net/http"
"time"
"github.com/break/junhong_cmp_fiber/pkg/constants"
"github.com/break/junhong_cmp_fiber/pkg/errors"
"github.com/bytedance/sonic"
"go.uber.org/zap"
@@ -37,9 +36,6 @@ type Client struct {
timeout time.Duration
logger *zap.Logger
maxRetries int
guard CallGuard // 卡网关调用守卫(限流与统计),为 nil 时不生效
// readonlyChecker 判断请求卡号是否为只读资产(测试环境只读模式),为 nil 时不生效。
readonlyChecker ReadonlyChecker
}
type attemptObserverKey struct{}
@@ -103,9 +99,6 @@ func (c *Client) WithRetry(maxRetries int) *Client {
// 流程:包装参数 → 序列化 → 加密 → 签名 → HTTP POST(带重试)→ 解析响应 → 检查业务状态码
// params: 请求参数结构体,内部自动包装为 {"params": <JSON>} 格式
func (c *Client) doRequest(ctx context.Context, path string, params interface{}) (json.RawMessage, error) {
if err := c.rejectReadonlyWrite(ctx, path, params); err != nil {
return nil, err
}
// 将参数包装为 {"params": ...} 格式后序列化
wrapper := requestWrapper{Params: params}
dataBytes, err := sonic.Marshal(wrapper)
@@ -114,10 +107,6 @@ func (c *Client) doRequest(ctx context.Context, path string, params interface{})
return nil, errors.Wrap(errors.CodeInternalError, err, "序列化业务数据失败")
}
if capture := rawCaptureFrom(ctx); capture != nil {
capture.Request = append(json.RawMessage(nil), dataBytes...)
}
// 加密业务数据(加密结果不变,可在重试间复用)
encryptedData, err := aesEncrypt(dataBytes, c.appSecret)
if err != nil {
@@ -130,9 +119,6 @@ func (c *Client) doRequest(ctx context.Context, path string, params interface{})
// doRequestWithoutPayloadLog 执行 Gateway 请求,但不记录请求体与响应体。
// 仅用于载荷含敏感内容(如付款凭证图片)的能力;成功与失败都只记录路径、耗时与结果摘要。
func (c *Client) doRequestWithoutPayloadLog(ctx context.Context, path string, params interface{}) (json.RawMessage, error) {
if err := c.rejectReadonlyWrite(ctx, path, params); err != nil {
return nil, err
}
dataBytes, err := sonic.Marshal(requestWrapper{Params: params})
if err != nil {
return nil, errors.Wrap(errors.CodeInternalError, err, "序列化业务数据失败")
@@ -152,7 +138,6 @@ func (c *Client) executeWithRetry(ctx context.Context, path, encryptedData strin
// 带重试的 HTTP 请求
var lastErr error
observer, _ := ctx.Value(attemptObserverKey{}).(AttemptObserver)
call, isCardCall := cardCallInfo(ctx)
for attempt := 0; attempt <= c.maxRetries; attempt++ {
if attempt > 0 {
// 检查用户 Context 是否已取消
@@ -173,35 +158,12 @@ func (c *Client) executeWithRetry(ctx context.Context, path, encryptedData strin
}
attemptNumber := attempt + 1
// 卡网关操作每次 HTTP 尝试前各取一次限流名额,内部重试同样计量;拿不到名额时本次调用立即结束。
if isCardCall && c.guard != nil {
call.Attempt = attemptNumber
if err := c.guard.BeforeAttempt(ctx, call); err != nil {
if attempt > 0 {
c.logger.Warn("Gateway 重试被限流中止", zap.String("path", path), zap.Int("attempt", attemptNumber), zap.NamedError("last_error", lastErr))
}
return nil, err
}
}
if observer != nil {
if err := observer.BeforeAttempt(ctx, attemptNumber); err != nil {
return nil, err
}
}
capture := rawCaptureFrom(ctx)
if capture != nil {
capture.resetResponse()
capture.Attempts = attemptNumber
}
attemptStartedAt := time.Now()
result, retryable, err := c.executeHTTPRequest(ctx, path, encryptedData, capture != nil && logPayload)
attemptDuration := time.Since(attemptStartedAt)
if gwErr, ok := AsGatewayError(err); ok {
gwErr.Duration = attemptDuration
}
if isCardCall && c.guard != nil {
c.guard.AfterAttempt(ctx, call, err, attemptDuration)
}
result, retryable, err := c.executeHTTPRequest(ctx, path, encryptedData)
if observer != nil {
if observeErr := observer.AfterAttempt(ctx, attemptNumber, err); observeErr != nil {
return nil, observeErr
@@ -246,12 +208,7 @@ func (c *Client) executeWithRetry(ctx context.Context, path, encryptedData strin
// executeHTTPRequest 执行单次 HTTP 请求(无重试逻辑)
// 返回值:响应数据、是否可重试、错误
// captureRaw 为 true 时把状态码、响应体与外层字段写入上下文上的原始数据收集器。
func (c *Client) executeHTTPRequest(ctx context.Context, path string, encryptedData string, captureRaw bool) (json.RawMessage, bool, error) {
var capture *RawCapture
if captureRaw {
capture = rawCaptureFrom(ctx)
}
func (c *Client) executeHTTPRequest(ctx context.Context, path string, encryptedData string) (json.RawMessage, bool, error) {
// 每次重试使用新的时间戳和签名
timestamp := time.Now().Unix()
sign := generateSign(c.appID, encryptedData, timestamp, c.appSecret)
@@ -281,43 +238,30 @@ func (c *Client) executeHTTPRequest(ctx context.Context, path string, encryptedD
if err != nil {
// 用户 Context 已取消 — 不可重试
if ctx.Err() != nil {
return nil, false, newGatewayError(constants.GatewayErrorNetwork, errors.Wrap(errors.CodeGatewayError, ctx.Err(), "请求被取消"))
return nil, false, errors.Wrap(errors.CodeGatewayError, ctx.Err(), "请求被取消")
}
// Client 超时 — 可重试
if reqCtx.Err() == context.DeadlineExceeded {
return nil, true, newGatewayError(constants.GatewayErrorTimeout, errors.Wrap(errors.CodeGatewayTimeout, err, "Gateway 请求超时"))
return nil, true, errors.Wrap(errors.CodeGatewayTimeout, err, "Gateway 请求超时")
}
// 其他网络错误(连接失败、DNS 解析等)— 可重试
return nil, true, newGatewayError(constants.GatewayErrorNetwork, errors.Wrap(errors.CodeGatewayError, err, "发送 HTTP 请求失败"))
return nil, true, errors.Wrap(errors.CodeGatewayError, err, "发送 HTTP 请求失败")
}
defer resp.Body.Close()
if capture != nil {
capture.HTTPStatus = resp.StatusCode
}
// HTTP 状态码错误 — 不可重试
if resp.StatusCode != http.StatusOK {
gwErr := newGatewayError(constants.GatewayErrorHTTP, errors.New(errors.CodeGatewayError, fmt.Sprintf("HTTP 状态码异常: %d", resp.StatusCode)))
gwErr.HTTPStatus = resp.StatusCode
return nil, false, gwErr
return nil, false, errors.New(errors.CodeGatewayError, fmt.Sprintf("HTTP 状态码异常: %d", resp.StatusCode))
}
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, false, newGatewayError(constants.GatewayErrorInvalidResponse, errors.Wrap(errors.CodeGatewayInvalidResp, err, "读取响应体失败"))
}
if capture != nil {
capture.Response = body
return nil, false, errors.Wrap(errors.CodeGatewayInvalidResp, err, "读取响应体失败")
}
var gatewayResp GatewayResponse
if err := sonic.Unmarshal(body, &gatewayResp); err != nil {
return nil, false, newGatewayError(constants.GatewayErrorInvalidResponse, errors.Wrap(errors.CodeGatewayInvalidResp, err, "解析 Gateway 响应失败"))
}
if capture != nil {
code := gatewayResp.Code
capture.GatewayCode, capture.GatewayMsg, capture.TraceID = &code, gatewayResp.Msg, gatewayResp.TraceID
return nil, false, errors.Wrap(errors.CodeGatewayInvalidResp, err, "解析 Gateway 响应失败")
}
// Gateway 业务错误 — 不可重试
@@ -327,10 +271,7 @@ func (c *Client) executeHTTPRequest(ctx context.Context, path string, encryptedD
zap.Int("gateway_code", gatewayResp.Code),
zap.String("gateway_msg", gatewayResp.Msg),
)
gwErr := newGatewayError(constants.GatewayErrorUpstream, errors.New(errors.CodeGatewayError, fmt.Sprintf("Gateway 业务错误: code=%d, msg=%s", gatewayResp.Code, gatewayResp.Msg)))
gwErr.ProviderCode = gatewayResp.Code
gwErr.ProviderMessage = truncateProviderMessage(gatewayResp.Msg)
return nil, false, gwErr
return nil, false, errors.New(errors.CodeGatewayError, fmt.Sprintf("Gateway 业务错误: code=%d, msg=%s", gatewayResp.Code, gatewayResp.Msg))
}
return gatewayResp.Data, false, nil

View File

@@ -4,74 +4,41 @@ package gateway
import (
"context"
"github.com/break/junhong_cmp_fiber/pkg/constants"
"github.com/break/junhong_cmp_fiber/pkg/errors"
)
// QueryCardStatus 查询流量卡状态
// POST /flow-card/status
func (c *Client) QueryCardStatus(ctx context.Context, req *CardStatusReq) (*CardStatusResp, error) {
ctx = withCardCall(ctx, constants.GatewayOpQueryStatus, cardNoOf(req))
return doRequestWithResponse[CardStatusResp](c, ctx, "/flow-card/status", req)
}
// QueryFlow 查询流量使用情况
// POST /flow-card/flow
func (c *Client) QueryFlow(ctx context.Context, req *FlowQueryReq) (*FlowUsageResp, error) {
ctx = withCardCall(ctx, constants.GatewayOpQueryFlow, flowCardNoOf(req))
return doRequestWithResponse[FlowUsageResp](c, ctx, "/flow-card/flow", req)
}
// QueryRealnameStatus 查询实名认证状态
// POST /flow-card/realName
func (c *Client) QueryRealnameStatus(ctx context.Context, req *CardStatusReq) (*RealnameStatusResp, error) {
ctx = withCardCall(ctx, constants.GatewayOpQueryRealname, cardNoOf(req))
return doRequestWithResponse[RealnameStatusResp](c, ctx, "/flow-card/realName", req)
}
// StopCard 流量卡停机
// POST /flow-card/cardStop
// 停机是外部状态写入:超时或连接失败时上游可能已受理,内部重试会对同一张卡连发并撞上游单卡频率限制,故只发一次请求。
func (c *Client) StopCard(ctx context.Context, req *CardOperationReq) error {
ctx = withCardCall(ctx, constants.GatewayOpStop, operationCardNoOf(req))
requestClient := *c
requestClient.maxRetries = 0
_, err := requestClient.doRequest(ctx, "/flow-card/cardStop", req)
_, err := c.doRequest(ctx, "/flow-card/cardStop", req)
return err
}
// StartCard 流量卡复机
// POST /flow-card/cardStart
// 复机同停机,只发一次请求,不使用内部重试。
func (c *Client) StartCard(ctx context.Context, req *CardOperationReq) error {
ctx = withCardCall(ctx, constants.GatewayOpStart, operationCardNoOf(req))
requestClient := *c
requestClient.maxRetries = 0
_, err := requestClient.doRequest(ctx, "/flow-card/cardStart", req)
_, err := c.doRequest(ctx, "/flow-card/cardStart", req)
return err
}
func cardNoOf(req *CardStatusReq) string {
if req == nil {
return ""
}
return req.CardNo
}
func flowCardNoOf(req *FlowQueryReq) string {
if req == nil {
return ""
}
return req.CardNo
}
func operationCardNoOf(req *CardOperationReq) string {
if req == nil {
return ""
}
return req.CardNo
}
// GetRealnameLink 获取实名认证跳转链接
// POST /flow-card/RealNameVerification
func (c *Client) GetRealnameLink(ctx context.Context, req *CardStatusReq) (*RealnameLinkResp, error) {

View File

@@ -138,64 +138,6 @@ type FlowUsageResp struct {
ICCID string `json:"iccid" description:"ICCID"`
Used FlexFloat `json:"used" description:"当月已用流量(MB,兼容数字或字符串)"`
Unit string `json:"unit" description:"流量单位(MB)"`
// UsedState 区分运营商"没有给值"与真实读数;零值表示没有给值(响应缺少 used 字段时不会调用解析)。
UsedState FlowReadingState `json:"-"`
}
// FlowReadingState 是流量读数的解析结果。
type FlowReadingState int
const (
// FlowReadingMissing 表示 used 缺失、为 null、为空或无法解析。
FlowReadingMissing FlowReadingState = iota
// FlowReadingPresent 表示 used 解析为数字(可能为负数、NaN、Inf,由调用方判断是否有效)。
FlowReadingPresent
)
// UnmarshalJSON 解析流量响应,并记录 used 是否给出了可解析的数字。
func (r *FlowUsageResp) UnmarshalJSON(data []byte) error {
type plain FlowUsageResp
var raw struct {
plain
Used json.RawMessage `json:"used"`
}
if err := json.Unmarshal(data, &raw); err != nil {
return err
}
*r = FlowUsageResp(raw.plain)
r.Used, r.UsedState = 0, FlowReadingMissing
if value, ok := parseFlowReading(raw.Used); ok {
r.Used, r.UsedState = FlexFloat(value), FlowReadingPresent
}
return nil
}
// HasReading 返回运营商是否给出了可解析的读数。
func (r *FlowUsageResp) HasReading() bool {
return r != nil && r.UsedState == FlowReadingPresent
}
// parseFlowReading 按 FlexFloat 的兼容规则解析读数,null、空、无法解析时返回 false。
func parseFlowReading(data json.RawMessage) (float64, bool) {
raw := strings.TrimSpace(string(data))
if raw == "" || raw == "null" {
return 0, false
}
if strings.HasPrefix(raw, "\"") && strings.HasSuffix(raw, "\"") {
unquoted, err := strconv.Unquote(raw)
if err != nil {
return 0, false
}
raw = strings.TrimSpace(unquoted)
}
if raw == "" {
return 0, false
}
value, err := strconv.ParseFloat(raw, 64)
if err != nil {
return 0, false
}
return value, true
}
// CardOperationReq 是停机/复机请求

View File

@@ -1,44 +0,0 @@
package gateway
import (
"context"
"encoding/json"
)
// RawCapture 收集一次网关调用的原始数据(最后一次 HTTP 尝试),供业务记录留存。
// 只保存加密前的明文业务请求与网关响应;不含加密报文、签名、应用标识、密钥与请求头。
type RawCapture struct {
// Request 是加密前的明文业务请求({"params": ...});不记录载荷的调用为空。
Request json.RawMessage
// HTTPStatus 是 HTTP 状态码;没拿到响应(超时、连接失败)时为 0。
HTTPStatus int
// Response 是网关原始响应体;没拿到响应体时为空。
Response []byte
// GatewayCode、GatewayMsg、TraceID 是网关响应外层字段;响应无法解析时为空。
GatewayCode *int
GatewayMsg string
TraceID string
// Attempts 是网关客户端内部 HTTP 尝试次数。
Attempts int
}
type rawCaptureKey struct{}
// WithRawCapture 在请求上下文上挂载原始数据收集器;未挂载的调用不收集。
func WithRawCapture(ctx context.Context) (context.Context, *RawCapture) {
capture := &RawCapture{}
return context.WithValue(ctx, rawCaptureKey{}, capture), capture
}
func rawCaptureFrom(ctx context.Context) *RawCapture {
capture, _ := ctx.Value(rawCaptureKey{}).(*RawCapture)
return capture
}
// resetResponse 在每次 HTTP 尝试前清空上一次的响应,保证留存的是最后一次尝试。
func (c *RawCapture) resetResponse() {
if c == nil {
return
}
c.HTTPStatus, c.Response, c.GatewayCode, c.GatewayMsg, c.TraceID = 0, nil, nil, "", ""
}

View File

@@ -1,83 +0,0 @@
package gateway
import (
"context"
"encoding/json"
"strings"
"go.uber.org/zap"
"github.com/break/junhong_cmp_fiber/pkg/constants"
"github.com/break/junhong_cmp_fiber/pkg/errors"
)
// readonlyAllowedPaths 是只读资产允许调用的查询类网关接口,这些调用不查只读表。
var readonlyAllowedPaths = map[string]struct{}{
"/flow-card/status": {},
"/flow-card/realName": {},
"/flow-card/flow": {},
"/device/info": {},
"/device/slot-info": {},
"/device/sync-info": {},
}
// ReadonlyChecker 判断网关请求卡号(卡为 ICCID,设备为 IMEI)是否为只读资产。
type ReadonlyChecker func(ctx context.Context, identifier string) (bool, error)
// WithReadonlyChecker 开启只读模式(支持链式调用):只读资产只允许查询类调用,
// 停机、复机、限速、实名认证链接与设备写操作在本地拦截、不发请求。checker 为 nil 时不生效(生产不开启)。
func (c *Client) WithReadonlyChecker(checker ReadonlyChecker) *Client {
c.readonlyChecker = checker
if checker != nil && c.logger != nil {
c.logger.Warn("Gateway 只读模式已开启:只读资产只允许查询类调用,其余调用一律拦截")
}
return c
}
// rejectReadonlyWrite 判断本次请求是否为只读资产的非查询调用;是则记日志并返回明确失败(不是结果未知),不发请求。
// 查只读表失败也按拦截处理:宁可调用失败,不放过真卡。
func (c *Client) rejectReadonlyWrite(ctx context.Context, path string, params interface{}) error {
if c.readonlyChecker == nil {
return nil
}
if _, ok := readonlyAllowedPaths[path]; ok {
return nil
}
cardNo := requestCardNo(params)
if cardNo == "" {
return nil
}
readonly, err := c.readonlyChecker(ctx, cardNo)
if err == nil && !readonly {
return nil
}
if c.logger != nil {
fields := []zap.Field{zap.String("path", path), zap.String("card_no", cardNo), zap.String("metric", "gateway.readonly_card.blocked")}
if err != nil {
fields = append(fields, zap.Error(err))
c.logger.Error("Gateway 只读表查询失败,按只读拦截,未发送请求", fields...)
} else {
c.logger.Warn("Gateway 只读资产非查询调用已拦截,未发送请求", fields...)
}
}
return newGatewayError(constants.GatewayErrorUpstream, errors.New(errors.CodeGatewayError, "测试环境只读资产,已拦截调用:"+path))
}
// requestCardNo 取请求里的卡号(cardNo 或 iccid 字段,大写),取不到时返回空串。
func requestCardNo(params interface{}) string {
raw, err := json.Marshal(params)
if err != nil {
return ""
}
var fields struct {
CardNo string `json:"cardNo"`
ICCID string `json:"iccid"`
}
if err := json.Unmarshal(raw, &fields); err != nil {
return ""
}
if fields.CardNo != "" {
return strings.ToUpper(strings.TrimSpace(fields.CardNo))
}
return strings.ToUpper(strings.TrimSpace(fields.ICCID))
}

View File

@@ -13,8 +13,6 @@ import (
"strings"
"github.com/bytedance/sonic"
auditinfra "github.com/break/junhong_cmp_fiber/internal/infrastructure/audit"
)
// Entry 是一个必须经过审计分类的 HTTP、Worker 或定时任务入口。
@@ -48,9 +46,6 @@ type Entry struct {
// Scan 扫描当前仓库中对外 HTTP、Asynq Worker 和定时任务注册入口。
func Scan(root string) ([]Entry, error) {
if missing := auditinfra.NewRegistry().MachineActionsWithoutPolicy(); len(missing) > 0 {
return nil, fmt.Errorf("以下可由系统任务或定时任务发起的审计动作未声明落库策略:%s", strings.Join(missing, ", "))
}
var entries []Entry
files := []string{
"internal/routes", "internal/application", "internal/domain", "internal/service",
@@ -206,7 +201,7 @@ func classifyWorker(file string, line int, taskType, handler string) Entry {
entry := Entry{
Key: fmt.Sprintf("worker:%s:%d:%s", file, line, taskType), Kind: "worker",
CodeEntry: fmt.Sprintf("%s:%d %s", file, line, handler), Owner: owner,
Summary: "处理异步任务 " + taskType, AuditEvent: "按审计注册表落库策略决定:系统任务未声明策略时只写应用日志",
Summary: "处理异步任务 " + taskType, AuditEvent: "按状态变化、人工触发、连续失败或高风险异常决定",
DomainLedger: ledgerDecision(owner), IntegrationLog: workerIntegrationDecision(taskType),
Outbox: "任务来源 Outbox/业务任务事实;消费端按稳定事件或任务 ID 幂等",
ActionCode: actionCode(owner, handler), ActionName: "处理异步任务(" + taskType + ")",

View File

@@ -11,7 +11,6 @@ import (
"github.com/gofiber/fiber/v2"
"github.com/hibiken/asynq"
"github.com/break/junhong_cmp_fiber/internal/gateway"
"github.com/break/junhong_cmp_fiber/internal/handler/validation"
dto "github.com/break/junhong_cmp_fiber/internal/model/dto"
packageExpiryQuery "github.com/break/junhong_cmp_fiber/internal/query/packageexpiry"
@@ -391,9 +390,6 @@ func (h *AssetHandler) CurrentPackage(c *fiber.Ctx) error {
// UpdatePackageExpiresAt 修改资产套餐过期时间
// PATCH /api/admin/assets/:identifier/packages/:package_usage_id/expires-at
func (h *AssetHandler) UpdatePackageExpiresAt(c *fiber.Ctx) error {
if err := rejectEnterpriseAssetAdmin(c); err != nil {
return err
}
asset, packageUsageID, err := h.resolveAssetPackageUsagePath(c)
if err != nil {
@@ -420,9 +416,6 @@ func (h *AssetHandler) UpdatePackageExpiresAt(c *fiber.Ctx) error {
// UpdatePackageUsage 修改资产套餐已用量
// PATCH /api/admin/assets/:identifier/packages/:package_usage_id/used-data
func (h *AssetHandler) UpdatePackageUsage(c *fiber.Ctx) error {
if err := rejectEnterpriseAssetAdmin(c); err != nil {
return err
}
asset, packageUsageID, err := h.resolveAssetPackageUsagePath(c)
if err != nil {
return err
@@ -454,7 +447,7 @@ func (h *AssetHandler) Stop(c *fiber.Ctx) error {
switch asset.AssetType {
case "device":
result, devErr := h.deviceService.StopDevice(gateway.WithRateLimitExempt(c.UserContext(), constants.RateLimitExemptManualClick), asset.AssetID)
result, devErr := h.deviceService.StopDevice(c.UserContext(), asset.AssetID)
if devErr != nil {
return devErr
}
@@ -463,7 +456,7 @@ func (h *AssetHandler) Stop(c *fiber.Ctx) error {
if asset.ICCID == "" {
return errors.New(errors.CodeInternalError, "卡 ICCID 为空,无法停机")
}
if cardErr := h.iotCardStopResume.ManualStopCard(gateway.WithRateLimitExempt(c.UserContext(), constants.RateLimitExemptManualClick), asset.ICCID); cardErr != nil {
if cardErr := h.iotCardStopResume.ManualStopCard(c.UserContext(), asset.ICCID); cardErr != nil {
return cardErr
}
return response.Success(c, nil)
@@ -482,7 +475,7 @@ func (h *AssetHandler) Start(c *fiber.Ctx) error {
switch asset.AssetType {
case "device":
if devErr := h.deviceService.StartDevice(gateway.WithRateLimitExempt(c.UserContext(), constants.RateLimitExemptManualClick), asset.AssetID); devErr != nil {
if devErr := h.deviceService.StartDevice(c.UserContext(), asset.AssetID); devErr != nil {
return devErr
}
return response.Success(c, nil)
@@ -490,7 +483,7 @@ func (h *AssetHandler) Start(c *fiber.Ctx) error {
if asset.ICCID == "" {
return errors.New(errors.CodeInternalError, "卡 ICCID 为空,无法复机")
}
if cardErr := h.iotCardStopResume.ManualStartCard(gateway.WithRateLimitExempt(c.UserContext(), constants.RateLimitExemptManualClick), asset.ICCID); cardErr != nil {
if cardErr := h.iotCardStopResume.ManualStartCard(c.UserContext(), asset.ICCID); cardErr != nil {
return cardErr
}
return response.Success(c, nil)
@@ -502,9 +495,6 @@ func (h *AssetHandler) Start(c *fiber.Ctx) error {
// Deactivate 停用资产
// PATCH /api/admin/assets/:identifier/deactivate
func (h *AssetHandler) Deactivate(c *fiber.Ctx) error {
if err := rejectEnterpriseAssetAdmin(c); err != nil {
return err
}
if h.assetLifecycleService == nil {
return errors.New(errors.CodeInternalError, "资产生命周期服务未配置")
}
@@ -599,9 +589,6 @@ func (h *AssetHandler) OperationLogs(c *fiber.Ctx) error {
// UpdatePollingStatus 更新资产轮询状态
// PATCH /api/admin/assets/:identifier/polling-status
func (h *AssetHandler) UpdatePollingStatus(c *fiber.Ctx) error {
if err := rejectEnterpriseAssetAdmin(c); err != nil {
return err
}
identifier := c.Params("identifier")
if identifier == "" {
return errors.New(errors.CodeInvalidParam)
@@ -690,9 +677,6 @@ func (h *AssetHandler) UpdateRealnamePolicy(c *fiber.Ctx) error {
// UpdateRealnameStatus 手动更新资产实名状态
// PATCH /api/admin/assets/:identifier/realname-status
func (h *AssetHandler) UpdateRealnameStatus(c *fiber.Ctx) error {
if err := rejectEnterpriseAssetAdmin(c); err != nil {
return err
}
identifier := c.Params("identifier")
if identifier == "" {
return errors.New(errors.CodeInvalidParam)
@@ -785,12 +769,3 @@ func assetIdentifierForAudit(asset *dto.AssetResolveResponse) string {
}
return asset.Identifier
}
// rejectEnterpriseAssetAdmin 拒绝企业账号执行平台管理类资产写操作(改套餐有效期与已用量、停用、开关轮询、改实名状态)。
// 企业账号即使对已授权资产也不能执行这些操作;停复机仍按企业授权范围放行(由 Resolve 约束)。
func rejectEnterpriseAssetAdmin(c *fiber.Ctx) error {
if middleware.GetUserTypeFromContext(c.UserContext()) == constants.UserTypeEnterprise {
return errors.New(errors.CodeForbidden, "企业账号无权执行该资产操作")
}
return nil
}

View File

@@ -1,87 +0,0 @@
package admin
import (
"github.com/gofiber/fiber/v2"
"github.com/break/junhong_cmp_fiber/internal/handler/validation"
"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"
)
// AdjustPackageTraffic 人工冲正套餐流量
// POST /api/admin/assets/:identifier/packages/:package_usage_id/traffic-adjustments
func (h *AssetHandler) AdjustPackageTraffic(c *fiber.Ctx) error {
if err := rejectEnterpriseAssetAdmin(c); err != nil {
return err
}
asset, packageUsageID, err := h.resolveAssetPackageUsagePath(c)
if err != nil {
return err
}
var req dto.AdjustAssetPackageTrafficRequest
if err := c.BodyParser(&req); err != nil {
return errors.New(errors.CodeInvalidParam, "请求参数格式不正确")
}
req.PackageUsageID = packageUsageID
if err := h.validator.Struct(&req); err != nil {
return errors.New(errors.CodeInvalidParam, validation.Message("冲正参数不合法", &req, err))
}
result, err := h.assetService.AdjustPackageTraffic(c.UserContext(), asset.AssetType, asset.AssetID, assetIdentifierForAudit(asset), &req)
if err != nil {
return err
}
return response.Success(c, result)
}
// ListPackageTrafficAdjustments 查询套餐冲正流水
// GET /api/admin/assets/:identifier/packages/:package_usage_id/traffic-adjustments
func (h *AssetHandler) ListPackageTrafficAdjustments(c *fiber.Ctx) error {
if err := rejectEnterpriseAssetAdmin(c); err != nil {
return err
}
asset, packageUsageID, err := h.resolveAssetPackageUsagePath(c)
if err != nil {
return err
}
var req dto.PackageTrafficAdjustmentListRequest
if err := c.QueryParser(&req); err != nil {
return errors.New(errors.CodeInvalidParam, "请求参数格式不正确")
}
req.PackageUsageID = packageUsageID
if err := h.validator.Struct(&req); err != nil {
return errors.New(errors.CodeInvalidParam, validation.Message("冲正流水查询参数不合法", &req, err))
}
result, err := h.assetService.ListPackageTrafficAdjustments(c.UserContext(), asset.AssetType, asset.AssetID, &req)
if err != nil {
return err
}
return response.Success(c, result)
}
// ListTrafficCollections 查询卡流量采集记录
// GET /api/admin/assets/:identifier/traffic-collections
func (h *AssetHandler) ListTrafficCollections(c *fiber.Ctx) error {
if err := rejectEnterpriseAssetAdmin(c); err != nil {
return err
}
asset, err := h.resolveByIdentifier(c)
if err != nil {
return err
}
if asset.AssetType != "card" {
return errors.New(errors.CodeInvalidParam, "采集记录只支持按卡查询")
}
var req dto.CardTrafficCollectionListRequest
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, validation.Message("采集记录查询参数不合法", &req, err))
}
result, err := h.assetService.ListCardTrafficCollections(c.UserContext(), asset.AssetID, &req)
if err != nil {
return err
}
return response.Success(c, result)
}

View File

@@ -1,195 +0,0 @@
package admin
import (
"strings"
"github.com/go-playground/validator/v10"
"github.com/gofiber/fiber/v2"
"github.com/break/junhong_cmp_fiber/internal/model/dto"
pollingSvc "github.com/break/junhong_cmp_fiber/internal/service/polling"
"github.com/break/junhong_cmp_fiber/pkg/errors"
"github.com/break/junhong_cmp_fiber/pkg/response"
)
// CarrierSchedulerHandler 按运营商账户调度的后台接口:调度参数、请求检查场景、失败告警、单卡轮询状态、按账户监控。
type CarrierSchedulerHandler struct {
service *pollingSvc.CarrierSchedulerService
validator *validator.Validate
}
// NewCarrierSchedulerHandler 创建按运营商账户调度 Handler。
func NewCarrierSchedulerHandler(service *pollingSvc.CarrierSchedulerService, validator *validator.Validate) *CarrierSchedulerHandler {
return &CarrierSchedulerHandler{service: service, validator: validator}
}
func (h *CarrierSchedulerHandler) validate(prefix string, req any) error {
if err := h.validator.Struct(req); err != nil {
return errors.New(errors.CodeInvalidParam, validationMessage(prefix, req, err))
}
return nil
}
// ListParams 查询运营商账户调度参数列表。GET /api/admin/polling-carrier-params
func (h *CarrierSchedulerHandler) ListParams(c *fiber.Ctx) error {
result, err := h.service.ListParams(c.UserContext())
if err != nil {
return err
}
return response.Success(c, result)
}
// GetParam 查询运营商账户调度参数。GET /api/admin/polling-carrier-params/:carrier_id
func (h *CarrierSchedulerHandler) GetParam(c *fiber.Ctx) error {
carrierID, err := rateLimitPathID(c, "carrier_id", "运营商账户 ID")
if err != nil {
return err
}
result, err := h.service.GetParam(c.UserContext(), carrierID)
if err != nil {
return err
}
return response.Success(c, result)
}
// SaveParam 保存运营商账户调度参数。PUT /api/admin/polling-carrier-params/:carrier_id
func (h *CarrierSchedulerHandler) SaveParam(c *fiber.Ctx) error {
var req dto.SaveCarrierPollingParamRequest
if err := c.BodyParser(&req); err != nil {
return errors.New(errors.CodeInvalidParam, "请求参数解析失败")
}
carrierID, err := rateLimitPathID(c, "carrier_id", "运营商账户 ID")
if err != nil {
return err
}
req.CarrierID = carrierID
if err := h.validate("运营商账户调度参数不合法", &req); err != nil {
return err
}
result, err := h.service.SaveParam(c.UserContext(), &req)
if err != nil {
return err
}
return response.Success(c, result)
}
// DeleteParam 删除运营商账户调度参数(恢复全局默认)。DELETE /api/admin/polling-carrier-params/:carrier_id
func (h *CarrierSchedulerHandler) DeleteParam(c *fiber.Ctx) error {
carrierID, err := rateLimitPathID(c, "carrier_id", "运营商账户 ID")
if err != nil {
return err
}
if err := h.service.DeleteParam(c.UserContext(), carrierID); err != nil {
return err
}
return response.Success(c, nil)
}
// ListScenes 查询请求检查场景。GET /api/admin/polling-check-scenes
func (h *CarrierSchedulerHandler) ListScenes(c *fiber.Ctx) error {
result, err := h.service.ListScenes(c.UserContext())
if err != nil {
return err
}
return response.Success(c, result)
}
// UpdateScene 修改请求检查场景。PUT /api/admin/polling-check-scenes/:scene
func (h *CarrierSchedulerHandler) UpdateScene(c *fiber.Ctx) error {
var req dto.UpdatePollingCheckSceneRequest
if err := c.BodyParser(&req); err != nil {
return errors.New(errors.CodeInvalidParam, "请求参数解析失败")
}
req.Scene = strings.TrimSpace(c.Params("scene"))
if req.Scene == "" {
return errors.New(errors.CodeInvalidParam, "无效的场景编码")
}
if err := h.validate("请求检查场景参数不合法", &req); err != nil {
return err
}
result, err := h.service.UpdateScene(c.UserContext(), &req)
if err != nil {
return err
}
return response.Success(c, result)
}
// ListAlerts 查询卡轮询失败告警列表。GET /api/admin/polling-failure-alerts
func (h *CarrierSchedulerHandler) ListAlerts(c *fiber.Ctx) error {
var req dto.PollingFailureAlertListRequest
if err := c.QueryParser(&req); err != nil {
return errors.New(errors.CodeInvalidParam, "请求参数解析失败")
}
if err := h.validate("卡轮询失败告警查询参数不合法", &req); err != nil {
return err
}
result, err := h.service.ListAlerts(c.UserContext(), &req)
if err != nil {
return err
}
return response.Success(c, result)
}
// AlertSummary 未处理告警按运营商账户 × 错误类别汇总。GET /api/admin/polling-failure-alerts/summary
func (h *CarrierSchedulerHandler) AlertSummary(c *fiber.Ctx) error {
result, err := h.service.AlertSummary(c.UserContext())
if err != nil {
return err
}
return response.Success(c, result)
}
// GetAlert 查询卡轮询失败告警详情。GET /api/admin/polling-failure-alerts/:id
func (h *CarrierSchedulerHandler) GetAlert(c *fiber.Ctx) error {
id, err := rateLimitPathID(c, "id", "告警 ID")
if err != nil {
return err
}
result, err := h.service.GetAlert(c.UserContext(), id)
if err != nil {
return err
}
return response.Success(c, result)
}
// HandleAlert 标记卡轮询失败告警已处理。PUT /api/admin/polling-failure-alerts/:id/status
func (h *CarrierSchedulerHandler) HandleAlert(c *fiber.Ctx) error {
var req dto.HandlePollingFailureAlertRequest
if err := c.BodyParser(&req); err != nil {
return errors.New(errors.CodeInvalidParam, "请求参数解析失败")
}
id, err := rateLimitPathID(c, "id", "告警 ID")
if err != nil {
return err
}
req.ID = id
if err := h.validate("卡轮询失败告警处理参数不合法", &req); err != nil {
return err
}
if err := h.service.HandleAlert(c.UserContext(), &req); err != nil {
return err
}
return response.Success(c, nil)
}
// CardState 查询单卡轮询状态。GET /api/admin/iot-cards/:id/polling-state
func (h *CarrierSchedulerHandler) CardState(c *fiber.Ctx) error {
id, err := rateLimitPathID(c, "id", "卡 ID")
if err != nil {
return err
}
result, err := h.service.CardState(c.UserContext(), id)
if err != nil {
return err
}
return response.Success(c, result)
}
// CarrierStats 按运营商账户的轮询队列与执行监控。GET /api/admin/polling-stats/carrier-queues
func (h *CarrierSchedulerHandler) CarrierStats(c *fiber.Ctx) error {
result, err := h.service.CarrierStats(c.UserContext())
if err != nil {
return err
}
return response.Success(c, result)
}

View File

@@ -1,343 +0,0 @@
package admin
import (
"strconv"
"github.com/go-playground/validator/v10"
"github.com/gofiber/fiber/v2"
"github.com/redis/go-redis/v9"
"github.com/break/junhong_cmp_fiber/internal/model/dto"
pollingService "github.com/break/junhong_cmp_fiber/internal/service/polling"
"github.com/break/junhong_cmp_fiber/pkg/errors"
"github.com/break/junhong_cmp_fiber/pkg/response"
)
// GatewayRateLimitHandler 运营商账户网关限流、上游错误分类、停复机记录与调用统计 Handler。
type GatewayRateLimitHandler struct {
service *pollingService.GatewayRateLimitService
redis *redis.Client
validator *validator.Validate
}
// NewGatewayRateLimitHandler 创建网关限流 Handler。
func NewGatewayRateLimitHandler(service *pollingService.GatewayRateLimitService, redisClient *redis.Client, validate *validator.Validate) *GatewayRateLimitHandler {
if validate == nil {
validate = validator.New()
}
return &GatewayRateLimitHandler{service: service, redis: redisClient, validator: validate}
}
func (h *GatewayRateLimitHandler) validate(prefix string, req any) error {
if err := h.validator.Struct(req); err != nil {
return errors.New(errors.CodeInvalidParam, validationMessage(prefix, req, err))
}
return nil
}
func rateLimitPathID(c *fiber.Ctx, name, label string) (uint, error) {
value, err := strconv.ParseUint(c.Params(name), 10, 64)
if err != nil || value == 0 {
return 0, errors.New(errors.CodeInvalidParam, "无效的"+label)
}
return uint(value), nil
}
// ListGroups 查询网关限流组列表。
// GET /api/admin/polling-rate-limit-groups
func (h *GatewayRateLimitHandler) ListGroups(c *fiber.Ctx) error {
var req dto.RateLimitGroupListRequest
if err := c.QueryParser(&req); err != nil {
return errors.New(errors.CodeInvalidParam, "请求参数解析失败")
}
if err := h.validate("网关限流组查询参数不合法", &req); err != nil {
return err
}
result, err := h.service.ListGroups(c.UserContext(), &req)
if err != nil {
return err
}
return response.SuccessWithPagination(c, result.List, result.Total, result.Page, result.PageSize)
}
// GetGroup 查询网关限流组详情。
// GET /api/admin/polling-rate-limit-groups/:id
func (h *GatewayRateLimitHandler) GetGroup(c *fiber.Ctx) error {
id, err := rateLimitPathID(c, "id", "限流组 ID")
if err != nil {
return err
}
result, err := h.service.GetGroup(c.UserContext(), id)
if err != nil {
return err
}
return response.Success(c, result)
}
// CreateGroup 新建网关限流组。
// POST /api/admin/polling-rate-limit-groups
func (h *GatewayRateLimitHandler) CreateGroup(c *fiber.Ctx) error {
var req dto.SaveRateLimitGroupRequest
if err := c.BodyParser(&req); err != nil {
return errors.New(errors.CodeInvalidParam, "请求参数解析失败")
}
if err := h.validate("网关限流组参数不合法", &req); err != nil {
return err
}
result, err := h.service.CreateGroup(c.UserContext(), &req)
if err != nil {
return err
}
return response.Success(c, result)
}
// UpdateGroup 修改网关限流组。
// PUT /api/admin/polling-rate-limit-groups/:id
func (h *GatewayRateLimitHandler) UpdateGroup(c *fiber.Ctx) error {
var req dto.SaveRateLimitGroupRequest
if err := c.BodyParser(&req); err != nil {
return errors.New(errors.CodeInvalidParam, "请求参数解析失败")
}
id, err := rateLimitPathID(c, "id", "限流组 ID")
if err != nil {
return err
}
req.ID = id
if err := h.validate("网关限流组参数不合法", &req); err != nil {
return err
}
result, err := h.service.UpdateGroup(c.UserContext(), &req)
if err != nil {
return err
}
return response.Success(c, result)
}
// UpdateGroupStatus 启停网关限流组。
// PUT /api/admin/polling-rate-limit-groups/:id/status
func (h *GatewayRateLimitHandler) UpdateGroupStatus(c *fiber.Ctx) error {
var req dto.UpdateRateLimitGroupStatusRequest
if err := c.BodyParser(&req); err != nil {
return errors.New(errors.CodeInvalidParam, "请求参数解析失败")
}
id, err := rateLimitPathID(c, "id", "限流组 ID")
if err != nil {
return err
}
req.ID = id
if err := h.validate("网关限流组状态参数不合法", &req); err != nil {
return err
}
if err := h.service.UpdateGroupStatus(c.UserContext(), req.ID, *req.Status); err != nil {
return err
}
return response.Success(c, nil)
}
// DeleteGroup 删除网关限流组。
// DELETE /api/admin/polling-rate-limit-groups/:id
func (h *GatewayRateLimitHandler) DeleteGroup(c *fiber.Ctx) error {
id, err := rateLimitPathID(c, "id", "限流组 ID")
if err != nil {
return err
}
if err := h.service.DeleteGroup(c.UserContext(), id); err != nil {
return err
}
return response.Success(c, nil)
}
// ListRules 查询上游错误分类规则列表。
// GET /api/admin/gateway-error-classifications
func (h *GatewayRateLimitHandler) ListRules(c *fiber.Ctx) error {
var req dto.GatewayErrorRuleListRequest
if err := c.QueryParser(&req); err != nil {
return errors.New(errors.CodeInvalidParam, "请求参数解析失败")
}
if err := h.validate("上游错误分类规则查询参数不合法", &req); err != nil {
return err
}
result, err := h.service.ListRules(c.UserContext(), &req)
if err != nil {
return err
}
return response.SuccessWithPagination(c, result.List, result.Total, result.Page, result.PageSize)
}
// CreateRule 新建上游错误分类规则。
// POST /api/admin/gateway-error-classifications
func (h *GatewayRateLimitHandler) CreateRule(c *fiber.Ctx) error {
var req dto.SaveGatewayErrorRuleRequest
if err := c.BodyParser(&req); err != nil {
return errors.New(errors.CodeInvalidParam, "请求参数解析失败")
}
if err := h.validate("上游错误分类规则参数不合法", &req); err != nil {
return err
}
result, err := h.service.CreateRule(c.UserContext(), &req)
if err != nil {
return err
}
return response.Success(c, result)
}
// UpdateRule 修改上游错误分类规则。
// PUT /api/admin/gateway-error-classifications/:id
func (h *GatewayRateLimitHandler) UpdateRule(c *fiber.Ctx) error {
var req dto.SaveGatewayErrorRuleRequest
if err := c.BodyParser(&req); err != nil {
return errors.New(errors.CodeInvalidParam, "请求参数解析失败")
}
id, err := rateLimitPathID(c, "id", "规则 ID")
if err != nil {
return err
}
req.ID = id
if err := h.validate("上游错误分类规则参数不合法", &req); err != nil {
return err
}
result, err := h.service.UpdateRule(c.UserContext(), &req)
if err != nil {
return err
}
return response.Success(c, result)
}
// UpdateRuleStatus 启停上游错误分类规则。
// PUT /api/admin/gateway-error-classifications/:id/status
func (h *GatewayRateLimitHandler) UpdateRuleStatus(c *fiber.Ctx) error {
var req dto.UpdateGatewayErrorRuleStatusRequest
if err := c.BodyParser(&req); err != nil {
return errors.New(errors.CodeInvalidParam, "请求参数解析失败")
}
id, err := rateLimitPathID(c, "id", "规则 ID")
if err != nil {
return err
}
req.ID = id
if err := h.validate("上游错误分类规则状态参数不合法", &req); err != nil {
return err
}
if err := h.service.UpdateRuleStatus(c.UserContext(), req.ID, *req.Status); err != nil {
return err
}
return response.Success(c, nil)
}
// DeleteRule 删除上游错误分类规则。
// DELETE /api/admin/gateway-error-classifications/:id
func (h *GatewayRateLimitHandler) DeleteRule(c *fiber.Ctx) error {
id, err := rateLimitPathID(c, "id", "规则 ID")
if err != nil {
return err
}
if err := h.service.DeleteRule(c.UserContext(), id); err != nil {
return err
}
return response.Success(c, nil)
}
// TestRule 用已启用规则(可附带草稿)对一段上游返回信息分类,不写任何数据。
// POST /api/admin/gateway-error-classifications/test
func (h *GatewayRateLimitHandler) TestRule(c *fiber.Ctx) error {
var req dto.TestGatewayErrorRuleRequest
if err := c.BodyParser(&req); err != nil {
return errors.New(errors.CodeInvalidParam, "请求参数解析失败")
}
if err := h.validate("上游错误分类测试参数不合法", &req); err != nil {
return err
}
result, err := h.service.TestRule(c.UserContext(), &req)
if err != nil {
return err
}
return response.Success(c, result)
}
// ListStopResumeRecords 查询停复机记录。
// GET /api/admin/card-stop-resume-records
func (h *GatewayRateLimitHandler) ListStopResumeRecords(c *fiber.Ctx) error {
var req dto.StopResumeRecordListRequest
if err := c.QueryParser(&req); err != nil {
return errors.New(errors.CodeInvalidParam, "请求参数解析失败,时间请使用 RFC3339 格式")
}
if err := h.validate("停复机记录查询参数不合法", &req); err != nil {
return err
}
result, err := h.service.ListStopResumeRecords(c.UserContext(), &req)
if err != nil {
return err
}
return response.SuccessWithPagination(c, result.List, result.Total, result.Page, result.PageSize)
}
// ListCardStopResumeRecords 查询单卡停复机历史。
// GET /api/admin/iot-cards/:id/stop-resume-records
func (h *GatewayRateLimitHandler) ListCardStopResumeRecords(c *fiber.Ctx) error {
var req dto.CardStopResumeRecordListRequest
if err := c.QueryParser(&req); err != nil {
return errors.New(errors.CodeInvalidParam, "请求参数解析失败,时间请使用 RFC3339 格式")
}
id, err := rateLimitPathID(c, "id", "IoT 卡 ID")
if err != nil {
return err
}
req.ID = id
if err := h.validate("单卡停复机记录查询参数不合法", &req); err != nil {
return err
}
result, err := h.service.ListCardStopResumeRecords(c.UserContext(), &req)
if err != nil {
return err
}
return response.SuccessWithPagination(c, result.List, result.Total, result.Page, result.PageSize)
}
// CarrierCallStats 按运营商账户汇总最近若干分钟的网关调用。
// GET /api/admin/polling-stats/carriers
func (h *GatewayRateLimitHandler) CarrierCallStats(c *fiber.Ctx) error {
var req dto.GatewayCallStatsRequest
if err := c.QueryParser(&req); err != nil {
return errors.New(errors.CodeInvalidParam, "请求参数解析失败")
}
if err := h.validate("网关调用统计参数不合法", &req); err != nil {
return err
}
result, err := h.service.CarrierCallStats(c.UserContext(), h.redis, &req)
if err != nil {
return err
}
return response.Success(c, result)
}
// CarrierCallTrend 查询单个运营商账户的网关调用趋势。
// GET /api/admin/polling-stats/carriers/:carrier_id/trend
func (h *GatewayRateLimitHandler) CarrierCallTrend(c *fiber.Ctx) error {
var req dto.GatewayCallTrendRequest
if err := c.QueryParser(&req); err != nil {
return errors.New(errors.CodeInvalidParam, "请求参数解析失败")
}
carrierID, err := strconv.ParseUint(c.Params("carrier_id"), 10, 64)
if err != nil {
return errors.New(errors.CodeInvalidParam, "无效的运营商账户 ID")
}
req.CarrierID = uint(carrierID)
if err := h.validate("网关调用趋势参数不合法", &req); err != nil {
return err
}
result, err := h.service.CarrierCallTrend(c.UserContext(), h.redis, &req)
if err != nil {
return err
}
return response.Success(c, result)
}
// PollingOptions 返回轮询与网关限流相关枚举。
// GET /api/admin/polling-options
func (h *GatewayRateLimitHandler) PollingOptions(c *fiber.Ctx) error {
result, err := h.service.PollingOptions(c.UserContext())
if err != nil {
return err
}
return response.Success(c, result)
}

View File

@@ -74,9 +74,6 @@ func (h *OrderHandler) Create(c *fiber.Ctx) error {
}
func (h *OrderHandler) Get(c *fiber.Ctx) error {
if err := rejectEnterpriseOrderAccess(c); err != nil {
return err
}
id, err := strconv.ParseUint(c.Params("id"), 10, 64)
if err != nil {
return errors.New(errors.CodeInvalidParam, "无效的订单ID")
@@ -90,32 +87,7 @@ func (h *OrderHandler) Get(c *fiber.Ctx) error {
return response.Success(c, order)
}
// List 获取订单列表(不限类别)。
func (h *OrderHandler) List(c *fiber.Ctx) error {
return h.list(c, "")
}
// ListPlatform 获取平台订单列表:后台下单且不走代理钱包。
func (h *OrderHandler) ListPlatform(c *fiber.Ctx) error {
return h.list(c, constants.OrderCategoryPlatform)
}
// ListAgent 获取代理商订单列表:后台下单且从代理钱包扣款。
func (h *OrderHandler) ListAgent(c *fiber.Ctx) error {
return h.list(c, constants.OrderCategoryAgent)
}
// ListOnline 获取在线支付订单列表:客户端下单。
func (h *OrderHandler) ListOnline(c *fiber.Ctx) error {
return h.list(c, constants.OrderCategoryOnline)
}
// list 是订单列表的唯一实现:参数解析、时间范围与代理买家条件只有一份,
// 按类别拆分的接口只额外固定订单类别,权限与数据范围与原列表一致。
func (h *OrderHandler) list(c *fiber.Ctx, category string) error {
if err := rejectEnterpriseOrderAccess(c); err != nil {
return err
}
var req dto.OrderListRequest
if err := c.QueryParser(&req); err != nil {
return errors.New(errors.CodeInvalidParam, "请求参数解析失败")
@@ -140,7 +112,7 @@ func (h *OrderHandler) list(c *fiber.Ctx, category string) error {
buyerID = 0
}
orders, err := h.service.List(ctx, &req, startTime, endTime, buyerType, buyerID, category)
orders, err := h.service.List(ctx, &req, startTime, endTime, buyerType, buyerID)
if err != nil {
return err
}
@@ -174,9 +146,6 @@ func (h *OrderHandler) Cancel(c *fiber.Ctx) error {
// 参数: order_type(订单类型), resource_id(资源ID), package_ids(套餐ID列表)
// 响应: 套餐总价、强充要求、实际支付金额等预检结果
func (h *OrderHandler) PurchaseCheck(c *fiber.Ctx) error {
if err := rejectEnterpriseOrderAccess(c); err != nil {
return err
}
var req dto.PurchaseCheckRequest
if err := c.BodyParser(&req); err != nil {
return errors.New(errors.CodeInvalidParam, "请求参数解析失败")
@@ -189,12 +158,3 @@ func (h *OrderHandler) PurchaseCheck(c *fiber.Ctx) error {
return response.Success(c, result)
}
// rejectEnterpriseOrderAccess 拒绝企业账号访问后台订单接口:后台对企业账号不加订单数据范围,
// 放行会看到全部订单。企业账号的下单与取消已由各自接口拒绝。
func rejectEnterpriseOrderAccess(c *fiber.Ctx) error {
if middleware.GetUserTypeFromContext(c.UserContext()) == constants.UserTypeEnterprise {
return errors.New(errors.CodeForbidden, "企业账号无权访问订单")
}
return nil
}

View File

@@ -1,384 +0,0 @@
package admin
import (
"github.com/go-playground/validator/v10"
"github.com/gofiber/fiber/v2"
packagesilenceapp "github.com/break/junhong_cmp_fiber/internal/application/packagesilence"
"github.com/break/junhong_cmp_fiber/internal/model/dto"
packagesilencequery "github.com/break/junhong_cmp_fiber/internal/query/packagesilence"
"github.com/break/junhong_cmp_fiber/pkg/errors"
"github.com/break/junhong_cmp_fiber/pkg/response"
)
// PackageSilenceHandler 后台沉默期:总开关、延期政策、延期资格、延期订单、线下登记、特殊延期、批次、异常、流水与首次购买套餐记录。
// Handler 只做绑定与参数校验,权限点、数据范围、金额与状态由用例判断。
type PackageSilenceHandler struct {
switches *packagesilenceapp.SwitchService
policies *packagesilenceapp.PolicyService
extensions *packagesilenceapp.ExtensionService
query *packagesilencequery.Query
validator *validator.Validate
}
// NewPackageSilenceHandler 创建后台沉默期 Handler。
func NewPackageSilenceHandler(
switches *packagesilenceapp.SwitchService,
policies *packagesilenceapp.PolicyService,
extensions *packagesilenceapp.ExtensionService,
query *packagesilencequery.Query,
validator *validator.Validate,
) *PackageSilenceHandler {
return &PackageSilenceHandler{switches: switches, policies: policies, extensions: extensions, query: query, validator: validator}
}
func (h *PackageSilenceHandler) bindQuery(c *fiber.Ctx, prefix string, req any) error {
if err := c.QueryParser(req); err != nil {
return errors.New(errors.CodeInvalidParam, "请求参数解析失败")
}
return h.validate(prefix, req)
}
func (h *PackageSilenceHandler) bindBody(c *fiber.Ctx, prefix string, req any) error {
if err := c.BodyParser(req); err != nil {
return errors.New(errors.CodeInvalidParam, "请求参数解析失败")
}
return h.validate(prefix, req)
}
func (h *PackageSilenceHandler) validate(prefix string, req any) error {
if h.validator == nil {
return nil
}
if err := h.validator.Struct(req); err != nil {
return errors.New(errors.CodeInvalidParam, validationMessage(prefix, req, err))
}
return nil
}
func silencePathID(c *fiber.Ctx) (uint, error) {
id, err := c.ParamsInt("id")
if err != nil || id <= 0 {
return 0, errors.New(errors.CodeInvalidParam, "ID 无效")
}
return uint(id), nil
}
// GetSwitch 查询沉默期总开关与打开前检查。GET /api/admin/package-silence/switch
func (h *PackageSilenceHandler) GetSwitch(c *fiber.Ctx) error {
result, err := h.switches.Status(c.UserContext())
if err != nil {
return err
}
return response.Success(c, result)
}
// UpdateSwitch 切换沉默期总开关(仅超级管理员)。PUT /api/admin/package-silence/switch
func (h *PackageSilenceHandler) UpdateSwitch(c *fiber.Ctx) error {
var req dto.SilenceSwitchRequest
if err := h.bindBody(c, "沉默期总开关参数不合法", &req); err != nil {
return err
}
result, err := h.switches.Toggle(c.UserContext(), *req.Enabled)
if err != nil {
return err
}
return response.Success(c, result)
}
// ListPolicies 延期政策列表。GET /api/admin/package-silence/policies
func (h *PackageSilenceHandler) ListPolicies(c *fiber.Ctx) error {
var req dto.SilencePolicyListRequest
if err := h.bindQuery(c, "延期政策查询参数不合法", &req); err != nil {
return err
}
items, total, err := h.policies.List(c.UserContext(), &req)
if err != nil {
return err
}
page, pageSize := req.Page, req.PageSize
if page < 1 {
page = 1
}
if pageSize < 1 {
pageSize = 20
}
return response.SuccessWithPagination(c, items, total, page, pageSize)
}
// GetPolicy 延期政策详情。GET /api/admin/package-silence/policies/:id
func (h *PackageSilenceHandler) GetPolicy(c *fiber.Ctx) error {
id, err := silencePathID(c)
if err != nil {
return err
}
result, err := h.policies.Get(c.UserContext(), id)
if err != nil {
return err
}
return response.Success(c, result)
}
// CreatePolicy 新增延期政策。POST /api/admin/package-silence/policies
func (h *PackageSilenceHandler) CreatePolicy(c *fiber.Ctx) error {
var req dto.SilencePolicyRequest
if err := h.bindBody(c, "延期政策参数不合法", &req); err != nil {
return err
}
result, err := h.policies.Create(c.UserContext(), &req)
if err != nil {
return err
}
return response.Success(c, result)
}
// UpdatePolicy 编辑延期政策。PUT /api/admin/package-silence/policies/:id
func (h *PackageSilenceHandler) UpdatePolicy(c *fiber.Ctx) error {
id, err := silencePathID(c)
if err != nil {
return err
}
var req dto.SilencePolicyRequest
if err := h.bindBody(c, "延期政策参数不合法", &req); err != nil {
return err
}
result, err := h.policies.Update(c.UserContext(), id, &req)
if err != nil {
return err
}
return response.Success(c, result)
}
// UpdatePolicyStatus 启停延期政策。PUT /api/admin/package-silence/policies/:id/status
func (h *PackageSilenceHandler) UpdatePolicyStatus(c *fiber.Ctx) error {
id, err := silencePathID(c)
if err != nil {
return err
}
var req dto.SilencePolicyStatusRequest
if err := h.bindBody(c, "延期政策状态参数不合法", &req); err != nil {
return err
}
result, err := h.policies.SetStatus(c.UserContext(), id, *req.Status)
if err != nil {
return err
}
return response.Success(c, result)
}
// MatchPolicy 查询套餐当前匹配的延期政策。GET /api/admin/package-silence/policies/match
func (h *PackageSilenceHandler) MatchPolicy(c *fiber.Ctx) error {
var req dto.SilencePolicyMatchRequest
if err := h.bindQuery(c, "政策匹配查询参数不合法", &req); err != nil {
return err
}
result, err := h.policies.MatchForPackage(c.UserContext(), req.PackageID)
if err != nil {
return err
}
return response.Success(c, result)
}
// Eligibility 延期资格。GET /api/admin/package-silence/eligibility
func (h *PackageSilenceHandler) Eligibility(c *fiber.Ctx) error {
var req dto.SilenceEligibilityRequest
if err := h.bindQuery(c, "延期资格查询参数不合法", &req); err != nil {
return err
}
result, err := h.extensions.Eligibility(c.UserContext(), req.PackageUsageID, packagesilenceapp.Scope{})
if err != nil {
return err
}
return response.Success(c, result)
}
// Quote 按天数试算付费延期。GET /api/admin/package-silence/quote
func (h *PackageSilenceHandler) Quote(c *fiber.Ctx) error {
var req dto.SilenceQuoteRequest
if err := h.bindQuery(c, "延期试算参数不合法", &req); err != nil {
return err
}
result, err := h.extensions.Quote(c.UserContext(), req.PackageUsageID, req.Days, packagesilenceapp.Scope{})
if err != nil {
return err
}
return response.Success(c, result)
}
// CreateQRCodeOrder 运营创建在线延期订单并生成支付二维码。POST /api/admin/package-silence/orders/qrcode
func (h *PackageSilenceHandler) CreateQRCodeOrder(c *fiber.Ctx) error {
var req dto.SilenceOnlineOrderRequest
if err := h.bindBody(c, "延期下单参数不合法", &req); err != nil {
return err
}
result, err := h.extensions.CreateOnlineOrder(c.UserContext(), packagesilenceapp.OnlineOrderCommand{
Request: req, Scope: packagesilenceapp.Scope{}, PayerClientIP: c.IP(),
})
if err != nil {
return err
}
return response.Success(c, result)
}
// RegisterOffline 登记线下付费延期。POST /api/admin/package-silence/orders/offline
func (h *PackageSilenceHandler) RegisterOffline(c *fiber.Ctx) error {
var req dto.SilenceOfflineOrderRequest
if err := h.bindBody(c, "线下付费延期参数不合法", &req); err != nil {
return err
}
result, err := h.extensions.RegisterOffline(c.UserContext(), req)
if err != nil {
return err
}
return response.Success(c, result)
}
// ListOrders 延期订单列表。GET /api/admin/package-silence/orders
func (h *PackageSilenceHandler) ListOrders(c *fiber.Ctx) error {
var req dto.SilenceOrderListRequest
if err := h.bindQuery(c, "延期订单查询参数不合法", &req); err != nil {
return err
}
result, err := h.query.ListOrders(c.UserContext(), packagesilencequery.OrderFilter{Request: req})
if err != nil {
return err
}
return response.Success(c, result)
}
// GetOrder 延期订单详情。GET /api/admin/package-silence/orders/:id
func (h *PackageSilenceHandler) GetOrder(c *fiber.Ctx) error {
id, err := silencePathID(c)
if err != nil {
return err
}
result, err := h.query.GetOrder(c.UserContext(), id, 0)
if err != nil {
return err
}
return response.Success(c, result)
}
// CloseOrder 作废待支付的延期订单。POST /api/admin/package-silence/orders/:id/close
func (h *PackageSilenceHandler) CloseOrder(c *fiber.Ctx) error {
id, err := silencePathID(c)
if err != nil {
return err
}
var req dto.SilenceOrderCancelRequest
if len(c.Body()) > 0 {
if err := h.bindBody(c, "作废参数不合法", &req); err != nil {
return err
}
}
// 先按数据范围查详情:看不到的订单不能作废
if _, err := h.query.GetOrder(c.UserContext(), id, 0); err != nil {
return err
}
result, err := h.extensions.CancelPendingOrder(c.UserContext(), packagesilenceapp.CancelOrderCommand{
OrderID: id, Reason: req.Reason,
})
if err != nil {
return err
}
return response.Success(c, result)
}
// SubmitSpecial 提交特殊延期(单个 / 批量)。POST /api/admin/package-silence/special
func (h *PackageSilenceHandler) SubmitSpecial(c *fiber.Ctx) error {
var req dto.SilenceSpecialRequest
if err := h.bindBody(c, "特殊延期参数不合法", &req); err != nil {
return err
}
result, err := h.extensions.SubmitSpecial(c.UserContext(), req)
if err != nil {
return err
}
return response.Success(c, result)
}
// ListBatches 延期批次列表。GET /api/admin/package-silence/batches
func (h *PackageSilenceHandler) ListBatches(c *fiber.Ctx) error {
var req dto.SilenceBatchListRequest
if err := h.bindQuery(c, "延期批次查询参数不合法", &req); err != nil {
return err
}
result, err := h.query.ListBatches(c.UserContext(), req)
if err != nil {
return err
}
return response.Success(c, result)
}
// GetBatch 延期批次详情(含明细)。GET /api/admin/package-silence/batches/:id
func (h *PackageSilenceHandler) GetBatch(c *fiber.Ctx) error {
id, err := silencePathID(c)
if err != nil {
return err
}
result, err := h.extensions.BatchDetail(c.UserContext(), id)
if err != nil {
return err
}
return response.Success(c, result)
}
// HandleException 异常明细重试 / 关闭。POST /api/admin/package-silence/extensions/:id/handle
func (h *PackageSilenceHandler) HandleException(c *fiber.Ctx) error {
id, err := silencePathID(c)
if err != nil {
return err
}
var req dto.SilenceExceptionHandleRequest
if err := h.bindBody(c, "异常处理参数不合法", &req); err != nil {
return err
}
result, err := h.extensions.HandleException(c.UserContext(), id, req)
if err != nil {
return err
}
return response.Success(c, result)
}
// ListRecords 套餐沉默期流水。GET /api/admin/package-silence/records
func (h *PackageSilenceHandler) ListRecords(c *fiber.Ctx) error {
var req dto.SilenceRecordListRequest
if err := h.bindQuery(c, "沉默期流水查询参数不合法", &req); err != nil {
return err
}
result, err := h.extensions.Records(c.UserContext(), req.PackageUsageID, packagesilenceapp.Scope{})
if err != nil {
return err
}
return response.Success(c, result)
}
// ListUsages 即将过期 / 已过期套餐。GET /api/admin/package-silence/usages
func (h *PackageSilenceHandler) ListUsages(c *fiber.Ctx) error {
var req dto.SilenceUsageListRequest
if err := h.bindQuery(c, "沉默期套餐查询参数不合法", &req); err != nil {
return err
}
result, err := h.query.ListUsages(c.UserContext(), req)
if err != nil {
return err
}
return response.Success(c, result)
}
// ListFirstPurchaseRecords 资产各世代首次购买套餐记录。GET /api/admin/assets/:asset_type/:id/first-purchase-records
func (h *PackageSilenceHandler) ListFirstPurchaseRecords(c *fiber.Ctx) error {
id, err := silencePathID(c)
if err != nil {
return err
}
assetType := c.Params("asset_type")
if assetType != "card" && assetType != "iot_card" && assetType != "device" {
return errors.New(errors.CodeInvalidParam, "资产类型仅支持 card 或 device")
}
result, err := h.query.ListFirstPurchaseRecords(c.UserContext(), assetType, id)
if err != nil {
return err
}
return response.Success(c, result)
}

View File

@@ -37,12 +37,7 @@ func (h *PackageUsageHandler) GetDailyRecords(c *fiber.Ctx) error {
endDate := c.Query("end_date", "")
// 任务 16.4: 调用 DailyRecordService.GetDailyRecords 获取日记录
page := c.QueryInt("page", 0)
pageSize := c.QueryInt("page_size", 0)
if page < 0 || pageSize < 0 || pageSize > 100 {
return errors.New(errors.CodeInvalidParam, "分页参数不合法,page_size 最大 100")
}
records, err := h.dailyRecordService.GetDailyRecords(c.UserContext(), uint(id), startDate, endDate, page, pageSize)
records, err := h.dailyRecordService.GetDailyRecords(c.UserContext(), uint(id), startDate, endDate)
if err != nil {
return err
}

View File

@@ -191,98 +191,3 @@ func (h *PollingConfigHandler) ListEnabled(c *fiber.Ctx) error {
return response.Success(c, configs)
}
// UpdatePriorities 按给定顺序整体调整轮询配置优先级(仅超级管理员)。
// PUT /api/admin/polling-configs/priorities
func (h *PollingConfigHandler) UpdatePriorities(c *fiber.Ctx) error {
var req dto.UpdatePollingConfigPrioritiesRequest
if err := c.BodyParser(&req); err != nil {
return errors.New(errors.CodeInvalidParam, "请求参数解析失败")
}
if len(req.OrderedIDs) == 0 {
return errors.New(errors.CodeInvalidParam, "轮询配置优先级参数不合法:配置 ID 列表不能为空")
}
if err := h.service.UpdatePriorities(c.UserContext(), &req); err != nil {
return err
}
return response.Success(c, nil)
}
// MatchPreview 预览一张卡的轮询配置匹配结果;GET 只按当前配置,POST 可带未保存修改,均不写数据。
// GET /api/admin/polling-configs/match-preview
// POST /api/admin/polling-configs/match-preview
func (h *PollingConfigHandler) MatchPreview(c *fiber.Ctx) error {
var req dto.PollingConfigMatchPreviewRequest
if c.Method() == fiber.MethodPost {
if err := c.BodyParser(&req); err != nil {
return errors.New(errors.CodeInvalidParam, "请求参数解析失败")
}
} else {
var query dto.PollingConfigMatchPreviewQuery
if err := c.QueryParser(&query); err != nil {
return errors.New(errors.CodeInvalidParam, "请求参数解析失败")
}
req.ICCID = query.ICCID
}
if len(req.Overrides) > 20 {
return errors.New(errors.CodeInvalidParam, "匹配预览参数不合法:未保存修改最多 20 条")
}
if req.ICCID == "" {
return errors.New(errors.CodeInvalidParam, "匹配预览参数不合法:ICCID 不能为空")
}
result, err := h.service.MatchPreview(c.UserContext(), &req)
if err != nil {
return err
}
return response.Success(c, result)
}
// CreateRecalculateTask 创建"立即重算到期时间"异步任务(仅超级管理员)。
// POST /api/admin/polling-configs/recalculate-due
func (h *PollingConfigHandler) CreateRecalculateTask(c *fiber.Ctx) error {
var req dto.PollingRecalculateDueRequest
if len(c.Body()) > 0 {
if err := c.BodyParser(&req); err != nil {
return errors.New(errors.CodeInvalidParam, "请求参数解析失败")
}
}
result, err := h.service.CreateRecalculateTask(c.UserContext(), &req)
if err != nil {
return err
}
return response.Success(c, result)
}
// GetRecalculateTask 查询重算到期时间任务进度。
// GET /api/admin/polling-configs/recalculate-due/:task_id
func (h *PollingConfigHandler) GetRecalculateTask(c *fiber.Ctx) error {
taskID := c.Params("task_id")
if taskID == "" {
return errors.New(errors.CodeInvalidParam, "重算任务 ID 不能为空")
}
result, err := h.service.GetRecalculateTask(c.UserContext(), taskID)
if err != nil {
return err
}
return response.Success(c, result)
}
// TierStats 查询分层轮询统计(结果缓存 5 分钟)。
// GET /api/admin/polling-stats/tiers
func (h *PollingConfigHandler) TierStats(c *fiber.Ctx) error {
result, err := h.service.TierStats(c.UserContext())
if err != nil {
return err
}
return response.Success(c, result)
}
// PreResetStats 查询重置日前加密效果统计。
// GET /api/admin/polling-stats/pre-reset
func (h *PollingConfigHandler) PreResetStats(c *fiber.Ctx) error {
result, err := h.service.PreResetStats(c.UserContext())
if err != nil {
return err
}
return response.Success(c, result)
}

View File

@@ -157,6 +157,7 @@ func (h *PollingManualTriggerHandler) GetStatus(c *fiber.Ctx) error {
constants.TaskTypePollingRealname,
constants.TaskTypePollingCarddata,
constants.TaskTypePollingPackage,
constants.TaskTypePollingProtect,
} {
size, _ := h.service.GetQueueSize(ctx, taskType)
queueSizes[taskType] = size
@@ -271,7 +272,6 @@ func (h *PollingManualTriggerHandler) getTaskTypeName(taskType string) string {
case constants.TaskTypePollingPackage:
return "套餐检查"
case constants.TaskTypePollingProtect:
// 保护期已下线,保留名称供历史手动触发记录展示。
return "保护期检查"
default:
return taskType

Some files were not shown because too many files have changed in this diff Show More