chore: 并入 iotlcm-openapi 历史到 openapi/(原提交 3b05d5a)

This commit is contained in:
MiFaZhan
2026-11-02 13:28:02 +08:00
484 changed files with 34732 additions and 0 deletions
@@ -0,0 +1,297 @@
package query
import (
"context"
"encoding/json"
"testing"
_ "github.com/gogf/gf/contrib/drivers/sqlite/v2"
"github.com/gogf/gf/v2/database/gdb"
"github.com/gogf/gf/v2/frame/g"
"git.5iot.com/iotlcm-public/iotlcm-openapi/internal/consts"
"git.5iot.com/iotlcm-public/iotlcm-openapi/internal/library/contexts"
"git.5iot.com/iotlcm-public/iotlcm-openapi/internal/model"
"github.com/gogf/gf/v2/util/gconv"
// 触发 service.RegisterIotTSimCardInfo,否则 loadSimBatchCards 会 panic
_ "git.5iot.com/iotlcm-public/iotlcm-openapi/internal/logic/openapiiot"
)
// setupImeiBatchDB 建一张最小可用的卡表并灌入覆盖各分支的样本数据。
//
// 样本设计(对齐 §7 测试场景):
// CARD-A 有 imei → 正常命中
// CARD-B imei 为空 → 卡存在但上游未上报值
// CARD-C 有 imei → 正常命中(入库带的值)
// CARD-D 属于另一个部门 → 数据权限过滤后视为 notFound
func setupImeiBatchDB(t *testing.T, ctx context.Context) {
t.Helper()
if err := gdb.SetConfig(gdb.Config{"default": gdb.ConfigGroup{{Type: "sqlite", Link: "sqlite::@file(:memory:)", MaxOpenConnCount: 1}}}); err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = g.DB().Close(ctx) })
if err := g.DB().GetCore().ClearTableFieldsAll(ctx); err != nil {
t.Fatal(err)
}
statements := []string{
`CREATE TABLE t_sim_card_info (
id INTEGER PRIMARY KEY,
iccid TEXT, msisdn TEXT, imsi TEXT, imei TEXT,
boss_card_status INTEGER, used_flow_mb REAL,
imei_sync_time DATETIME, imei_update_time DATETIME,
dept_id INTEGER, tenant_id TEXT
)`,
// FilterAuth 的 GetDeptAndSub 会查 sys_dept(本部门及以下数据权限)
`CREATE TABLE sys_dept (dept_id INTEGER PRIMARY KEY, ancestors TEXT)`,
`INSERT INTO t_sim_card_info
(id, iccid, msisdn, imsi, imei, boss_card_status, used_flow_mb, imei_sync_time, imei_update_time, dept_id, tenant_id)
VALUES
(1,'CARD-A','13800000001','460000000000001','865466070817587',2,10.5,'2026-10-12 04:31:07','2026-10-12 04:31:07',7,'A'),
(2,'CARD-B','13800000002','460000000000002','', 2,20.0,'2026-10-12 04:31:09',NULL, 7,'A'),
(3,'CARD-C','13800000003','460000000000003','123456789012345',2,30.0,NULL, '2026-09-01 08:00:00',7,'A'),
(4,'CARD-D','13800000004','460000000000004','999888777666555',2,40.0,'2026-10-12 04:31:11','2026-10-12 04:31:11',8,'A')`,
}
for _, statement := range statements {
if _, err := g.DB().Exec(ctx, statement); err != nil {
t.Fatal(err)
}
}
}
// apiDataScopeRoleId 对齐 middleware.go:28 的 apiDataScopeRoleId(包私有,此处复刻值)。
const apiDataScopeRoleId int64 = 17
// imeiBatchCtx 复现 ApiCtx 中间件注入的身份:API 客户固定 RoleId=17 +
// SysRoleDataScopeDeptAndBelow(对齐 middleware.go:99),使数据权限过滤真实生效。
func imeiBatchCtx() context.Context {
ctx := contexts.Set(context.Background(), &model.Context{
User: &model.Identity{
BaseClaims: model.BaseClaims{
ID: 0,
TenantId: "A",
DeptId: 7,
Roles: []model.Role{
{RoleId: apiDataScopeRoleId, DataScope: consts.SysRoleDataScopeDeptAndBelow},
},
},
},
AgentUser: &model.BaseClaimsApi{DeptId: 7, TenantId: "A"},
})
contexts.SetDataValue(ctx, "currentRoleId", gconv.String(apiDataScopeRoleId))
contexts.SetDataValue(ctx, "currentRoleDataScope", consts.SysRoleDataScopeDeptAndBelow)
return ctx
}
// TestQuerySimImeiBatchSemantics 覆盖 §7 T11/T12/T16 的核心语义:
// - 有值的卡返回 imei
// - imei 为空但卡存在 → matched=true 且 imei="",不算 notFound(T16)
// - 不存在的卡 → matched=false + CARD_NOT_FOUND(T12)
// - 跨部门的卡 → 数据权限过滤后视为 notFound(T13)
func TestQuerySimImeiBatchSemantics(t *testing.T) {
ctx := imeiBatchCtx()
setupImeiBatchDB(t, ctx)
output, err := NewQuerySim().QuerySimImeiBatch(ctx, &model.QuerySimImeiBatchParam{
AppId: "app",
Cards: []model.QuerySimBatchCard{
{Iccid: "CARD-A"},
{Iccid: "CARD-B"},
{Iccid: "CARD-C"},
{Iccid: "CARD-D"},
{Iccid: "CARD-MISSING"},
},
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if output.Total != 5 {
t.Fatalf("total = %d, want 5", output.Total)
}
// A/B/C 命中,D(跨部门)与 MISSING 未命中
if output.MatchedCount != 3 || output.NotFoundCount != 2 {
t.Fatalf("matched=%d notFound=%d, want 3/2", output.MatchedCount, output.NotFoundCount)
}
if len(output.Items) != 5 {
t.Fatalf("items = %d, want 5 (按输入顺序逐卡返回)", len(output.Items))
}
// 顺序必须与输入一致(契约:按输入顺序逐卡返回)。
// 注意:未命中的 item 不返回 iccid(omitempty),所以必须用下标访问,
// 不能用 iccid 建索引,否则未命中项会取到零值。
ordered := []string{"CARD-A", "CARD-B", "CARD-C", "CARD-D", "CARD-MISSING"}
for i, wantIccid := range ordered {
if i == 3 || i == 4 {
continue // D 与 MISSING 未命中,不返回 iccid
}
if output.Items[i].Iccid != wantIccid {
t.Fatalf("items[%d].iccid = %q, want %q(必须保持输入顺序)", i, output.Items[i].Iccid, wantIccid)
}
}
a, b, c := output.Items[0], output.Items[1], output.Items[2]
d, missing := output.Items[3], output.Items[4]
if !a.Matched || a.Imei != "865466070817587" {
t.Fatalf("CARD-A: %+v", a)
}
// CARD-B:imei 为空但 matched=true(关键语义,不算 notFound)
if !b.Matched {
t.Fatalf("CARD-B 应为 matched=true(卡存在,只是上游没给值): %+v", b)
}
if b.Imei != "" {
t.Fatalf("CARD-B imei 应为空串: %q", b.Imei)
}
// CARD-C:入库就带值
if !c.Matched || c.Imei != "123456789012345" {
t.Fatalf("CARD-C: %+v", c)
}
// CARD-D / MISSING:未命中,且不泄露 imei
for _, tc := range []struct {
name string
item model.QuerySimImeiBatchItem
}{{"CARD-D(跳部门)", d}, {"CARD-MISSING(不存在)", missing}} {
item := tc.item
if item.Matched {
t.Fatalf("%s 不应命中: %+v", tc.name, item)
}
if item.ErrorCode != "CARD_NOT_FOUND" {
t.Fatalf("%s errorCode = %q, want CARD_NOT_FOUND", tc.name, item.ErrorCode)
}
if item.Imei != "" {
t.Fatalf("%s 未命中时不应返回 imei: %q", tc.name, item.Imei)
}
}
}
// TestQuerySimImeiBatchValidation 覆盖 §7 T14/T15 的参数校验。
func TestQuerySimImeiBatchValidation(t *testing.T) {
ctx := imeiBatchCtx()
setupImeiBatchDB(t, ctx)
svc := NewQuerySim()
cases := []struct {
name string
in *model.QuerySimImeiBatchParam
}{
{"nil 参数", nil},
{"appId 为空", &model.QuerySimImeiBatchParam{Cards: []model.QuerySimBatchCard{{Iccid: "CARD-A"}}}},
{"cards 为空", &model.QuerySimImeiBatchParam{AppId: "app"}},
}
for _, tc := range cases {
if _, err := svc.QuerySimImeiBatch(ctx, tc.in); err == nil {
t.Fatalf("%s: 应返回错误", tc.name)
}
}
// 超过 1000 张(T14)
tooMany := make([]model.QuerySimBatchCard, 0, querySimBatchMaxCards+1)
for i := 0; i <= querySimBatchMaxCards; i++ {
tooMany = append(tooMany, model.QuerySimBatchCard{Iccid: "CARD-" + string(rune('0'+i%10)) + string(rune('0'+i/10%10)) + string(rune('0'+i/100%10)) + string(rune('0'+i/1000%10))})
}
if _, err := svc.QuerySimImeiBatch(ctx, &model.QuerySimImeiBatchParam{AppId: "app", Cards: tooMany}); err == nil {
t.Fatal("超过 1000 张应被拒绝")
}
// 重复标识(parseSimBatchIdentifiers 既有逻辑)
if _, err := svc.QuerySimImeiBatch(ctx, &model.QuerySimImeiBatchParam{
AppId: "app",
Cards: []model.QuerySimBatchCard{{Iccid: "CARD-A"}, {Iccid: "CARD-A"}},
}); err == nil {
t.Fatal("重复标识应被拒绝")
}
// 单标识超长(simBatchIdentifierMaxLen)
longIccid := make([]byte, simBatchIdentifierMaxLen+1)
for i := range longIccid {
longIccid[i] = '9'
}
if _, err := svc.QuerySimImeiBatch(ctx, &model.QuerySimImeiBatchParam{
AppId: "app",
Cards: []model.QuerySimBatchCard{{Iccid: string(longIccid)}},
}); err == nil {
t.Fatal("超长标识应被拒绝")
}
// 多标识互相矛盾:ICCID 属于 CARD-A 但 MSISDN 属于 CARD-B(既有校验逻辑)
if _, err := svc.QuerySimImeiBatch(ctx, &model.QuerySimImeiBatchParam{
AppId: "app",
Cards: []model.QuerySimBatchCard{{Iccid: "CARD-A", Msisdn: "13800000002"}},
}); err != nil {
t.Fatalf("多标识查询本身应合法: %v", err)
}
// 矛盾组合应返回 notFound 而非错误(simBatchCardMatchesIdentifier 拦下)
conflict, err := svc.QuerySimImeiBatch(ctx, &model.QuerySimImeiBatchParam{
AppId: "app",
Cards: []model.QuerySimBatchCard{{Iccid: "CARD-A", Msisdn: "13800000002"}},
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if conflict.NotFoundCount != 1 || conflict.Items[0].Matched {
t.Fatalf("矛盾的 iccid+msisdn 组合应判为 notFound: %+v", conflict.Items[0])
}
}
// TestQuerySimImeiBatchByMsisdnAndImsi 验证三种标识都能定位到卡。
func TestQuerySimImeiBatchByMsisdnAndImsi(t *testing.T) {
ctx := imeiBatchCtx()
setupImeiBatchDB(t, ctx)
output, err := NewQuerySim().QuerySimImeiBatch(ctx, &model.QuerySimImeiBatchParam{
AppId: "app",
Cards: []model.QuerySimBatchCard{
{Msisdn: "13800000001"},
{Imsi: "460000000000003"},
},
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if output.MatchedCount != 2 {
t.Fatalf("matched = %d, want 2", output.MatchedCount)
}
for _, item := range output.Items {
if !item.Matched || item.Iccid == "" {
t.Fatalf("应通过 msisdn/imsi 定位到卡并回填 iccid: %+v", item)
}
}
}
// TestQuerySimImeiBatchItemJSONShape 锁死对外字段集:只允许
// matched / iccid / imei / errorCode / errorMessage,任何额外字段(例如
// imeiSyncTime、imeiUpdateTime)都会让该用例失败,避免内部字段泄露给客户。
func TestQuerySimImeiBatchItemJSONShape(t *testing.T) {
ctx := imeiBatchCtx()
setupImeiBatchDB(t, ctx)
output, err := NewQuerySim().QuerySimImeiBatch(ctx, &model.QuerySimImeiBatchParam{
AppId: "app",
Cards: []model.QuerySimBatchCard{{Iccid: "CARD-A"}, {Iccid: "CARD-MISSING"}},
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
allowed := map[string]bool{
"matched": true, "iccid": true, "imei": true,
"errorCode": true, "errorMessage": true,
}
for i, item := range output.Items {
raw, marshalErr := json.Marshal(item)
if marshalErr != nil {
t.Fatalf("items[%d] 序列化失败: %v", i, marshalErr)
}
var fields map[string]any
if err := json.Unmarshal(raw, &fields); err != nil {
t.Fatalf("items[%d] 反序列化失败: %v", i, err)
}
for name := range fields {
if !allowed[name] {
t.Fatalf("items[%d] 出现非预期对外字段 %q: %s", i, name, raw)
}
}
}
}
+312
View File
@@ -0,0 +1,312 @@
package query
import (
"context"
"strings"
"github.com/gogf/gf/v2/errors/gerror"
"git.5iot.com/iotlcm-public/iotlcm-openapi/internal/consts"
"git.5iot.com/iotlcm-public/iotlcm-openapi/internal/dao"
"git.5iot.com/iotlcm-public/iotlcm-openapi/internal/model"
"git.5iot.com/iotlcm-public/iotlcm-openapi/internal/model/entity"
"git.5iot.com/iotlcm-public/iotlcm-openapi/internal/service"
)
// querySimBatchMaxCards 批量查询单次的卡数量上限(与 QueryTrafficCutoffConfig 同量级)。
// 纯本地库读、不调上游,故偏保守;如需更大块,改这一个常量即可(注意 IN 列表与报文大小)。
const querySimBatchMaxCards = 1000
// simBatchIdentifierMaxLen 单标识允许的最大字符数,对齐 t_sim_card_info 列宽(varchar(32))并留余量,
// 用于把请求体内存占用钉死在可控范围(1000 张 * 3 字段 * 64 ≈ 192KB),防止超长字符串滥用。
const simBatchIdentifierMaxLen = 64
type simBatchIdentifier struct {
Msisdn string
Iccid string
Imsi string
}
func validateSimBatchIdentifier(item simBatchIdentifier) error {
count := 0
if item.Msisdn != "" {
if len(item.Msisdn) > simBatchIdentifierMaxLen {
return gerror.New("msisdn长度超限")
}
count++
}
if item.Iccid != "" {
if len(item.Iccid) > simBatchIdentifierMaxLen {
return gerror.New("iccid长度超限")
}
count++
}
if item.Imsi != "" {
if len(item.Imsi) > simBatchIdentifierMaxLen {
return gerror.New("imsi长度超限")
}
count++
}
if count == 0 {
return gerror.New("msisdn、iccid、imsi至少传一个")
}
return nil
}
// simBatchCardMatchesIdentifier 校验返回的卡是否同时匹配输入里提供的所有标识。
// 防止单条输入同时填了多个互相矛盾的标识(如 iccid 属于 A、msisdn 属于 B)时,
// 仍返回优先级最高那个标识对应卡的数据。沿用 traffic_cutoff_config.go 的同名校验逻辑。
func simBatchCardMatchesIdentifier(card *entity.TSimCardInfo, identifier simBatchIdentifier) bool {
if card == nil {
return false
}
if identifier.Iccid != "" && strings.TrimSpace(card.Iccid) != identifier.Iccid {
return false
}
if identifier.Msisdn != "" && strings.TrimSpace(card.Msisdn) != identifier.Msisdn {
return false
}
if identifier.Imsi != "" && strings.TrimSpace(card.Imsi) != identifier.Imsi {
return false
}
return true
}
func simBatchIdentifierKey(item simBatchIdentifier) string {
if item.Iccid != "" {
return "iccid:" + item.Iccid
}
if item.Msisdn != "" {
return "msisdn:" + item.Msisdn
}
return "imsi:" + item.Imsi
}
// parseSimBatchIdentifiers 校验并去重,返回按 iccid/msisdn/imsi 分桶的标识列表。
func parseSimBatchIdentifiers(cards []model.QuerySimBatchCard) ([]simBatchIdentifier, error) {
identifiers := make([]simBatchIdentifier, 0, len(cards))
seen := make(map[string]struct{}, len(cards))
for _, item := range cards {
identifier := simBatchIdentifier{
Msisdn: strings.TrimSpace(item.Msisdn),
Iccid: strings.TrimSpace(item.Iccid),
Imsi: strings.TrimSpace(item.Imsi),
}
if err := validateSimBatchIdentifier(identifier); err != nil {
return nil, err
}
key := simBatchIdentifierKey(identifier)
if _, ok := seen[key]; ok {
return nil, gerror.Newf("卡片标识重复:%s", key)
}
seen[key] = struct{}{}
identifiers = append(identifiers, identifier)
}
return identifiers, nil
}
// loadSimBatchCards 按标识分桶做 WhereIn 查询(自动带部门数据权限),返回 type:key -> 卡 的映射。
func loadSimBatchCards(ctx context.Context, identifiers []simBatchIdentifier) (map[string]*entity.TSimCardInfo, error) {
columns := dao.TSimCardInfo.Columns()
result := make(map[string]*entity.TSimCardInfo, len(identifiers))
lookup := func(field string, values []string) error {
if len(values) == 0 {
return nil
}
var cards []*entity.TSimCardInfo
err := service.IotTSimCardInfo().Model(ctx).
Fields(columns.Iccid, columns.Msisdn, columns.Imsi, columns.BossCardStatus, columns.UsedFlowMb,
columns.Imei).
WhereIn(field, values).Scan(&cards)
if err != nil {
return gerror.Wrap(err, "批量查询卡信息失败")
}
for _, card := range cards {
if card == nil {
continue
}
if card.Iccid != "" {
result["iccid:"+strings.TrimSpace(card.Iccid)] = card
}
if card.Msisdn != "" {
result["msisdn:"+strings.TrimSpace(card.Msisdn)] = card
}
if card.Imsi != "" {
result["imsi:"+strings.TrimSpace(card.Imsi)] = card
}
}
return nil
}
iccidValues, msisdnValues, imsiValues := make([]string, 0), make([]string, 0), make([]string, 0)
for _, item := range identifiers {
switch {
case item.Iccid != "":
iccidValues = append(iccidValues, item.Iccid)
case item.Msisdn != "":
msisdnValues = append(msisdnValues, item.Msisdn)
default:
imsiValues = append(imsiValues, item.Imsi)
}
}
if err := lookup(columns.Iccid, iccidValues); err != nil {
return nil, err
}
if err := lookup(columns.Msisdn, msisdnValues); err != nil {
return nil, err
}
if err := lookup(columns.Imsi, imsiValues); err != nil {
return nil, err
}
return result, nil
}
// QuerySimStatusBatch 批量查询卡状态(纯读本地库,不调上游通道)。
// 返回结构与单卡 QuerySimStatus 一致(iccid / lifeCycle 内部码 / lifeCycleName),
// 但按输入顺序逐卡返回,未匹配项用 Matched=false + errorCode 标记。
func (s *sQuerySim) QuerySimStatusBatch(ctx context.Context, in *model.QuerySimStatusBatchParam) (*model.QuerySimStatusBatchOutput, error) {
if in == nil {
return nil, gerror.New("查询参数不能为空")
}
if strings.TrimSpace(in.AppId) == "" {
return nil, gerror.New("appId不能为空")
}
if len(in.Cards) == 0 {
return nil, gerror.New("查询卡不能为空")
}
if len(in.Cards) > querySimBatchMaxCards {
return nil, gerror.Newf("单次最多查询%d张卡", querySimBatchMaxCards)
}
identifiers, err := parseSimBatchIdentifiers(in.Cards)
if err != nil {
return nil, err
}
cardMap, err := loadSimBatchCards(ctx, identifiers)
if err != nil {
return nil, err
}
output := &model.QuerySimStatusBatchOutput{
Total: len(identifiers),
Items: make([]model.QuerySimStatusBatchItem, 0, len(identifiers)),
}
for _, identifier := range identifiers {
result := model.QuerySimStatusBatchItem{}
card := cardMap[simBatchIdentifierKey(identifier)]
if card == nil || !simBatchCardMatchesIdentifier(card, identifier) {
result.ErrorCode = "CARD_NOT_FOUND"
result.ErrorMessage = "卡片不存在或无权限"
output.NotFoundCount++
output.Items = append(output.Items, result)
continue
}
result.Matched = true
result.Iccid = strings.TrimSpace(card.Iccid)
statusName, ok := consts.V3ApiSimBossCardStatusNameMap[card.BossCardStatus]
if !ok {
statusName = "未知"
}
result.LifeCycle = card.BossCardStatus
result.LifeCycleName = statusName
output.MatchedCount++
output.Items = append(output.Items, result)
}
return output, nil
}
// QuerySimUsageBatch 批量查询卡流量(used_flow_mb,纯读本地库)。
func (s *sQuerySim) QuerySimUsageBatch(ctx context.Context, in *model.QuerySimUsageBatchParam) (*model.QuerySimUsageBatchOutput, error) {
if in == nil {
return nil, gerror.New("查询参数不能为空")
}
if strings.TrimSpace(in.AppId) == "" {
return nil, gerror.New("appId不能为空")
}
if len(in.Cards) == 0 {
return nil, gerror.New("查询卡不能为空")
}
if len(in.Cards) > querySimBatchMaxCards {
return nil, gerror.Newf("单次最多查询%d张卡", querySimBatchMaxCards)
}
identifiers, err := parseSimBatchIdentifiers(in.Cards)
if err != nil {
return nil, err
}
cardMap, err := loadSimBatchCards(ctx, identifiers)
if err != nil {
return nil, err
}
output := &model.QuerySimUsageBatchOutput{
Total: len(identifiers),
Items: make([]model.QuerySimUsageBatchItem, 0, len(identifiers)),
}
for _, identifier := range identifiers {
result := model.QuerySimUsageBatchItem{}
card := cardMap[simBatchIdentifierKey(identifier)]
if card == nil || !simBatchCardMatchesIdentifier(card, identifier) {
result.ErrorCode = "CARD_NOT_FOUND"
result.ErrorMessage = "卡片不存在或无权限"
output.NotFoundCount++
output.Items = append(output.Items, result)
continue
}
result.Matched = true
result.Iccid = strings.TrimSpace(card.Iccid)
result.UseUnit = "MB"
result.UseAmount = card.UsedFlowMb
output.MatchedCount++
output.Items = append(output.Items, result)
}
return output, nil
}
// QuerySimImeiBatch 批量查询卡 IMEI(纯读本地库,不调上游通道)。
//
// 数据来源是 t_sim_card_info.imei 快照,由 worker 的 IMEI 全量同步链路维护;
// 另有一个补充来源:单卡 /openapi/v3/query/sim-imei 在库值为空时会实时调运营商
// 并回写(不写 imei_sync_time,只写 imei_update_time)。
//
// 语义要点:
// - imei 为空**不算 notFound**:卡存在但上游从未查到值(如不支持的模板通道)。
// - 本接口**不触发任何上游请求**,是纯本地库读(与 sim-status-batch 一致)。
func (s *sQuerySim) QuerySimImeiBatch(ctx context.Context, in *model.QuerySimImeiBatchParam) (*model.QuerySimImeiBatchOutput, error) {
if in == nil {
return nil, gerror.New("查询参数不能为空")
}
if strings.TrimSpace(in.AppId) == "" {
return nil, gerror.New("appId不能为空")
}
if len(in.Cards) == 0 {
return nil, gerror.New("查询卡不能为空")
}
if len(in.Cards) > querySimBatchMaxCards {
return nil, gerror.Newf("单次最多查询%d张卡", querySimBatchMaxCards)
}
identifiers, err := parseSimBatchIdentifiers(in.Cards)
if err != nil {
return nil, err
}
cardMap, err := loadSimBatchCards(ctx, identifiers)
if err != nil {
return nil, err
}
output := &model.QuerySimImeiBatchOutput{
Total: len(identifiers),
Items: make([]model.QuerySimImeiBatchItem, 0, len(identifiers)),
}
for _, identifier := range identifiers {
result := model.QuerySimImeiBatchItem{}
card := cardMap[simBatchIdentifierKey(identifier)]
if card == nil || !simBatchCardMatchesIdentifier(card, identifier) {
result.ErrorCode = "CARD_NOT_FOUND"
result.ErrorMessage = "卡片不存在或无权限"
output.NotFoundCount++
output.Items = append(output.Items, result)
continue
}
result.Matched = true
result.Iccid = strings.TrimSpace(card.Iccid)
result.Imei = strings.TrimSpace(card.Imei)
output.MatchedCount++
output.Items = append(output.Items, result)
}
return output, nil
}
@@ -0,0 +1,74 @@
package query
import (
"context"
"fmt"
"strings"
"github.com/gogf/gf/v2/errors/gerror"
"git.5iot.com/iotlcm-public/iotlcm-openapi/internal/consts"
"git.5iot.com/iotlcm-public/iotlcm-openapi/internal/dao"
"git.5iot.com/iotlcm-public/iotlcm-openapi/internal/library/contexts"
"git.5iot.com/iotlcm-public/iotlcm-openapi/internal/model"
)
type batchStatusRecord struct {
Id int64 `orm:"id"`
BatchNo string `orm:"batch_no"`
BatchAsyncStatus int `orm:"batch_async_status"`
SourceChannel string `orm:"source_channel"`
ExecutionMode int `orm:"execution_mode"`
SourceType int `orm:"source_type"`
TotalCount int `orm:"total_count"`
ValidCount int `orm:"valid_count"`
SuccessCount int `orm:"success_count"`
FailCount int `orm:"fail_count"`
RunResult int `orm:"run_result"`
FailReason string `orm:"fail_reason"`
}
func (s *sQuerySim) QueryBatchStatus(ctx context.Context, in *model.QueryBatchStatusParam) (*model.QueryBatchStatusOutput, error) {
if in == nil {
return nil, gerror.New("查询参数不能为空")
}
if strings.TrimSpace(in.AppId) == "" {
return nil, gerror.New("appId不能为空")
}
batchNo := strings.TrimSpace(in.BatchNo)
if batchNo == "" {
return nil, gerror.New("batchNo不能为空")
}
tenantID := strings.TrimSpace(contexts.GetTenantId(ctx))
deptID := contexts.GetAgentDeptId(ctx)
if tenantID == "" || deptID <= 0 {
return nil, gerror.New("当前接口调用方信息不完整")
}
columns := dao.BatchMain.Columns()
var batch batchStatusRecord
err := dao.BatchMain.Ctx(ctx).
Where(columns.TenantId, tenantID).
Where(columns.DeptId, deptID).
Where(columns.BatchNo, batchNo).
Where(fmt.Sprintf("NOT (%s = ? AND COALESCE(%s, '') <> '' AND %s <> %s)", columns.BatchType, columns.SourceBatchNo, columns.SourceBatchNo, columns.BatchNo), consts.BatchTypeAllocate).
Scan(&batch)
if err != nil {
return nil, gerror.Wrap(err, "查询批次状态失败")
}
if batch.Id == 0 {
return nil, gerror.New("批次不存在或无权限")
}
return &model.QueryBatchStatusOutput{
BatchNo: batch.BatchNo,
BatchAsyncStatus: batch.BatchAsyncStatus,
SourceChannel: batch.SourceChannel,
ExecutionMode: batch.ExecutionMode,
SourceType: batch.SourceType,
TotalCount: batch.TotalCount,
ValidCount: batch.ValidCount,
SuccessCount: batch.SuccessCount,
FailCount: batch.FailCount,
RunResult: batch.RunResult,
FailReason: batch.FailReason,
}, nil
}
@@ -0,0 +1,47 @@
package query
import (
"context"
"testing"
_ "github.com/gogf/gf/contrib/drivers/sqlite/v2"
"github.com/gogf/gf/v2/database/gdb"
"github.com/gogf/gf/v2/frame/g"
"git.5iot.com/iotlcm-public/iotlcm-openapi/internal/library/contexts"
"git.5iot.com/iotlcm-public/iotlcm-openapi/internal/model"
)
func TestBatchStatusScopeAndAllocationChildren(t *testing.T) {
ctx := context.Background()
if err := gdb.SetConfig(gdb.Config{"default": gdb.ConfigGroup{{Type: "sqlite", Link: "sqlite::@file(:memory:)", MaxOpenConnCount: 1}}}); err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = g.DB().Close(ctx) })
if err := g.DB().GetCore().ClearTableFieldsAll(ctx); err != nil {
t.Fatal(err)
}
for _, statement := range []string{
"CREATE TABLE batch_main (id INTEGER PRIMARY KEY, batch_no TEXT, batch_type INTEGER, source_batch_no TEXT, tenant_id TEXT, dept_id INTEGER, batch_async_status INTEGER, source_channel TEXT, execution_mode INTEGER, source_type INTEGER, total_count INTEGER, valid_count INTEGER, success_count INTEGER, fail_count INTEGER, run_result INTEGER, fail_reason TEXT)",
"INSERT INTO batch_main VALUES (1,'mine',101,'mine','A',7,3,'api',1,2,1,1,1,0,2,''),(2,'other-tenant',101,'','B',7,3,'api',1,2,1,1,1,0,2,''),(3,'other-dept',101,'','A',8,3,'api',1,2,1,1,1,0,2,''),(4,'parent',3,'','A',7,2,'api',2,2,2,2,1,0,1,''),(5,'child',3,'parent','A',7,2,'api',2,2,1,1,0,0,1,''),(6,'self',3,'self','A',7,3,'api',2,2,1,1,1,0,2,'')",
} {
if _, err := g.DB().Exec(ctx, statement); err != nil {
t.Fatal(err)
}
}
ctx = contexts.Set(ctx, &model.Context{
User: &model.Identity{BaseClaims: model.BaseClaims{TenantId: "A"}},
AgentUser: &model.BaseClaimsApi{DeptId: 7, TenantId: "A"},
})
for _, batchNo := range []string{"mine", "parent", "self"} {
result, err := NewQuerySim().QueryBatchStatus(ctx, &model.QueryBatchStatusParam{AppId: "app", BatchNo: batchNo})
if err != nil || result == nil || result.BatchNo != batchNo || result.SourceChannel != "api" {
t.Fatalf("batch %s: result=%+v err=%v", batchNo, result, err)
}
}
for _, batchNo := range []string{"other-tenant", "other-dept", "child", "missing"} {
if _, err := NewQuerySim().QueryBatchStatus(ctx, &model.QueryBatchStatusParam{AppId: "app", BatchNo: batchNo}); err == nil {
t.Fatalf("batch exposed: %s", batchNo)
}
}
}
+426
View File
@@ -0,0 +1,426 @@
package query
import (
"context"
"fmt"
"git.5iot.com/iotlcm-public/iotlcm-openapi/internal/consts"
"git.5iot.com/iotlcm-public/iotlcm-openapi/internal/dao"
"git.5iot.com/iotlcm-public/iotlcm-openapi/internal/model"
"git.5iot.com/iotlcm-public/iotlcm-openapi/internal/service"
"math"
"strings"
"github.com/gogf/gf/v2/errors/gerror"
"github.com/gogf/gf/v2/frame/g"
"github.com/gogf/gf/v2/os/gtime"
)
type sQuerySim struct {
}
func NewQuerySim() *sQuerySim {
return &sQuerySim{}
}
func init() {
service.RegisterQuerySim(NewQuerySim())
}
func (s *sQuerySim) querySim(ctx context.Context, input *model.QuerySimInput) (output *model.QuerySimInfoCard, err error) {
if input.Iccid == "" && input.Msisdn == "" && input.Imsi == "" {
g.Log().Debugf(ctx, "QuerySim.QuerySimCard, Iccid, Msisdn, Imsi are all empty, input: %v", input)
return nil, gerror.New("Iccid, Msisdn, Imsi 不能全为空")
}
field := ""
value := ""
if input.Iccid != "" {
field = dao.TSimCardInfo.Columns().Iccid
value = input.Iccid
} else if input.Msisdn != "" {
field = dao.TSimCardInfo.Columns().Msisdn
value = input.Msisdn
} else if input.Imsi != "" {
field = dao.TSimCardInfo.Columns().Imsi
value = input.Imsi
}
cardInfoTable := dao.TSimCardInfo.Table()
sysDept := dao.SysDept.Table()
flowPackage := dao.TSimFlowPackageInfo.Table()
packageCategory := dao.TSimPackageCategory.Table()
err = service.IotTSimCardInfo().
Model(ctx).
Where(field, value).
LeftJoin(sysDept, sysDept+"."+dao.SysDept.Columns().DeptId+"="+cardInfoTable+"."+dao.TSimCardInfo.Columns().DeptId).
LeftJoin(flowPackage, flowPackage+"."+dao.TSimFlowPackageInfo.Columns().PackageCode+"="+cardInfoTable+"."+dao.TSimCardInfo.Columns().CurrentPackageCode).
LeftJoin(packageCategory, packageCategory+"."+dao.TSimPackageCategory.Columns().CategoryCode+"="+flowPackage+"."+dao.TSimFlowPackageInfo.Columns().CategoryCode).
Fields(
cardInfoTable+".*",
sysDept+"."+dao.SysDept.Columns().DeptName+" as DeptName",
sysDept+"."+dao.SysDept.Columns().DeptKey+" as DeptKey",
sysDept+"."+dao.SysDept.Columns().AreaCode+" as DeptAreaCode",
flowPackage+"."+dao.TSimFlowPackageInfo.Columns().PackageName+" as PackageName",
flowPackage+"."+dao.TSimFlowPackageInfo.Columns().CategoryCode+" as CategoryCode",
packageCategory+"."+dao.TSimPackageCategory.Columns().CategoryName+" as CategoryName",
).
Limit(1).
Scan(&output)
if err != nil {
g.Log().Debugf(ctx, "QuerySim.QuerySimCard, List sim card info failed, input: %v, err: %v", input, err)
return nil, gerror.New("查询sim卡信息失败,请确认ICCID,MSISDN,IMSI是否正确")
}
if output == nil {
return nil, gerror.New("查询sim卡信息失败,请确认ICCID,MSISDN,IMSI是否正确")
}
return output, nil
}
func queryDate(t *gtime.Time) string {
if t == nil || t.IsZero() {
return ""
}
return t.Format("Y-m-d")
}
func queryDateTime(t *gtime.Time) string {
if t == nil || t.IsZero() {
return ""
}
return t.Format("Y-m-d H:i:s")
}
func queryRemaining(total, used float64) string {
remaining := total - used
if remaining < 0 {
remaining = 0
}
return fmt.Sprintf("%.1f", remaining)
}
func queryRemainingInt(total, used int) string {
remaining := total - used
if remaining < 0 {
remaining = 0
}
return fmt.Sprintf("%d", remaining)
}
func queryValidityPeriod(begin, end *gtime.Time) int {
if begin == nil || begin.IsZero() || end == nil || end.IsZero() {
return 0
}
beginTime := begin.Time
endTime := end.Time
months := (endTime.Year()-beginTime.Year())*12 + int(endTime.Month()-beginTime.Month())
if endTime.Day() >= beginTime.Day()-1 {
months++
}
if months < 0 {
return 0
}
return months
}
func possibleRegionCodes(regionCode string) []string {
seen := map[string]struct{}{}
codes := make([]string, 0, 4)
add := func(code string) {
if code == "" {
return
}
if _, ok := seen[code]; ok {
return
}
seen[code] = struct{}{}
codes = append(codes, code)
}
add(regionCode)
if len(regionCode) == 12 {
if regionCode[0:2] != "00" {
add(regionCode[0:2] + "0000000000")
}
if regionCode[2:4] != "00" {
add(regionCode[0:4] + "00000000")
}
if regionCode[4:6] != "00" {
add(regionCode[0:6] + "000000")
}
} else {
if len(regionCode) >= 4 {
add(regionCode[:2] + "0000")
}
if len(regionCode) >= 6 {
add(regionCode[:4] + "00")
}
}
return codes
}
func (s *sQuerySim) queryLocation(ctx context.Context, regionCode string) string {
regionCode = strings.TrimSpace(regionCode)
if regionCode == "" {
return ""
}
type division struct {
RegionCode string `json:"regionCode"`
RegionName string `json:"regionName"`
RegionParent string `json:"regionParent"`
}
var divisions []*division
err := dao.TDivisionCode.Ctx(ctx).
WhereIn(dao.TDivisionCode.Columns().RegionCode, possibleRegionCodes(regionCode)).
Scan(&divisions)
if err != nil || len(divisions) == 0 {
return regionCode
}
byCode := make(map[string]*division, len(divisions))
for _, item := range divisions {
byCode[item.RegionCode] = item
}
item := byCode[regionCode]
if item == nil {
return regionCode
}
names := make([]string, 0, 4)
for item != nil {
if item.RegionName != "" {
names = append([]string{item.RegionName}, names...)
}
if item.RegionParent == "" || item.RegionParent == "0" {
break
}
item = byCode[item.RegionParent]
}
if len(names) == 0 {
return regionCode
}
if names[0] != "中国" {
names = append([]string{"中国"}, names...)
}
return strings.Join(names, "/")
}
func (s *sQuerySim) queryLocationByAreaCode(ctx context.Context, areaCode string) string {
areaCode = strings.TrimSpace(areaCode)
if areaCode == "" {
return ""
}
type division struct {
RegionCode string `json:"regionCode"`
RegionName string `json:"regionName"`
RegionParent string `json:"regionParent"`
}
byCode := make(map[string]*division)
currentCode := ""
for _, code := range possibleRegionCodes(areaCode) {
var item *division
err := dao.TDivisionCode.Ctx(ctx).
Where(dao.TDivisionCode.Columns().RegionCode, code).
Scan(&item)
if err != nil {
return areaCode
}
if item == nil {
continue
}
byCode[item.RegionCode] = item
currentCode = item.RegionCode
break
}
if currentCode == "" {
return areaCode
}
names := make([]string, 0, 4)
for currentCode != "" {
item := byCode[currentCode]
if item == nil {
err := dao.TDivisionCode.Ctx(ctx).
Where(dao.TDivisionCode.Columns().RegionCode, currentCode).
Scan(&item)
if err != nil {
return areaCode
}
if item == nil {
break
}
byCode[item.RegionCode] = item
}
if item.RegionName != "" {
names = append([]string{item.RegionName}, names...)
}
if item.RegionParent == "" || item.RegionParent == "0" {
break
}
currentCode = item.RegionParent
}
if len(names) == 0 {
return areaCode
}
if names[0] != "中国" {
names = append([]string{"中国"}, names...)
}
return strings.Join(names, "/")
}
func (s *sQuerySim) QuerySimInfo(ctx context.Context, input *model.QuerySimInput) (output *model.QuerySimInfoOutput, err error) {
g.Log().Error(ctx, input)
simCard, err := s.querySim(ctx, input)
if err != nil {
return nil, err
}
output = &model.QuerySimInfoOutput{
Iccid: simCard.Iccid,
Msisdn: simCard.Msisdn,
Imsi: simCard.Imsi,
Imei: simCard.Imei,
Username: simCard.DeptName,
Location: s.queryLocationByAreaCode(ctx, simCard.DeptAreaCode),
PlanCategory: simCard.CategoryName,
CurrentPlanName: simCard.PackageName,
Plangroup: "",
Planid: simCard.CurrentPackageCode,
Appid: simCard.DeptKey,
AllowPlanChange: 0,
LastSyncTime: queryDateTime(simCard.StatusSyncTime),
AccessType: consts.V3ApiSimAccessTypeNameMap[simCard.AccessType],
CardStatus: consts.V3ApiSimBossCardStatusNameMap[simCard.BossCardStatus],
CardType: consts.V3ApiSimCardTypeNameMap[simCard.CardType],
NetworkType: consts.V3ApiSimNetModelNameMap[simCard.NetModel],
ActivationDate: queryDate(simCard.OutStockTime),
ActiveDate: queryDate(simCard.FlowPkgBeginAt),
ValidityPeriod: queryValidityPeriod(simCard.FlowPkgBeginAt, simCard.FlowPkgEndAt),
ExpiryDate: queryDate(simCard.FlowPkgEndAt),
TestPeriod: 0,
TestPeriodExpiryDate: nil,
SilentPeriod: 0,
SilentPeriodExpiryDate: nil,
DataService: simCard.DataServiceStatus,
PlanData: int(math.Round(simCard.PackageTotalFlowMb)),
UsedData: simCard.UsedFlowMb,
RemainingData: queryRemaining(simCard.PackageTotalFlowMb, simCard.UsedFlowMb),
SmsService: simCard.SmsServiceStatus,
PlanSMS: simCard.PackageTotalSms,
UsedSMS: simCard.UsedSms,
RemainingSMS: queryRemainingInt(simCard.PackageTotalSms, simCard.UsedSms),
VoiceService: simCard.VoiceServiceStatus,
PlanMinutes: simCard.PackageTotalVoice,
UsedMinutes: simCard.UsedVoice,
RemainingMinutes: queryRemainingInt(simCard.PackageTotalVoice, simCard.UsedVoice),
InternationalRoamingService: simCard.RoamServiceStatus,
PlanInternationalData: int(math.Round(simCard.PackageTotalRoamFlowMb)),
UsedInternationalData: int(math.Round(simCard.UsedRoamFlowMb)),
RemainingInternationalData: queryRemaining(simCard.PackageTotalRoamFlowMb, simCard.UsedRoamFlowMb),
Description: nil,
}
return output, nil
}
func (s *sQuerySim) QuerySimLimit(ctx context.Context, input *model.QuerySimInput) (output *model.QuerySimLimitOutput, err error) {
simCard, err := s.querySim(ctx, input)
if err != nil {
return nil, err
}
trafficCutoffName := "删除"
if simCard.TrafficCutoffStatus == 1 {
trafficCutoffName = "已开启"
} else if simCard.TrafficCutoffStatus == 2 {
trafficCutoffName = "已关闭"
}
output = &model.QuerySimLimitOutput{
TrafficCutoffStatus: simCard.TrafficCutoffStatus,
TrafficCutoffName: trafficCutoffName,
TrafficCutoffThreshold: simCard.TrafficCutoffThreshold,
}
return output, nil
}
func (s *sQuerySim) QuerySimImei(ctx context.Context, input *model.QuerySimInput) (output *model.QuerySimImeiOutput, err error) {
simCard, err := s.querySim(ctx, input)
if err != nil {
return nil, err
}
if imei := strings.TrimSpace(simCard.Imei); imei != "" {
return &model.QuerySimImeiOutput{Imei: imei}, nil
}
upstreamInput := &model.SimImeiInput{
SimKeys: []*model.SimKey{{
ChanKey: strings.TrimSpace(simCard.ChanKey),
ICCID: strings.TrimSpace(simCard.Iccid),
IMSI: strings.TrimSpace(simCard.Imsi),
MSISDN: strings.TrimSpace(simCard.Msisdn),
}},
}
upstreamResults, err := service.UpstreamChan().QuerySimImei(ctx, upstreamInput)
if err != nil {
g.Log().Warningf(ctx, "QuerySim.QuerySimImei upstream query failed, iccid: %s, err: %v", simCard.Iccid, err)
return &model.QuerySimImeiOutput{Imei: ""}, nil
}
for _, item := range upstreamResults {
if item.Code != 0 {
continue
}
if imei := strings.TrimSpace(item.ResData.Imei); imei != "" {
columns := dao.TSimCardInfo.Columns()
// imei_update_time 记录"值实际变更"的时间(单卡回填必然是从空到有值,
// 故一定发生变更);imei_sync_time 专属于 worker 轮询链路,
// 此处不写,避免污染"轮询覆盖度"指标。
if _, updateErr := service.IotTSimCardInfo().
Model(ctx).
Where(columns.Id, simCard.Id).
Data(g.Map{
columns.Imei: imei,
columns.ImeiUpdateTime: gtime.Now(),
}).
Update(); updateErr != nil {
g.Log().Warningf(ctx, "QuerySim.QuerySimImei persist upstream imei failed, card id: %d, err: %v", simCard.Id, updateErr)
}
return &model.QuerySimImeiOutput{Imei: imei}, nil
}
}
output = &model.QuerySimImeiOutput{
Imei: "",
}
return output, nil
}
func (s *sQuerySim) QuerySimUsage(ctx context.Context, input *model.QuerySimInput) (output *model.QuerySimUsageOutput, err error) {
simCard, err := s.querySim(ctx, input)
if err != nil {
return nil, err
}
output = &model.QuerySimUsageOutput{
UseUnit: "MB",
UseAmount: simCard.UsedFlowMb,
}
return output, nil
}
// QuerySimStatus 查询SIM卡状态(纯读本地库,不调用上游通道)
func (s *sQuerySim) QuerySimStatus(ctx context.Context, input *model.QuerySimInput) (output *model.QuerySimCardStatusOutput, err error) {
simCard, err := s.querySim(ctx, input)
if err != nil {
return nil, err
}
statusName, ok := consts.V3ApiSimBossCardStatusNameMap[simCard.BossCardStatus]
if !ok {
statusName = "未知"
}
output = &model.QuerySimCardStatusOutput{
Iccid: simCard.Iccid,
LifeCycle: simCard.BossCardStatus,
LifeCycleName: statusName,
}
return output, nil
}
+431
View File
@@ -0,0 +1,431 @@
package query
import (
"context"
"encoding/json"
"errors"
"fmt"
"git.5iot.com/iotlcm-public/iotlcm-openapi/internal/dao"
"git.5iot.com/iotlcm-public/iotlcm-openapi/internal/library/bcache"
"git.5iot.com/iotlcm-public/iotlcm-openapi/internal/library/contexts"
"git.5iot.com/iotlcm-public/iotlcm-openapi/internal/library/utils"
"git.5iot.com/iotlcm-public/iotlcm-openapi/internal/model"
"git.5iot.com/iotlcm-public/iotlcm-openapi/internal/model/entity"
"git.5iot.com/iotlcm-public/iotlcm-openapi/internal/service"
"net/url"
"strings"
"sync/atomic"
"time"
"github.com/gogf/gf/v2/errors/gcode"
"github.com/gogf/gf/v2/errors/gerror"
"github.com/gogf/gf/v2/frame/g"
"github.com/gogf/gf/v2/os/gtime"
"github.com/golang-jwt/jwt/v4"
"github.com/google/uuid"
)
const ydOperatorTokenURL = "https://api.iot.10086.cn/v5/ec/get/token"
type ydOperatorTokenResp struct {
Status string `json:"status"`
Message string `json:"message"`
Result []struct {
Token string `json:"token"`
Ttl int64 `json:"ttl"`
} `json:"result"`
}
var ydOperatorTokenSeq atomic.Int64
type sQueryToken struct {
}
func NewQueryToken() *sQueryToken {
return &sQueryToken{}
}
func init() {
service.RegisterQueryToken(NewQueryToken())
}
// Token 获取Token
func (l *sQueryToken) Token(ctx context.Context, input model.TokenInputApi) (res model.TokenOutputApi, err error) {
// 查询API集成信息
var tApi entity.TApi
err = dao.TApi.Ctx(ctx).Where(dao.TApi.Columns().AppKey, input.AppKey).Scan(&tApi)
if err != nil {
g.Log().Errorf(ctx, "QueryToken.Token, Select t api by app key failed, err: %v, input: %v", err, input)
return res, gerror.New("APPKey或APPSecret错误")
}
// 校验APPKey是否存在和APPSecret是否正确
if tApi.Id == 0 {
g.Log().Debugf(ctx, "QueryToken.Token, The t api does not exist, input: %v", input)
return res, gerror.New("APPKey或APPSecret错误")
}
if tApi.AppSecret != input.AppSecret {
g.Log().Debugf(ctx, "QueryToken.Token, APPSecret verification failed, input: %v", input)
return res, gerror.New("APPKey或APPSecret错误")
}
if tApi.Status == 1 {
g.Log().Debugf(ctx, "QueryToken.Token, The t api is disabled, input: %v", input)
return res, gerror.New("API集成已停用")
}
// 验证客户
// dept, err := service.SysDept().GetDeptById(ctx, tApi.DeptId)
var dept entity.SysDept
err = dao.SysDept.Ctx(ctx).Where(dao.SysDept.Columns().DeptId, tApi.DeptId).Scan(&dept)
if err != nil {
g.Log().Errorf(ctx, "QueryToken.Token, Select dept by id failed, err: %v, input: %v", err, input)
return res, gerror.New("APPKey或APPSecret错误")
}
if dept.DeptId == 0 {
g.Log().Debugf(ctx, "QueryToken.Token, The organization does not exist, input: %v", input)
return res, gerror.New("APPKey或APPSecret错误")
}
g.Log().Debugf(ctx, "dept.DeptId: %v", dept.DeptId)
if dept.DeptKey == "" {
g.Log().Debugf(ctx, "QueryToken.Token, The organization does not have API service enabled, input: %v", input)
return res, gerror.New("客户未开启API服务")
}
identity := &model.CustomClaimsApi{
BaseClaimsApi: model.BaseClaimsApi{
UUID: strings.ReplaceAll(uuid.New().String(), "-", ""),
ApiId: tApi.Id,
AppKey: tApi.AppKey,
GroupId: tApi.GroupId,
DeptKey: dept.DeptKey,
DeptName: dept.DeptName,
DeptType: dept.DeptType,
TenantId: dept.TenantId,
DeptId: dept.DeptId,
LoginAt: time.Now().Unix(),
},
}
contextModel := new(model.Context)
ctx = contexts.Set(ctx, contextModel)
if input.Refresh == "" {
input.Refresh = "1"
} else if input.Refresh != "0" && input.Refresh != "1" {
return res, gerror.New("刷新参数错误")
}
res.AppId = dept.DeptKey
if input.Refresh == "0" {
tokenUuid, cacheErr := bcache.GetApiAuthActiveToken(ctx, dept.DeptId)
if cacheErr == nil && tokenUuid != "" {
token, tokenErr := l.GenerateShortToken(ctx, tokenUuid)
if tokenErr != nil {
return res, tokenErr
}
res.AccessToken = token
return res, nil
}
}
if input.Refresh == "1" {
tokenUuid, cacheErr := bcache.GetApiAuthActiveToken(ctx, dept.DeptId)
if cacheErr == nil && tokenUuid != "" {
_ = bcache.DelApiAuthToken(ctx, tokenUuid)
}
_ = bcache.DelApiAuthActiveToken(ctx, dept.DeptId)
}
_, token, err := l.GenerateToken(ctx, identity)
if err != nil {
return res, err
}
res.AccessToken = token
return res, nil
}
// OperatorToken 获取移动运营商Token(仅需chanKey,不校验白名单和AppId)
func (l *sQueryToken) OperatorToken(ctx context.Context, input model.OperatorTokenInput) (res model.OperatorTokenOutput, err error) {
return l.fetchYdOperatorToken(ctx, input, false)
}
// OperatorTokenWithAuth 获取移动运营商Token(校验白名单+AppId,需传appId+chanKey)
func (l *sQueryToken) OperatorTokenWithAuth(ctx context.Context, input model.OperatorTokenInput) (res model.OperatorTokenOutput, err error) {
if input.AppId == "" {
return res, gerror.New("AppId不能为空")
}
return l.fetchYdOperatorToken(ctx, input, true)
}
// fetchYdOperatorToken 内部实现,checkShare=true时校验白名单和AppId
func (l *sQueryToken) fetchYdOperatorToken(ctx context.Context, input model.OperatorTokenInput, checkShare bool) (res model.OperatorTokenOutput, err error) {
if input.ChanKey == "" {
return res, gerror.New("通道Key不能为空")
}
fields := []any{
dao.TSimChannels.Columns().TempKey,
dao.TSimChannels.Columns().ChanAlias,
dao.TSimChannels.Columns().ChanKey,
dao.TSimChannels.Columns().AppKey,
dao.TSimChannels.Columns().AppSecret,
}
if checkShare {
fields = append(fields, dao.TSimChannels.Columns().ShareEnabled)
}
var simChannels entity.TSimChannels
dbErr := dao.TSimChannels.DB().Model(dao.TSimChannels.Table()).Safe().Ctx(ctx).
Fields(fields...).
Where(dao.TSimChannels.Columns().ChanKey, input.ChanKey).
Scan(&simChannels)
if dbErr != nil {
g.Log().Errorf(ctx, "QueryToken.fetchYdOperatorToken, Select sim channel by chan key failed, err: %v, input: %v", dbErr, input)
return res, gerror.New("通道不存在")
}
if simChannels.ChanKey == "" {
return res, gerror.New("通道不存在")
}
if checkShare {
if simChannels.ShareEnabled != 1 {
g.Log().Warningf(ctx, "QueryToken.fetchYdOperatorToken, sim channel share not enabled, chanKey: %s", input.ChanKey)
return res, gerror.New("该通道未开放共享Token")
}
if input.AppId != simChannels.AppKey {
g.Log().Warningf(ctx, "QueryToken.fetchYdOperatorToken, appId mismatch, chanKey: %s, inputAppId: %s, channelAppId: %s", input.ChanKey, input.AppId, simChannels.AppKey)
return res, gerror.New("AppId与通道不匹配")
}
}
var tempModel string
if simChannels.TempKey != "" {
var template entity.TSimChannelTemplate
if tmplErr := dao.TSimChannelTemplate.Ctx(ctx).
Fields(dao.TSimChannelTemplate.Columns().TempModel).
Where(dao.TSimChannelTemplate.Columns().TempKey, simChannels.TempKey).
Limit(1).Scan(&template); tmplErr == nil {
tempModel = strings.TrimSpace(template.TempModel)
}
}
if strings.EqualFold(tempModel, "shared_token") {
result, sharedErr := service.UpstreamChan().GetSharedToken(ctx, input.ChanKey)
if sharedErr != nil {
return res, gerror.Wrap(sharedErr, "获取共享Token失败")
}
return model.OperatorTokenOutput{
ChanAlias: result.ChanAlias,
ChanKey: result.ChanKey,
AccessToken: result.AccessToken,
AccessTokenExpire: result.AccessTokenExpire,
}, nil
}
if simChannels.AppKey == "" || simChannels.AppSecret == "" {
return res, gerror.New("通道不存在")
}
now := time.Now()
transID := buildYdOperatorTokenTransID(simChannels.AppKey, now)
params := g.Map{
"appid": simChannels.AppKey,
"password": simChannels.AppSecret,
"transid": transID,
"refresh": "0",
}
g.Log().Infof(ctx, "QueryToken.OperatorToken, request yd token url: %s, chanKey: %s", buildYdOperatorTokenRequestURL(simChannels.AppKey, simChannels.AppSecret, transID), input.ChanKey)
client := g.Client()
client.SetTimeout(10 * time.Second)
resp, err := client.Get(ctx, ydOperatorTokenURL, params)
if err != nil {
g.Log().Errorf(ctx, "QueryToken.OperatorToken, request yd token failed, err: %v, chanKey: %s", err, input.ChanKey)
return res, gerror.New("获取移动运营商Token失败")
}
body := resp.ReadAllString()
var ydResp ydOperatorTokenResp
if err = json.Unmarshal([]byte(body), &ydResp); err != nil {
g.Log().Errorf(ctx, "QueryToken.OperatorToken, parse yd token response failed, err: %v, chanKey: %s, body: %s", err, input.ChanKey, body)
return res, gerror.New("解析移动运营商Token响应失败")
}
if ydResp.Status != "0" {
g.Log().Warningf(ctx, "QueryToken.OperatorToken, yd token response error, status: %s, message: %s, chanKey: %s", ydResp.Status, ydResp.Message, input.ChanKey)
if ydResp.Message != "" {
return res, gerror.New(ydResp.Message)
}
return res, gerror.New("获取移动运营商Token失败")
}
if len(ydResp.Result) == 0 || ydResp.Result[0].Token == "" {
return res, gerror.New("移动运营商Token为空")
}
if ydResp.Result[0].Ttl <= 0 {
return res, gerror.New("移动运营商Token有效期无效")
}
return model.OperatorTokenOutput{
ChanAlias: simChannels.ChanAlias,
ChanKey: simChannels.ChanKey,
AccessToken: ydResp.Result[0].Token,
AccessTokenExpire: gtime.NewFromTime(now.Add(time.Duration(ydResp.Result[0].Ttl) * time.Second)),
}, nil
}
func buildYdOperatorTokenTransID(appKey string, now time.Time) string {
return appKey + now.Format("20060102150405") + fmt.Sprintf("%08d", ydOperatorTokenSeq.Add(1)%100000000)
}
func buildYdOperatorTokenRequestURL(appKey, password, transID string) string {
values := url.Values{}
values.Set("appid", appKey)
values.Set("password", password)
values.Set("transid", transID)
values.Set("refresh", "0")
return ydOperatorTokenURL + "?" + values.Encode()
}
// GenerateToken 生成Token
func (s *sQueryToken) GenerateToken(ctx context.Context, customer *model.CustomClaimsApi) (claims *model.CustomClaimsApi, shortToken string, err error) {
ets := g.Cfg().MustGet(ctx, "jwt.expiresTime", "3650d").String()
bts := g.Cfg().MustGet(ctx, "jwt.bufferTime", "1d").String()
iss := g.Cfg().MustGet(ctx, "jwt.issuer", "JieIOTAPI").String()
sk := g.Cfg().MustGet(ctx, "jwt.signingKey", "49c54195e73304e74a8429b178965865").String()
et, _ := utils.ParseDuration(ets)
bt, _ := utils.ParseDuration(bts)
g.Log().Infof(ctx, "GenerateToken: customer:%+v, et:%s, bt:%s, iss:%s", customer, et, bt, iss)
tokenUuid := strings.ReplaceAll(uuid.New().String(), "-", "")
expiresAt := time.Now().Add(et).Unix()
claims = &model.CustomClaimsApi{
BaseClaimsApi: model.BaseClaimsApi{
UUID: tokenUuid,
ApiId: customer.ApiId,
AppKey: customer.AppKey,
GroupId: customer.GroupId,
DeptKey: customer.DeptKey,
DeptName: customer.DeptName,
DeptType: customer.DeptType,
DeptId: customer.DeptId,
TenantId: customer.TenantId,
LoginAt: time.Now().Unix(),
},
BufferTime: int64(bt / time.Second),
RegisteredClaims: jwt.RegisteredClaims{
Audience: jwt.ClaimStrings{"JIEIOT"},
ExpiresAt: jwt.NewNumericDate(time.Unix(expiresAt, 0)),
NotBefore: jwt.NewNumericDate(time.Now().Add(-1000)),
Issuer: iss,
},
}
fullToken := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
fullTokenString, err := fullToken.SignedString([]byte(sk))
if err != nil {
return nil, "", err
}
if err = bcache.SetApiAuthToken(ctx, tokenUuid, fullTokenString, et); err != nil {
return nil, "", err
}
if err = bcache.SetApiAuthActiveToken(ctx, customer.DeptId, tokenUuid, et); err != nil {
return nil, "", err
}
shortToken, err = s.GenerateShortToken(ctx, tokenUuid)
if err != nil {
return nil, "", err
}
return claims, shortToken, nil
}
// GenerateShortToken 生成短令牌
func (s *sQueryToken) GenerateShortToken(ctx context.Context, tokenUuid string) (token string, err error) {
sk := g.Cfg().MustGet(ctx, "jwt.signingKey", "49c54195e73304e74a8429b178965865").String()
shortToken := jwt.NewWithClaims(jwt.SigningMethodHS256, &model.ApiTokenClaims{
UUID: tokenUuid,
})
delete(shortToken.Header, "typ")
return shortToken.SignedString([]byte(sk))
}
// 解析Token
func (s *sQueryToken) ParseToken(ctx context.Context, token string) (claims *model.CustomClaimsApi, err error) {
sk := g.Cfg().MustGet(ctx, "jwt.signingKey", "49c54195e73304e74a8429b178965865").String()
t, err := jwt.ParseWithClaims(token, &model.ApiTokenClaims{}, func(token *jwt.Token) (interface{}, error) {
return []byte(sk), nil
})
if err != nil {
g.Log().Warningf(ctx, "sApiAuth.ParseToken short token err: %+v", err)
return nil, err
}
shortClaims, ok := t.Claims.(*model.ApiTokenClaims)
if !ok || !t.Valid {
return nil, errors.New("token invalid")
}
fullTokenString, err := bcache.GetApiAuthToken(ctx, shortClaims.UUID)
if err != nil {
g.Log().Warningf(ctx, "sApiAuth.ParseToken get full token err: %+v", err)
return nil, err
}
fullToken, err := jwt.ParseWithClaims(fullTokenString, &model.CustomClaimsApi{}, func(token *jwt.Token) (interface{}, error) {
return []byte(sk), nil
})
if err != nil {
g.Log().Warningf(ctx, "sApiAuth.ParseToken full token err: %+v", err)
return nil, err
}
claims, ok = fullToken.Claims.(*model.CustomClaimsApi)
if !ok || !fullToken.Valid {
return nil, errors.New("token invalid")
}
if claims.ExpiresAt == nil || claims.ExpiresAt.Unix() < time.Now().Unix() {
return nil, errors.New("token expired")
}
if claims.UUID != shortClaims.UUID {
return nil, errors.New("token invalid")
}
return claims, nil
}
// 删除Token
func (s *sQueryToken) DeleteToken(ctx context.Context, token string) (err error) {
sk := g.Cfg().MustGet(ctx, "jwt.signingKey", "49c54195e73304e74a8429b178965865").String()
t, err := jwt.ParseWithClaims(token, &model.ApiTokenClaims{}, func(token *jwt.Token) (interface{}, error) {
return []byte(sk), nil
})
if err != nil {
return err
}
shortClaims, ok := t.Claims.(*model.ApiTokenClaims)
if !ok {
return errors.New("token invalid")
}
return bcache.DelApiAuthToken(ctx, shortClaims.UUID)
}
// 获取token
func (s *sQueryToken) GetAccessToken(ctx context.Context) (token string, err error) {
headerToken := g.RequestFromCtx(ctx).Header.Get("X-Access-Token")
if headerToken == "" {
return "", gerror.NewCode(gcode.CodeNotImplemented)
}
g.Log().Error(ctx, headerToken)
token = strings.TrimPrefix(headerToken, "Bearer ")
if token == "" {
token = headerToken
}
return token, nil
}
// 根据Token获取当前登录客户信息
func (s *sQueryToken) GetCurrentCustomer(ctx context.Context) (claims *model.CustomClaimsApi, err error) {
token, err := s.GetAccessToken(ctx)
g.Log().Debugf(ctx, "sQueryToken.GetCurrentCustomer token: %+v, err: %+v", token, err)
if err != nil {
return nil, err
}
claims, err = s.ParseToken(ctx, token)
g.Log().Debugf(ctx, "sApiAuth.GetCurrentCustomer claims: %+v, err: %+v", claims, err)
if err != nil {
g.Log().Warningf(ctx, "sApiAuth.GetCurrentCustomer err: %+v", err)
return nil, err
}
return claims, nil
}
@@ -0,0 +1,201 @@
package query
import (
"context"
"strings"
"github.com/gogf/gf/v2/errors/gerror"
"git.5iot.com/iotlcm-public/iotlcm-openapi/internal/dao"
"git.5iot.com/iotlcm-public/iotlcm-openapi/internal/model"
"git.5iot.com/iotlcm-public/iotlcm-openapi/internal/model/entity"
"git.5iot.com/iotlcm-public/iotlcm-openapi/internal/service"
)
const queryTrafficCutoffConfigMaxCards = 1000
type trafficCutoffIdentifier struct {
Msisdn string
Iccid string
Imsi string
}
func validateTrafficCutoffIdentifier(item trafficCutoffIdentifier) error {
count := 0
if item.Msisdn != "" {
count++
}
if item.Iccid != "" {
count++
}
if item.Imsi != "" {
count++
}
if count == 0 {
return gerror.New("msisdn、iccid、imsi至少传一个")
}
return nil
}
func trafficCutoffIdentifierKey(item trafficCutoffIdentifier) string {
if item.Iccid != "" {
return "iccid:" + item.Iccid
}
if item.Msisdn != "" {
return "msisdn:" + item.Msisdn
}
return "imsi:" + item.Imsi
}
func trafficCutoffCardMatchesIdentifier(card *entity.TSimCardInfo, identifier trafficCutoffIdentifier) bool {
if card == nil {
return false
}
if identifier.Iccid != "" && strings.TrimSpace(card.Iccid) != identifier.Iccid {
return false
}
if identifier.Msisdn != "" && strings.TrimSpace(card.Msisdn) != identifier.Msisdn {
return false
}
if identifier.Imsi != "" && strings.TrimSpace(card.Imsi) != identifier.Imsi {
return false
}
return true
}
func (s *sQuerySim) QueryTrafficCutoffConfig(ctx context.Context, in *model.QueryTrafficCutoffConfigParam) (*model.QueryTrafficCutoffConfigOutput, error) {
if in == nil {
return nil, gerror.New("查询参数不能为空")
}
if strings.TrimSpace(in.AppId) == "" {
return nil, gerror.New("appId不能为空")
}
if len(in.Cards) == 0 {
return nil, gerror.New("查询卡不能为空")
}
if len(in.Cards) > queryTrafficCutoffConfigMaxCards {
return nil, gerror.Newf("单次最多查询%d张卡", queryTrafficCutoffConfigMaxCards)
}
identifiers := make([]trafficCutoffIdentifier, 0, len(in.Cards))
seen := make(map[string]struct{}, len(in.Cards))
for _, item := range in.Cards {
identifier := trafficCutoffIdentifier{Msisdn: strings.TrimSpace(item.Msisdn), Iccid: strings.TrimSpace(item.Iccid), Imsi: strings.TrimSpace(item.Imsi)}
if err := validateTrafficCutoffIdentifier(identifier); err != nil {
return nil, err
}
key := trafficCutoffIdentifierKey(identifier)
if _, ok := seen[key]; ok {
return nil, gerror.Newf("卡片标识重复:%s", key)
}
seen[key] = struct{}{}
identifiers = append(identifiers, identifier)
}
cardMap, err := loadTrafficCutoffCards(ctx, identifiers)
if err != nil {
return nil, err
}
output := &model.QueryTrafficCutoffConfigOutput{Total: len(identifiers), Items: make([]model.QueryTrafficCutoffConfigItem, 0, len(identifiers))}
for _, identifier := range identifiers {
result := model.QueryTrafficCutoffConfigItem{}
card := cardMap[trafficCutoffIdentifierKey(identifier)]
if !trafficCutoffCardMatchesIdentifier(card, identifier) {
result.ErrorCode = "CARD_NOT_FOUND"
result.ErrorMessage = "卡片不存在或无权限"
output.NotFoundCount++
output.Items = append(output.Items, result)
continue
}
result.Matched = true
result.Iccid = strings.TrimSpace(card.Iccid)
result.TrafficCutoffStatus = card.TrafficCutoffStatus
result.TrafficCutoffStatusName = trafficCutoffStatusName(card.TrafficCutoffStatus)
result.TrafficCutoffThreshold = card.TrafficCutoffThreshold
if result.TrafficCutoffStatus == 0 {
result.TrafficCutoffThreshold = 0
}
result.TrafficCutoffThresholdUnit = "MB"
if in.IncludeUsage {
packageTotalFlowMb := card.PackageTotalFlowMb
usedFlowMb := card.UsedFlowMb
remainingFlowMb := maxFloat(packageTotalFlowMb-usedFlowMb, 0)
result.PackageTotalFlowMb = &packageTotalFlowMb
result.UsedFlowMb = &usedFlowMb
result.RemainingFlowMb = &remainingFlowMb
}
output.MatchedCount++
output.Items = append(output.Items, result)
}
return output, nil
}
func loadTrafficCutoffCards(ctx context.Context, identifiers []trafficCutoffIdentifier) (map[string]*entity.TSimCardInfo, error) {
columns := dao.TSimCardInfo.Columns()
result := make(map[string]*entity.TSimCardInfo, len(identifiers))
lookup := func(field string, values []string) error {
if len(values) == 0 {
return nil
}
var cards []*entity.TSimCardInfo
err := service.IotTSimCardInfo().Model(ctx).
Fields(columns.Iccid, columns.Msisdn, columns.Imsi, columns.PackageTotalFlowMb, columns.UsedFlowMb, columns.TrafficCutoffStatus, columns.TrafficCutoffThreshold).
WhereIn(field, values).Scan(&cards)
if err != nil {
return gerror.Wrap(err, "查询达量断网配置失败")
}
for _, card := range cards {
if card == nil {
continue
}
if card.Iccid != "" {
result["iccid:"+strings.TrimSpace(card.Iccid)] = card
}
if card.Msisdn != "" {
result["msisdn:"+strings.TrimSpace(card.Msisdn)] = card
}
if card.Imsi != "" {
result["imsi:"+strings.TrimSpace(card.Imsi)] = card
}
}
return nil
}
iccidValues, msisdnValues, imsiValues := make([]string, 0), make([]string, 0), make([]string, 0)
for _, item := range identifiers {
switch {
case item.Iccid != "":
iccidValues = append(iccidValues, item.Iccid)
case item.Msisdn != "":
msisdnValues = append(msisdnValues, item.Msisdn)
default:
imsiValues = append(imsiValues, item.Imsi)
}
}
if err := lookup(columns.Iccid, iccidValues); err != nil {
return nil, err
}
if err := lookup(columns.Msisdn, msisdnValues); err != nil {
return nil, err
}
if err := lookup(columns.Imsi, imsiValues); err != nil {
return nil, err
}
return result, nil
}
func trafficCutoffStatusName(status int) string {
switch status {
case 1:
return "已开启"
case 2:
return "已关闭"
default:
return "删除"
}
}
func maxFloat(value, floor float64) float64 {
if value < floor {
return floor
}
return value
}