Compare commits

3 Commits

Author SHA1 Message Date
b4a9e0c1c9 归档
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 7m52s
2026-04-30 15:59:40 +08:00
948243b13d 修改 2026-04-30 15:49:40 +08:00
5425e2ce19 保留提案基线以便团队 worktree 按契约执行
Constraint: omx team 默认使用独立 worktree,要求 leader 工作区干净
Rejected: git stash 提案文件 | 会让 worker 无法读取当前提案契约
Confidence: high
Scope-risk: narrow
Directive: 后续实现必须继续严格按 tasks.md 顺序推进,不得擅自跳步
Tested: git status 仅剩未跟踪的 .omx 上下文文件
Not-tested: 未验证提案内容本身的实现正确性
2026-04-30 15:11:32 +08:00
19 changed files with 1120 additions and 178 deletions

View File

@@ -3610,6 +3610,14 @@ components:
description: 本次开机运行时间(秒)
nullable: true
type: string
signal_bad_reason:
description: 信号较弱时的怀疑原因提示
nullable: true
type: string
signal_quality:
description: 信号综合判断(信号很好/信号正常/信号较弱/信号很差/暂无数据)
nullable: true
type: string
sinr:
description: 信噪比(dB)
nullable: true
@@ -3906,6 +3914,14 @@ components:
description: 设备本次开机运行时间(秒)
nullable: true
type: string
signal_bad_reason:
description: 信号较弱时的怀疑原因提示
nullable: true
type: string
signal_quality:
description: 信号综合判断(信号很好/信号正常/信号较弱/信号很差/暂无数据)
nullable: true
type: string
sinr:
description: 信噪比(dB)
nullable: true
@@ -13578,9 +13594,11 @@ paths:
- `device_name`: 设备名称
- `device_model`: 设备型号
- `device_type`: 设备类型
- `max_sim_slots`: 最大插槽数默认4
- `imei`: 设备 IMEI
- `manufacturer`: 制造商
- `iccid_1` ~ `iccid_4`: 绑定的卡 ICCID卡必须已存在且未绑定
- `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` 必须留空,否则该行失败
- 列格式:设置为文本格式(避免长数字被转为科学记数法)
requestBody:
content:

View File

@@ -641,6 +641,8 @@ func mapDeviceGatewayInfoToClientInfo(g *dto.DeviceGatewayInfo) *dto.DeviceRealt
v := int64(*g.Sinr)
info.Sinr = &v
}
info.SignalQuality = g.SignalQuality
info.SignalBadReason = g.SignalBadReason
info.SSID = g.SSID
info.WifiEnabled = g.WifiEnabled
info.WifiPassword = g.WifiPassword

View File

@@ -19,17 +19,17 @@ 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"`
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:"卡ICCIDasset_type=card时有效"`
BoundDeviceID *uint `json:"bound_device_id,omitempty" description:"绑定的设备IDasset_type=card时有效"`
@@ -97,28 +97,28 @@ type AssetRealtimeStatusResponse struct {
// AssetPackageResponse 资产套餐信息
type AssetPackageResponse struct {
PackageUsageID uint `json:"package_usage_id" description:"套餐使用记录ID"`
PackageID uint `json:"package_id" description:"套餐ID"`
PackageName string `json:"package_name" description:"套餐名称"`
PackageType string `json:"package_type" description:"套餐类型formal/addon"`
OrderID uint `json:"order_id" description:"关联订单ID无订单分配时为0"`
OrderNo string `json:"order_no,omitempty" description:"订单号快照(无订单分配时为空)"`
RefundID *uint `json:"refund_id,omitempty" description:"退款主键ID快照未退款时为空"`
RefundNo string `json:"refund_no,omitempty" description:"退款单号快照(未退款时为空)"`
UsageType string `json:"usage_type" description:"使用类型single_card/device"`
Status int `json:"status" description:"状态0待生效 1生效中 2已用完 3已过期 4已失效"`
StatusName string `json:"status_name" 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"`
ActivatedAt *time.Time `json:"activated_at,omitempty" description:"激活时间(待生效套餐为空)"`
ExpiresAt *time.Time `json:"expires_at,omitempty" description:"到期时间(待生效套餐为空)"`
MasterUsageID *uint `json:"master_usage_id,omitempty" description:"主套餐ID加油包时有值"`
Priority int `json:"priority" description:"优先级"`
PaidAmount *int64 `json:"paid_amount,omitempty" description:"购买实付金额(分),线下支付或无订单分配时为空"`
CreatedAt time.Time `json:"created_at" description:"创建时间"`
PackageUsageID uint `json:"package_usage_id" description:"套餐使用记录ID"`
PackageID uint `json:"package_id" description:"套餐ID"`
PackageName string `json:"package_name" description:"套餐名称"`
PackageType string `json:"package_type" description:"套餐类型formal/addon"`
OrderID uint `json:"order_id" description:"关联订单ID无订单分配时为0"`
OrderNo string `json:"order_no,omitempty" description:"订单号快照(无订单分配时为空)"`
RefundID *uint `json:"refund_id,omitempty" description:"退款主键ID快照未退款时为空"`
RefundNo string `json:"refund_no,omitempty" description:"退款单号快照(未退款时为空)"`
UsageType string `json:"usage_type" description:"使用类型single_card/device"`
Status int `json:"status" description:"状态0待生效 1生效中 2已用完 3已过期 4已失效"`
StatusName string `json:"status_name" 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"`
ActivatedAt *time.Time `json:"activated_at,omitempty" description:"激活时间(待生效套餐为空)"`
ExpiresAt *time.Time `json:"expires_at,omitempty" description:"到期时间(待生效套餐为空)"`
MasterUsageID *uint `json:"master_usage_id,omitempty" description:"主套餐ID加油包时有值"`
Priority int `json:"priority" description:"优先级"`
PaidAmount *int64 `json:"paid_amount,omitempty" description:"购买实付金额(分),线下支付或无订单分配时为空"`
CreatedAt time.Time `json:"created_at" description:"创建时间"`
}
// AssetPackagesResult 套餐列表分页结果
@@ -180,10 +180,12 @@ type DeviceGatewayInfo struct {
LastUpdateTime *string `json:"last_update_time,omitempty" description:"设备信息最后更新时间"`
// 信号相关
Rsrp *int `json:"rsrp,omitempty" description:"参考信号接收功率(dBm)"`
Rsrq *int `json:"rsrq,omitempty" description:"参考信号接收质量(dB)"`
Rssi *string `json:"rssi,omitempty" description:"接收信号强度"`
Sinr *int `json:"sinr,omitempty" description:"信噪比(dB)"`
Rsrp *int `json:"rsrp,omitempty" description:"参考信号接收功率(dBm)"`
Rsrq *int `json:"rsrq,omitempty" description:"参考信号接收质量(dB)"`
Rssi *string `json:"rssi,omitempty" description:"接收信号强度"`
Sinr *int `json:"sinr,omitempty" description:"信噪比(dB)"`
SignalQuality *string `json:"signal_quality,omitempty" description:"信号综合判断(信号很好/信号正常/信号较弱/信号很差/暂无数据)"`
SignalBadReason *string `json:"signal_bad_reason,omitempty" description:"信号较弱时的怀疑原因提示"`
// WiFi 相关
SSID *string `json:"ssid,omitempty" description:"WiFi热点名称"`

View File

@@ -89,10 +89,12 @@ type DeviceRealtimeInfo struct {
LastUpdateTime *string `json:"last_update_time,omitempty" description:"设备信息最后更新时间"`
// === 信号相关 ===
Rsrp *int64 `json:"rsrp,omitempty" description:"参考信号接收功率(dBm)"`
Rsrq *int64 `json:"rsrq,omitempty" description:"参考信号接收质量(dB)"`
Rssi *string `json:"rssi,omitempty" description:"接收信号强度"`
Sinr *int64 `json:"sinr,omitempty" description:"信噪比(dB)"`
Rsrp *int64 `json:"rsrp,omitempty" description:"参考信号接收功率(dBm)"`
Rsrq *int64 `json:"rsrq,omitempty" description:"参考信号接收质量(dB)"`
Rssi *string `json:"rssi,omitempty" description:"接收信号强度"`
Sinr *int64 `json:"sinr,omitempty" description:"信噪比(dB)"`
SignalQuality *string `json:"signal_quality,omitempty" description:"信号综合判断(信号很好/信号正常/信号较弱/信号很差/暂无数据)"`
SignalBadReason *string `json:"signal_bad_reason,omitempty" description:"信号较弱时的怀疑原因提示"`
// === WiFi 相关 ===
SSID *string `json:"ssid,omitempty" description:"WiFi热点名称"`

View File

@@ -481,6 +481,9 @@ func mapSyncRespToDeviceGatewayInfo(r *gateway.SyncDeviceInfoResp) *dto.DeviceGa
IMSI: flexStrPtr(r.IMSI),
Status: flexIntPtr(r.Status),
}
signalQuality, signalBadReason := buildSignalSummary(info.Rsrp, info.Rsrq, info.Rssi, info.Sinr)
info.SignalQuality = strPtr(signalQuality)
info.SignalBadReason = strPtr(signalBadReason)
if r.BatteryLevel != nil {
n := int(*r.BatteryLevel)
info.BatteryLevel = &n
@@ -713,28 +716,28 @@ func (s *Service) GetPackages(ctx context.Context, assetType string, id uint, pa
pkgType = pkg.PackageType
}
item := &dto.AssetPackageResponse{
PackageUsageID: u.ID,
PackageID: u.PackageID,
PackageName: pkgName,
PackageType: pkgType,
OrderID: u.OrderID,
OrderNo: u.OrderNo,
RefundID: u.RefundID,
RefundNo: u.RefundNo,
UsageType: u.UsageType,
Status: u.Status,
StatusName: packageStatusName(u.Status),
RealTotalMB: metrics.RealTotalMB,
RealUsedMB: metrics.RealUsedMB,
VirtualTotalMB: metrics.VirtualTotalMB,
VirtualUsedMB: metrics.VirtualUsedMB,
ReductionPct: metrics.ReductionPct,
ActivatedAt: u.ActivatedAt,
ExpiresAt: u.ExpiresAt,
MasterUsageID: u.MasterUsageID,
Priority: u.Priority,
PaidAmount: u.PaidAmount,
CreatedAt: u.CreatedAt,
PackageUsageID: u.ID,
PackageID: u.PackageID,
PackageName: pkgName,
PackageType: pkgType,
OrderID: u.OrderID,
OrderNo: u.OrderNo,
RefundID: u.RefundID,
RefundNo: u.RefundNo,
UsageType: u.UsageType,
Status: u.Status,
StatusName: packageStatusName(u.Status),
RealTotalMB: metrics.RealTotalMB,
RealUsedMB: metrics.RealUsedMB,
VirtualTotalMB: metrics.VirtualTotalMB,
VirtualUsedMB: metrics.VirtualUsedMB,
ReductionPct: metrics.ReductionPct,
ActivatedAt: u.ActivatedAt,
ExpiresAt: u.ExpiresAt,
MasterUsageID: u.MasterUsageID,
Priority: u.Priority,
PaidAmount: u.PaidAmount,
CreatedAt: u.CreatedAt,
}
all = append(all, item)
}
@@ -791,28 +794,28 @@ func (s *Service) GetCurrentPackage(ctx context.Context, assetType string, id ui
pkgType = pkg.PackageType
}
return &dto.AssetPackageResponse{
PackageUsageID: usage.ID,
PackageID: usage.PackageID,
PackageName: pkgName,
PackageType: pkgType,
OrderID: usage.OrderID,
OrderNo: usage.OrderNo,
RefundID: usage.RefundID,
RefundNo: usage.RefundNo,
UsageType: usage.UsageType,
Status: usage.Status,
StatusName: packageStatusName(usage.Status),
RealTotalMB: metrics.RealTotalMB,
RealUsedMB: metrics.RealUsedMB,
VirtualTotalMB: metrics.VirtualTotalMB,
VirtualUsedMB: metrics.VirtualUsedMB,
ReductionPct: metrics.ReductionPct,
ActivatedAt: usage.ActivatedAt,
ExpiresAt: usage.ExpiresAt,
MasterUsageID: usage.MasterUsageID,
Priority: usage.Priority,
PaidAmount: usage.PaidAmount,
CreatedAt: usage.CreatedAt,
PackageUsageID: usage.ID,
PackageID: usage.PackageID,
PackageName: pkgName,
PackageType: pkgType,
OrderID: usage.OrderID,
OrderNo: usage.OrderNo,
RefundID: usage.RefundID,
RefundNo: usage.RefundNo,
UsageType: usage.UsageType,
Status: usage.Status,
StatusName: packageStatusName(usage.Status),
RealTotalMB: metrics.RealTotalMB,
RealUsedMB: metrics.RealUsedMB,
VirtualTotalMB: metrics.VirtualTotalMB,
VirtualUsedMB: metrics.VirtualUsedMB,
ReductionPct: metrics.ReductionPct,
ActivatedAt: usage.ActivatedAt,
ExpiresAt: usage.ExpiresAt,
MasterUsageID: usage.MasterUsageID,
Priority: usage.Priority,
PaidAmount: usage.PaidAmount,
CreatedAt: usage.CreatedAt,
}, nil
}

View File

@@ -0,0 +1,159 @@
package asset
import "strings"
const (
signalQualityExcellent = "信号很好"
signalQualityNormal = "信号正常"
signalQualityWeak = "信号较弱"
signalQualityPoor = "信号很差"
signalQualityNoData = "暂无数据"
signalReasonWeakCoverage = "怀疑当前位置信号覆盖较弱"
signalReasonInterference = "怀疑周围干扰较多"
signalReasonBlocked = "怀疑设备所处位置遮挡较强"
signalReasonUnstable = "怀疑网络环境不稳定"
signalReasonUnknown = "暂时无法判断"
)
// buildSignalSummary 根据四个原始信号指标输出统一的信号摘要。
func buildSignalSummary(rsrp, rsrq *int, rssi *string, sinr *int) (string, string) {
score := 0
seen := 0
coverageBad := false
interferenceBad := false
blockedBad := false
unstableBad := false
if rsrp != nil {
seen++
switch {
case *rsrp >= -90:
score += 2
case *rsrp >= -105:
score += 1
case *rsrp >= -115:
score -= 1
coverageBad = true
default:
score -= 2
coverageBad = true
blockedBad = true
}
}
if rsrq != nil {
seen++
switch {
case *rsrq >= -10:
score += 2
case *rsrq >= -15:
score += 1
case *rsrq >= -18:
score -= 1
interferenceBad = true
default:
score -= 2
interferenceBad = true
unstableBad = true
}
}
if sinr != nil {
seen++
switch {
case *sinr >= 20:
score += 2
case *sinr >= 13:
score += 1
case *sinr >= 5:
score -= 1
interferenceBad = true
default:
score -= 2
interferenceBad = true
unstableBad = true
}
}
if rssiValue, ok := parseRSSI(rssi); ok {
seen++
switch {
case rssiValue >= -65:
score += 2
case rssiValue >= -75:
score += 1
case rssiValue >= -85:
score -= 1
coverageBad = true
default:
score -= 2
coverageBad = true
blockedBad = true
}
}
if seen == 0 {
return signalQualityNoData, signalReasonUnknown
}
quality := signalQualityNormal
switch {
case score >= 6:
quality = signalQualityExcellent
case score >= 2:
quality = signalQualityNormal
case score >= -2:
quality = signalQualityWeak
default:
quality = signalQualityPoor
}
if quality == signalQualityExcellent || quality == signalQualityNormal {
return quality, signalReasonUnknown
}
switch {
case blockedBad:
return quality, signalReasonBlocked
case interferenceBad:
return quality, signalReasonInterference
case coverageBad:
return quality, signalReasonWeakCoverage
case unstableBad:
return quality, signalReasonUnstable
default:
return quality, signalReasonUnknown
}
}
func parseRSSI(rssi *string) (int, bool) {
if rssi == nil {
return 0, false
}
value := strings.TrimSpace(*rssi)
if value == "" {
return 0, false
}
sign := 1
number := 0
started := false
for i, ch := range value {
if ch == '-' && i == 0 {
sign = -1
continue
}
if ch >= '0' && ch <= '9' {
started = true
number = number*10 + int(ch-'0')
continue
}
if started {
break
}
}
if !started {
return 0, false
}
return sign * number, true
}

View File

@@ -5,22 +5,23 @@ import (
)
var snapshotFieldSet = map[string]struct{}{
"id": {},
"virtual_no": {},
"iccid": {},
"imei": {},
"sn": {},
"msisdn": {},
"shop_id": {},
"status": {},
"series_id": {},
"enable_polling": {},
"realname_policy": {},
"real_name_status": {},
"network_status": {},
"stop_reason": {},
"deleted": {},
"asset_status": {},
"id": {},
"virtual_no": {},
"device_virtual_no": {},
"iccid": {},
"imei": {},
"sn": {},
"msisdn": {},
"shop_id": {},
"status": {},
"series_id": {},
"enable_polling": {},
"realname_policy": {},
"real_name_status": {},
"network_status": {},
"stop_reason": {},
"deleted": {},
"asset_status": {},
}
// normalizeOperationContent 将原始 before/after 数据归一化为“操作内容”。
@@ -250,34 +251,49 @@ func getOperationFieldDesc(operationType string) map[string]string {
switch strings.TrimSpace(operationType) {
case "card_allocate", "device_allocate":
return mergeFieldDesc(common, map[string]string{
"target_shop_id": "目标店铺ID",
"to_shop_id": "目标店铺ID",
"device_ids": "设备ID列表",
"card_ids": "卡ID列表",
"selection_type": "选择方式",
"new_status": "变更后状态",
"target_shop_id": "目标店铺ID",
"target_shop_name": "目标店铺名称",
"to_shop_id": "目标店铺ID",
"to_shop_name": "目标店铺名称",
"device_ids": "设备ID列表",
"device_virtual_nos": "设备虚拟号列表",
"card_ids": "卡ID列表",
"iccids": "卡ICCID列表",
"selection_type": "选择方式",
"new_status": "变更后状态",
})
case "card_recall", "device_recall":
return mergeFieldDesc(common, map[string]string{
"source_shop_id": "来源店铺ID",
"target_shop": "回收后归属店铺(空表示平台库存)",
"device_ids": "设备ID列表",
"card_ids": "卡ID列表",
"selection_type": "选择方式",
"new_status": "变更后状态",
"source_shop_id": "来源店铺ID",
"source_shop_name": "来源店铺名称",
"target_shop": "回收后归属店铺ID空表示平台库存",
"target_shop_name": "回收后归属店铺名称",
"device_ids": "设备ID列表",
"device_virtual_nos": "设备虚拟号列表",
"card_ids": "卡ID列表",
"iccids": "卡ICCID列表",
"selection_type": "选择方式",
"new_status": "变更后状态",
})
case "device_bind_card":
return map[string]string{
"binding_id": "绑定记录ID",
"slot_position": "插槽位置",
"iot_card_id": "ID",
"iccid": "卡ICCID",
"binding_id": "绑定记录ID",
"slot_position": "插槽位置",
"device_id": "设备ID",
"device_virtual_no": "设备虚拟号",
"device_imei": "设备IMEI",
"iot_card_id": "卡ID",
"iccid": "卡ICCID",
}
case "device_unbind_card":
return map[string]string{
"binding_id": "绑定记录ID",
"iot_card_id": "ID",
"unbind": "是否执行解绑",
"binding_id": "绑定记录ID",
"device_id": "设备ID",
"device_virtual_no": "设备虚拟号",
"device_imei": "设备IMEI",
"iot_card_id": "卡ID",
"iccid": "卡ICCID",
"unbind": "是否执行解绑",
}
case "device_stop", "device_start":
return mergeFieldDesc(common, map[string]string{
@@ -297,7 +313,9 @@ func getOperationFieldDesc(operationType string) map[string]string {
}
case "device_switch_card":
return map[string]string{
"target_iccid": "目标ICCID",
"device_virtual_no": "设备虚拟号",
"device_imei": "设备IMEI",
"target_iccid": "目标ICCID",
}
case "device_switch_mode":
return map[string]string{
@@ -312,9 +330,11 @@ func getOperationFieldDesc(operationType string) map[string]string {
"action": "设备恢复出厂动作",
}
case "asset_polling_status", "card_polling_status":
return map[string]string{
return mergeFieldDesc(common, map[string]string{
"enable_polling": "轮询开关true启用/false禁用",
}
"card_ids": "卡ID列表",
"iccids": "卡ICCID列表",
})
case "asset_realname_policy", "card_realname_policy":
return map[string]string{
"realname_policy": "实名策略none/before_order/after_order",
@@ -324,10 +344,13 @@ func getOperationFieldDesc(operationType string) map[string]string {
"real_name_status": "实名状态0未实名/1已实名",
}
case "asset_deactivate", "card_delete", "card_batch_delete", "device_delete":
return map[string]string{
"asset_status": "资产状态",
"deleted": "是否删除",
}
return mergeFieldDesc(common, map[string]string{
"asset_status": "资产状态",
"deleted": "是否删除",
"iccids": "卡ICCID列表",
"device_virtual_nos": "设备虚拟号列表",
"device_imei": "设备IMEI",
})
case "device_import_task_create", "iot_card_import_task_create":
return map[string]string{
"batch_no": "批次号",
@@ -337,7 +360,19 @@ func getOperationFieldDesc(operationType string) map[string]string {
"card_category": "卡类型(仅卡导入)",
}
default:
return common
return mergeFieldDesc(common, map[string]string{
"card_id": "卡ID",
"iccid": "卡ICCID",
"iccids": "卡ICCID列表",
"device_id": "设备ID",
"device_virtual_no": "设备虚拟号",
"device_virtual_nos": "设备虚拟号列表",
"device_imei": "设备IMEI",
"device_sn": "设备SN",
"shop_name": "店铺名称",
"target_shop_name": "目标店铺名称",
"source_shop_name": "来源店铺名称",
})
}
}

View File

@@ -2,6 +2,7 @@ package device
import (
"context"
"fmt"
"strings"
"github.com/break/junhong_cmp_fiber/internal/model"
@@ -62,6 +63,8 @@ func (s *Service) logDeviceOperation(
snapshot := deviceSnapshot(device)
beforeContent := stripDeviceOperationMeta(beforeData)
afterContent := stripDeviceOperationMeta(afterData)
enrichDeviceOperationContent(beforeContent, beforeData)
enrichDeviceOperationContent(afterContent, afterData)
params.BeforeData, params.AfterData = assetAuditSvc.WrapOperationContent(beforeContent, afterContent, snapshot)
if device != nil {
@@ -81,7 +84,7 @@ func stripDeviceOperationMeta(data map[string]any) map[string]any {
}
out := make(map[string]any, len(data))
for k, v := range data {
if k == "device" || k == "card" || k == "cards" || k == "asset_snapshot" || k == "operation_content" {
if k == "device" || k == "devices" || k == "card" || k == "cards" || k == "asset_snapshot" || k == "operation_content" {
continue
}
out[k] = v
@@ -92,6 +95,116 @@ func stripDeviceOperationMeta(data map[string]any) map[string]any {
return out
}
func enrichDeviceOperationContent(content map[string]any, raw map[string]any) {
if len(raw) == 0 {
return
}
if deviceRaw, ok := raw["device"].(map[string]any); ok {
mergeReadableDeviceFields(content, deviceRaw)
}
if cardRaw, ok := raw["card"].(map[string]any); ok {
mergeReadableCardFields(content, cardRaw)
}
switch devicesRaw := raw["devices"].(type) {
case []map[string]any:
deviceIDs, virtualNos := collectDeviceReadableLists(devicesRaw)
if len(deviceIDs) > 0 && content["device_ids"] == nil {
content["device_ids"] = deviceIDs
}
if len(virtualNos) > 0 && content["device_virtual_nos"] == nil {
content["device_virtual_nos"] = virtualNos
}
case []any:
devices := make([]map[string]any, 0, len(devicesRaw))
for _, item := range devicesRaw {
deviceMap, ok := item.(map[string]any)
if !ok {
continue
}
devices = append(devices, deviceMap)
}
deviceIDs, virtualNos := collectDeviceReadableLists(devices)
if len(deviceIDs) > 0 && content["device_ids"] == nil {
content["device_ids"] = deviceIDs
}
if len(virtualNos) > 0 && content["device_virtual_nos"] == nil {
content["device_virtual_nos"] = virtualNos
}
}
}
func mergeReadableDeviceFields(content map[string]any, device map[string]any) {
if len(device) == 0 {
return
}
if _, ok := content["device_id"]; !ok {
if v, exists := device["id"]; exists {
content["device_id"] = v
}
}
if _, ok := content["device_virtual_no"]; !ok {
if v := stringifyDeviceAuditValue(device["virtual_no"]); v != "" {
content["device_virtual_no"] = v
}
}
if _, ok := content["device_imei"]; !ok {
if v := stringifyDeviceAuditValue(device["imei"]); v != "" {
content["device_imei"] = v
}
}
if _, ok := content["device_sn"]; !ok {
if v := stringifyDeviceAuditValue(device["sn"]); v != "" {
content["device_sn"] = v
}
}
}
func mergeReadableCardFields(content map[string]any, card map[string]any) {
if len(card) == 0 {
return
}
if _, ok := content["iot_card_id"]; !ok {
if v, exists := card["id"]; exists {
content["iot_card_id"] = v
}
}
if _, ok := content["iccid"]; !ok {
if v := stringifyDeviceAuditValue(card["iccid"]); v != "" {
content["iccid"] = v
}
}
}
func collectDeviceReadableLists(devices []map[string]any) ([]any, []string) {
deviceIDs := make([]any, 0, len(devices))
virtualNos := make([]string, 0, len(devices))
for _, device := range devices {
if id, ok := device["id"]; ok {
deviceIDs = append(deviceIDs, id)
}
if virtualNo := stringifyDeviceAuditValue(device["virtual_no"]); virtualNo != "" {
virtualNos = append(virtualNos, virtualNo)
}
}
return deviceIDs, virtualNos
}
func stringifyDeviceAuditValue(v any) string {
switch vv := v.(type) {
case string:
return vv
case fmt.Stringer:
return vv.String()
default:
if vv == nil {
return ""
}
return fmt.Sprint(vv)
}
}
func deviceSnapshot(device *model.Device) map[string]any {
if device == nil {
return nil
@@ -99,6 +212,8 @@ func deviceSnapshot(device *model.Device) map[string]any {
return map[string]any{
"id": device.ID,
"virtual_no": device.VirtualNo,
"imei": device.IMEI,
"sn": device.SN,
"shop_id": device.ShopID,
"status": device.Status,
"series_id": device.SeriesID,

View File

@@ -450,13 +450,16 @@ func (s *Service) AllocateDevices(ctx context.Context, req *dto.AllocateDevicesR
}, nil
}
beforeData := make([]map[string]any, 0, len(devices))
for _, device := range devices {
beforeData = append(beforeData, deviceSnapshot(device))
}
newStatus := constants.DeviceStatusDistributed
targetShopID := req.TargetShopID
targetShopName := s.getAuditShopName(ctx, &targetShopID)
shopMap := s.loadShopData(ctx, devices)
beforeData := make([]map[string]any, 0, len(devices))
for _, device := range devices {
snapshot := deviceSnapshot(device)
snapshot["shop_name"] = deviceShopMapValue(shopMap, device.ShopID)
beforeData = append(beforeData, snapshot)
}
err = s.db.Transaction(func(tx *gorm.DB) error {
txDeviceStore := postgres.NewDeviceStore(tx, nil)
@@ -492,8 +495,9 @@ func (s *Service) AllocateDevices(ctx context.Context, req *dto.AllocateDevicesR
nil,
map[string]any{"devices": beforeData},
map[string]any{
"target_shop_id": req.TargetShopID,
"failed_items": failedItems,
"target_shop_id": req.TargetShopID,
"target_shop_name": targetShopName,
"failed_items": failedItems,
},
len(devices),
len(deviceIDs),
@@ -511,9 +515,10 @@ func (s *Service) AllocateDevices(ctx context.Context, req *dto.AllocateDevicesR
nil,
map[string]any{"devices": beforeData},
map[string]any{
"target_shop_id": req.TargetShopID,
"device_ids": deviceIDs,
"failed_items": failedItems,
"target_shop_id": req.TargetShopID,
"target_shop_name": targetShopName,
"device_ids": deviceIDs,
"failed_items": failedItems,
},
len(devices),
len(deviceIDs),
@@ -629,11 +634,6 @@ func (s *Service) RecallDevices(ctx context.Context, req *dto.RecallDevicesReque
}, nil
}
beforeData := make([]map[string]any, 0, len(devices))
for _, device := range devices {
beforeData = append(beforeData, deviceSnapshot(device))
}
var newShopID *uint
var newStatus int
if isPlatform {
@@ -643,6 +643,14 @@ func (s *Service) RecallDevices(ctx context.Context, req *dto.RecallDevicesReque
newShopID = operatorShopID
newStatus = constants.DeviceStatusDistributed
}
targetShopName := s.getAuditRecallTargetShopName(ctx, newShopID)
shopMap := s.loadShopData(ctx, devices)
beforeData := make([]map[string]any, 0, len(devices))
for _, device := range devices {
snapshot := deviceSnapshot(device)
snapshot["shop_name"] = deviceShopMapValue(shopMap, device.ShopID)
beforeData = append(beforeData, snapshot)
}
err = s.db.Transaction(func(tx *gorm.DB) error {
txDeviceStore := postgres.NewDeviceStore(tx, nil)
@@ -684,8 +692,9 @@ func (s *Service) RecallDevices(ctx context.Context, req *dto.RecallDevicesReque
nil,
map[string]any{"devices": beforeData},
map[string]any{
"device_ids": deviceIDs,
"failed_items": failedItems,
"device_ids": deviceIDs,
"failed_items": failedItems,
"target_shop_name": targetShopName,
},
len(devices),
len(deviceIDs),
@@ -703,9 +712,10 @@ func (s *Service) RecallDevices(ctx context.Context, req *dto.RecallDevicesReque
nil,
map[string]any{"devices": beforeData},
map[string]any{
"device_ids": deviceIDs,
"failed_items": failedItems,
"target_shop": newShopID,
"device_ids": deviceIDs,
"failed_items": failedItems,
"target_shop": newShopID,
"target_shop_name": targetShopName,
},
len(devices),
len(deviceIDs),
@@ -748,6 +758,31 @@ func (s *Service) validateDirectSubordinate(ctx context.Context, operatorShopID
return nil
}
func (s *Service) getAuditShopName(ctx context.Context, shopID *uint) string {
if shopID == nil || *shopID == 0 {
return ""
}
var shop model.Shop
if err := s.db.WithContext(ctx).Unscoped().First(&shop, *shopID).Error; err != nil {
return ""
}
return shop.ShopName
}
func (s *Service) getAuditRecallTargetShopName(ctx context.Context, shopID *uint) string {
if shopID == nil {
return "平台库存"
}
return s.getAuditShopName(ctx, shopID)
}
func deviceShopMapValue(shopMap map[uint]string, shopID *uint) string {
if shopID == nil {
return ""
}
return shopMap[*shopID]
}
func (s *Service) loadShopData(ctx context.Context, devices []*model.Device) map[uint]string {
shopIDs := make([]uint, 0)
shopIDSet := make(map[uint]bool)

View File

@@ -2,6 +2,7 @@ package iot_card
import (
"context"
"fmt"
"github.com/break/junhong_cmp_fiber/internal/model"
assetAuditSvc "github.com/break/junhong_cmp_fiber/internal/service/asset_audit"
@@ -55,7 +56,11 @@ func normalizeCardAuditPayload(beforeData, afterData map[string]any) (map[string
}
}
}
return assetAuditSvc.WrapOperationContent(stripCardOperationMeta(beforeData), stripCardOperationMeta(afterData), snapshot)
beforeContent := stripCardOperationMeta(beforeData)
afterContent := stripCardOperationMeta(afterData)
enrichCardOperationContent(beforeContent, beforeData)
enrichCardOperationContent(afterContent, afterData)
return assetAuditSvc.WrapOperationContent(beforeContent, afterContent, snapshot)
}
func stripCardOperationMeta(data map[string]any) map[string]any {
@@ -75,20 +80,136 @@ func stripCardOperationMeta(data map[string]any) map[string]any {
return out
}
func enrichCardOperationContent(content map[string]any, raw map[string]any) {
if len(raw) == 0 {
return
}
if cardRaw, ok := raw["card"].(map[string]any); ok {
mergeReadableCardFields(content, cardRaw)
}
if deviceRaw, ok := raw["device"].(map[string]any); ok {
mergeReadableDeviceFields(content, deviceRaw)
}
switch cardsRaw := raw["cards"].(type) {
case []map[string]any:
cardIDs, iccids := collectCardReadableLists(cardsRaw)
if len(cardIDs) > 0 && content["card_ids"] == nil {
content["card_ids"] = cardIDs
}
if len(iccids) > 0 && content["iccids"] == nil {
content["iccids"] = iccids
}
case []any:
cards := make([]map[string]any, 0, len(cardsRaw))
for _, item := range cardsRaw {
cardMap, ok := item.(map[string]any)
if !ok {
continue
}
cards = append(cards, cardMap)
}
cardIDs, iccids := collectCardReadableLists(cards)
if len(cardIDs) > 0 && content["card_ids"] == nil {
content["card_ids"] = cardIDs
}
if len(iccids) > 0 && content["iccids"] == nil {
content["iccids"] = iccids
}
}
}
func mergeReadableCardFields(content map[string]any, card map[string]any) {
if len(card) == 0 {
return
}
if _, ok := content["card_id"]; !ok {
if v, exists := card["id"]; exists {
content["card_id"] = v
}
}
if _, ok := content["iccid"]; !ok {
if v := stringifyCardAuditValue(card["iccid"]); v != "" {
content["iccid"] = v
}
}
if _, ok := content["device_virtual_no"]; !ok {
if v := stringifyCardAuditValue(card["device_virtual_no"]); v != "" {
content["device_virtual_no"] = v
}
}
}
func mergeReadableDeviceFields(content map[string]any, device map[string]any) {
if len(device) == 0 {
return
}
if _, ok := content["device_id"]; !ok {
if v, exists := device["id"]; exists {
content["device_id"] = v
}
}
if _, ok := content["device_virtual_no"]; !ok {
if v := stringifyCardAuditValue(device["virtual_no"]); v != "" {
content["device_virtual_no"] = v
}
}
if _, ok := content["device_imei"]; !ok {
if v := stringifyCardAuditValue(device["imei"]); v != "" {
content["device_imei"] = v
}
}
if _, ok := content["device_sn"]; !ok {
if v := stringifyCardAuditValue(device["sn"]); v != "" {
content["device_sn"] = v
}
}
}
func collectCardReadableLists(cards []map[string]any) ([]any, []string) {
cardIDs := make([]any, 0, len(cards))
iccids := make([]string, 0, len(cards))
for _, card := range cards {
if id, ok := card["id"]; ok {
cardIDs = append(cardIDs, id)
}
if iccid := stringifyCardAuditValue(card["iccid"]); iccid != "" {
iccids = append(iccids, iccid)
}
}
return cardIDs, iccids
}
func stringifyCardAuditValue(v any) string {
switch vv := v.(type) {
case string:
return vv
case fmt.Stringer:
return vv.String()
default:
if vv == nil {
return ""
}
return fmt.Sprint(vv)
}
}
func cardSnapshot(card *model.IotCard) map[string]any {
if card == nil {
return nil
}
return map[string]any{
"id": card.ID,
"iccid": card.ICCID,
"shop_id": card.ShopID,
"status": card.Status,
"series_id": card.SeriesID,
"enable_polling": card.EnablePolling,
"realname_policy": card.RealnamePolicy,
"real_name_status": card.RealNameStatus,
"network_status": card.NetworkStatus,
"stop_reason": card.StopReason,
"id": card.ID,
"iccid": card.ICCID,
"device_virtual_no": card.DeviceVirtualNo,
"shop_id": card.ShopID,
"status": card.Status,
"series_id": card.SeriesID,
"enable_polling": card.EnablePolling,
"realname_policy": card.RealnamePolicy,
"real_name_status": card.RealNameStatus,
"network_status": card.NetworkStatus,
"stop_reason": card.StopReason,
}
}

View File

@@ -494,13 +494,16 @@ func (s *Service) AllocateCards(ctx context.Context, req *dto.AllocateStandalone
}
}
shopMap := s.loadShopNames(ctx, cards)
targetShopName := s.getAuditShopName(ctx, &req.ToShopID)
beforeData := make([]map[string]any, 0, len(cards))
for _, card := range cards {
beforeData = append(beforeData, map[string]any{
"id": card.ID,
"iccid": card.ICCID,
"shop_id": card.ShopID,
"status": card.Status,
"id": card.ID,
"iccid": card.ICCID,
"shop_id": card.ShopID,
"shop_name": shopMapValue(shopMap, card.ShopID),
"status": card.Status,
})
}
s.logCardAudit(ctx, assetAuditSvc.BuildLogParams{
@@ -515,6 +518,7 @@ func (s *Service) AllocateCards(ctx context.Context, req *dto.AllocateStandalone
},
AfterData: map[string]any{
"to_shop_id": req.ToShopID,
"to_shop_name": targetShopName,
"new_status": newStatus,
"failed_items": failedItems,
},
@@ -702,13 +706,16 @@ func (s *Service) RecallCards(ctx context.Context, req *dto.RecallStandaloneCard
}
}
shopMap := s.loadShopNames(ctx, successCards)
targetShopName := s.getAuditRecallTargetShopName(ctx, newShopID)
beforeData := make([]map[string]any, 0, len(successCards))
for _, card := range successCards {
beforeData = append(beforeData, map[string]any{
"id": card.ID,
"iccid": card.ICCID,
"shop_id": card.ShopID,
"status": card.Status,
"id": card.ID,
"iccid": card.ICCID,
"shop_id": card.ShopID,
"shop_name": shopMapValue(shopMap, card.ShopID),
"status": card.Status,
})
}
s.logCardAudit(ctx, assetAuditSvc.BuildLogParams{
@@ -722,9 +729,10 @@ func (s *Service) RecallCards(ctx context.Context, req *dto.RecallStandaloneCard
"cards": beforeData,
},
AfterData: map[string]any{
"to_shop_id": newShopID,
"new_status": newStatus,
"failed_items": failedItems,
"to_shop_id": newShopID,
"target_shop_name": targetShopName,
"new_status": newStatus,
"failed_items": failedItems,
},
})
@@ -774,6 +782,31 @@ func (s *Service) validateDirectSubordinate(ctx context.Context, operatorShopID
return nil
}
func (s *Service) getAuditShopName(ctx context.Context, shopID *uint) string {
if shopID == nil || *shopID == 0 {
return ""
}
var shop model.Shop
if err := s.db.WithContext(ctx).Unscoped().First(&shop, *shopID).Error; err != nil {
return ""
}
return shop.ShopName
}
func (s *Service) getAuditRecallTargetShopName(ctx context.Context, shopID *uint) string {
if shopID == nil {
return "平台库存"
}
return s.getAuditShopName(ctx, shopID)
}
func shopMapValue(shopMap map[uint]string, shopID *uint) string {
if shopID == nil {
return ""
}
return shopMap[*shopID]
}
func (s *Service) getCardsForAllocation(ctx context.Context, req *dto.AllocateStandaloneCardsRequest, operatorShopID *uint) ([]*model.IotCard, error) {
switch req.SelectionType {
case dto.SelectionTypeList:
@@ -1443,9 +1476,9 @@ func (s *Service) BatchUpdatePollingStatus(ctx context.Context, cardIDs []uint,
BatchTotal: len(cardIDs),
FailCount: len(cardIDs),
AfterData: map[string]any{
"card_ids": cardIDs,
"enable_polling": enablePolling,
"trigger_source": "batch",
"card_ids": cardIDs,
"enable_polling": enablePolling,
"trigger_source": "batch",
},
})
return err
@@ -1857,11 +1890,11 @@ func (s *Service) ManualUpdateRealnameStatus(ctx context.Context, cardID uint, r
AssetID: freshCard.ID,
AssetIdentifier: freshCard.ICCID,
BeforeData: map[string]any{
"real_name_status": oldStatus,
"real_name_status": oldStatus,
"first_realname_at": card.FirstRealnameAt,
},
AfterData: map[string]any{
"real_name_status": freshCard.RealNameStatus,
"real_name_status": freshCard.RealNameStatus,
"first_realname_at": freshCard.FirstRealnameAt,
},
})

View File

@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-04-30

View File

@@ -0,0 +1,122 @@
## Context
当前仓库已经存在资产域审计日志能力,核心入口为 `internal/service/asset_audit`,并由 `iot_card``device``asset` 等多个 Service 在写操作节点调用。现状问题不是“没有日志”,而是很多 `operation_content` 更偏向研发排障视角,包含大量 `card_id``device_id``target_shop_id``binding_id` 之类的内部主键,对业务人员不友好。
设备实时状态方面,后台管理端通过 `asset.Service.fetchDeviceGatewayInfo()` 从 Gateway 获取完整设备信息C 端再通过 `mapDeviceGatewayInfoToClientInfo()` 复用该结果。`rsrp``rsrq``rssi``sinr` 已完整透出,但缺少“人能直接看懂”的归纳结论,因此同一问题会在 B 端与 C 端重复解释。
约束如下:
- 必须遵循 `Handler -> Service -> Store -> Model` 分层,不在 Handler 中拼复杂业务判断。
- 不新增自动化测试文件,本次仅规划实现与手工验证路径。
- 审计日志优化必须兼容现有字段,避免破坏现有依赖方。
- 信号综合字段只新增,不替换或删除原始信号字段。
## Goals / Non-Goals
**Goals:**
- 让资产操作审计日志的 `before_data/after_data` 在保留内部 ID 的同时,补足业务可读字段。
- 为设备实时状态新增 `signal_quality``signal_bad_reason` 两个综合字段。
- 统一 B 端与 C 端对信号综合字段的输出文案和判定逻辑。
- 将“怀疑原因”设计为面向小白的提示语,而不是通信指标术语解释。
**Non-Goals:**
- 不调整店铺删除时“店铺下仍有账号”的业务规则。
- 不处理“禁用企业账号看不到”的假问题。
- 不修改现有原始信号字段 `rsrp``rsrq``rssi``sinr` 的保留与含义。
- 不新增独立的信号诊断接口或独立日志查询接口。
## Decisions
### 决策1审计日志采用“补充可读字段”而不是“替换内部字段”
**选择**:保留现有 `..._id``..._ids``target_shop_id` 等内部字段,同时补充可读字段,如 `iccid``iccids``device_virtual_no``device_imei``shop_name``enterprise_name`
**理由**
- 兼容已有日志消费方与排障场景。
- 业务查看与研发定位可同时满足,不需要二选一。
- 变更影响更小,不需要迁移历史日志结构。
**备选方案**:直接把内部 ID 字段改写为名称或业务标识。
**放弃原因**:会损失排障精度,也可能影响已存在的日志解析逻辑。
### 决策2可读字段在各业务写入点就近补齐
**选择**:在 `iot_card``device``asset` 等具体业务 Service 组装审计参数时,就近把业务标识和名称补进 `BeforeData` / `AfterData``asset_audit` 负责统一封装但不做过度猜测。
**理由**
- 具体业务 Service 最清楚当前上下文里哪张卡、哪台设备、哪家店铺是本次操作对象。
- 避免在 `asset_audit` 层引入大量额外查询和猜测逻辑。
- 更符合现有项目里“Service 负责业务语义拼装”的边界。
**备选方案**:在 `asset_audit` 中根据 ID 反查名称并自动补全。
**放弃原因**:会让通用审计层承担过多领域知识,也会增加额外查询成本和隐式行为。
### 决策3信号综合字段在 B 端统一计算C 端复用映射结果
**选择**:在后台管理端 Gateway 映射链路中新增统一的信号摘要计算函数,先产出 `DeviceGatewayInfo.signal_quality``DeviceGatewayInfo.signal_bad_reason`,再由 C 端映射函数直接透传。
**理由**
- 避免 B 端和 C 端分别实现一套阈值和文案,造成口径漂移。
- 现有 C 端本来就是从 B 端 DTO 映射,复用成本最低。
- 未来如果要调阈值或文案,只需要维护一处。
**备选方案**B 端与 C 端各自计算。
**放弃原因**:重复逻辑多,后续难保证一致。
### 决策4信号综合字段采用“结果 + 怀疑原因”的轻量模型
**选择**
- `signal_quality` 使用固定的用户可读枚举:`信号很好 / 信号正常 / 信号较弱 / 信号很差 / 暂无数据`
- `signal_bad_reason` 使用怀疑式提示语:`怀疑当前位置信号覆盖较弱 / 怀疑周围干扰较多 / 怀疑设备所处位置遮挡较强 / 怀疑网络环境不稳定 / 暂时无法判断`
**理由**
- 适合小白用户快速理解,不要求其理解通信指标定义。
- “怀疑”语气符合基于多指标估算而非绝对诊断的产品事实。
- 可在不暴露复杂阈值的前提下给出下一步判断方向。
**备选方案**直接输出“RSRP 低 / SINR 低 / RSRQ 差”等技术术语。
**放弃原因**:对非技术用户不友好,无法满足本次需求目标。
### 决策5信号判定使用固定优先级规则避免多原因并列
**选择**:当多个原始指标同时异常时,按固定优先级输出单一 `signal_bad_reason`,优先表达最容易被用户理解的问题类型;当数据缺失或不足时输出“暂时无法判断”。
**理由**
- 单字段只返回一个原因,前端展示更稳定。
- 避免一次返回多个原因造成文案冗长和理解负担。
- 更适合现有 DTO 结构,不需要额外数组字段。
**备选方案**:返回原因列表。
**放弃原因**:复杂度更高,也不符合“只新增两个字段”的收敛目标。
## Risks / Trade-offs
- **[风险] 审计日志可读字段补充不一致** → **缓解**:在设计中约束“同类资源尽量使用统一字段名”,例如卡统一使用 `iccid`/`iccids`,设备统一使用 `device_virtual_no``device_imei`
- **[风险] 信号怀疑原因与真实现场不完全一致** → **缓解**:文案统一使用“怀疑”语气,并保留原始四个指标供专业人员复核。
- **[风险] 设备实时 DTO 新增字段后文档未同步** → **缓解**:实现阶段同步更新 DTO 描述与生成文档。
- **[权衡] 不在审计通用层自动反查名称会保留部分业务代码重复** → **接受**:本次优先保持边界清晰与最小侵入。
## Migration Plan
1. 在变更目录中明确审计日志与信号摘要的规格要求。
2. 实现阶段先补充 B 端设备实时 DTO 与统一信号摘要函数,再同步 C 端映射。
3. 分批调整资产审计日志主要写入点,优先覆盖当前已确认大量写入内部主键的链路。
4. 生成并检查接口文档,确认新增字段在后台管理端与 C 端响应中可见。
5. 通过手工接口调用与数据库日志抽样确认行为符合预期。
**回滚策略:**
- 若信号摘要文案或阈值不符合预期,可仅回滚摘要计算与 DTO 新字段,不影响原始四个指标。
- 若审计日志可读字段补充引发问题,可回滚具体写入点变更,保留现有日志表与基础审计能力。
## Open Questions
- `signal_bad_reason` 的优先级阈值最终是否需要沉淀到 `pkg/constants` 作为可复用常量,还是先在单一计算函数中集中维护。
- 审计日志中店铺相关字段是否统一使用 `shop_name`,还是区分 `source_shop_name` / `target_shop_name` 以更明确表达分配回收语义。
## 实施记录2026-04-30
- 已覆盖的审计写入主链路:`internal/service/iot_card/service.go` 单卡分配/回收,`internal/service/device/service.go` 设备分配/回收,`internal/service/device/binding.go` 绑卡/解绑,配合 `internal/service/iot_card/audit.go``internal/service/device/audit.go` 统一补齐可读字段。
- 审计查询侧已同步扩展字段说明:`internal/service/asset_audit/operation_content.go` 新增 `iccids``device_virtual_no``device_virtual_nos``target_shop_name``source_shop_name` 等说明口径。
- 设备实时状态已统一在 B 端计算 `signal_quality``signal_bad_reason`,再由 C 端 DTO 复用映射结果。
- 已完成静态验证:`go build ./...`
- 已完成文档验证:`GOCACHE=/tmp/jh-gocache go run cmd/gendocs/main.go``docs/admin-openapi.yaml` 中可见 `signal_quality``signal_bad_reason` 与新增 DTO 字段。
- 待补联调验证:当前会话未直接调用后台管理端 / C 端接口,也未触发新的资产操作写入,因此仍需在联调环境补做新日志样本与接口返回抽样。

View File

@@ -0,0 +1,26 @@
## Why
当前资产操作审计日志虽然已经落库,但很多操作内容仍主要记录内部主键 ID业务侧排查时很难直接看懂“到底操作了哪张卡、哪台设备、哪家店铺”。同时设备实时状态里虽然已有 `rsrp``rsrq``rssi``sinr` 四个原始指标,但一线使用人员难以理解,影响 C 端与后台管理端对设备信号状态的快速判断。
## What Changes
- 新增 `feature-001-asset-audit-readable-content`:优化资产操作审计日志的操作内容结构,在保留内部 ID 的同时补充业务可读字段,如 `iccid`、设备标识、店铺名称、企业名称等。
- 新增 `feature-002-device-signal-summary`:基于设备实时状态中的 `rsrp``rsrq``rssi``sinr` 计算两个新的综合字段,分别表达“信号好坏”和“怀疑原因”。
- 统一后台管理端与 C 端设备实时信息输出口径,确保两个新增信号字段在两端含义一致。
- 明确本次变更不处理店铺删除规则,也不处理企业账号列表过滤问题,避免需求范围漂移。
## Capabilities
### New Capabilities
- `asset-audit-readable-content`: 定义资产操作审计日志中面向业务可读的操作内容字段要求与展示语义。
- `device-signal-summary`: 定义设备实时信号综合字段的计算输出、文案口径与 B/C 端返回要求。
### Modified Capabilities
-
## Impact
- 影响资产审计日志相关服务与写入点,主要涉及 `internal/service/asset_audit``internal/service/iot_card``internal/service/device``internal/service/asset`
- 影响设备实时状态 DTO 与映射逻辑,主要涉及 `internal/model/dto/asset_dto.go``internal/model/dto/client_asset_dto.go``internal/service/asset/service.go``internal/handler/app/client_asset.go`
- 影响后台管理端与 C 端既有响应结构,但属于向后兼容的新增字段,不引入破坏性变更。
- 无新增外部依赖、无新增数据表、无新增路由。

View File

@@ -0,0 +1,49 @@
## ADDED Requirements
### Requirement: 资产操作审计日志必须补充业务可读字段
系统 SHALL 在资产操作审计日志中保留现有内部主键字段,同时 MUST 为关键操作对象补充业务可读字段,确保业务侧无需查库即可理解操作内容。
#### Scenario: 卡相关日志补充 ICCID
- **WHEN** 系统记录针对单卡或多卡的分配、回收、删除、实名、停复机等操作日志
- **THEN** `before_data``after_data` 除内部卡 ID 外,还包含对应的 `iccid``iccids`
#### Scenario: 设备相关日志补充设备标识
- **WHEN** 系统记录设备绑定、解绑、切卡、远程控制、删除等操作日志
- **THEN** `before_data``after_data` 除内部设备 ID 外还包含至少一种业务可读设备标识如虚拟号、IMEI 或 SN
#### Scenario: 店铺相关日志补充店铺名称
- **WHEN** 系统记录资产分配、资产回收或归属变更类日志
- **THEN** `before_data``after_data` 在店铺 ID 之外还包含相应店铺名称,便于直接识别归属变化
### Requirement: 审计日志可读字段必须遵循兼容新增原则
系统 SHALL 以向后兼容方式扩展资产审计日志内容,不得通过删除现有内部字段来换取可读性。
#### Scenario: 现有内部字段仍然保留
- **WHEN** 系统为审计日志补充可读字段
- **THEN** 现有 `card_id``device_id``target_shop_id``binding_id` 等内部字段仍然保留,不被移除或重命名
#### Scenario: 新增字段不影响旧日志读取
- **WHEN** 现有日志消费方继续读取资产操作日志
- **THEN** 旧字段结构保持可用,新增可读字段仅作为补充信息出现
### Requirement: 同类审计场景必须使用统一的可读字段命名
系统 SHALL 在同类资产审计场景中使用稳定一致的可读字段命名,避免同一语义在不同日志里出现多套字段名。
#### Scenario: 单卡与批量卡操作字段一致
- **WHEN** 系统分别记录单卡操作和批量卡操作日志
- **THEN** 单卡场景使用 `iccid`,批量场景使用 `iccids` 或卡对象列表中的 `iccid` 字段,命名保持一致
#### Scenario: 设备相关可读字段命名稳定
- **WHEN** 系统记录多个设备相关操作日志
- **THEN** 设备业务标识字段使用统一命名,不在不同接口间随意切换字段语义
### Requirement: 审计日志补充可读字段不得引入额外破坏性变更
系统 SHALL 仅增强资产审计日志的可读性,不得借本次变更调整店铺删除规则、账号过滤规则或其他无关业务行为。
#### Scenario: 店铺删除规则保持不变
- **WHEN** 本次变更上线
- **THEN** 店铺删除相关业务规则不因审计日志优化而发生变化
#### Scenario: 企业账号列表行为保持不变
- **WHEN** 本次变更上线
- **THEN** 账号列表接口的企业账号展示逻辑不因审计日志优化而发生变化

View File

@@ -0,0 +1,65 @@
## ADDED Requirements
### Requirement: 设备实时状态必须新增信号综合字段
系统 SHALL 在设备实时状态响应中保留 `rsrp``rsrq``rssi``sinr` 四个原始字段,并新增 `signal_quality``signal_bad_reason` 两个综合字段。
#### Scenario: 后台管理端返回综合字段
- **WHEN** 管理员查询设备实时状态且 Gateway 返回了信号相关数据
- **THEN** 响应同时包含原始四个信号字段以及 `signal_quality``signal_bad_reason`
#### Scenario: C 端返回综合字段
- **WHEN** 个人客户查询设备实时状态且 Gateway 返回了信号相关数据
- **THEN** 响应同时包含原始四个信号字段以及 `signal_quality``signal_bad_reason`
### Requirement: 信号好坏字段必须使用面向小白的固定文案
系统 SHALL 使用固定中文文案表达信号综合好坏,不得直接要求用户理解通信技术指标。
#### Scenario: 信号很好
- **WHEN** 多项信号指标显示设备当前网络状态较优
- **THEN** `signal_quality` 返回 `信号很好`
#### Scenario: 信号正常
- **WHEN** 信号指标处于可接受区间且未出现明显异常
- **THEN** `signal_quality` 返回 `信号正常`
#### Scenario: 信号较弱或很差
- **WHEN** 信号指标显示覆盖、质量或抗干扰能力明显下降
- **THEN** `signal_quality` 返回 `信号较弱``信号很差`
#### Scenario: 数据不足
- **WHEN** 四个原始信号字段全部缺失或不足以支撑判断
- **THEN** `signal_quality` 返回 `暂无数据`
### Requirement: 信号怀疑原因字段必须使用怀疑式提示语
系统 SHALL 使用单一的、面向非专业用户的“怀疑原因”文案解释信号异常,不得直接输出技术术语结论。
#### Scenario: 覆盖较弱
- **WHEN** 信号指标更接近覆盖不足问题
- **THEN** `signal_bad_reason` 返回 `怀疑当前位置信号覆盖较弱`
#### Scenario: 干扰较多
- **WHEN** 信号指标更接近干扰或质量下降问题
- **THEN** `signal_bad_reason` 返回 `怀疑周围干扰较多`
#### Scenario: 遮挡较强
- **WHEN** 信号指标更接近设备所处位置存在遮挡的问题
- **THEN** `signal_bad_reason` 返回 `怀疑设备所处位置遮挡较强`
#### Scenario: 网络环境不稳定
- **WHEN** 多项指标波动或组合异常但无法归为单一覆盖问题
- **THEN** `signal_bad_reason` 返回 `怀疑网络环境不稳定`
#### Scenario: 暂时无法判断
- **WHEN** 原始数据缺失或组合不足以产出可信原因
- **THEN** `signal_bad_reason` 返回 `暂时无法判断`
### Requirement: B 端与 C 端必须复用同一套信号摘要口径
系统 SHALL 在后台管理端统一计算信号综合字段,并由 C 端复用同一结果映射,确保两端口径一致。
#### Scenario: 两端返回一致文案
- **WHEN** 同一设备在后台管理端与 C 端分别查询实时状态
- **THEN** 两端返回的 `signal_quality``signal_bad_reason` 含义一致,不出现口径分叉
#### Scenario: 调整规则只需修改一处
- **WHEN** 后续调整信号摘要阈值或文案
- **THEN** 系统只需修改统一计算逻辑,即可同步影响后台管理端与 C 端返回结果

View File

@@ -0,0 +1,31 @@
## 1. 审计日志可读性增强
- [x] 1.1 梳理当前资产审计日志主要写入点,标记仍只写内部主键字段的卡、设备、归属变更相关链路
- [x] 1.2 按卡、设备、店铺归属三类场景补充统一的业务可读字段,保持现有内部字段不删除不改名
- [x] 1.3 调整资产审计日志操作内容字段描述与归一化逻辑,确保新增可读字段在查询结果中稳定返回
- [ ] 1.4 通过手工抽样检查典型资产日志记录,确认能直接看出卡标识、设备标识与店铺归属信息
## 2. 设备信号综合字段
- [x] 2.1 为后台管理端设备实时 DTO 新增 `signal_quality``signal_bad_reason` 字段,并补充中文描述
- [x] 2.2 实现统一的信号摘要计算逻辑,基于 `rsrp``rsrq``rssi``sinr` 输出固定的用户友好文案
- [x] 2.3 将后台管理端信号摘要结果映射到 C 端设备实时 DTO保持两端字段名和文案口径一致
- [ ] 2.4 手工调用后台管理端与 C 端相关接口,确认新增字段可见且原始四个信号字段仍然保留
## 3. 文档与交付检查
- [x] 3.1 更新相关接口文档或生成产物,确保新增响应字段在文档中可见
- [x] 3.2 复核本次变更未引入店铺删除规则调整或企业账号列表行为变更
- [x] 3.3 记录本次变更的手工验证结果与剩余风险,保证提案可直接进入实现阶段
## 验证记录
- 已完成:`go build ./...`
- 已完成:`GOCACHE=/tmp/jh-gocache go run cmd/gendocs/main.go`
- 已完成:抽样查询 `tb_asset_operation_log` 近期 `card_allocate/card_recall/device_allocate/device_recall/device_bind_card/device_unbind_card` 记录,确认当前线上历史日志仍以内部字段为主,覆盖面与本次修复范围一致
- 未完成:未在当前会话内直接调用后台管理端与 C 端接口,也未触发新的资产审计写操作生成新日志样本
## 剩余风险
- 需在可用的联调环境中补做任务 1.4 与 2.4,确认新写入日志样本和两端实时接口返回值与本次代码改动一致
- 现有历史资产审计日志不会自动回填新增可读字段,本次变更只覆盖新的写入结果

View File

@@ -0,0 +1,53 @@
# asset-audit-readable-content Specification
## Purpose
TBD - created by archiving change improve-audit-log-readability-and-signal-summary. Update Purpose after archive.
## Requirements
### Requirement: 资产操作审计日志必须补充业务可读字段
系统 SHALL 在资产操作审计日志中保留现有内部主键字段,同时 MUST 为关键操作对象补充业务可读字段,确保业务侧无需查库即可理解操作内容。
#### Scenario: 卡相关日志补充 ICCID
- **WHEN** 系统记录针对单卡或多卡的分配、回收、删除、实名、停复机等操作日志
- **THEN** `before_data``after_data` 除内部卡 ID 外,还包含对应的 `iccid``iccids`
#### Scenario: 设备相关日志补充设备标识
- **WHEN** 系统记录设备绑定、解绑、切卡、远程控制、删除等操作日志
- **THEN** `before_data``after_data` 除内部设备 ID 外还包含至少一种业务可读设备标识如虚拟号、IMEI 或 SN
#### Scenario: 店铺相关日志补充店铺名称
- **WHEN** 系统记录资产分配、资产回收或归属变更类日志
- **THEN** `before_data``after_data` 在店铺 ID 之外还包含相应店铺名称,便于直接识别归属变化
### Requirement: 审计日志可读字段必须遵循兼容新增原则
系统 SHALL 以向后兼容方式扩展资产审计日志内容,不得通过删除现有内部字段来换取可读性。
#### Scenario: 现有内部字段仍然保留
- **WHEN** 系统为审计日志补充可读字段
- **THEN** 现有 `card_id``device_id``target_shop_id``binding_id` 等内部字段仍然保留,不被移除或重命名
#### Scenario: 新增字段不影响旧日志读取
- **WHEN** 现有日志消费方继续读取资产操作日志
- **THEN** 旧字段结构保持可用,新增可读字段仅作为补充信息出现
### Requirement: 同类审计场景必须使用统一的可读字段命名
系统 SHALL 在同类资产审计场景中使用稳定一致的可读字段命名,避免同一语义在不同日志里出现多套字段名。
#### Scenario: 单卡与批量卡操作字段一致
- **WHEN** 系统分别记录单卡操作和批量卡操作日志
- **THEN** 单卡场景使用 `iccid`,批量场景使用 `iccids` 或卡对象列表中的 `iccid` 字段,命名保持一致
#### Scenario: 设备相关可读字段命名稳定
- **WHEN** 系统记录多个设备相关操作日志
- **THEN** 设备业务标识字段使用统一命名,不在不同接口间随意切换字段语义
### Requirement: 审计日志补充可读字段不得引入额外破坏性变更
系统 SHALL 仅增强资产审计日志的可读性,不得借本次变更调整店铺删除规则、账号过滤规则或其他无关业务行为。
#### Scenario: 店铺删除规则保持不变
- **WHEN** 本次变更上线
- **THEN** 店铺删除相关业务规则不因审计日志优化而发生变化
#### Scenario: 企业账号列表行为保持不变
- **WHEN** 本次变更上线
- **THEN** 账号列表接口的企业账号展示逻辑不因审计日志优化而发生变化

View File

@@ -0,0 +1,69 @@
# device-signal-summary Specification
## Purpose
TBD - created by archiving change improve-audit-log-readability-and-signal-summary. Update Purpose after archive.
## Requirements
### Requirement: 设备实时状态必须新增信号综合字段
系统 SHALL 在设备实时状态响应中保留 `rsrp``rsrq``rssi``sinr` 四个原始字段,并新增 `signal_quality``signal_bad_reason` 两个综合字段。
#### Scenario: 后台管理端返回综合字段
- **WHEN** 管理员查询设备实时状态且 Gateway 返回了信号相关数据
- **THEN** 响应同时包含原始四个信号字段以及 `signal_quality``signal_bad_reason`
#### Scenario: C 端返回综合字段
- **WHEN** 个人客户查询设备实时状态且 Gateway 返回了信号相关数据
- **THEN** 响应同时包含原始四个信号字段以及 `signal_quality``signal_bad_reason`
### Requirement: 信号好坏字段必须使用面向小白的固定文案
系统 SHALL 使用固定中文文案表达信号综合好坏,不得直接要求用户理解通信技术指标。
#### Scenario: 信号很好
- **WHEN** 多项信号指标显示设备当前网络状态较优
- **THEN** `signal_quality` 返回 `信号很好`
#### Scenario: 信号正常
- **WHEN** 信号指标处于可接受区间且未出现明显异常
- **THEN** `signal_quality` 返回 `信号正常`
#### Scenario: 信号较弱或很差
- **WHEN** 信号指标显示覆盖、质量或抗干扰能力明显下降
- **THEN** `signal_quality` 返回 `信号较弱``信号很差`
#### Scenario: 数据不足
- **WHEN** 四个原始信号字段全部缺失或不足以支撑判断
- **THEN** `signal_quality` 返回 `暂无数据`
### Requirement: 信号怀疑原因字段必须使用怀疑式提示语
系统 SHALL 使用单一的、面向非专业用户的“怀疑原因”文案解释信号异常,不得直接输出技术术语结论。
#### Scenario: 覆盖较弱
- **WHEN** 信号指标更接近覆盖不足问题
- **THEN** `signal_bad_reason` 返回 `怀疑当前位置信号覆盖较弱`
#### Scenario: 干扰较多
- **WHEN** 信号指标更接近干扰或质量下降问题
- **THEN** `signal_bad_reason` 返回 `怀疑周围干扰较多`
#### Scenario: 遮挡较强
- **WHEN** 信号指标更接近设备所处位置存在遮挡的问题
- **THEN** `signal_bad_reason` 返回 `怀疑设备所处位置遮挡较强`
#### Scenario: 网络环境不稳定
- **WHEN** 多项指标波动或组合异常但无法归为单一覆盖问题
- **THEN** `signal_bad_reason` 返回 `怀疑网络环境不稳定`
#### Scenario: 暂时无法判断
- **WHEN** 原始数据缺失或组合不足以产出可信原因
- **THEN** `signal_bad_reason` 返回 `暂时无法判断`
### Requirement: B 端与 C 端必须复用同一套信号摘要口径
系统 SHALL 在后台管理端统一计算信号综合字段,并由 C 端复用同一结果映射,确保两端口径一致。
#### Scenario: 两端返回一致文案
- **WHEN** 同一设备在后台管理端与 C 端分别查询实时状态
- **THEN** 两端返回的 `signal_quality``signal_bad_reason` 含义一致,不出现口径分叉
#### Scenario: 调整规则只需修改一处
- **WHEN** 后续调整信号摘要阈值或文案
- **THEN** 系统只需修改统一计算逻辑,即可同步影响后台管理端与 C 端返回结果