From 21b73a750dbe397f6bd2f73fa88312f65e044f6a Mon Sep 17 00:00:00 2001 From: huang Date: Wed, 6 May 2026 09:57:58 +0800 Subject: [PATCH] =?UTF-8?q?c=E7=AB=AF=E5=BD=93=E5=89=8D=E5=A5=97=E9=A4=90?= =?UTF-8?q?=E5=BC=80=E5=A7=8B=E6=97=B6=E9=97=B4=E8=BF=87=E6=9C=9F=E6=97=B6?= =?UTF-8?q?=E9=97=B4,=E4=BB=A5=E5=8F=8Aexcel=E5=AF=BC=E5=85=A5=E5=AD=97?= =?UTF-8?q?=E6=AE=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/handler/app/client_asset.go | 86 ++++++++++++------------ internal/model/dto/asset_dto.go | 24 ++++--- internal/model/dto/client_asset_dto.go | 16 +++-- internal/routes/device.go | 2 + internal/service/asset/service.go | 2 + internal/task/device_import.go | 1 + openspec/specs/client-asset-info/spec.md | 8 ++- openspec/specs/device-import/spec.md | 29 +++++--- pkg/utils/excel.go | 24 ++++--- 9 files changed, 109 insertions(+), 83 deletions(-) diff --git a/internal/handler/app/client_asset.go b/internal/handler/app/client_asset.go index a3e1fd2..1586180 100644 --- a/internal/handler/app/client_asset.go +++ b/internal/handler/app/client_asset.go @@ -152,48 +152,50 @@ func (h *ClientAssetHandler) GetAssetInfo(c *fiber.Ctx) error { phone, _ := middleware.GetCustomerPhone(c) resp := &dto.AssetInfoResponse{ - BoundPhone: phone, - AssetType: resolved.Asset.AssetType, - AssetID: resolved.Asset.AssetID, - Identifier: resolved.Identifier, - VirtualNo: resolved.Asset.VirtualNo, - Status: resolved.Asset.Status, - StatusName: resolved.Asset.StatusName, - RealNameStatus: resolved.Asset.RealNameStatus, - RealNameStatusName: constants.GetRealNameStatusName(resolved.Asset.RealNameStatus), - CarrierName: resolved.Asset.CarrierName, - Generation: strconv.Itoa(resolved.Generation), - WalletBalance: resolved.WalletBalance, - ActivatedAt: resolved.Asset.ActivatedAt, - CurrentPackage: resolved.Asset.CurrentPackage, - CurrentPackageUsageID: resolved.Asset.CurrentPackageUsageID, - RealTotalMB: resolved.Asset.RealTotalMB, - RealUsedMB: resolved.Asset.RealUsedMB, - VirtualTotalMB: resolved.Asset.VirtualTotalMB, - VirtualUsedMB: resolved.Asset.VirtualUsedMB, - ReductionPct: resolved.Asset.ReductionPct, - DeviceName: resolved.Asset.DeviceName, - IMEI: resolved.Asset.IMEI, - SN: resolved.Asset.SN, - DeviceModel: resolved.Asset.DeviceModel, - DeviceType: resolved.Asset.DeviceType, - Manufacturer: resolved.Asset.Manufacturer, - MaxSimSlots: resolved.Asset.MaxSimSlots, - BoundCardCount: resolved.Asset.BoundCardCount, - Cards: resolved.Asset.Cards, - DeviceProtectStatus: resolved.Asset.DeviceProtectStatus, - ICCID: resolved.Asset.ICCID, - MSISDN: resolved.Asset.MSISDN, - CarrierID: resolved.Asset.CarrierID, - CarrierType: resolved.Asset.CarrierType, - NetworkStatus: resolved.Asset.NetworkStatus, - NetworkStatusName: constants.GetNetworkStatusName(resolved.Asset.NetworkStatus), - ActivationStatus: resolved.Asset.ActivationStatus, - ActivationStatusName: constants.GetActivationStatusName(resolved.Asset.ActivationStatus), - CardCategory: resolved.Asset.CardCategory, - BoundDeviceID: resolved.Asset.BoundDeviceID, - BoundDeviceNo: resolved.Asset.BoundDeviceNo, - BoundDeviceName: resolved.Asset.BoundDeviceName, + BoundPhone: phone, + AssetType: resolved.Asset.AssetType, + AssetID: resolved.Asset.AssetID, + Identifier: resolved.Identifier, + VirtualNo: resolved.Asset.VirtualNo, + Status: resolved.Asset.Status, + StatusName: resolved.Asset.StatusName, + RealNameStatus: resolved.Asset.RealNameStatus, + RealNameStatusName: constants.GetRealNameStatusName(resolved.Asset.RealNameStatus), + CarrierName: resolved.Asset.CarrierName, + Generation: strconv.Itoa(resolved.Generation), + WalletBalance: resolved.WalletBalance, + ActivatedAt: resolved.Asset.ActivatedAt, + CurrentPackage: resolved.Asset.CurrentPackage, + CurrentPackageUsageID: resolved.Asset.CurrentPackageUsageID, + CurrentPackageActivatedAt: resolved.Asset.CurrentPackageActivatedAt, + CurrentPackageExpiresAt: resolved.Asset.CurrentPackageExpiresAt, + RealTotalMB: resolved.Asset.RealTotalMB, + RealUsedMB: resolved.Asset.RealUsedMB, + VirtualTotalMB: resolved.Asset.VirtualTotalMB, + VirtualUsedMB: resolved.Asset.VirtualUsedMB, + ReductionPct: resolved.Asset.ReductionPct, + DeviceName: resolved.Asset.DeviceName, + IMEI: resolved.Asset.IMEI, + SN: resolved.Asset.SN, + DeviceModel: resolved.Asset.DeviceModel, + DeviceType: resolved.Asset.DeviceType, + Manufacturer: resolved.Asset.Manufacturer, + MaxSimSlots: resolved.Asset.MaxSimSlots, + BoundCardCount: resolved.Asset.BoundCardCount, + Cards: resolved.Asset.Cards, + DeviceProtectStatus: resolved.Asset.DeviceProtectStatus, + ICCID: resolved.Asset.ICCID, + MSISDN: resolved.Asset.MSISDN, + CarrierID: resolved.Asset.CarrierID, + CarrierType: resolved.Asset.CarrierType, + NetworkStatus: resolved.Asset.NetworkStatus, + NetworkStatusName: constants.GetNetworkStatusName(resolved.Asset.NetworkStatus), + ActivationStatus: resolved.Asset.ActivationStatus, + ActivationStatusName: constants.GetActivationStatusName(resolved.Asset.ActivationStatus), + CardCategory: resolved.Asset.CardCategory, + BoundDeviceID: resolved.Asset.BoundDeviceID, + BoundDeviceNo: resolved.Asset.BoundDeviceNo, + BoundDeviceName: resolved.Asset.BoundDeviceName, } // 调用服务层获取设备实时 Gateway 数据(per D-11) diff --git a/internal/model/dto/asset_dto.go b/internal/model/dto/asset_dto.go index edcbe5a..a026bb5 100644 --- a/internal/model/dto/asset_dto.go +++ b/internal/model/dto/asset_dto.go @@ -19,17 +19,19 @@ type AssetResolveResponse struct { CreatedAt time.Time `json:"created_at" description:"创建时间"` UpdatedAt time.Time `json:"updated_at" description:"更新时间"` // 状态聚合字段 - RealNameStatus int `json:"real_name_status" description:"实名状态:0未实名 1已实名"` - RealNameAt *time.Time `json:"real_name_at" description:"最近一次完成实名的时间,未实名时为 null"` - RealnamePolicy string `json:"realname_policy" description:"实名认证策略 (none:无需实名, before_order:先实名后充值/购买, after_order:先充值/购买后实名)"` - CurrentPackage string `json:"current_package" description:"当前套餐名称(无套餐时为空)"` - CurrentPackageUsageID *uint `json:"current_package_usage_id" description:"当前主套餐的套餐使用记录ID,无主套餐时为 null"` - RealTotalMB int64 `json:"real_total_mb" description:"当前主套餐真实总量(MB)"` - RealUsedMB int64 `json:"real_used_mb" description:"当前主套餐真实已用量(MB)"` - VirtualTotalMB int64 `json:"virtual_total_mb" description:"当前主套餐业务停机阈值(MB)"` - VirtualUsedMB float64 `json:"virtual_used_mb" description:"当前主套餐展示已用量(MB)"` - ReductionPct float64 `json:"reduction_pct" description:"展示增幅比例,公式为(real_total_mb / virtual_total_mb) - 1"` - DeviceProtectStatus string `json:"device_protect_status,omitempty" description:"设备保护期状态:none/stop/start(仅asset_type=device时有效)"` + RealNameStatus int `json:"real_name_status" description:"实名状态:0未实名 1已实名"` + RealNameAt *time.Time `json:"real_name_at" description:"最近一次完成实名的时间,未实名时为 null"` + RealnamePolicy string `json:"realname_policy" description:"实名认证策略 (none:无需实名, before_order:先实名后充值/购买, after_order:先充值/购买后实名)"` + CurrentPackage string `json:"current_package" description:"当前套餐名称(无套餐时为空)"` + CurrentPackageUsageID *uint `json:"current_package_usage_id" description:"当前主套餐的套餐使用记录ID,无主套餐时为 null"` + CurrentPackageActivatedAt *time.Time `json:"current_package_activated_at,omitempty" description:"当前主套餐开始时间"` + CurrentPackageExpiresAt *time.Time `json:"current_package_expires_at,omitempty" description:"当前主套餐过期时间"` + RealTotalMB int64 `json:"real_total_mb" description:"当前主套餐真实总量(MB)"` + RealUsedMB int64 `json:"real_used_mb" description:"当前主套餐真实已用量(MB)"` + VirtualTotalMB int64 `json:"virtual_total_mb" description:"当前主套餐业务停机阈值(MB)"` + VirtualUsedMB float64 `json:"virtual_used_mb" description:"当前主套餐展示已用量(MB)"` + ReductionPct float64 `json:"reduction_pct" description:"展示增幅比例,公式为(real_total_mb / virtual_total_mb) - 1"` + DeviceProtectStatus string `json:"device_protect_status,omitempty" description:"设备保护期状态:none/stop/start(仅asset_type=device时有效)"` // 绑定关系字段 ICCID string `json:"iccid,omitempty" description:"卡ICCID(asset_type=card时有效)"` BoundDeviceID *uint `json:"bound_device_id,omitempty" description:"绑定的设备ID(asset_type=card时有效)"` diff --git a/internal/model/dto/client_asset_dto.go b/internal/model/dto/client_asset_dto.go index 15a54a1..b190be4 100644 --- a/internal/model/dto/client_asset_dto.go +++ b/internal/model/dto/client_asset_dto.go @@ -33,13 +33,15 @@ type AssetInfoResponse struct { ActivatedAt *time.Time `json:"activated_at,omitempty" description:"激活时间"` // === 套餐信息(通用) === - CurrentPackage string `json:"current_package" description:"当前套餐名称(无套餐时为空)"` - CurrentPackageUsageID *uint `json:"current_package_usage_id" description:"当前主套餐的套餐使用记录ID,无主套餐时为 null"` - RealTotalMB int64 `json:"real_total_mb" description:"当前主套餐真实总量(MB)"` - RealUsedMB int64 `json:"real_used_mb" description:"当前主套餐真实已用量(MB)"` - VirtualTotalMB int64 `json:"virtual_total_mb" description:"当前主套餐业务停机阈值(MB)"` - VirtualUsedMB float64 `json:"virtual_used_mb" description:"当前主套餐展示已用量(MB)"` - ReductionPct float64 `json:"reduction_pct" description:"展示增幅比例,公式为(real_total_mb / virtual_total_mb) - 1"` + CurrentPackage string `json:"current_package" description:"当前套餐名称(无套餐时为空)"` + CurrentPackageUsageID *uint `json:"current_package_usage_id" description:"当前主套餐的套餐使用记录ID,无主套餐时为 null"` + CurrentPackageActivatedAt *time.Time `json:"current_package_activated_at,omitempty" description:"当前主套餐开始时间"` + CurrentPackageExpiresAt *time.Time `json:"current_package_expires_at,omitempty" description:"当前主套餐过期时间"` + RealTotalMB int64 `json:"real_total_mb" description:"当前主套餐真实总量(MB)"` + RealUsedMB int64 `json:"real_used_mb" description:"当前主套餐真实已用量(MB)"` + VirtualTotalMB int64 `json:"virtual_total_mb" description:"当前主套餐业务停机阈值(MB)"` + VirtualUsedMB float64 `json:"virtual_used_mb" description:"当前主套餐展示已用量(MB)"` + ReductionPct float64 `json:"reduction_pct" description:"展示增幅比例,公式为(real_total_mb / virtual_total_mb) - 1"` // === 设备专属字段(asset_type=device 时有效) === DeviceName string `json:"device_name,omitempty" description:"设备名称"` diff --git a/internal/routes/device.go b/internal/routes/device.go index a339960..1b9bc82 100644 --- a/internal/routes/device.go +++ b/internal/routes/device.go @@ -89,6 +89,7 @@ func registerDeviceRoutes(router fiber.Router, handler *admin.DeviceHandler, imp - 文件格式:仅支持 .xlsx (Excel 2007+) - 必须包含列(首行为表头): - ` + "`virtual_no`" + `: 设备虚拟号(**必填**,全局唯一;**为空的行记为失败,不再静默跳过**) + - ` + "`sn`" + `: 设备 SN - ` + "`device_name`" + `: 设备名称 - ` + "`device_model`" + `: 设备型号 - ` + "`device_type`" + `: 设备类型 @@ -97,6 +98,7 @@ func registerDeviceRoutes(router fiber.Router, handler *admin.DeviceHandler, imp - ` + "`max_sim_slots`" + `: 最大插槽数(默认4,范围1-4) - ` + "`iccid_1`" + ` ~ ` + "`iccid_4`" + `: 固定槽位的卡 ICCID(` + "`iccid_1`" + `=槽1,` + "`iccid_2`" + `=槽2,` + "`iccid_3`" + `=槽3,` + "`iccid_4`" + `=槽4;卡必须已存在且未绑定) - 槽位规则:中间留空不会触发前移补位;如果 ` + "`max_sim_slots=2`" + `,则 ` + "`iccid_3`" + `、` + "`iccid_4`" + ` 必须留空,否则该行失败 +- 固定列顺序:` + "`virtual_no`" + `、` + "`sn`" + `、` + "`device_name`" + `、` + "`device_model`" + `、` + "`device_type`" + `、` + "`imei`" + `、` + "`manufacturer`" + `、` + "`max_sim_slots`" + `、` + "`iccid_1`" + `、` + "`iccid_2`" + `、` + "`iccid_3`" + `、` + "`iccid_4`" + ` - 列格式:设置为文本格式(避免长数字被转为科学记数法)`, Tags: []string{"设备管理"}, Input: new(dto.ImportDeviceRequest), diff --git a/internal/service/asset/service.go b/internal/service/asset/service.go index 20222bc..678ad9c 100644 --- a/internal/service/asset/service.go +++ b/internal/service/asset/service.go @@ -299,6 +299,8 @@ func (s *Service) fillPackageInfo(ctx context.Context, resp *dto.AssetResolveRes resp.CurrentPackageUsageID = &usage.ID resp.CurrentPackage = usage.PackageName + resp.CurrentPackageActivatedAt = usage.ActivatedAt + resp.CurrentPackageExpiresAt = usage.ExpiresAt if resp.CurrentPackage == "" { pkg, pkgErr := s.packageStore.GetByID(ctx, usage.PackageID) if pkgErr == nil && pkg != nil { diff --git a/internal/task/device_import.go b/internal/task/device_import.go index a7992cd..15bc10d 100644 --- a/internal/task/device_import.go +++ b/internal/task/device_import.go @@ -304,6 +304,7 @@ func (h *DeviceImportHandler) processBatch(ctx context.Context, task *model.Devi device := &model.Device{ VirtualNo: row.VirtualNo, + SN: row.SN, IMEI: row.IMEI, DeviceName: row.DeviceName, DeviceModel: row.DeviceModel, diff --git a/openspec/specs/client-asset-info/spec.md b/openspec/specs/client-asset-info/spec.md index ca7d8bc..79bc79b 100644 --- a/openspec/specs/client-asset-info/spec.md +++ b/openspec/specs/client-asset-info/spec.md @@ -4,12 +4,18 @@ ### Requirement: B1 资产基本信息查询接口 -系统 SHALL 提供 `GET /api/c/v1/asset/info?identifier=xxx`,并且 MUST 要求个人客户认证(C 端 Token)。接口 MUST 复用 `asset.Service.Resolve()` 解析标识符,并在调用时使用 `gorm.SkipDataPermission(ctx)` 以绕过 shop_id 数据权限过滤。请求参数 MUST 包含 `identifier`(ICCID、虚拟号、设备号之一)。响应体 SHALL 返回 `asset_type`、`asset_id`、`identifier`、`virtual_no`、`status`、`real_name_status`、`carrier`、`generation`、`wallet_balance`。错误码/消息 MUST 至少包含:`INVALID_PARAM/参数错误`、`FORBIDDEN/无权限操作该资产或资源不存在`、`ASSET_NOT_FOUND/资产不存在`。 +系统 SHALL 提供 `GET /api/c/v1/asset/info?identifier=xxx`,并且 MUST 要求个人客户认证(C 端 Token)。接口 MUST 复用 `asset.Service.Resolve()` 解析标识符,并在调用时使用 `gorm.SkipDataPermission(ctx)` 以绕过 shop_id 数据权限过滤。请求参数 MUST 包含 `identifier`(ICCID、虚拟号、设备号之一)。响应体 SHALL 返回 `asset_type`、`asset_id`、`identifier`、`virtual_no`、`status`、`real_name_status`、`carrier`、`generation`、`wallet_balance`。当存在当前主套餐时,响应体 MUST 额外返回 `current_package`、`current_package_usage_id`、`current_package_activated_at`、`current_package_expires_at` 以及当前套餐流量字段。错误码/消息 MUST 至少包含:`INVALID_PARAM/参数错误`、`FORBIDDEN/无权限操作该资产或资源不存在`、`ASSET_NOT_FOUND/资产不存在`。 #### Scenario: 个人客户查询已绑定资产 - **WHEN** 客户携带有效 Token 调用 `GET /api/c/v1/asset/info?identifier=8986xxxx` 且资产已绑定到本人 - **THEN** 系统返回 200,包含资产基础信息与当前 generation +#### Scenario: 当前主套餐返回开始时间和过期时间 +- **GIVEN** 该资产存在一条生效中的主套餐使用记录 +- **WHEN** 客户调用 `GET /api/c/v1/asset/info` +- **THEN** 响应体中的 `current_package_activated_at` 等于该主套餐的 `activated_at` +- **AND** 响应体中的 `current_package_expires_at` 等于该主套餐的 `expires_at` + --- ### Requirement: B2 可购买套餐列表接口 diff --git a/openspec/specs/device-import/spec.md b/openspec/specs/device-import/spec.md index a28dee1..ed73c5e 100644 --- a/openspec/specs/device-import/spec.md +++ b/openspec/specs/device-import/spec.md @@ -20,16 +20,17 @@ TBD - created by archiving change add-device-management. Update Purpose after ar - **列位置(固定,不可变)**: ``` 第1列(索引0): 虚拟号(必填,全局唯一) - 第2列(索引1): 设备名称(可选) - 第3列(索引2): 设备型号(可选) - 第4列(索引3): 设备类型(可选) - 第5列(索引4): IMEI(可选) - 第6列(索引5): 制造商(可选) - 第7列(索引6): 最大SIM槽数(可选,默认4,范围1-4) - 第8列(索引7): 卡1 ICCID(可选,对应槽位1) - 第9列(索引8): 卡2 ICCID(可选,对应槽位2) - 第10列(索引9): 卡3 ICCID(可选,对应槽位3) - 第11列(索引10): 卡4 ICCID(可选,对应槽位4) + 第2列(索引1): SN(可选) + 第3列(索引2): 设备名称(可选) + 第4列(索引3): 设备型号(可选) + 第5列(索引4): 设备类型(可选) + 第6列(索引5): IMEI(可选) + 第7列(索引6): 制造商(可选) + 第8列(索引7): 最大SIM槽数(可选,默认4,范围1-4) + 第9列(索引8): 卡1 ICCID(可选,对应槽位1) + 第10列(索引9): 卡2 ICCID(可选,对应槽位2) + 第11列(索引10): 卡3 ICCID(可选,对应槽位3) + 第12列(索引11): 卡4 ICCID(可选,对应槽位4) ``` - **列格式**: 所有列应设置为文本格式(避免数字被转为科学记数法) @@ -67,10 +68,16 @@ TBD - created by archiving change add-device-management. Update Purpose after ar #### Scenario: 中文表头正常导入 -- **GIVEN** Excel 文件第1行表头为 `虚拟号 | 设备名称 | 设备型号 | 设备类型 | IMEI | 制造商 | 最大SIM槽数 | 卡1 | 卡2 | 卡3 | 卡4` +- **GIVEN** Excel 文件第1行表头为 `虚拟号 | SN | 设备名称 | 设备型号 | 设备类型 | IMEI | 制造商 | 最大SIM槽数 | 卡1 | 卡2 | 卡3 | 卡4` - **WHEN** 系统解析该 Excel 文件 - **THEN** 系统跳过第1行,从第2行开始按列位置解析数据 +#### Scenario: SN 列按固定顺序导入 + +- **GIVEN** 某行第2列(索引1)填写 `SN-001` +- **WHEN** 系统解析并导入该行 +- **THEN** 创建设备记录时 `sn = "SN-001"` + #### Scenario: 仅填写卡2和卡3时保留原始槽位 - **GIVEN** 某行 `最大SIM槽数 = 3` diff --git a/pkg/utils/excel.go b/pkg/utils/excel.go index aef6e31..729a8ce 100644 --- a/pkg/utils/excel.go +++ b/pkg/utils/excel.go @@ -42,6 +42,7 @@ type DeviceParseResult struct { type DeviceRow struct { Line int VirtualNo string + SN string IMEI string // 设备IMEI,对应 Excel IMEI 列,用于 Gateway API 调用 DeviceName string DeviceModel string @@ -132,8 +133,8 @@ func ParseDeviceExcel(filePath string) (*DeviceParseResult, error) { } // 按固定列位置读取,第1行为表头跳过: - // col0=虚拟号 col1=设备名称 col2=设备型号 col3=设备类型 col4=IMEI - // col5=制造商 col6=最大SIM槽数 col7~10=卡1~4 ICCID + // col0=虚拟号 col1=SN col2=设备名称 col3=设备型号 col4=设备类型 + // col5=IMEI col6=制造商 col7=最大SIM槽数 col8~11=卡1~4 ICCID for i := 1; i < len(rows); i++ { record := rows[i] lineNum := i + 1 @@ -148,26 +149,27 @@ func ParseDeviceExcel(filePath string) (*DeviceParseResult, error) { } row.VirtualNo = getCol(0) - row.DeviceName = getCol(1) - row.DeviceModel = getCol(2) - row.DeviceType = getCol(3) - row.IMEI = getCol(4) - row.Manufacturer = getCol(5) - maxSimSlotsStr := getCol(6) + row.SN = getCol(1) + row.DeviceName = getCol(2) + row.DeviceModel = getCol(3) + row.DeviceType = getCol(4) + row.IMEI = getCol(5) + row.Manufacturer = getCol(6) + maxSimSlotsStr := getCol(7) row.SlotICCIDs = make([]DeviceSlotICCID, 0, 4) - for j := 7; j <= 10; j++ { + for j := 8; j <= 11; j++ { iccid := getCol(j) if iccid != "" { row.SlotICCIDs = append(row.SlotICCIDs, DeviceSlotICCID{ - SlotPosition: j - 6, + SlotPosition: j - 7, ICCID: iccid, }) } } // 整行目标列皆为空,视为空行跳过,不计入 total - if row.VirtualNo == "" && row.DeviceName == "" && row.DeviceModel == "" && + if row.VirtualNo == "" && row.SN == "" && row.DeviceName == "" && row.DeviceModel == "" && row.DeviceType == "" && row.IMEI == "" && row.Manufacturer == "" && maxSimSlotsStr == "" && len(row.SlotICCIDs) == 0 { continue