feat: 实现卡和设备的套餐系列绑定功能
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 5m37s

- 添加 Device 和 IotCard 模型的 SeriesID 字段
- 实现 DeviceService 和 IotCardService 的套餐系列绑定逻辑
- 添加 DeviceStore 和 IotCardStore 的数据库操作方法
- 更新 API 接口和路由支持套餐系列绑定
- 创建数据库迁移脚本(000027_add_series_binding_fields)
- 添加完整的单元测试和集成测试
- 更新 OpenAPI 文档
- 归档 OpenSpec 变更文档

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-01-28 19:49:45 +08:00
parent 1da680a790
commit a945a4f554
38 changed files with 2906 additions and 318 deletions

View File

@@ -10,6 +10,7 @@ import (
"time"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/pkg/constants"
pkggorm "github.com/break/junhong_cmp_fiber/pkg/gorm"
"github.com/break/junhong_cmp_fiber/pkg/response"
"github.com/break/junhong_cmp_fiber/tests/testutils/integ"
@@ -474,3 +475,260 @@ func TestIotCard_GetByICCID(t *testing.T) {
assert.NotEqual(t, 0, result.Code, "未认证请求应返回错误码")
})
}
func TestIotCard_BatchSetSeriesBinding(t *testing.T) {
env := integ.NewIntegrationTestEnv(t)
// 创建测试数据
shop := env.CreateTestShop("测试店铺", 1, nil)
agentAccount := env.CreateTestAccount(fmt.Sprintf("agent_%d", time.Now().UnixNano()), "password123", constants.UserTypeAgent, &shop.ID, nil)
// 创建套餐系列和分配
series := createTestPackageSeries(t, env, "测试系列")
allocation := createTestAllocation(t, env, shop.ID, series.ID, 0)
// 创建测试卡(归属于该店铺)
timestamp := time.Now().Unix() % 1000000
cards := []*model.IotCard{
{ICCID: fmt.Sprintf("TEST%06d001", timestamp), CardType: "data_card", CarrierID: 1, Status: 1, ShopID: &shop.ID},
{ICCID: fmt.Sprintf("TEST%06d002", timestamp), CardType: "data_card", CarrierID: 1, Status: 1, ShopID: &shop.ID},
{ICCID: fmt.Sprintf("TEST%06d003", timestamp), CardType: "data_card", CarrierID: 1, Status: 1, ShopID: &shop.ID},
}
for _, card := range cards {
require.NoError(t, env.TX.Create(card).Error)
}
t.Run("批量设置卡系列绑定-成功", func(t *testing.T) {
body := map[string]interface{}{
"iccids": []string{cards[0].ICCID, cards[1].ICCID},
"series_allocation_id": allocation.ID,
}
jsonBody, _ := json.Marshal(body)
resp, err := env.AsUser(agentAccount).Request("PATCH", "/api/admin/iot-cards/series-binding", jsonBody)
require.NoError(t, err)
defer resp.Body.Close()
assert.Equal(t, 200, resp.StatusCode)
var result response.Response
err = json.NewDecoder(resp.Body).Decode(&result)
require.NoError(t, err)
assert.Equal(t, 0, result.Code, "应返回成功: %s", result.Message)
// 验证响应数据
dataMap := result.Data.(map[string]interface{})
assert.Equal(t, float64(2), dataMap["success_count"], "应有2张卡成功绑定")
assert.Equal(t, float64(0), dataMap["fail_count"], "应无失败")
// 验证数据库中数据已更新
var updatedCard model.IotCard
err = env.RawDB().Where("iccid = ?", cards[0].ICCID).First(&updatedCard).Error
require.NoError(t, err)
assert.NotNil(t, updatedCard.SeriesAllocationID)
assert.Equal(t, allocation.ID, *updatedCard.SeriesAllocationID)
})
t.Run("清除卡系列绑定-series_allocation_id=0", func(t *testing.T) {
body := map[string]interface{}{
"iccids": []string{cards[0].ICCID},
"series_allocation_id": 0,
}
jsonBody, _ := json.Marshal(body)
resp, err := env.AsUser(agentAccount).Request("PATCH", "/api/admin/iot-cards/series-binding", jsonBody)
require.NoError(t, err)
defer resp.Body.Close()
assert.Equal(t, 200, resp.StatusCode)
var result response.Response
err = json.NewDecoder(resp.Body).Decode(&result)
require.NoError(t, err)
assert.Equal(t, 0, result.Code)
// 验证数据库中绑定已清除
var updatedCard model.IotCard
err = env.RawDB().Where("iccid = ?", cards[0].ICCID).First(&updatedCard).Error
require.NoError(t, err)
assert.Nil(t, updatedCard.SeriesAllocationID, "系列分配应被清除")
})
t.Run("批量设置-部分卡不存在", func(t *testing.T) {
body := map[string]interface{}{
"iccids": []string{cards[2].ICCID, "NONEXISTENT_ICCID_999"},
"series_allocation_id": allocation.ID,
}
jsonBody, _ := json.Marshal(body)
resp, err := env.AsUser(agentAccount).Request("PATCH", "/api/admin/iot-cards/series-binding", jsonBody)
require.NoError(t, err)
defer resp.Body.Close()
assert.Equal(t, 200, resp.StatusCode)
var result response.Response
err = json.NewDecoder(resp.Body).Decode(&result)
require.NoError(t, err)
assert.Equal(t, 0, result.Code)
// 验证响应数据
dataMap := result.Data.(map[string]interface{})
assert.Equal(t, float64(1), dataMap["success_count"], "应有1张卡成功")
assert.Equal(t, float64(1), dataMap["fail_count"], "应有1张卡失败")
// 验证失败列表
failedItems := dataMap["failed_items"].([]interface{})
assert.Len(t, failedItems, 1)
failedItem := failedItems[0].(map[string]interface{})
assert.Equal(t, "NONEXISTENT_ICCID_999", failedItem["iccid"])
})
t.Run("设置不存在的系列分配-应失败", func(t *testing.T) {
body := map[string]interface{}{
"iccids": []string{cards[2].ICCID},
"series_allocation_id": 999999,
}
jsonBody, _ := json.Marshal(body)
resp, err := env.AsUser(agentAccount).Request("PATCH", "/api/admin/iot-cards/series-binding", jsonBody)
require.NoError(t, err)
defer resp.Body.Close()
var result response.Response
err = json.NewDecoder(resp.Body).Decode(&result)
require.NoError(t, err)
assert.NotEqual(t, 0, result.Code, "不存在的系列分配应返回错误")
})
t.Run("设置禁用的系列分配-应失败", func(t *testing.T) {
// 创建一个禁用的分配
disabledSeries := createTestPackageSeries(t, env, "禁用系列")
disabledAllocation := createTestAllocation(t, env, shop.ID, disabledSeries.ID, 0)
env.TX.Model(&model.ShopSeriesAllocation{}).Where("id = ?", disabledAllocation.ID).Update("status", constants.StatusDisabled)
body := map[string]interface{}{
"iccids": []string{cards[2].ICCID},
"series_allocation_id": disabledAllocation.ID,
}
jsonBody, _ := json.Marshal(body)
resp, err := env.AsUser(agentAccount).Request("PATCH", "/api/admin/iot-cards/series-binding", jsonBody)
require.NoError(t, err)
defer resp.Body.Close()
var result response.Response
err = json.NewDecoder(resp.Body).Decode(&result)
require.NoError(t, err)
assert.NotEqual(t, 0, result.Code, "禁用的系列分配应返回错误")
})
t.Run("代理商设置其他店铺的卡-应失败", func(t *testing.T) {
// 创建另一个店铺和卡
otherShop := env.CreateTestShop("其他店铺", 1, nil)
otherCard := &model.IotCard{
ICCID: fmt.Sprintf("OTH%010d", time.Now().Unix()%10000000000),
CardType: "data_card",
CarrierID: 1,
Status: 1,
ShopID: &otherShop.ID,
}
require.NoError(t, env.TX.Create(otherCard).Error)
body := map[string]interface{}{
"iccids": []string{otherCard.ICCID},
"series_allocation_id": allocation.ID,
}
jsonBody, _ := json.Marshal(body)
resp, err := env.AsUser(agentAccount).Request("PATCH", "/api/admin/iot-cards/series-binding", jsonBody)
require.NoError(t, err)
defer resp.Body.Close()
var result response.Response
err = json.NewDecoder(resp.Body).Decode(&result)
require.NoError(t, err)
// 验证全部失败(因为卡不属于当前店铺)
dataMap := result.Data.(map[string]interface{})
assert.Equal(t, float64(0), dataMap["success_count"], "不应有成功")
assert.Equal(t, float64(1), dataMap["fail_count"], "应全部失败")
})
t.Run("超级管理员可以设置任意店铺的卡", func(t *testing.T) {
// 创建另一个店铺和卡
anotherShop := env.CreateTestShop("另一个店铺", 1, nil)
anotherCard := &model.IotCard{
ICCID: fmt.Sprintf("ADM%010d", time.Now().Unix()%10000000000),
CardType: "data_card",
CarrierID: 1,
Status: 1,
ShopID: &anotherShop.ID,
}
require.NoError(t, env.TX.Create(anotherCard).Error)
// 为这个店铺创建系列分配
anotherAllocation := createTestAllocation(t, env, anotherShop.ID, series.ID, 0)
body := map[string]interface{}{
"iccids": []string{anotherCard.ICCID},
"series_allocation_id": anotherAllocation.ID,
}
jsonBody, _ := json.Marshal(body)
resp, err := env.AsSuperAdmin().Request("PATCH", "/api/admin/iot-cards/series-binding", jsonBody)
require.NoError(t, err)
defer resp.Body.Close()
assert.Equal(t, 200, resp.StatusCode)
var result response.Response
err = json.NewDecoder(resp.Body).Decode(&result)
require.NoError(t, err)
assert.Equal(t, 0, result.Code, "超级管理员应能设置任意店铺的卡")
// 验证成功
dataMap := result.Data.(map[string]interface{})
assert.Equal(t, float64(1), dataMap["success_count"])
})
t.Run("未认证请求应返回错误", func(t *testing.T) {
body := map[string]interface{}{
"iccids": []string{cards[0].ICCID},
"series_allocation_id": allocation.ID,
}
jsonBody, _ := json.Marshal(body)
resp, err := env.ClearAuth().Request("PATCH", "/api/admin/iot-cards/series-binding", jsonBody)
require.NoError(t, err)
defer resp.Body.Close()
var result response.Response
err = json.NewDecoder(resp.Body).Decode(&result)
require.NoError(t, err)
assert.NotEqual(t, 0, result.Code, "未认证请求应返回错误码")
})
t.Run("空ICCID列表-返回成功但无操作", func(t *testing.T) {
body := map[string]interface{}{
"iccids": []string{},
"series_allocation_id": allocation.ID,
}
jsonBody, _ := json.Marshal(body)
resp, err := env.AsUser(agentAccount).Request("PATCH", "/api/admin/iot-cards/series-binding", jsonBody)
require.NoError(t, err)
defer resp.Body.Close()
var result response.Response
err = json.NewDecoder(resp.Body).Decode(&result)
require.NoError(t, err)
assert.Equal(t, 0, result.Code, "当前实现:空列表返回成功")
if result.Data != nil {
dataMap := result.Data.(map[string]interface{})
assert.Equal(t, float64(0), dataMap["success_count"], "空列表无成功项")
}
})
}