Compare commits
2 Commits
6a3fffc46c
...
1ee554daec
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1ee554daec | ||
|
|
34a7cf0f3c |
@@ -5740,6 +5740,16 @@ components:
|
||||
amount:
|
||||
description: 变动金额(分,正数为入账,负数为扣款)
|
||||
type: integer
|
||||
asset_id:
|
||||
description: 资产ID(仅套餐扣款流水有值)
|
||||
minimum: 0
|
||||
type: integer
|
||||
asset_identifier:
|
||||
description: 资产标识快照(ICCID、IMEI或虚拟号,仅套餐扣款流水有值)
|
||||
type: string
|
||||
asset_type:
|
||||
description: 资产类型(iot_card:物联网卡, device:设备,仅套餐扣款流水有值)
|
||||
type: string
|
||||
balance_after:
|
||||
description: 变动后余额(分)
|
||||
type: integer
|
||||
|
||||
@@ -50,6 +50,9 @@ type AgentWalletTransaction struct {
|
||||
ReferenceType *string `gorm:"column:reference_type;type:varchar(50);comment:关联业务类型(order | commission | withdrawal | topup)" json:"reference_type,omitempty"`
|
||||
ReferenceID *uint `gorm:"column:reference_id;comment:关联业务ID" json:"reference_id,omitempty"`
|
||||
RelatedShopID *uint `gorm:"column:related_shop_id;comment:关联店铺ID(代购时记录下级店铺)" json:"related_shop_id,omitempty"`
|
||||
AssetType string `gorm:"column:asset_type;type:varchar(20);not null;default:'';comment:资产类型(iot_card-物联网卡 | device-设备)" json:"asset_type,omitempty"`
|
||||
AssetID uint `gorm:"column:asset_id;not null;default:0;comment:资产ID快照(卡ID或设备ID)" json:"asset_id,omitempty"`
|
||||
AssetIdentifier string `gorm:"column:asset_identifier;type:varchar(100);not null;default:'';comment:资产标识快照(ICCID、IMEI或虚拟号)" json:"asset_identifier,omitempty"`
|
||||
Remark *string `gorm:"column:remark;type:text;comment:备注" json:"remark,omitempty"`
|
||||
Metadata *string `gorm:"column:metadata;type:jsonb;comment:扩展信息(如手续费、支付方式等)" json:"metadata,omitempty"`
|
||||
Creator uint `gorm:"column:creator;not null;comment:创建人ID" json:"creator"`
|
||||
|
||||
@@ -60,6 +60,9 @@ type MainWalletTransactionItem struct {
|
||||
Amount int64 `json:"amount" description:"变动金额(分,正数为入账,负数为扣款)"`
|
||||
BalanceBefore int64 `json:"balance_before" description:"变动前余额(分)"`
|
||||
BalanceAfter int64 `json:"balance_after" description:"变动后余额(分)"`
|
||||
AssetType string `json:"asset_type" description:"资产类型(iot_card:物联网卡, device:设备,仅套餐扣款流水有值)"`
|
||||
AssetID uint `json:"asset_id" description:"资产ID(仅套餐扣款流水有值)"`
|
||||
AssetIdentifier string `json:"asset_identifier" description:"资产标识快照(ICCID、IMEI或虚拟号,仅套餐扣款流水有值)"`
|
||||
Remark string `json:"remark" description:"备注(可为空)"`
|
||||
CreatedAt string `json:"created_at" description:"流水时间"`
|
||||
}
|
||||
|
||||
@@ -728,6 +728,7 @@ func (s *Service) CreateH5Order(ctx context.Context, req *dto.CreateOrderRequest
|
||||
BuyerID: orderBuyerID,
|
||||
IotCardID: req.IotCardID,
|
||||
DeviceID: req.DeviceID,
|
||||
AssetIdentifier: buildOrderAssetIdentifier(validationResult),
|
||||
TotalAmount: totalAmount,
|
||||
PaymentMethod: paymentMethod,
|
||||
PaymentStatus: paymentStatus,
|
||||
@@ -988,11 +989,11 @@ func (s *Service) getCostPrice(ctx context.Context, shopID uint, packageID uint)
|
||||
// ctx: 上下文
|
||||
// tx: 事务对象
|
||||
// wallet: 扣款前的钱包快照
|
||||
// orderID: 订单ID
|
||||
// order: 订单快照
|
||||
// amount: 扣款金额(正数)
|
||||
// purchaseRole: 订单角色
|
||||
// relatedShopID: 关联店铺ID(代购场景填充下级店铺ID)
|
||||
func (s *Service) createWalletTransaction(ctx context.Context, tx *gorm.DB, wallet *model.AgentWallet, orderID uint, amount int64, purchaseRole string, relatedShopID *uint) error {
|
||||
func (s *Service) createWalletTransaction(ctx context.Context, tx *gorm.DB, wallet *model.AgentWallet, order *model.Order, amount int64, purchaseRole string, relatedShopID *uint) error {
|
||||
var subtype *string
|
||||
remark := "购买套餐"
|
||||
if purchaseRole == "" {
|
||||
@@ -1021,6 +1022,7 @@ func (s *Service) createWalletTransaction(ctx context.Context, tx *gorm.DB, wall
|
||||
}
|
||||
|
||||
userID := middleware.GetUserIDFromContext(ctx)
|
||||
assetType, assetID, assetIdentifier := buildAgentWalletTransactionAssetSnapshot(order)
|
||||
|
||||
// 创建钱包流水记录
|
||||
transaction := &model.AgentWalletTransaction{
|
||||
@@ -1034,8 +1036,11 @@ func (s *Service) createWalletTransaction(ctx context.Context, tx *gorm.DB, wall
|
||||
BalanceAfter: wallet.Balance - amount,
|
||||
Status: constants.TransactionStatusSuccess,
|
||||
ReferenceType: strPtr(constants.ReferenceTypeOrder),
|
||||
ReferenceID: &orderID,
|
||||
ReferenceID: &order.ID,
|
||||
RelatedShopID: relatedShopID,
|
||||
AssetType: assetType,
|
||||
AssetID: assetID,
|
||||
AssetIdentifier: assetIdentifier,
|
||||
Remark: &remark,
|
||||
Creator: userID,
|
||||
ShopIDTag: wallet.ShopIDTag,
|
||||
@@ -1049,6 +1054,51 @@ func (s *Service) createWalletTransaction(ctx context.Context, tx *gorm.DB, wall
|
||||
return nil
|
||||
}
|
||||
|
||||
// buildAgentWalletTransactionAssetSnapshot 从订单中生成代理钱包扣款流水的资产快照。
|
||||
func buildAgentWalletTransactionAssetSnapshot(order *model.Order) (string, uint, string) {
|
||||
if order == nil {
|
||||
return "", 0, ""
|
||||
}
|
||||
switch order.OrderType {
|
||||
case model.OrderTypeSingleCard:
|
||||
if order.IotCardID == nil {
|
||||
return constants.AssetTypeIotCard, 0, order.AssetIdentifier
|
||||
}
|
||||
return constants.AssetTypeIotCard, *order.IotCardID, order.AssetIdentifier
|
||||
case model.OrderTypeDevice:
|
||||
if order.DeviceID == nil {
|
||||
return constants.AssetTypeDevice, 0, order.AssetIdentifier
|
||||
}
|
||||
return constants.AssetTypeDevice, *order.DeviceID, order.AssetIdentifier
|
||||
default:
|
||||
return "", 0, order.AssetIdentifier
|
||||
}
|
||||
}
|
||||
|
||||
// buildOrderAssetIdentifier 生成订单资产标识快照。
|
||||
// 后台按标识下单时保留请求标识;按ID下单时用卡ICCID或设备IMEI/虚拟号兜底。
|
||||
func buildOrderAssetIdentifier(result *purchase_validation.PurchaseValidationResult) string {
|
||||
if result == nil {
|
||||
return ""
|
||||
}
|
||||
if result.Card != nil {
|
||||
return result.Card.ICCID
|
||||
}
|
||||
if result.Device != nil {
|
||||
return firstNonEmpty(result.Device.IMEI, result.Device.VirtualNo)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func firstNonEmpty(values ...string) string {
|
||||
for _, value := range values {
|
||||
if value != "" {
|
||||
return value
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// strPtr 字符串指针辅助函数
|
||||
func strPtr(s string) *string {
|
||||
return &s
|
||||
@@ -1113,7 +1163,7 @@ func (s *Service) createOrderWithWalletPayment(ctx context.Context, order *model
|
||||
if order.PurchaseRole == model.PurchaseRolePurchaseForSubordinate {
|
||||
relatedShopID = &buyerShopID
|
||||
}
|
||||
if err := s.createWalletTransaction(ctx, tx, wallet, order.ID, actualAmount, order.PurchaseRole, relatedShopID); err != nil {
|
||||
if err := s.createWalletTransaction(ctx, tx, wallet, order, actualAmount, order.PurchaseRole, relatedShopID); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -1593,7 +1643,7 @@ func (s *Service) WalletPay(ctx context.Context, orderID uint, buyerType string,
|
||||
return errors.New(errors.CodeInsufficientBalance, "余额不足或并发冲突")
|
||||
}
|
||||
|
||||
if err := s.createWalletTransaction(ctx, tx, wallet, order.ID, order.TotalAmount, order.PurchaseRole, nil); err != nil {
|
||||
if err := s.createWalletTransaction(ctx, tx, wallet, order, order.TotalAmount, order.PurchaseRole, nil); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
|
||||
@@ -739,6 +739,9 @@ func (s *Service) ListMainWalletTransactions(ctx context.Context, shopID uint, r
|
||||
Amount: t.Amount,
|
||||
BalanceBefore: t.BalanceBefore,
|
||||
BalanceAfter: t.BalanceAfter,
|
||||
AssetType: t.AssetType,
|
||||
AssetID: t.AssetID,
|
||||
AssetIdentifier: t.AssetIdentifier,
|
||||
Remark: remark,
|
||||
CreatedAt: t.CreatedAt.Format("2006-01-02 15:04:05"),
|
||||
})
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
ALTER TABLE tb_agent_wallet_transaction
|
||||
DROP COLUMN IF EXISTS asset_identifier,
|
||||
DROP COLUMN IF EXISTS asset_id,
|
||||
DROP COLUMN IF EXISTS asset_type;
|
||||
@@ -0,0 +1,10 @@
|
||||
-- 为代理钱包流水新增消费资产快照字段
|
||||
-- 仅新产生的套餐扣款流水在消费发生时写入,存量历史流水不回填。
|
||||
ALTER TABLE tb_agent_wallet_transaction
|
||||
ADD COLUMN IF NOT EXISTS asset_type VARCHAR(20) NOT NULL DEFAULT '',
|
||||
ADD COLUMN IF NOT EXISTS asset_id BIGINT NOT NULL DEFAULT 0,
|
||||
ADD COLUMN IF NOT EXISTS asset_identifier VARCHAR(100) NOT NULL DEFAULT '';
|
||||
|
||||
COMMENT ON COLUMN tb_agent_wallet_transaction.asset_type IS '消费资产类型快照:iot_card-物联网卡 | device-设备';
|
||||
COMMENT ON COLUMN tb_agent_wallet_transaction.asset_id IS '消费资产ID快照(卡ID或设备ID)';
|
||||
COMMENT ON COLUMN tb_agent_wallet_transaction.asset_identifier IS '消费资产标识快照(ICCID、IMEI或虚拟号)';
|
||||
@@ -88,6 +88,7 @@ psql -h $HOST -1 -f output/step2_02_package_usages.sql
|
||||
psql -h $HOST -1 -f output/step2_03_card_data_usage_update.sql
|
||||
psql -h $HOST -1 -f output/step2_04_agent_wallet_init.sql
|
||||
psql -h $HOST -1 -f output/step2_05_agent_wallet_transactions.sql
|
||||
psql -h $HOST -1 -f output/step2_06_asset_series_update.sql
|
||||
```
|
||||
|
||||
`-1` = 单事务,失败回滚整文件;`ON CONFLICT DO NOTHING` 保证重跑安全。
|
||||
@@ -125,7 +126,7 @@ packages: # 同理
|
||||
legacy_meal_name: "三网充电宝全国畅玩年卡套餐..."
|
||||
target_package_id: 2 ← 必填:新库 tb_package.id
|
||||
|
||||
series: # 可选(留 null 表示不写入 tb_iot_card.series_id)
|
||||
series: # 仅保留奇成套餐系列快照,生成器不要求填写
|
||||
- legacy_series_id: "..."
|
||||
legacy_series_name: "国内卡 无预存30天"
|
||||
target_series_id: null
|
||||
@@ -209,6 +210,8 @@ real_used_mb = total_bytes_cnt / (1 + flow_add_discount / 100)
|
||||
|
||||
设备上的卡在新系统运行态会按设备维度判断有效套餐和复机条件,因此迁移时只用设备当前槽位卡(默认 `sim_iccid_1`)作为奇成套餐来源,生成一条 `usage_type=device`、`device_id=设备ID` 的套餐使用记录。其他槽位绑定卡不重复生成主套餐,避免同一设备出现多条并列生效主套餐。
|
||||
|
||||
设备和卡的 `series_id` 以新系统目标套餐为准:脚本通过 `mapping.packages[].target_package_id` 对应的 `tb_package.series_id` 写入资产,并额外生成 `step2_06_asset_series_update.sql`,用于修复已经导入但因 `ON CONFLICT DO NOTHING` 没有被 step1 更新到的资产。
|
||||
|
||||
## 7. 重要约束
|
||||
|
||||
- ❗ 所有奇成查询走 `SET TRANSACTION READ ONLY`,严禁误写。
|
||||
|
||||
@@ -34,12 +34,12 @@ packages:
|
||||
legacy_meal_name: "三网充电宝全国畅玩年卡套餐1000G/月(12个月)"
|
||||
target_package_id: null # 必填:新库 tb_package.id
|
||||
|
||||
# ============== 套餐系列映射(可选) ==============
|
||||
# 仅当你需要给迁移卡设置 tb_iot_card.series_id 时才填
|
||||
# ============== 套餐系列快照(可选) ==============
|
||||
# 仅保留奇成套餐系列供人工参考;资产 series_id 会从 target_package_id 对应的 tb_package.series_id 反查
|
||||
series:
|
||||
- legacy_series_id: "95A038B9D3014F9DA1539EC092D9DBAA"
|
||||
legacy_series_name: "国内卡 无预存30天"
|
||||
target_series_id: null # 可选:新库 tb_package_series.id,null=不写入
|
||||
target_series_id: null # 可选参考字段,生成 SQL 不依赖这里
|
||||
|
||||
# ============== 代理-店铺映射 ==============
|
||||
# scan_legacy 会按"奇成 tbl_card.agent_id"分组写入,你只需填 target_shop_code
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
step2_03_card_data_usage_update.sql - 卡累计流量 UPDATE
|
||||
step2_04_agent_wallet_init.sql - 代理钱包初始化(main + commission)
|
||||
step2_05_agent_wallet_transactions.sql - 代理钱包初始化流水
|
||||
step2_06_asset_series_update.sql - 资产套餐系列回填
|
||||
|
||||
设计原则:
|
||||
- 所有 INSERT 通过 SELECT 子查询动态取 id,不硬编码新库 id
|
||||
@@ -143,6 +144,29 @@ def _device_package_source_iccid(device: DeviceRow) -> str:
|
||||
)
|
||||
|
||||
|
||||
def _target_series_id_expr_for_iccid_19(
|
||||
iccid_19: str,
|
||||
card_metas: dict[str, LegacyCardMeta],
|
||||
mapping: Mapping,
|
||||
) -> str:
|
||||
"""按卡当前套餐映射生成新库套餐系列 SQL 表达式。
|
||||
|
||||
系列归属以新系统目标套餐为准,避免奇成套餐系列与新库系列二次映射不一致。
|
||||
"""
|
||||
if not iccid_19:
|
||||
return "NULL"
|
||||
meta = card_metas.get(iccid_19)
|
||||
if meta is None or not meta.current_meal_id:
|
||||
return "NULL"
|
||||
pkg_map = mapping.lookup_package(meta.current_meal_id)
|
||||
if pkg_map is None or not pkg_map.target_package_id:
|
||||
return "NULL"
|
||||
return (
|
||||
"(SELECT series_id FROM tb_package "
|
||||
f"WHERE id = {pkg_map.target_package_id} AND deleted_at IS NULL LIMIT 1)"
|
||||
)
|
||||
|
||||
|
||||
def _device_by_virtual_no(devices: list[DeviceRow]) -> dict[str, DeviceRow]:
|
||||
return {d.virtual_no: d for d in devices}
|
||||
|
||||
@@ -285,8 +309,7 @@ def _write_iot_cards(
|
||||
|
||||
card_category = "industry" if c.is_industry else "normal"
|
||||
shop_code = _resolve_card_shop_code(c, meta, mapping)
|
||||
series = mapping.lookup_series(_meal_to_series_id(meta, mapping))
|
||||
series_id_expr = str(series.target_series_id) if series and series.target_series_id else "NULL"
|
||||
series_id_expr = _target_series_id_expr_for_iccid_19(c.iccid_19, card_metas, mapping)
|
||||
|
||||
f.write(
|
||||
"INSERT INTO tb_iot_card (\n"
|
||||
@@ -316,19 +339,6 @@ def _write_iot_cards(
|
||||
)
|
||||
|
||||
|
||||
def _meal_to_series_id(meta: LegacyCardMeta, mapping: Mapping) -> str:
|
||||
"""由 meta 的 meal_id 经 mapping.packages 间接拿不到 series,所以这里直接靠
|
||||
mapping.series 用 legacy_series_id 反查就行。简化:返回空字符串(让上层走 series 映射)。
|
||||
|
||||
实际上 series_id 仅在套餐有 meal_series_id 时才有意义。考虑到 LegacyCardMeta 没存
|
||||
series_id,这里返回 '' — 让 series_id_expr 走 NULL 分支。后续若需要,scan_legacy
|
||||
可把 series_id 也下沉到 card_meta。
|
||||
"""
|
||||
_ = meta
|
||||
_ = mapping
|
||||
return ""
|
||||
|
||||
|
||||
def _write_devices(
|
||||
path: Path,
|
||||
devices: list[DeviceRow],
|
||||
@@ -349,13 +359,15 @@ def _write_devices(
|
||||
f.write(_file_header("step1_02 设备资产导入 (tb_device)"))
|
||||
for d in devices:
|
||||
shop_code = _resolve_device_shop_code(d, card_metas, mapping)
|
||||
source_iccid = _device_package_source_iccid(d)
|
||||
series_id_expr = _target_series_id_expr_for_iccid_19(source_iccid, card_metas, mapping)
|
||||
f.write(
|
||||
"INSERT INTO tb_device (\n"
|
||||
" virtual_no, max_sim_slots, imei,\n"
|
||||
" status, asset_status, generation, online_status,\n"
|
||||
" enable_polling, realname_policy,\n"
|
||||
" software_version, switch_mode,\n"
|
||||
" batch_no, shop_id,\n"
|
||||
" batch_no, shop_id, series_id,\n"
|
||||
" creator, updater, created_at, updated_at\n"
|
||||
") VALUES (\n"
|
||||
f" {_sql_str(d.virtual_no)}, {d.max_sim_slots}, "
|
||||
@@ -363,7 +375,7 @@ def _write_devices(
|
||||
" 1, 1, 1, 0,\n"
|
||||
" TRUE, 'after_order',\n"
|
||||
" '', '0',\n"
|
||||
f" {_sql_str(mapping.migration_batch_no)}, {_shop_id_sub(shop_code)},\n"
|
||||
f" {_sql_str(mapping.migration_batch_no)}, {_shop_id_sub(shop_code)}, {series_id_expr},\n"
|
||||
f" {user_id}, {user_id}, NOW(), NOW()\n"
|
||||
")\n"
|
||||
"ON CONFLICT (virtual_no) WHERE deleted_at IS NULL DO NOTHING;\n\n"
|
||||
@@ -607,6 +619,7 @@ def write_step2(
|
||||
output_dir / "step2_02_package_usages.sql", cards, resolved_packages, devices_by_virtual_no, usage_snapshots, user_id,
|
||||
)
|
||||
_write_card_data_usage(output_dir / "step2_03_card_data_usage_update.sql", cards, usage_snapshots)
|
||||
_write_asset_series_update(output_dir / "step2_06_asset_series_update.sql", cards, resolved_packages, devices_by_virtual_no)
|
||||
|
||||
# 代理钱包:只为 mapping.agents 中 target_shop_code 已填的项写入
|
||||
eligible_agents = [a for a in mapping.agents.values() if (a.target_shop_code or "").strip()]
|
||||
@@ -802,6 +815,50 @@ def _write_card_data_usage(path: Path, cards: list[CardRow], usage_snapshots: di
|
||||
)
|
||||
|
||||
|
||||
def _write_asset_series_update(
|
||||
path: Path,
|
||||
cards: list[CardRow],
|
||||
resolved_packages: dict[str, tuple[int, str]],
|
||||
devices_by_virtual_no: dict[str, DeviceRow],
|
||||
) -> None:
|
||||
"""按迁移套餐回填资产套餐系列。
|
||||
|
||||
step1 的 INSERT 遇到已存在资产会 DO NOTHING,所以这里用幂等 UPDATE 覆盖已导入资产的漏写场景。
|
||||
"""
|
||||
with path.open("w", encoding="utf-8") as f:
|
||||
f.write(_file_header("step2_06 资产套餐系列回填 (tb_iot_card / tb_device)"))
|
||||
for c in cards:
|
||||
if c.iccid_19 not in resolved_packages:
|
||||
continue
|
||||
target_pkg_id, _expire = resolved_packages[c.iccid_19]
|
||||
f.write(
|
||||
"UPDATE tb_iot_card c\n"
|
||||
"SET series_id = p.series_id,\n"
|
||||
" updated_at = NOW()\n"
|
||||
"FROM tb_package p\n"
|
||||
f"WHERE c.iccid = {_sql_str(c.iccid_full)} AND c.deleted_at IS NULL\n"
|
||||
f" AND p.id = {target_pkg_id} AND p.deleted_at IS NULL\n"
|
||||
" AND p.series_id IS NOT NULL\n"
|
||||
" AND c.series_id IS DISTINCT FROM p.series_id;\n\n"
|
||||
)
|
||||
|
||||
asset = _runtime_asset_for_card(c, devices_by_virtual_no)
|
||||
if asset is None:
|
||||
continue
|
||||
asset_type, asset_identifier = asset
|
||||
if asset_type == "device":
|
||||
f.write(
|
||||
"UPDATE tb_device d\n"
|
||||
"SET series_id = p.series_id,\n"
|
||||
" updated_at = NOW()\n"
|
||||
"FROM tb_package p\n"
|
||||
f"WHERE d.virtual_no = {_sql_str(asset_identifier)} AND d.deleted_at IS NULL\n"
|
||||
f" AND p.id = {target_pkg_id} AND p.deleted_at IS NULL\n"
|
||||
" AND p.series_id IS NOT NULL\n"
|
||||
" AND d.series_id IS DISTINCT FROM p.series_id;\n\n"
|
||||
)
|
||||
|
||||
|
||||
def _write_agent_wallet_init(
|
||||
path: Path,
|
||||
eligible_agents,
|
||||
|
||||
Reference in New Issue
Block a user