feat: 新增数据库迁移,重命名 device_no 为 virtual_no,新增 iot_card.virtual_no 和 package.virtual_ratio 字段
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 7m3s

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
This commit is contained in:
2026-03-14 18:27:28 +08:00
parent b5147d1acb
commit b9c3875c08
77 changed files with 5832 additions and 2393 deletions

View File

@@ -56,6 +56,7 @@ const (
TaskTypePollingRealname = "polling:realname" // 实名状态检查
TaskTypePollingCarddata = "polling:carddata" // 卡流量检查
TaskTypePollingPackage = "polling:package" // 套餐流量检查
TaskTypePollingProtect = "polling:protect" // 保护期一致性检查
// 套餐激活任务类型
TaskTypePackageFirstActivation = "package:first:activation" // 首次实名激活
@@ -203,3 +204,9 @@ const (
AuthorizerTypePlatform = UserTypePlatform // 平台用户授权(2)
AuthorizerTypeAgent = UserTypeAgent // 代理账号授权(3)
)
// 设备保护期相关时长常量
const (
DeviceProtectPeriodDuration = 1 * time.Hour // 设备停/复机保护期时长1小时
DeviceRefreshCooldownDuration = 30 * time.Second // 设备网关刷新冷却时长30秒
)

View File

@@ -285,3 +285,31 @@ func RedisOrderIdempotencyKey(businessKey string) string {
func RedisOrderCreateLockKey(carrierType string, carrierID uint) string {
return fmt.Sprintf("order:create:lock:%s:%d", carrierType, carrierID)
}
// ========================================
// 设备保护期相关 Redis Key
// ========================================
// RedisDeviceProtectKey 生成设备保护期 Redis 键
// action: "stop"(停机保护期)或 "start"(复机保护期),两者互斥
// 过期时间DeviceProtectPeriodDuration1 小时)
func RedisDeviceProtectKey(deviceID uint, action string) string {
return fmt.Sprintf("protect:device:%d:%s", deviceID, action)
}
// RedisDeviceRefreshCooldownKey 生成设备刷新冷却期 Redis 键
// 用途:防止同一设备短时间内多次调网关刷新(限频)
// 过期时间DeviceRefreshCooldownDuration30 秒)
func RedisDeviceRefreshCooldownKey(deviceID uint) string {
return fmt.Sprintf("refresh:cooldown:device:%d", deviceID)
}
// ========================================
// 轮询保护期队列 Redis Key
// ========================================
// RedisPollingQueueProtectKey 保护期一致性检查轮询队列键
// 用途:存储需要进行保护期一致性检查的卡 IDZSet按时间排序
func RedisPollingQueueProtectKey() string {
return "polling:queue:protect"
}

View File

@@ -52,5 +52,6 @@ func BuildDocHandlers() *bootstrap.Handlers {
PollingAlert: admin.NewPollingAlertHandler(nil),
PollingCleanup: admin.NewPollingCleanupHandler(nil),
PollingManualTrigger: admin.NewPollingManualTriggerHandler(nil),
Asset: admin.NewAssetHandler(nil, nil, nil),
}
}

View File

@@ -164,6 +164,9 @@ func (h *Handler) registerPollingHandlers() {
h.mux.HandleFunc(constants.TaskTypePollingPackage, pollingHandler.HandlePackageCheck)
h.logger.Info("注册套餐检查任务处理器", zap.String("task_type", constants.TaskTypePollingPackage))
h.mux.HandleFunc(constants.TaskTypePollingProtect, pollingHandler.HandleProtectConsistencyCheck)
h.logger.Info("注册保护期一致性检查任务处理器", zap.String("task_type", constants.TaskTypePollingProtect))
}
func (h *Handler) registerPackageActivationHandlers() {

View File

@@ -9,10 +9,11 @@ import (
"github.com/xuri/excelize/v2"
)
// CardInfo 卡信息(ICCID + MSISDN)
// CardInfo 卡信息(ICCID + MSISDN + VirtualNo)
type CardInfo struct {
ICCID string
MSISDN string
ICCID string
MSISDN string
VirtualNo string
}
// CSVParseResult Excel/CSV 解析结果
@@ -33,7 +34,7 @@ type CSVParseError struct {
// DeviceRow 设备导入数据行
type DeviceRow struct {
Line int
DeviceNo string
VirtualNo string
DeviceName string
DeviceModel string
DeviceType string
@@ -128,8 +129,8 @@ func ParseDeviceExcel(filePath string) ([]DeviceRow, int, error) {
row := DeviceRow{Line: lineNum}
// 提取各字段
if idx := colIndex["device_no"]; idx >= 0 && idx < len(record) {
row.DeviceNo = strings.TrimSpace(record[idx])
if idx := colIndex["virtual_no"]; idx >= 0 && idx < len(record) {
row.VirtualNo = strings.TrimSpace(record[idx])
}
if idx := colIndex["device_name"]; idx >= 0 && idx < len(record) {
row.DeviceName = strings.TrimSpace(record[idx])
@@ -161,8 +162,8 @@ func ParseDeviceExcel(filePath string) ([]DeviceRow, int, error) {
}
}
// 跳过设备号为空的行
if row.DeviceNo == "" {
// 跳过虚拟号为空的行
if row.VirtualNo == "" {
continue
}
@@ -204,47 +205,44 @@ func parseCardRows(rows [][]string) (*CSVParseResult, error) {
ParseErrors: make([]CSVParseError, 0),
}
// 检测表头 (第1行)
headerSkipped := false
iccidCol, msisdnCol := -1, -1
iccidCol, msisdnCol, virtualNoCol := -1, -1, -1
if len(rows) > 0 {
iccidCol, msisdnCol = findCardColumns(rows[0])
iccidCol, msisdnCol, virtualNoCol = findCardColumns(rows[0])
if iccidCol >= 0 && msisdnCol >= 0 {
headerSkipped = true
}
}
// 确定数据开始行
startLine := 0
if headerSkipped {
startLine = 1
}
// 解析数据行
for i := startLine; i < len(rows); i++ {
row := rows[i]
lineNum := i + 1 // Excel行号从1开始
lineNum := i + 1
// 跳过空行
if len(row) == 0 {
continue
}
// 提取字段
iccid := ""
msisdn := ""
virtualNo := ""
if iccidCol >= 0 {
// 有表头,使用列索引
if iccidCol < len(row) {
iccid = strings.TrimSpace(row[iccidCol])
}
if msisdnCol < len(row) {
msisdn = strings.TrimSpace(row[msisdnCol])
}
if virtualNoCol >= 0 && virtualNoCol < len(row) {
virtualNo = strings.TrimSpace(row[virtualNoCol])
}
} else {
// 无表头,假设第一列ICCID,第二列MSISDN
if len(row) >= 1 {
iccid = strings.TrimSpace(row[0])
}
@@ -253,11 +251,9 @@ func parseCardRows(rows [][]string) (*CSVParseResult, error) {
}
}
// 验证
result.TotalCount++
if iccid == "" && msisdn == "" {
// 空行,跳过
continue
}
@@ -280,45 +276,50 @@ func parseCardRows(rows [][]string) (*CSVParseResult, error) {
}
result.Cards = append(result.Cards, CardInfo{
ICCID: iccid,
MSISDN: msisdn,
ICCID: iccid,
MSISDN: msisdn,
VirtualNo: virtualNo,
})
}
return result, nil
}
// findCardColumns 查找ICCIDMSISDN列索引
// findCardColumns 查找ICCIDMSISDN和VirtualNo列索引
// 支持中英文列名识别
func findCardColumns(header []string) (iccidCol, msisdnCol int) {
iccidCol, msisdnCol = -1, -1
func findCardColumns(header []string) (iccidCol, msisdnCol, virtualNoCol int) {
iccidCol, msisdnCol, virtualNoCol = -1, -1, -1
for i, col := range header {
colLower := strings.ToLower(strings.TrimSpace(col))
// 识别ICCID列
if colLower == "iccid" || colLower == "卡号" || colLower == "号码" {
if iccidCol == -1 { // 只取第一个匹配
if iccidCol == -1 {
iccidCol = i
}
}
// 识别MSISDN列
if colLower == "msisdn" || colLower == "接入号" || colLower == "手机号" || colLower == "电话" {
if msisdnCol == -1 { // 只取第一个匹配
if msisdnCol == -1 {
msisdnCol = i
}
}
if colLower == "virtual_no" || colLower == "virtualno" || colLower == "虚拟号" || colLower == "设备号" {
if virtualNoCol == -1 {
virtualNoCol = i
}
}
}
return iccidCol, msisdnCol
return iccidCol, msisdnCol, virtualNoCol
}
// buildDeviceColumnIndex 构建设备导入列索引
// 识别表头中的列名,返回列名到列索引的映射
func buildDeviceColumnIndex(header []string) map[string]int {
index := map[string]int{
"device_no": -1,
"virtual_no": -1,
"device_name": -1,
"device_model": -1,
"device_type": -1,