chore: 并入 iotlcm-onelink-mock 历史到 onelink-mock/(原提交 f398595)

This commit is contained in:
MiFaZhan
2026-11-02 13:28:02 +08:00
64 changed files with 10585 additions and 0 deletions
+45
View File
@@ -0,0 +1,45 @@
package cmd
import (
"context"
"os"
"git.5iot.com/iotlcm-public/iotlcm-onelink-mock/internal/controller/gateway"
"git.5iot.com/iotlcm-public/iotlcm-onelink-mock/internal/service"
"github.com/gogf/gf/v2/errors/gerror"
"github.com/gogf/gf/v2/frame/g"
"github.com/gogf/gf/v2/os/gcmd"
)
var Main = gcmd.Command{
Name: "main",
Usage: "main",
Brief: "start OneLink mock service",
Func: func(ctx context.Context, parser *gcmd.Parser) error {
var (
server = g.Server()
runtimeLogCapacity = g.Cfg().MustGet(ctx, "runtimeLog.capacity", 500).Int()
runtimeLogs = service.NewRuntimeLogStore(runtimeLogCapacity)
databaseLink = g.Cfg().MustGet(ctx, "database.default.link", "sqlite::@file(data/onelink_mock.db)").String()
)
if value := os.Getenv("BAFFLE_ADDR"); value != "" {
server.SetAddr(value)
}
if value := os.Getenv("BAFFLE_SQLITE_PATH"); value != "" {
databaseLink = value
}
g.Log().SetWriter(runtimeLogs)
g.Log().SetWriterColorEnable(false)
server.Logger().SetWriter(runtimeLogs)
server.Logger().SetWriterColorEnable(false)
mockService, err := service.NewService(databaseLink, runtimeLogs)
if err != nil {
return gerror.Wrap(err, "initialize mock service")
}
gateway.New(mockService).Register(server)
g.Log().Infof(ctx, "OneLink mock service listening on %s", server.GetListenedAddress())
server.Run()
return nil
},
}
@@ -0,0 +1,59 @@
package gateway
import (
"net/http"
"git.5iot.com/iotlcm-public/iotlcm-onelink-mock/internal/service"
"github.com/gogf/gf/v2/net/ghttp"
)
type Controller struct {
service *service.Service
}
func New(mockService *service.Service) *Controller {
return &Controller{service: mockService}
}
func (c *Controller) Register(server *ghttp.Server) {
server.Group("/", func(group *ghttp.RouterGroup) {
group.Middleware(c.cors)
group.ALL("/healthz", c.forward(c.service.Health, false))
group.ALL("/api/state", c.forward(c.service.AdminState, true))
group.ALL("/api/appids", c.forward(c.service.AdminAppIDs, true))
group.ALL("/api/cards", c.forward(c.service.AdminCards, true))
group.ALL("/api/card-groups", c.forward(c.service.AdminCardGroups, true))
group.ALL("/api/cards/export", c.forward(c.service.AdminCardsExport, true))
group.ALL("/api/settings", c.forward(c.service.AdminSettings, true))
group.ALL("/api/orders", c.forward(c.service.AdminOrders, true))
group.ALL("/api/operation-logs", c.forward(c.service.AdminOperationLogs, false))
group.ALL("/api/runtime-logs", c.forward(c.service.AdminRuntimeLogs, false))
group.ALL("/api/sync", c.forward(c.service.AdminSync, true))
group.ALL("/api/generate", c.forward(c.service.AdminGenerate, true))
group.ALL("/v5/*endpoint", c.forward(c.service.OneLink, true))
group.ALL("/ec/*endpoint", c.forward(c.service.OneLink, true))
group.ALL("/sim/*endpoint", c.forward(c.service.Chan, true))
})
}
func (c *Controller) forward(handler http.HandlerFunc, operationLogging bool) ghttp.HandlerFunc {
var next http.Handler = handler
if operationLogging {
next = c.service.WithOperationLogging(next)
}
return func(r *ghttp.Request) {
next.ServeHTTP(r.Response.Writer, r.Request)
}
}
func (c *Controller) cors(r *ghttp.Request) {
r.Response.Header().Set("Access-Control-Allow-Origin", "*")
r.Response.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization")
r.Response.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
if r.Method == http.MethodOptions {
r.Response.WriteStatus(http.StatusNoContent)
r.ExitAll()
return
}
r.Middleware.Next()
}
@@ -0,0 +1,85 @@
// ==========================================================================
// Code generated and maintained by GoFrame CLI tool. DO NOT EDIT.
// ==========================================================================
package internal
import (
"context"
"github.com/gogf/gf/v2/database/gdb"
"github.com/gogf/gf/v2/frame/g"
)
// MockCardGroupDao is the data access object for the table mock_card_group.
type MockCardGroupDao struct {
table string // table is the underlying table name of the DAO.
group string // group is the database configuration group name of the current DAO.
columns MockCardGroupColumns // columns contains all the column names of Table for convenient usage.
handlers []gdb.ModelHandler // handlers for customized model modification.
}
// MockCardGroupColumns defines and stores column names for the table mock_card_group.
type MockCardGroupColumns struct {
Id string //
Name string //
CreatedAt string //
UpdatedAt string //
}
// mockCardGroupColumns holds the columns for the table mock_card_group.
var mockCardGroupColumns = MockCardGroupColumns{
Id: "id",
Name: "name",
CreatedAt: "created_at",
UpdatedAt: "updated_at",
}
// NewMockCardGroupDao creates and returns a new DAO object for table data access.
func NewMockCardGroupDao(handlers ...gdb.ModelHandler) *MockCardGroupDao {
return &MockCardGroupDao{
group: "default",
table: "mock_card_group",
columns: mockCardGroupColumns,
handlers: handlers,
}
}
// DB retrieves and returns the underlying raw database management object of the current DAO.
func (dao *MockCardGroupDao) DB() gdb.DB {
return g.DB(dao.group)
}
// Table returns the table name of the current DAO.
func (dao *MockCardGroupDao) Table() string {
return dao.table
}
// Columns returns all column names of the current DAO.
func (dao *MockCardGroupDao) Columns() MockCardGroupColumns {
return dao.columns
}
// Group returns the database configuration group name of the current DAO.
func (dao *MockCardGroupDao) Group() string {
return dao.group
}
// Ctx creates and returns a Model for the current DAO. It automatically sets the context for the current operation.
func (dao *MockCardGroupDao) Ctx(ctx context.Context) *gdb.Model {
model := dao.DB().Model(dao.table)
for _, handler := range dao.handlers {
model = handler(model)
}
return model.Safe().Ctx(ctx)
}
// Transaction wraps the transaction logic using function f.
// It rolls back the transaction and returns the error if function f returns a non-nil error.
// It commits the transaction and returns nil if function f returns nil.
//
// Note: Do not commit or roll back the transaction in function f,
// as it is automatically handled by this function.
func (dao *MockCardGroupDao) Transaction(ctx context.Context, f func(ctx context.Context, tx gdb.TX) error) (err error) {
return dao.Ctx(ctx).Transaction(ctx, f)
}
@@ -0,0 +1,97 @@
// ==========================================================================
// Code generated and maintained by GoFrame CLI tool. DO NOT EDIT.
// ==========================================================================
package internal
import (
"context"
"github.com/gogf/gf/v2/database/gdb"
"github.com/gogf/gf/v2/frame/g"
)
// MockOrderDao is the data access object for the table mock_order.
type MockOrderDao struct {
table string // table is the underlying table name of the DAO.
group string // group is the database configuration group name of the current DAO.
columns MockOrderColumns // columns contains all the column names of Table for convenient usage.
handlers []gdb.ModelHandler // handlers for customized model modification.
}
// MockOrderColumns defines and stores column names for the table mock_order.
type MockOrderColumns struct {
Id string //
OrderNum string //
Msisdn string //
Iccid string //
Imsi string //
Type string //
OperType string //
ApnName string //
Status string //
CreatedAt string //
}
// mockOrderColumns holds the columns for the table mock_order.
var mockOrderColumns = MockOrderColumns{
Id: "id",
OrderNum: "order_num",
Msisdn: "msisdn",
Iccid: "iccid",
Imsi: "imsi",
Type: "type",
OperType: "oper_type",
ApnName: "apn_name",
Status: "status",
CreatedAt: "created_at",
}
// NewMockOrderDao creates and returns a new DAO object for table data access.
func NewMockOrderDao(handlers ...gdb.ModelHandler) *MockOrderDao {
return &MockOrderDao{
group: "default",
table: "mock_order",
columns: mockOrderColumns,
handlers: handlers,
}
}
// DB retrieves and returns the underlying raw database management object of the current DAO.
func (dao *MockOrderDao) DB() gdb.DB {
return g.DB(dao.group)
}
// Table returns the table name of the current DAO.
func (dao *MockOrderDao) Table() string {
return dao.table
}
// Columns returns all column names of the current DAO.
func (dao *MockOrderDao) Columns() MockOrderColumns {
return dao.columns
}
// Group returns the database configuration group name of the current DAO.
func (dao *MockOrderDao) Group() string {
return dao.group
}
// Ctx creates and returns a Model for the current DAO. It automatically sets the context for the current operation.
func (dao *MockOrderDao) Ctx(ctx context.Context) *gdb.Model {
model := dao.DB().Model(dao.table)
for _, handler := range dao.handlers {
model = handler(model)
}
return model.Safe().Ctx(ctx)
}
// Transaction wraps the transaction logic using function f.
// It rolls back the transaction and returns the error if function f returns a non-nil error.
// It commits the transaction and returns nil if function f returns nil.
//
// Note: Do not commit or roll back the transaction in function f,
// as it is automatically handled by this function.
func (dao *MockOrderDao) Transaction(ctx context.Context, f func(ctx context.Context, tx gdb.TX) error) (err error) {
return dao.Ctx(ctx).Transaction(ctx, f)
}
@@ -0,0 +1,187 @@
// ==========================================================================
// Code generated and maintained by GoFrame CLI tool. DO NOT EDIT.
// ==========================================================================
package internal
import (
"context"
"github.com/gogf/gf/v2/database/gdb"
"github.com/gogf/gf/v2/frame/g"
)
// MockTSimCardDao is the data access object for the table mock_t_sim_card.
type MockTSimCardDao struct {
table string // table is the underlying table name of the DAO.
group string // group is the database configuration group name of the current DAO.
columns MockTSimCardColumns // columns contains all the column names of Table for convenient usage.
handlers []gdb.ModelHandler // handlers for customized model modification.
}
// MockTSimCardColumns defines and stores column names for the table mock_t_sim_card.
type MockTSimCardColumns struct {
Id string //
GroupId string //
Msisdn string //
Iccid string //
Imsi string //
Imei string //
Remark string //
Carrier string //
PackageCode string //
PackageName string //
PackageStatus string //
PackageTotalFlowMb string //
IsFlowPool string //
CardStatus string //
LastChangeDate string //
StopReason string //
FrozenStatus string //
FrozenReason string //
FrozenOperTime string //
OpenDate string //
ActiveDate string //
OnOffStatus string //
SessionStatus string //
ApnId string //
SessionIpv4 string //
SessionIpv6Prefix string //
SessionIpv6 string //
SessionCreateDate string //
Rat string //
ApnListJson string //
ApnUsageJson string //
PrimaryApnName string //
PrimaryApnStatus string //
PrimaryApnUsageKb string //
PrimaryApnUsageBytes string //
PccCode string //
PccUsageKb string //
CurrentMonthUsageKb string //
PoolUsageBytes string //
DailyUsageKb string //
MonthlyUsageKb string //
DailyQueryDate string //
MonthlyQueryMonth string //
MonthlyDeadline string //
GrowthPerMinuteKb string //
AutoGrowthEnabled string //
LastUsageUpdateTime string //
CardBindStatus string //
RegionLimitStatus string //
AreaRestrictType string //
RestrictedArea string //
Exported string //
ExportCheckedAt string //
CreatedAt string //
UpdatedAt string //
}
// mockTSimCardColumns holds the columns for the table mock_t_sim_card.
var mockTSimCardColumns = MockTSimCardColumns{
Id: "id",
GroupId: "group_id",
Msisdn: "msisdn",
Iccid: "iccid",
Imsi: "imsi",
Imei: "imei",
Remark: "remark",
Carrier: "carrier",
PackageCode: "package_code",
PackageName: "package_name",
PackageStatus: "package_status",
PackageTotalFlowMb: "package_total_flow_mb",
IsFlowPool: "is_flow_pool",
CardStatus: "card_status",
LastChangeDate: "last_change_date",
StopReason: "stop_reason",
FrozenStatus: "frozen_status",
FrozenReason: "frozen_reason",
FrozenOperTime: "frozen_oper_time",
OpenDate: "open_date",
ActiveDate: "active_date",
OnOffStatus: "on_off_status",
SessionStatus: "session_status",
ApnId: "apn_id",
SessionIpv4: "session_ipv4",
SessionIpv6Prefix: "session_ipv6_prefix",
SessionIpv6: "session_ipv6",
SessionCreateDate: "session_create_date",
Rat: "rat",
ApnListJson: "apn_list_json",
ApnUsageJson: "apn_usage_json",
PrimaryApnName: "primary_apn_name",
PrimaryApnStatus: "primary_apn_status",
PrimaryApnUsageKb: "primary_apn_usage_kb",
PrimaryApnUsageBytes: "primary_apn_usage_bytes",
PccCode: "pcc_code",
PccUsageKb: "pcc_usage_kb",
CurrentMonthUsageKb: "current_month_usage_kb",
PoolUsageBytes: "pool_usage_bytes",
DailyUsageKb: "daily_usage_kb",
MonthlyUsageKb: "monthly_usage_kb",
DailyQueryDate: "daily_query_date",
MonthlyQueryMonth: "monthly_query_month",
MonthlyDeadline: "monthly_deadline",
GrowthPerMinuteKb: "growth_per_minute_kb",
AutoGrowthEnabled: "auto_growth_enabled",
LastUsageUpdateTime: "last_usage_update_time",
CardBindStatus: "card_bind_status",
RegionLimitStatus: "region_limit_status",
AreaRestrictType: "area_restrict_type",
RestrictedArea: "restricted_area",
Exported: "exported",
ExportCheckedAt: "export_checked_at",
CreatedAt: "created_at",
UpdatedAt: "updated_at",
}
// NewMockTSimCardDao creates and returns a new DAO object for table data access.
func NewMockTSimCardDao(handlers ...gdb.ModelHandler) *MockTSimCardDao {
return &MockTSimCardDao{
group: "default",
table: "mock_t_sim_card",
columns: mockTSimCardColumns,
handlers: handlers,
}
}
// DB retrieves and returns the underlying raw database management object of the current DAO.
func (dao *MockTSimCardDao) DB() gdb.DB {
return g.DB(dao.group)
}
// Table returns the table name of the current DAO.
func (dao *MockTSimCardDao) Table() string {
return dao.table
}
// Columns returns all column names of the current DAO.
func (dao *MockTSimCardDao) Columns() MockTSimCardColumns {
return dao.columns
}
// Group returns the database configuration group name of the current DAO.
func (dao *MockTSimCardDao) Group() string {
return dao.group
}
// Ctx creates and returns a Model for the current DAO. It automatically sets the context for the current operation.
func (dao *MockTSimCardDao) Ctx(ctx context.Context) *gdb.Model {
model := dao.DB().Model(dao.table)
for _, handler := range dao.handlers {
model = handler(model)
}
return model.Safe().Ctx(ctx)
}
// Transaction wraps the transaction logic using function f.
// It rolls back the transaction and returns the error if function f returns a non-nil error.
// It commits the transaction and returns nil if function f returns nil.
//
// Note: Do not commit or roll back the transaction in function f,
// as it is automatically handled by this function.
func (dao *MockTSimCardDao) Transaction(ctx context.Context, f func(ctx context.Context, tx gdb.TX) error) (err error) {
return dao.Ctx(ctx).Transaction(ctx, f)
}
@@ -0,0 +1,22 @@
// =================================================================================
// This file is auto-generated by the GoFrame CLI tool. You may modify it as needed.
// =================================================================================
package dao
import (
"git.5iot.com/iotlcm-public/iotlcm-onelink-mock/internal/dao/internal"
)
// mockCardGroupDao is the data access object for the table mock_card_group.
// You can define custom methods on it to extend its functionality as needed.
type mockCardGroupDao struct {
*internal.MockCardGroupDao
}
var (
// MockCardGroup is a globally accessible object for table mock_card_group operations.
MockCardGroup = mockCardGroupDao{internal.NewMockCardGroupDao()}
)
// Add your custom methods and functionality below.
+22
View File
@@ -0,0 +1,22 @@
// =================================================================================
// This file is auto-generated by the GoFrame CLI tool. You may modify it as needed.
// =================================================================================
package dao
import (
"git.5iot.com/iotlcm-public/iotlcm-onelink-mock/internal/dao/internal"
)
// mockOrderDao is the data access object for the table mock_order.
// You can define custom methods on it to extend its functionality as needed.
type mockOrderDao struct {
*internal.MockOrderDao
}
var (
// MockOrder is a globally accessible object for table mock_order operations.
MockOrder = mockOrderDao{internal.NewMockOrderDao()}
)
// Add your custom methods and functionality below.
@@ -0,0 +1,22 @@
// =================================================================================
// This file is auto-generated by the GoFrame CLI tool. You may modify it as needed.
// =================================================================================
package dao
import (
"git.5iot.com/iotlcm-public/iotlcm-onelink-mock/internal/dao/internal"
)
// mockTSimCardDao is the data access object for the table mock_t_sim_card.
// You can define custom methods on it to extend its functionality as needed.
type mockTSimCardDao struct {
*internal.MockTSimCardDao
}
var (
// MockTSimCard is a globally accessible object for table mock_t_sim_card operations.
MockTSimCard = mockTSimCardDao{internal.NewMockTSimCardDao()}
)
// Add your custom methods and functionality below.
@@ -0,0 +1,18 @@
// =================================================================================
// Code generated and maintained by GoFrame CLI tool. DO NOT EDIT.
// =================================================================================
package do
import (
"github.com/gogf/gf/v2/frame/g"
)
// MockCardGroup is the golang structure of table mock_card_group for DAO operations like Where/Data.
type MockCardGroup struct {
g.Meta `orm:"table:mock_card_group, do:true"`
Id any //
Name any //
CreatedAt any //
UpdatedAt any //
}
@@ -0,0 +1,24 @@
// =================================================================================
// Code generated and maintained by GoFrame CLI tool. DO NOT EDIT.
// =================================================================================
package do
import (
"github.com/gogf/gf/v2/frame/g"
)
// MockOrder is the golang structure of table mock_order for DAO operations like Where/Data.
type MockOrder struct {
g.Meta `orm:"table:mock_order, do:true"`
Id any //
OrderNum any //
Msisdn any //
Iccid any //
Imsi any //
Type any //
OperType any //
ApnName any //
Status any //
CreatedAt any //
}
@@ -0,0 +1,69 @@
// =================================================================================
// Code generated and maintained by GoFrame CLI tool. DO NOT EDIT.
// =================================================================================
package do
import (
"github.com/gogf/gf/v2/frame/g"
)
// MockTSimCard is the golang structure of table mock_t_sim_card for DAO operations like Where/Data.
type MockTSimCard struct {
g.Meta `orm:"table:mock_t_sim_card, do:true"`
Id any //
GroupId any //
Msisdn any //
Iccid any //
Imsi any //
Imei any //
Remark any //
Carrier any //
PackageCode any //
PackageName any //
PackageStatus any //
PackageTotalFlowMb any //
IsFlowPool any //
CardStatus any //
LastChangeDate any //
StopReason any //
FrozenStatus any //
FrozenReason any //
FrozenOperTime any //
OpenDate any //
ActiveDate any //
OnOffStatus any //
SessionStatus any //
ApnId any //
SessionIpv4 any //
SessionIpv6Prefix any //
SessionIpv6 any //
SessionCreateDate any //
Rat any //
ApnListJson any //
ApnUsageJson any //
PrimaryApnName any //
PrimaryApnStatus any //
PrimaryApnUsageKb any //
PrimaryApnUsageBytes any //
PccCode any //
PccUsageKb any //
CurrentMonthUsageKb any //
PoolUsageBytes any //
DailyUsageKb any //
MonthlyUsageKb any //
DailyQueryDate any //
MonthlyQueryMonth any //
MonthlyDeadline any //
GrowthPerMinuteKb any //
AutoGrowthEnabled any //
LastUsageUpdateTime any //
CardBindStatus any //
RegionLimitStatus any //
AreaRestrictType any //
RestrictedArea any //
Exported any //
ExportCheckedAt any //
CreatedAt any //
UpdatedAt any //
}
@@ -0,0 +1,13 @@
// =================================================================================
// Code generated and maintained by GoFrame CLI tool. DO NOT EDIT.
// =================================================================================
package entity
// MockCardGroup is the golang structure for table mock_card_group.
type MockCardGroup struct {
Id int `json:"id" orm:"id" description:""` //
Name string `json:"name" orm:"name" description:""` //
CreatedAt string `json:"createdAt" orm:"created_at" description:""` //
UpdatedAt string `json:"updatedAt" orm:"updated_at" description:""` //
}
@@ -0,0 +1,19 @@
// =================================================================================
// Code generated and maintained by GoFrame CLI tool. DO NOT EDIT.
// =================================================================================
package entity
// MockOrder is the golang structure for table mock_order.
type MockOrder struct {
Id int `json:"id" orm:"id" description:""` //
OrderNum string `json:"orderNum" orm:"order_num" description:""` //
Msisdn string `json:"msisdn" orm:"msisdn" description:""` //
Iccid string `json:"iccid" orm:"iccid" description:""` //
Imsi string `json:"imsi" orm:"imsi" description:""` //
Type string `json:"type" orm:"type" description:""` //
OperType string `json:"operType" orm:"oper_type" description:""` //
ApnName string `json:"apnName" orm:"apn_name" description:""` //
Status string `json:"status" orm:"status" description:""` //
CreatedAt string `json:"createdAt" orm:"created_at" description:""` //
}
@@ -0,0 +1,64 @@
// =================================================================================
// Code generated and maintained by GoFrame CLI tool. DO NOT EDIT.
// =================================================================================
package entity
// MockTSimCard is the golang structure for table mock_t_sim_card.
type MockTSimCard struct {
Id int `json:"id" orm:"id" description:""` //
GroupId int `json:"groupId" orm:"group_id" description:""` //
Msisdn string `json:"msisdn" orm:"msisdn" description:""` //
Iccid string `json:"iccid" orm:"iccid" description:""` //
Imsi string `json:"imsi" orm:"imsi" description:""` //
Imei string `json:"imei" orm:"imei" description:""` //
Remark string `json:"remark" orm:"remark" description:""` //
Carrier string `json:"carrier" orm:"carrier" description:""` //
PackageCode string `json:"packageCode" orm:"package_code" description:""` //
PackageName string `json:"packageName" orm:"package_name" description:""` //
PackageStatus int `json:"packageStatus" orm:"package_status" description:""` //
PackageTotalFlowMb float32 `json:"packageTotalFlowMb" orm:"package_total_flow_mb" description:""` //
IsFlowPool int `json:"isFlowPool" orm:"is_flow_pool" description:""` //
CardStatus string `json:"cardStatus" orm:"card_status" description:""` //
LastChangeDate string `json:"lastChangeDate" orm:"last_change_date" description:""` //
StopReason string `json:"stopReason" orm:"stop_reason" description:""` //
FrozenStatus string `json:"frozenStatus" orm:"frozen_status" description:""` //
FrozenReason string `json:"frozenReason" orm:"frozen_reason" description:""` //
FrozenOperTime string `json:"frozenOperTime" orm:"frozen_oper_time" description:""` //
OpenDate string `json:"openDate" orm:"open_date" description:""` //
ActiveDate string `json:"activeDate" orm:"active_date" description:""` //
OnOffStatus string `json:"onOffStatus" orm:"on_off_status" description:""` //
SessionStatus string `json:"sessionStatus" orm:"session_status" description:""` //
ApnId string `json:"apnId" orm:"apn_id" description:""` //
SessionIpv4 string `json:"sessionIpv4" orm:"session_ipv4" description:""` //
SessionIpv6Prefix string `json:"sessionIpv6Prefix" orm:"session_ipv6_prefix" description:""` //
SessionIpv6 string `json:"sessionIpv6" orm:"session_ipv6" description:""` //
SessionCreateDate string `json:"sessionCreateDate" orm:"session_create_date" description:""` //
Rat string `json:"rat" orm:"rat" description:""` //
ApnListJson string `json:"apnListJson" orm:"apn_list_json" description:""` //
ApnUsageJson string `json:"apnUsageJson" orm:"apn_usage_json" description:""` //
PrimaryApnName string `json:"primaryApnName" orm:"primary_apn_name" description:""` //
PrimaryApnStatus string `json:"primaryApnStatus" orm:"primary_apn_status" description:""` //
PrimaryApnUsageKb float32 `json:"primaryApnUsageKb" orm:"primary_apn_usage_kb" description:""` //
PrimaryApnUsageBytes int `json:"primaryApnUsageBytes" orm:"primary_apn_usage_bytes" description:""` //
PccCode string `json:"pccCode" orm:"pcc_code" description:""` //
PccUsageKb float32 `json:"pccUsageKb" orm:"pcc_usage_kb" description:""` //
CurrentMonthUsageKb float32 `json:"currentMonthUsageKb" orm:"current_month_usage_kb" description:""` //
PoolUsageBytes int `json:"poolUsageBytes" orm:"pool_usage_bytes" description:""` //
DailyUsageKb float32 `json:"dailyUsageKb" orm:"daily_usage_kb" description:""` //
MonthlyUsageKb float32 `json:"monthlyUsageKb" orm:"monthly_usage_kb" description:""` //
DailyQueryDate string `json:"dailyQueryDate" orm:"daily_query_date" description:""` //
MonthlyQueryMonth string `json:"monthlyQueryMonth" orm:"monthly_query_month" description:""` //
MonthlyDeadline string `json:"monthlyDeadline" orm:"monthly_deadline" description:""` //
GrowthPerMinuteKb float32 `json:"growthPerMinuteKb" orm:"growth_per_minute_kb" description:""` //
AutoGrowthEnabled int `json:"autoGrowthEnabled" orm:"auto_growth_enabled" description:""` //
LastUsageUpdateTime string `json:"lastUsageUpdateTime" orm:"last_usage_update_time" description:""` //
CardBindStatus string `json:"cardBindStatus" orm:"card_bind_status" description:""` //
RegionLimitStatus string `json:"regionLimitStatus" orm:"region_limit_status" description:""` //
AreaRestrictType string `json:"areaRestrictType" orm:"area_restrict_type" description:""` //
RestrictedArea string `json:"restrictedArea" orm:"restricted_area" description:""` //
Exported int `json:"exported" orm:"exported" description:""` //
ExportCheckedAt string `json:"exportCheckedAt" orm:"export_checked_at" description:""` //
CreatedAt string `json:"createdAt" orm:"created_at" description:""` //
UpdatedAt string `json:"updatedAt" orm:"updated_at" description:""` //
}
+561
View File
@@ -0,0 +1,561 @@
package service
import (
"encoding/json"
"fmt"
"io"
"net/http"
"strconv"
"strings"
"time"
)
func (s *Service) adminState(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
w.WriteHeader(http.StatusMethodNotAllowed)
return
}
writeJSON(w, http.StatusOK, s.store.Snapshot())
}
func (s *Service) adminAppIDs(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodPost:
var cred Credential
if err := json.NewDecoder(r.Body).Decode(&cred); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
return
}
if cred.AppID == "" || cred.Password == "" {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "appid and password are required"})
return
}
if cred.Name == "" {
cred.Name = cred.AppID
}
cred.Enabled = true
cred.CreatedAt = time.Now().Format("2006-01-02 15:04:05")
err := s.store.SaveMutation(func(st *Store) error {
for i := range st.AppIDs {
if st.AppIDs[i].AppID == cred.AppID {
st.AppIDs[i].Password = cred.Password
st.AppIDs[i].Name = cred.Name
st.AppIDs[i].Enabled = cred.Enabled
st.AppIDs[i].LastToken = ""
st.AppIDs[i].TokenExpires = ""
delete(st.Tokens, cred.AppID)
return nil
}
}
st.AppIDs = append(st.AppIDs, cred)
return nil
})
if err != nil {
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
return
}
writeJSON(w, http.StatusOK, s.store.Snapshot())
case http.MethodDelete:
appid := r.URL.Query().Get("appid")
_ = s.store.SaveMutation(func(st *Store) error {
next := st.AppIDs[:0]
for _, c := range st.AppIDs {
if c.AppID != appid {
next = append(next, c)
}
}
st.AppIDs = next
delete(st.Tokens, appid)
return nil
})
writeJSON(w, http.StatusOK, s.store.Snapshot())
default:
w.WriteHeader(http.StatusMethodNotAllowed)
}
}
func (s *Service) adminCards(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodGet:
page, pageSize := pagination(r)
filter, err := cardFilterFromRequest(r)
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
return
}
writeJSON(w, http.StatusOK, s.store.QueryCardsByGroup(filter.keyword, filter.exportStatus, filter.groupID, filter.filterGroup, page, pageSize))
case http.MethodPost:
var card SIMCard
if err := json.NewDecoder(r.Body).Decode(&card); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
return
}
normalizeCard(&card)
err := s.store.SaveMutation(func(st *Store) error {
for i := range st.Cards {
if st.Cards[i].MSISDN == card.MSISDN {
st.Cards[i] = card
return nil
}
}
st.Cards = append(st.Cards, card)
return nil
})
if err != nil {
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
return
}
writeJSON(w, http.StatusOK, s.store.Snapshot())
case http.MethodDelete:
msisdn := r.URL.Query().Get("msisdn")
_ = s.store.SaveMutation(func(st *Store) error {
next := st.Cards[:0]
for _, c := range st.Cards {
if c.MSISDN != msisdn {
next = append(next, c)
}
}
st.Cards = next
return nil
})
writeJSON(w, http.StatusOK, s.store.Snapshot())
default:
w.WriteHeader(http.StatusMethodNotAllowed)
}
}
func (s *Service) adminCardGroups(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodGet:
st := s.store.Snapshot()
counts := map[int64]int{}
for _, card := range st.Cards {
counts[card.GroupID]++
}
for i := range st.Groups {
st.Groups[i].CardCount = counts[st.Groups[i].ID]
}
writeJSON(w, http.StatusOK, st.Groups)
case http.MethodPost:
var input struct {
Name string `json:"name"`
}
if err := json.NewDecoder(r.Body).Decode(&input); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
return
}
input.Name = strings.TrimSpace(input.Name)
if input.Name == "" {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "group name is required"})
return
}
var created CardGroup
err := s.store.SaveMutation(func(st *Store) error {
for _, group := range st.Groups {
if strings.EqualFold(group.Name, input.Name) {
return fmt.Errorf("group name already exists")
}
}
id := int64(1)
for _, group := range st.Groups {
if group.ID >= id {
id = group.ID + 1
}
}
now := time.Now().Format("2006-01-02 15:04:05")
created = CardGroup{ID: id, Name: input.Name, CreatedAt: now, UpdatedAt: now}
st.Groups = append(st.Groups, created)
return nil
})
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
return
}
writeJSON(w, http.StatusOK, created)
case http.MethodDelete:
id, err := strconv.ParseInt(r.URL.Query().Get("id"), 10, 64)
if err != nil || id <= 0 {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid group id"})
return
}
err = s.store.SaveMutation(func(st *Store) error {
found := false
next := st.Groups[:0]
for _, group := range st.Groups {
if group.ID == id {
found = true
} else {
next = append(next, group)
}
}
if !found {
return fmt.Errorf("card group not found")
}
st.Groups = next
for i := range st.Cards {
if st.Cards[i].GroupID == id {
st.Cards[i].GroupID = 0
st.Cards[i].GroupName = ""
}
}
return nil
})
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
return
}
writeJSON(w, http.StatusOK, s.store.Snapshot())
default:
w.WriteHeader(http.StatusMethodNotAllowed)
}
}
func (s *Service) adminSettings(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
w.WriteHeader(http.StatusMethodNotAllowed)
return
}
var settings SimulationSettings
if err := json.NewDecoder(r.Body).Decode(&settings); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
return
}
_ = s.store.SaveMutation(func(st *Store) error {
st.Settings = settings
return nil
})
writeJSON(w, http.StatusOK, s.store.Snapshot())
}
func (s *Service) adminOrders(w http.ResponseWriter, r *http.Request) {
if r.Method == http.MethodGet {
page, pageSize := pagination(r)
writeJSON(w, http.StatusOK, s.store.QueryOrders(page, pageSize))
return
}
if r.Method != http.MethodDelete {
w.WriteHeader(http.StatusMethodNotAllowed)
return
}
_ = s.store.SaveMutation(func(st *Store) error {
st.Orders = []Order{}
return nil
})
writeJSON(w, http.StatusOK, s.store.Snapshot())
}
func pagination(r *http.Request) (int, int) {
page, _ := strconv.Atoi(r.URL.Query().Get("page"))
pageSize, _ := strconv.Atoi(r.URL.Query().Get("pageSize"))
if page < 1 {
page = 1
}
if pageSize < 1 {
pageSize = 20
}
if pageSize > 100 {
pageSize = 100
}
return page, pageSize
}
type cardFilter struct {
keyword string
exportStatus string
groupID int64
filterGroup bool
}
func cardFilterFromRequest(r *http.Request) (cardFilter, error) {
filter := cardFilter{
keyword: r.URL.Query().Get("keyword"),
exportStatus: strings.TrimSpace(r.URL.Query().Get("exportStatus")),
}
if filter.exportStatus != "" && filter.exportStatus != "exported" && filter.exportStatus != "not_exported" {
return cardFilter{}, fmt.Errorf("invalid export status")
}
groupRaw := strings.TrimSpace(r.URL.Query().Get("groupId"))
if groupRaw == "" {
return filter, nil
}
groupID, err := strconv.ParseInt(groupRaw, 10, 64)
if err != nil || groupID < 0 {
return cardFilter{}, fmt.Errorf("invalid group id")
}
filter.groupID = groupID
filter.filterGroup = true
return filter, nil
}
func (s *Service) adminGenerate(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
w.WriteHeader(http.StatusMethodNotAllowed)
return
}
count, groupID, err := generationRequest(r)
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
return
}
result, err := s.store.GenerateCardsInGroup(count, groupID)
if err != nil {
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
return
}
writeJSON(w, http.StatusOK, result)
}
func (s *Service) adminSync(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
w.WriteHeader(http.StatusMethodNotAllowed)
return
}
count, _, err := generationRequest(r)
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
return
}
result, err := s.store.GenerateCards(count)
if err != nil {
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
return
}
writeJSON(w, http.StatusOK, result)
}
func (s *Service) adminCardsExport(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
w.WriteHeader(http.StatusMethodNotAllowed)
return
}
var cards []SIMCard
if r.URL.Query().Get("selection") == "filtered" {
filter, err := cardFilterFromRequest(r)
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
return
}
cards = s.store.QueryAllCardsByGroup(filter.keyword, filter.exportStatus, filter.groupID, filter.filterGroup)
} else {
cards = exportCardsByMSISDN(s.store.Snapshot().Cards, r.URL.Query().Get("msisdns"))
}
raw, err := exportInboundCardsXLSX(cards)
if err != nil {
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
return
}
exportedAt := time.Now().Format("2006-01-02 15:04:05")
exportedMSISDNs := make(map[string]struct{}, len(cards))
for _, card := range cards {
exportedMSISDNs[card.MSISDN] = struct{}{}
}
if err := s.store.SaveMutation(func(st *Store) error {
for i := range st.Cards {
if _, ok := exportedMSISDNs[st.Cards[i].MSISDN]; ok {
st.Cards[i].Exported = true
st.Cards[i].ExportCheckedAt = exportedAt
}
}
return nil
}); err != nil {
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
return
}
w.Header().Set("Content-Type", "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet")
w.Header().Set("Content-Disposition", `attachment; filename="sim_card_export.xlsx"`)
w.WriteHeader(http.StatusOK)
_, _ = w.Write(raw)
}
func exportCardsByMSISDN(cards []SIMCard, rawMSISDNs string) []SIMCard {
rawMSISDNs = strings.TrimSpace(rawMSISDNs)
if rawMSISDNs == "" {
return cards
}
selected := map[string]bool{}
for _, item := range strings.FieldsFunc(rawMSISDNs, func(r rune) bool {
return r == ',' || r == '_' || r == ' '
}) {
item = strings.TrimSpace(item)
if item != "" {
selected[item] = true
}
}
if len(selected) == 0 {
return cards
}
out := make([]SIMCard, 0, len(selected))
for _, card := range cards {
if selected[card.MSISDN] {
out = append(out, card)
}
}
return out
}
func generationCount(r *http.Request) (int, error) {
count := 100
if r.Body != nil {
var payload struct {
Count int `json:"count"`
}
if err := json.NewDecoder(r.Body).Decode(&payload); err == nil && payload.Count > 0 {
count = payload.Count
}
}
if value := r.URL.Query().Get("count"); value != "" {
parsed, err := strconv.Atoi(value)
if err != nil {
return 0, err
}
count = parsed
}
if count <= 0 {
return 0, strconv.ErrSyntax
}
return count, nil
}
func generationRequest(r *http.Request) (int, int64, error) {
var input struct {
Count int `json:"count"`
GroupID *int64 `json:"groupId"`
}
if err := json.NewDecoder(r.Body).Decode(&input); err != nil {
if err == io.EOF {
count, countErr := generationCount(r)
return count, 0, countErr
}
return 0, 0, err
}
if input.Count <= 0 {
return 0, 0, fmt.Errorf("count must be greater than 0")
}
if input.GroupID == nil || *input.GroupID < 0 {
return input.Count, 0, nil
}
return input.Count, *input.GroupID, nil
}
func normalizeCard(card *SIMCard) {
now := time.Now()
card.MSISDN = strings.TrimSpace(card.MSISDN)
card.ICCID = strings.TrimSpace(card.ICCID)
card.IMSI = strings.TrimSpace(card.IMSI)
if card.CardStatus == "" {
card.CardStatus = "2"
}
if card.StopReason == "" {
card.StopReason = defaultStopReason(card.CardStatus)
}
if card.FrozenStatus == "" {
card.FrozenStatus = "0"
}
if card.FrozenOperTime == "" {
card.FrozenOperTime = diagnosticTime(now)
}
if card.LastChangeDate == "" {
card.LastChangeDate = now.Format("2006-01-02 15:04:05")
}
if card.OpenDate == "" {
card.OpenDate = now.Format("2006-01-02 15:04:05")
}
if card.ActiveDate == "" {
card.ActiveDate = now.Format("2006-01-02 15:04:05")
}
if card.OnOffStatus == "" {
card.OnOffStatus = "1"
}
if card.SessionStatus == "" {
card.SessionStatus = "01"
}
if card.SessionStatus != "00" && card.SessionStatus != "01" {
card.SessionStatus = "00"
}
if card.SessionCreateDate == "" {
card.SessionCreateDate = card.LastChangeDate
}
if card.SessionCreateDate == "" {
card.SessionCreateDate = card.LastChangeDate
}
if card.RAT == "" {
card.RAT = "6"
}
if card.CardBindStatus == "" {
card.CardBindStatus = "1"
}
if card.RegionLimitStatus == "" {
card.RegionLimitStatus = "0"
}
if card.AreaRestrictType == "" {
card.AreaRestrictType = "1"
}
if card.RestrictedArea == "" {
card.RestrictedArea = "200"
}
if card.LastUsageUpdateTime == "" {
card.LastUsageUpdateTime = now.Format(time.RFC3339)
}
card.GrowthPerMinuteKB = 0
if len(card.APNs) == 0 {
card.APNs = []APN{{Name: "CMIOT", Status: "1", UsageBytes: card.MonthUsageKB * 1024}}
}
for i := range card.APNs {
apn := &card.APNs[i]
if apn.Name == "" {
apn.Name = "CMIOT"
}
if apn.Status == "" {
apn.Status = "1"
}
if apn.DiagnosisStatus == "" {
apn.DiagnosisStatus = diagnosisStatus(apn.Status)
}
if apn.ModifyTime == "" {
apn.ModifyTime = diagnosticTime(now)
}
if apn.ServiceUsageState == "" {
apn.ServiceUsageState = "1"
}
if len(apn.PCCs) == 0 {
apn.PCCs = []PCC{{ServiceCode: "default", Name: "APN 缺省流量", PolicyStatus: "2", ServiceUsageState: "1", ModifyTime: apn.ModifyTime}}
}
for j := range apn.PCCs {
if apn.PCCs[j].ServiceCode == "" {
apn.PCCs[j].ServiceCode = "default"
}
if apn.PCCs[j].Name == "" {
apn.PCCs[j].Name = "APN 缺省流量"
}
if apn.PCCs[j].PolicyStatus == "" {
apn.PCCs[j].PolicyStatus = "2"
}
if apn.PCCs[j].ServiceUsageState == "" {
apn.PCCs[j].ServiceUsageState = "1"
}
if apn.PCCs[j].ModifyTime == "" {
apn.PCCs[j].ModifyTime = apn.ModifyTime
}
}
}
}
func diagnosticTime(value time.Time) string { return value.Format("20060102150405") }
func defaultStopReason(cardStatus string) string {
if cardStatus == "4" {
return "000020000000"
}
return "000000000000"
}
func diagnosisStatus(status string) string {
switch status {
case "0":
return "4"
case "1":
return "2"
default:
return status
}
}
+475
View File
@@ -0,0 +1,475 @@
package service
import (
"archive/zip"
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
)
func TestAdminAppIDUpdateClearsCachedToken(t *testing.T) {
service, err := NewService(t.TempDir() + "/onelink_mock.db")
if err != nil {
t.Fatal(err)
}
appid := service.store.Snapshot().AppIDs[0].AppID
if err := service.store.SaveMutation(func(st *Store) error {
st.Tokens[appid] = Token{AppID: appid, Value: "old-token", ExpiresAt: time.Now().Add(time.Hour)}
st.AppIDs[0].LastToken = "old-token"
st.AppIDs[0].TokenExpires = time.Now().Add(time.Hour).Format("2006-01-02 15:04:05")
return nil
}); err != nil {
t.Fatal(err)
}
body := strings.NewReader(`{"appid":"` + appid + `","password":"demo_password","name":"default"}`)
req := httptest.NewRequest(http.MethodPost, "/api/appids", body)
req.Header.Set("Content-Type", "application/json")
res := httptest.NewRecorder()
service.Router().ServeHTTP(res, req)
if res.Code != http.StatusOK {
t.Fatalf("status = %d body=%s", res.Code, res.Body.String())
}
st := service.store.Snapshot()
if _, ok := st.Tokens[appid]; ok {
t.Fatalf("cached token was not cleared: %#v", st.Tokens[appid])
}
if st.AppIDs[0].LastToken != "" || st.AppIDs[0].TokenExpires != "" {
t.Fatalf("appid token display was not cleared: %#v", st.AppIDs[0])
}
}
func TestAdminCardsExportMarksExportedCardsAndPersists(t *testing.T) {
dbPath := t.TempDir() + "/onelink_mock.db"
service, err := NewService(dbPath)
if err != nil {
t.Fatal(err)
}
if _, err := service.store.GenerateCards(2); err != nil {
t.Fatal(err)
}
cards := service.store.Snapshot().Cards
req := httptest.NewRequest(http.MethodGet, "/api/cards/export?msisdns="+cards[0].MSISDN, nil)
res := httptest.NewRecorder()
service.Router().ServeHTTP(res, req)
if res.Code != http.StatusOK {
t.Fatalf("status = %d body=%s", res.Code, res.Body.String())
}
exportedPage := service.store.QueryCards("", "exported", 1, 20)
if exportedPage.Total != 1 || exportedPage.Items[0].MSISDN != cards[0].MSISDN {
t.Fatalf("exported cards = %#v", exportedPage)
}
if all := service.store.QueryCards("", "", 1, 20); len(all.Items) != 2 || all.Items[0].MSISDN != cards[1].MSISDN {
t.Fatalf("cards are not newest-first: %#v", all.Items)
}
reloaded, err := NewService(dbPath)
if err != nil {
t.Fatal(err)
}
persisted := reloaded.store.QueryCards("", "exported", 1, 20)
if persisted.Total != 1 || persisted.Items[0].MSISDN != cards[0].MSISDN || persisted.Items[0].ExportCheckedAt == "" {
t.Fatalf("persisted exported cards = %#v", persisted)
}
}
func TestAdminCardRegionLimitStatusPersistsAndQueries(t *testing.T) {
dbPath := t.TempDir() + "/onelink_mock.db"
service, err := NewService(dbPath)
if err != nil {
t.Fatal(err)
}
if _, err := service.store.GenerateCards(1); err != nil {
t.Fatal(err)
}
card := service.store.Snapshot().Cards[0]
card.RegionLimitStatus = "1"
body, err := json.Marshal(card)
if err != nil {
t.Fatal(err)
}
res := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/api/cards", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
service.Router().ServeHTTP(res, req)
if res.Code != http.StatusOK {
t.Fatalf("save status = %d body=%s", res.Code, res.Body.String())
}
reloaded, err := NewService(dbPath)
if err != nil {
t.Fatal(err)
}
if got := reloaded.store.Snapshot().Cards[0].RegionLimitStatus; got != "1" {
t.Fatalf("persisted region limit status = %q, want 1", got)
}
token := "region-limit-test-token"
if err := reloaded.store.SaveMutation(func(st *Store) error {
st.Tokens[st.AppIDs[0].AppID] = Token{Value: token, ExpiresAt: time.Now().Add(time.Hour)}
return nil
}); err != nil {
t.Fatal(err)
}
query := func(path string, target any) {
method := http.MethodGet
var requestBody *bytes.Reader
if strings.HasPrefix(path, "/sim/") {
method = http.MethodPost
requestBody = bytes.NewReader([]byte(`{"simKeys":[{"msisdn":"` + card.MSISDN + `"}],"reqData":{}}`))
} else {
requestBody = bytes.NewReader(nil)
}
queryString := ""
if method == http.MethodGet {
queryString = "?token=" + token + "&msisdn=" + card.MSISDN
}
req := httptest.NewRequest(method, path+queryString, requestBody)
req.Header.Set("Content-Type", "application/json")
res := httptest.NewRecorder()
reloaded.Router().ServeHTTP(res, req)
if res.Code != http.StatusOK {
t.Fatalf("%s status = %d body=%s", path, res.Code, res.Body.String())
}
if err := json.Unmarshal(res.Body.Bytes(), target); err != nil {
t.Fatal(err)
}
}
var oneLink Response
query("/v5/ec/query/region-limit-status", &oneLink)
result, ok := oneLink.Result[0].(map[string]any)
if !ok || len(oneLink.Result) != 1 || result["regionLimitStatus"] != "1" {
t.Fatalf("one link region response = %#v", oneLink)
}
var chanResponses []chanResponse[chanRegionLimitStatus]
query("/sim/query/sim-region-limit-status", &chanResponses)
if len(chanResponses) != 1 || chanResponses[0].ResData.RegionLimitStatus != "1" {
t.Fatalf("chan region response = %#v", chanResponses)
}
}
func TestAdminGenerateAppendsRequestedCount(t *testing.T) {
service, err := NewService(t.TempDir() + "/onelink_mock.db")
if err != nil {
t.Fatal(err)
}
body := strings.NewReader(`{"count":2}`)
req := httptest.NewRequest(http.MethodPost, "/api/generate", body)
req.Header.Set("Content-Type", "application/json")
res := httptest.NewRecorder()
service.Router().ServeHTTP(res, req)
if res.Code != http.StatusOK {
t.Fatalf("status = %d body=%s", res.Code, res.Body.String())
}
var result GenerateResult
if err := json.Unmarshal(res.Body.Bytes(), &result); err != nil {
t.Fatal(err)
}
if result.Generated != 2 || result.Total != 2 {
t.Fatalf("result = %#v, want generated 2 total 2", result)
}
if got := service.store.Snapshot(); len(got.Cards) != 2 {
t.Fatalf("store cards = %d, want 2", len(got.Cards))
}
}
func TestAdminGenerateAssignsSelectedGroup(t *testing.T) {
service, err := NewService(t.TempDir() + "/onelink_mock.db")
if err != nil {
t.Fatal(err)
}
create := httptest.NewRequest(http.MethodPost, "/api/card-groups", strings.NewReader(`{"name":"批次一"}`))
create.Header.Set("Content-Type", "application/json")
createdRes := httptest.NewRecorder()
service.Router().ServeHTTP(createdRes, create)
if createdRes.Code != http.StatusOK {
t.Fatalf("create group status = %d body=%s", createdRes.Code, createdRes.Body.String())
}
var group CardGroup
if err := json.Unmarshal(createdRes.Body.Bytes(), &group); err != nil {
t.Fatal(err)
}
generate := httptest.NewRequest(http.MethodPost, "/api/generate", strings.NewReader(fmt.Sprintf(`{"count":2,"groupId":%d}`, group.ID)))
generate.Header.Set("Content-Type", "application/json")
res := httptest.NewRecorder()
service.Router().ServeHTTP(res, generate)
if res.Code != http.StatusOK {
t.Fatalf("generate status = %d body=%s", res.Code, res.Body.String())
}
for _, card := range service.store.Snapshot().Cards {
if card.GroupID != group.ID || card.GroupName != group.Name {
t.Fatalf("card group = %#v, want %d/%s", card, group.ID, group.Name)
}
}
}
func TestAdminCardsQueriesByAnyExactIdentifier(t *testing.T) {
service, err := NewService(t.TempDir() + "/onelink_mock.db")
if err != nil {
t.Fatal(err)
}
if _, err := service.store.GenerateCards(2); err != nil {
t.Fatal(err)
}
cards := service.store.Snapshot().Cards
for _, keyword := range []string{cards[0].MSISDN, cards[0].ICCID, cards[0].IMSI} {
t.Run(keyword, func(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/api/cards?keyword="+keyword, nil)
res := httptest.NewRecorder()
service.Router().ServeHTTP(res, req)
if res.Code != http.StatusOK {
t.Fatalf("status = %d body=%s", res.Code, res.Body.String())
}
var result struct {
Items []SIMCard `json:"items"`
Total int `json:"total"`
}
if err := json.Unmarshal(res.Body.Bytes(), &result); err != nil {
t.Fatal(err)
}
if result.Total != 1 || len(result.Items) != 1 || result.Items[0].MSISDN != cards[0].MSISDN {
t.Fatalf("result = %#v, want the exact matching card", result)
}
})
}
}
func TestAdminCardsQueryReturnsEmptyPageWhenNotFound(t *testing.T) {
service, err := NewService(t.TempDir() + "/onelink_mock.db")
if err != nil {
t.Fatal(err)
}
req := httptest.NewRequest(http.MethodGet, "/api/cards?keyword=not-found", nil)
res := httptest.NewRecorder()
service.Router().ServeHTTP(res, req)
if res.Code != http.StatusOK {
t.Fatalf("status = %d body=%s", res.Code, res.Body.String())
}
var result struct {
Items []SIMCard `json:"items"`
Total int `json:"total"`
}
if err := json.Unmarshal(res.Body.Bytes(), &result); err != nil {
t.Fatal(err)
}
if result.Items == nil || result.Total != 0 || len(result.Items) != 0 {
t.Fatalf("result = %#v, want an empty page", result)
}
}
func TestAdminCardsExportUsesInboundExcelHeaders(t *testing.T) {
service, err := NewService(t.TempDir() + "/onelink_mock.db")
if err != nil {
t.Fatal(err)
}
if _, err := service.store.GenerateCards(1); err != nil {
t.Fatal(err)
}
req := httptest.NewRequest(http.MethodGet, "/api/cards/export", nil)
res := httptest.NewRecorder()
service.Router().ServeHTTP(res, req)
if res.Code != http.StatusOK {
t.Fatalf("status = %d body=%s", res.Code, res.Body.String())
}
if contentType := res.Header().Get("Content-Type"); contentType != "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" {
t.Fatalf("content type = %q", contentType)
}
workbookText := unzipWorkbookText(t, res.Body.Bytes())
for _, header := range inboundExportHeaders {
if !strings.Contains(workbookText, header) {
t.Fatalf("export workbook missing header %q in %s", header, workbookText)
}
}
if !strings.Contains(workbookText, "channel_yd_mock") || !strings.Contains(workbookText, "CMIOT-SD-500M-24M-D01") {
t.Fatalf("export workbook missing expected generated values: %s", workbookText)
}
for _, marker := range []string{"sharedStrings.xml", "styles.xml"} {
if !zipContainsFile(t, res.Body.Bytes(), "xl/"+marker) {
t.Fatalf("export workbook missing xl/%s", marker)
}
}
}
func zipContainsFile(t *testing.T, raw []byte, name string) bool {
t.Helper()
reader, err := zip.NewReader(bytes.NewReader(raw), int64(len(raw)))
if err != nil {
t.Fatal(err)
}
for _, file := range reader.File {
if file.Name == name {
return true
}
}
return false
}
func TestAdminCardsExportFiltersSelectedMSISDNs(t *testing.T) {
service, err := NewService(t.TempDir() + "/onelink_mock.db")
if err != nil {
t.Fatal(err)
}
if _, err := service.store.GenerateCards(3); err != nil {
t.Fatal(err)
}
cards := service.store.Snapshot().Cards
selected := cards[0].MSISDN + "," + cards[2].MSISDN
req := httptest.NewRequest(http.MethodGet, "/api/cards/export?msisdns="+selected, nil)
res := httptest.NewRecorder()
service.Router().ServeHTTP(res, req)
if res.Code != http.StatusOK {
t.Fatalf("status = %d body=%s", res.Code, res.Body.String())
}
workbookText := unzipWorkbookText(t, res.Body.Bytes())
if !strings.Contains(workbookText, cards[0].MSISDN) || !strings.Contains(workbookText, cards[2].MSISDN) {
t.Fatalf("export workbook missing selected cards: %s", workbookText)
}
if strings.Contains(workbookText, cards[1].MSISDN) {
t.Fatalf("export workbook included unselected card %s: %s", cards[1].MSISDN, workbookText)
}
}
func TestAdminCardsExportFilteredResultsAcrossPages(t *testing.T) {
service, err := NewService(t.TempDir() + "/onelink_mock.db")
if err != nil {
t.Fatal(err)
}
createdAt := time.Now().Format("2006-01-02 15:04:05")
if err = service.store.SaveMutation(func(st *Store) error {
st.Groups = append(st.Groups,
CardGroup{ID: 7, Name: "group-seven", CreatedAt: createdAt, UpdatedAt: createdAt},
CardGroup{ID: 8, Name: "group-eight", CreatedAt: createdAt, UpdatedAt: createdAt},
)
return nil
}); err != nil {
t.Fatal(err)
}
if _, err = service.store.GenerateCardsInGroup(25, 7); err != nil {
t.Fatal(err)
}
if _, err = service.store.GenerateCardsInGroup(5, 8); err != nil {
t.Fatal(err)
}
groupSevenCards := service.store.QueryAllCardsByGroup("", "", 7, true)
if len(groupSevenCards) != 25 {
t.Fatalf("group 7 cards = %d, want 25", len(groupSevenCards))
}
alreadyExported := groupSevenCards[0].MSISDN
if err = service.store.SaveMutation(func(st *Store) error {
for i := range st.Cards {
if st.Cards[i].MSISDN == alreadyExported {
st.Cards[i].Exported = true
st.Cards[i].ExportCheckedAt = "2026-08-27 00:00:00"
break
}
}
return nil
}); err != nil {
t.Fatal(err)
}
req := httptest.NewRequest(http.MethodGet, "/api/cards/export?selection=filtered&groupId=7&exportStatus=not_exported", nil)
res := httptest.NewRecorder()
service.Router().ServeHTTP(res, req)
if res.Code != http.StatusOK {
t.Fatalf("status = %d body=%s", res.Code, res.Body.String())
}
workbookText := unzipWorkbookText(t, res.Body.Bytes())
for _, card := range groupSevenCards[1:] {
if !strings.Contains(workbookText, card.MSISDN) {
t.Fatalf("export workbook missing filtered card %s", card.MSISDN)
}
}
if strings.Contains(workbookText, alreadyExported) {
t.Fatalf("export workbook included already exported card %s", alreadyExported)
}
for _, card := range service.store.QueryAllCardsByGroup("", "", 8, true) {
if strings.Contains(workbookText, card.MSISDN) {
t.Fatalf("export workbook included card from another group %s", card.MSISDN)
}
}
if exported := service.store.QueryAllCardsByGroup("", "exported", 7, true); len(exported) != 25 {
t.Fatalf("exported group 7 cards = %d, want 25", len(exported))
}
if exported := service.store.QueryAllCardsByGroup("", "exported", 8, true); len(exported) != 0 {
t.Fatalf("exported group 8 cards = %d, want 0", len(exported))
}
}
func TestAdminCardsExportFilteredResultsByKeyword(t *testing.T) {
service, err := NewService(t.TempDir() + "/onelink_mock.db")
if err != nil {
t.Fatal(err)
}
if _, err = service.store.GenerateCards(3); err != nil {
t.Fatal(err)
}
cards := service.store.Snapshot().Cards
req := httptest.NewRequest(http.MethodGet, "/api/cards/export?selection=filtered&keyword="+cards[1].ICCID, nil)
res := httptest.NewRecorder()
service.Router().ServeHTTP(res, req)
if res.Code != http.StatusOK {
t.Fatalf("status = %d body=%s", res.Code, res.Body.String())
}
workbookText := unzipWorkbookText(t, res.Body.Bytes())
if !strings.Contains(workbookText, cards[1].MSISDN) {
t.Fatalf("export workbook missing keyword match %s", cards[1].MSISDN)
}
for _, card := range []SIMCard{cards[0], cards[2]} {
if strings.Contains(workbookText, card.MSISDN) {
t.Fatalf("export workbook included non-matching card %s", card.MSISDN)
}
}
}
func TestAdminCardsExportFilteredResultsRejectsInvalidFilter(t *testing.T) {
service, err := NewService(t.TempDir() + "/onelink_mock.db")
if err != nil {
t.Fatal(err)
}
req := httptest.NewRequest(http.MethodGet, "/api/cards/export?selection=filtered&exportStatus=invalid", nil)
res := httptest.NewRecorder()
service.Router().ServeHTTP(res, req)
if res.Code != http.StatusBadRequest {
t.Fatalf("status = %d body=%s", res.Code, res.Body.String())
}
}
func unzipWorkbookText(t *testing.T, raw []byte) string {
t.Helper()
reader, err := zip.NewReader(bytes.NewReader(raw), int64(len(raw)))
if err != nil {
t.Fatal(err)
}
var out strings.Builder
for _, file := range reader.File {
if !strings.HasSuffix(file.Name, ".xml") {
continue
}
rc, err := file.Open()
if err != nil {
t.Fatal(err)
}
_, _ = io.Copy(&out, rc)
_ = rc.Close()
}
return out.String()
}
+612
View File
@@ -0,0 +1,612 @@
package service
import (
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"strconv"
"strings"
"time"
)
const (
chanSuccessCode = 0
chanErrorCode = 9999
)
type chanSimKey struct {
ChanKey string `json:"chanKey"`
ICCID string `json:"iccid"`
IMSI string `json:"imsi"`
MSISDN string `json:"msisdn"`
}
type chanRequest struct {
SimKeys []chanSimKey `json:"simKeys"`
ReqData json.RawMessage `json:"reqData"`
}
type chanResponse[T any] struct {
Code int `json:"code"`
Message string `json:"message"`
SimKey chanSimKey `json:"simKey"`
ResData T `json:"resData"`
}
type chanBasicInfo struct {
ActiveDate string `json:"activeDate"`
OpenDate string `json:"openDate"`
Remark string `json:"remark"`
}
type chanSession struct {
ApnID string `json:"apnId"`
Rat string `json:"rat"`
IPv4 string `json:"ipv4"`
IPv6 string `json:"ipv6"`
IPv6Prefix string `json:"ipv6Prefix"`
Status string `json:"status"`
CreateTime string `json:"createTime"`
}
type chanCardStatus struct {
CardStatus int `json:"cardStatus"`
LastChangeDate string `json:"lastChangeDate"`
}
type chanIMEI struct {
IMEI string `json:"imei"`
}
type chanUsage struct {
DataUsage float64 `json:"dataUsage"`
}
type chanMonthlyUsage struct {
DeadLine string `json:"deadLine"`
DataAmount float64 `json:"dataAmount"`
}
type chanDailyUsage struct {
QueryDate string `json:"queryDate"`
DataAmount float64 `json:"dataAmount"`
}
type chanAPN struct {
APNName string `json:"apnName"`
Status string `json:"status"`
}
type chanAPNList struct {
APNList []chanAPN `json:"apnList"`
}
type chanOrder struct {
OrderNum string `json:"orderNum"`
}
type chanBindStatus struct {
BindStatus string `json:"bindStatus"`
}
type chanDeviceStatus struct {
DeviceStatus string `json:"deviceStatus"`
}
type chanRegionLimitStatus struct {
RegionLimitStatus string `json:"regionLimitStatus"`
}
type chanReqData struct {
QueryDate string `json:"queryDate"`
OperType string `json:"operType"`
APNName string `json:"apnName"`
TestType *int `json:"testType"`
}
func (s *Service) chanAPI(w http.ResponseWriter, r *http.Request) {
path := strings.TrimPrefix(r.URL.Path, "/sim/")
switch path {
case "query/sim-basic-info":
s.chanQueryBasicInfo(w, r)
case "query/sim-session":
s.chanQuerySession(w, r)
case "query/sim-status", "query/sim-status-batch":
s.chanQueryStatus(w, r)
case "query/sim-card-bind-status":
s.chanQueryBindStatus(w, r)
case "query/sim-device-status":
s.chanQueryDeviceStatus(w, r)
case "query/sim-region-limit-status":
s.chanQueryRegionLimitStatus(w, r)
case "query/sim-imei":
s.chanQueryIMEI(w, r)
case "query/sim-datausage":
s.chanQueryDataUsage(w, r)
case "query/sim-datausage-monthly", "query/sim-datausage-monthly-batch":
s.chanQueryMonthly(w, r)
case "query/sim-datausage-daily", "query/sim-datausage-daily-batch":
s.chanQueryDaily(w, r)
case "query/sim-apn-info":
s.chanQueryAPN(w, r)
case "change/sim-status":
s.chanChangeStatus(w, r)
case "change/sim-apn":
s.chanChangeAPN(w, r)
default:
writeJSON(w, http.StatusNotFound, map[string]string{"message": "unknown sim endpoint"})
}
}
func parseChanRequest(r *http.Request) (chanRequest, error) {
var req chanRequest
if r.Method != http.MethodGet && r.Body != nil {
body, err := io.ReadAll(io.LimitReader(r.Body, 2<<20))
if err != nil {
return req, err
}
if len(strings.TrimSpace(string(body))) > 0 {
if err := json.Unmarshal(body, &req); err != nil {
return req, fmt.Errorf("invalid JSON body: %w", err)
}
}
}
if len(req.SimKeys) == 0 {
req.SimKeys = simKeysFromQuery(r.URL.Query())
}
if len(req.ReqData) == 0 {
data := chanReqData{
QueryDate: r.URL.Query().Get("queryDate"),
OperType: r.URL.Query().Get("operType"),
APNName: r.URL.Query().Get("apnName"),
}
if value := r.URL.Query().Get("testType"); value != "" {
if parsed, err := strconv.Atoi(value); err == nil {
data.TestType = &parsed
}
}
if data.QueryDate != "" || data.OperType != "" || data.APNName != "" || data.TestType != nil {
req.ReqData, _ = json.Marshal(data)
}
}
return req, nil
}
func simKeysFromQuery(q url.Values) []chanSimKey {
if raw := q.Get("simKeys"); raw != "" {
var keys []chanSimKey
if json.Unmarshal([]byte(raw), &keys) == nil {
return keys
}
}
var values []chanSimKey
for _, item := range []struct {
key string
name string
}{
{"iccids", "iccid"}, {"imsis", "imsi"}, {"msisdns", "msisdn"},
} {
for _, value := range splitIDs(q.Get(item.key)) {
key := chanSimKey{}
switch item.name {
case "iccid":
key.ICCID = value
case "imsi":
key.IMSI = value
case "msisdn":
key.MSISDN = value
}
values = append(values, key)
}
}
if len(values) == 0 {
key := chanSimKey{ICCID: q.Get("iccid"), IMSI: q.Get("imsi"), MSISDN: q.Get("msisdn")}
if key.ICCID != "" || key.IMSI != "" || key.MSISDN != "" {
values = append(values, key)
}
}
return values
}
func splitIDs(raw string) []string {
if raw == "" {
return nil
}
raw = strings.ReplaceAll(raw, ",", "_")
parts := strings.Split(raw, "_")
values := make([]string, 0, len(parts))
for _, part := range parts {
if value := strings.TrimSpace(part); value != "" {
values = append(values, value)
}
}
return values
}
func decodeChanReqData(req chanRequest) chanReqData {
var data chanReqData
_ = json.Unmarshal(req.ReqData, &data)
return data
}
func chanSuccess[T any](key chanSimKey, data T) chanResponse[T] {
return chanResponse[T]{Code: chanSuccessCode, Message: "success", SimKey: key, ResData: data}
}
func chanFailure[T any](key chanSimKey, message string) chanResponse[T] {
return chanResponse[T]{Code: chanErrorCode, Message: message, SimKey: key, ResData: *new(T)}
}
func writeChanResponses[T any](w http.ResponseWriter, responses []chanResponse[T]) {
writeJSON(w, http.StatusOK, responses)
}
func (s *Service) chanCards(r *http.Request) (chanRequest, Store, error) {
req, err := parseChanRequest(r)
if err != nil {
return req, Store{}, err
}
return req, s.store.Snapshot(), nil
}
func findChanCard(st Store, key chanSimKey) (SIMCard, bool) {
if key.ICCID != "" {
for _, card := range st.Cards {
if card.ICCID == key.ICCID {
return card, true
}
}
}
if key.IMSI != "" {
for _, card := range st.Cards {
if card.IMSI == key.IMSI {
return card, true
}
}
}
if key.MSISDN != "" {
for _, card := range st.Cards {
if card.MSISDN == key.MSISDN {
return card, true
}
}
}
return SIMCard{}, false
}
func chanRequestError[T any](w http.ResponseWriter, err error) {
writeJSON(w, http.StatusBadRequest, []chanResponse[T]{{Code: chanErrorCode, Message: err.Error()}})
}
func (s *Service) chanQueryBasicInfo(w http.ResponseWriter, r *http.Request) {
req, st, err := s.chanCards(r)
if err != nil {
chanRequestError[chanBasicInfo](w, err)
return
}
responses := make([]chanResponse[chanBasicInfo], 0, len(req.SimKeys))
for _, key := range req.SimKeys {
card, ok := findChanCard(st, key)
if !ok {
responses = append(responses, chanFailure[chanBasicInfo](key, "card not found"))
continue
}
responses = append(responses, chanSuccess(key, chanBasicInfo{ActiveDate: card.ActiveDate, OpenDate: card.OpenDate, Remark: card.Remark}))
}
writeChanResponses(w, responses)
}
func (s *Service) chanQuerySession(w http.ResponseWriter, r *http.Request) {
req, st, err := s.chanCards(r)
if err != nil {
chanRequestError[chanSession](w, err)
return
}
responses := make([]chanResponse[chanSession], 0, len(req.SimKeys))
for _, key := range req.SimKeys {
card, ok := findChanCard(st, key)
if !ok {
responses = append(responses, chanFailure[chanSession](key, "card not found"))
continue
}
responses = append(responses, chanSuccess(key, chanSession{ApnID: primaryAPN(card).Name, Rat: card.RAT, IPv4: card.IP, IPv6: card.IPv6, IPv6Prefix: card.IPv6Prefix, Status: chanSessionStatus(card.SessionStatus), CreateTime: card.LastChangeDate}))
}
writeChanResponses(w, responses)
}
func (s *Service) chanQueryStatus(w http.ResponseWriter, r *http.Request) {
req, st, err := s.chanCards(r)
if err != nil {
chanRequestError[chanCardStatus](w, err)
return
}
responses := make([]chanResponse[chanCardStatus], 0, len(req.SimKeys))
for _, key := range req.SimKeys {
card, ok := findChanCard(st, key)
if !ok {
responses = append(responses, chanFailure[chanCardStatus](key, "card not found"))
continue
}
status, _ := strconv.Atoi(card.CardStatus)
responses = append(responses, chanSuccess(key, chanCardStatus{CardStatus: status, LastChangeDate: card.LastChangeDate}))
}
writeChanResponses(w, responses)
}
func (s *Service) chanQueryBindStatus(w http.ResponseWriter, r *http.Request) {
req, st, err := s.chanCards(r)
if err != nil {
chanRequestError[chanBindStatus](w, err)
return
}
responses := make([]chanResponse[chanBindStatus], 0, len(req.SimKeys))
for _, key := range req.SimKeys {
card, ok := findChanCard(st, key)
if !ok {
responses = append(responses, chanFailure[chanBindStatus](key, "card not found"))
continue
}
responses = append(responses, chanSuccess(key, chanBindStatus{BindStatus: card.CardBindStatus}))
}
writeChanResponses(w, responses)
}
func (s *Service) chanQueryDeviceStatus(w http.ResponseWriter, r *http.Request) {
req, st, err := s.chanCards(r)
if err != nil {
chanRequestError[chanDeviceStatus](w, err)
return
}
responses := make([]chanResponse[chanDeviceStatus], 0, len(req.SimKeys))
for _, key := range req.SimKeys {
card, ok := findChanCard(st, key)
if !ok {
responses = append(responses, chanFailure[chanDeviceStatus](key, "card not found"))
continue
}
responses = append(responses, chanSuccess(key, chanDeviceStatus{DeviceStatus: chanSessionStatus(card.OnOffStatus)}))
}
writeChanResponses(w, responses)
}
func (s *Service) chanQueryRegionLimitStatus(w http.ResponseWriter, r *http.Request) {
req, st, err := s.chanCards(r)
if err != nil {
chanRequestError[chanRegionLimitStatus](w, err)
return
}
responses := make([]chanResponse[chanRegionLimitStatus], 0, len(req.SimKeys))
for _, key := range req.SimKeys {
card, ok := findChanCard(st, key)
if !ok {
responses = append(responses, chanFailure[chanRegionLimitStatus](key, "card not found"))
continue
}
responses = append(responses, chanSuccess(key, chanRegionLimitStatus{RegionLimitStatus: card.RegionLimitStatus}))
}
writeChanResponses(w, responses)
}
func (s *Service) chanQueryIMEI(w http.ResponseWriter, r *http.Request) {
req, st, err := s.chanCards(r)
if err != nil {
chanRequestError[chanIMEI](w, err)
return
}
responses := make([]chanResponse[chanIMEI], 0, len(req.SimKeys))
for _, key := range req.SimKeys {
card, ok := findChanCard(st, key)
if !ok {
responses = append(responses, chanFailure[chanIMEI](key, "card not found"))
continue
}
responses = append(responses, chanSuccess(key, chanIMEI{IMEI: card.IMEI}))
}
writeChanResponses(w, responses)
}
func (s *Service) chanQueryDataUsage(w http.ResponseWriter, r *http.Request) {
req, st, err := s.chanCards(r)
if err != nil {
chanRequestError[chanUsage](w, err)
return
}
responses := make([]chanResponse[chanUsage], 0, len(req.SimKeys))
for _, key := range req.SimKeys {
card, ok := findChanCard(st, key)
if !ok {
responses = append(responses, chanFailure[chanUsage](key, "card not found"))
continue
}
responses = append(responses, chanSuccess(key, chanUsage{DataUsage: card.MonthUsageKB / 1024}))
}
writeChanResponses(w, responses)
}
func (s *Service) chanQueryMonthly(w http.ResponseWriter, r *http.Request) {
req, st, err := s.chanCards(r)
if err != nil {
chanRequestError[chanMonthlyUsage](w, err)
return
}
data := decodeChanReqData(req)
responses := make([]chanResponse[chanMonthlyUsage], 0, len(req.SimKeys))
for _, key := range req.SimKeys {
card, ok := findChanCard(st, key)
if !ok {
responses = append(responses, chanFailure[chanMonthlyUsage](key, "card not found"))
continue
}
amount := card.MonthUsageKB
if data.QueryDate != "" && len(data.QueryDate) == 6 && data.QueryDate != time.Now().Format("200601") {
amount = card.MonthlyBaseKB
}
responses = append(responses, chanSuccess(key, chanMonthlyUsage{DeadLine: time.Now().AddDate(0, 0, -1).Format("20060102"), DataAmount: amount / 1024}))
}
writeChanResponses(w, responses)
}
func (s *Service) chanQueryDaily(w http.ResponseWriter, r *http.Request) {
req, st, err := s.chanCards(r)
if err != nil {
chanRequestError[chanDailyUsage](w, err)
return
}
data := decodeChanReqData(req)
queryDate := data.QueryDate
if queryDate == "" {
queryDate = r.URL.Query().Get("queryDate")
}
if queryDate == "" {
queryDate = time.Now().Format("20060102")
}
responses := make([]chanResponse[chanDailyUsage], 0, len(req.SimKeys))
for _, key := range req.SimKeys {
card, ok := findChanCard(st, key)
if !ok {
responses = append(responses, chanFailure[chanDailyUsage](key, "card not found"))
continue
}
responses = append(responses, chanSuccess(key, chanDailyUsage{QueryDate: queryDate, DataAmount: card.DailyBaseKB / 1024}))
}
writeChanResponses(w, responses)
}
func (s *Service) chanQueryAPN(w http.ResponseWriter, r *http.Request) {
req, st, err := s.chanCards(r)
if err != nil {
chanRequestError[chanAPNList](w, err)
return
}
responses := make([]chanResponse[chanAPNList], 0, len(req.SimKeys))
for _, key := range req.SimKeys {
card, ok := findChanCard(st, key)
if !ok {
responses = append(responses, chanFailure[chanAPNList](key, "card not found"))
continue
}
apns := make([]chanAPN, 0, len(card.APNs))
for _, apn := range card.APNs {
apns = append(apns, chanAPN{APNName: apn.Name, Status: apn.Status})
}
responses = append(responses, chanSuccess(key, chanAPNList{APNList: apns}))
}
writeChanResponses(w, responses)
}
func (s *Service) chanChangeStatus(w http.ResponseWriter, r *http.Request) {
req, err := parseChanRequest(r)
if err != nil {
chanRequestError[chanOrder](w, err)
return
}
data := decodeChanReqData(req)
if data.OperType != "0" && data.OperType != "1" {
responses := make([]chanResponse[chanOrder], 0, len(req.SimKeys))
for _, key := range req.SimKeys {
responses = append(responses, chanFailure[chanOrder](key, "unsupported operType, expected 0(stop) or 1(resume)"))
}
writeChanResponses(w, responses)
return
}
responses := make([]chanResponse[chanOrder], 0, len(req.SimKeys))
err = s.store.SaveMutation(func(st *Store) error {
for _, key := range req.SimKeys {
idx := findChanCardIndex(*st, key)
if idx < 0 {
responses = append(responses, chanFailure[chanOrder](key, "card not found"))
continue
}
card := &st.Cards[idx]
if data.OperType == "0" {
card.CardStatus = "4"
} else {
card.CardStatus = "2"
}
card.LastChangeDate = time.Now().Format("2006-01-02 15:04:05")
orderNum := "ORD" + time.Now().Format("20060102150405") + randomHex(3)
st.Orders = append(st.Orders, Order{OrderNum: orderNum, MSISDN: card.MSISDN, ICCID: card.ICCID, IMSI: card.IMSI, Type: "sim-status", OperType: data.OperType, Status: "completed", CreatedAt: card.LastChangeDate})
responses = append(responses, chanSuccess(key, chanOrder{OrderNum: orderNum}))
}
return nil
})
if err != nil {
chanRequestError[chanOrder](w, err)
return
}
writeChanResponses(w, responses)
}
func (s *Service) chanChangeAPN(w http.ResponseWriter, r *http.Request) {
req, err := parseChanRequest(r)
if err != nil {
chanRequestError[chanOrder](w, err)
return
}
data := decodeChanReqData(req)
if data.APNName == "" || (data.OperType != "0" && data.OperType != "1") {
responses := make([]chanResponse[chanOrder], 0, len(req.SimKeys))
for _, key := range req.SimKeys {
responses = append(responses, chanFailure[chanOrder](key, "apnName and operType are required"))
}
writeChanResponses(w, responses)
return
}
responses := make([]chanResponse[chanOrder], 0, len(req.SimKeys))
err = s.store.SaveMutation(func(st *Store) error {
for _, key := range req.SimKeys {
idx := findChanCardIndex(*st, key)
if idx < 0 {
responses = append(responses, chanFailure[chanOrder](key, "card not found"))
continue
}
card := &st.Cards[idx]
status := "0"
if data.OperType == "0" {
status = "1"
}
found := false
for i := range card.APNs {
if strings.EqualFold(card.APNs[i].Name, data.APNName) {
card.APNs[i].Status = status
found = true
}
}
if !found {
card.APNs = append(card.APNs, APN{Name: data.APNName, Status: status})
}
orderNum := "APN" + time.Now().Format("20060102150405") + randomHex(3)
now := time.Now().Format("2006-01-02 15:04:05")
st.Orders = append(st.Orders, Order{OrderNum: orderNum, MSISDN: card.MSISDN, ICCID: card.ICCID, IMSI: card.IMSI, Type: "sim-apn-function", OperType: data.OperType, APNName: data.APNName, Status: "completed", CreatedAt: now})
responses = append(responses, chanSuccess(key, chanOrder{OrderNum: orderNum}))
}
return nil
})
if err != nil {
chanRequestError[chanOrder](w, err)
return
}
writeChanResponses(w, responses)
}
func findChanCardIndex(st Store, key chanSimKey) int {
for i, card := range st.Cards {
if (key.ICCID != "" && card.ICCID == key.ICCID) || (key.IMSI != "" && card.IMSI == key.IMSI) || (key.MSISDN != "" && card.MSISDN == key.MSISDN) {
return i
}
}
return -1
}
func chanSessionStatus(status string) string {
switch strings.ToUpper(strings.TrimSpace(status)) {
case "01", "1", "0", "ONLINE":
return "0"
case "00", "-1", "OFFLINE":
return "-1"
default:
return "-2"
}
}
@@ -0,0 +1,126 @@
package service
import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
)
func TestChanQueryEndpoints(t *testing.T) {
service, err := NewService(t.TempDir()+"/onelink_mock.db")
if err != nil {
t.Fatal(err)
}
if _, err := service.store.GenerateCards(2); err != nil {
t.Fatal(err)
}
cards := service.store.Snapshot().Cards
body, _ := json.Marshal(map[string]any{
"simKeys": []map[string]string{{"chanKey": "channel_yd_mock", "iccid": cards[0].ICCID, "imsi": cards[0].IMSI, "msisdn": cards[0].MSISDN}},
"reqData": map[string]string{"queryDate": "20260101"},
})
paths := []string{
"/sim/query/sim-basic-info",
"/sim/query/sim-session",
"/sim/query/sim-status",
"/sim/query/sim-status-batch",
"/sim/query/sim-card-bind-status",
"/sim/query/sim-device-status",
"/sim/query/sim-region-limit-status",
"/sim/query/sim-imei",
"/sim/query/sim-datausage",
"/sim/query/sim-datausage-monthly",
"/sim/query/sim-datausage-monthly-batch",
"/sim/query/sim-datausage-daily",
"/sim/query/sim-datausage-daily-batch",
"/sim/query/sim-apn-info",
}
for _, path := range paths {
t.Run(path, func(t *testing.T) {
req := httptest.NewRequest(http.MethodPost, path, bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
res := httptest.NewRecorder()
service.Router().ServeHTTP(res, req)
if res.Code != http.StatusOK {
t.Fatalf("status = %d body=%s", res.Code, res.Body.String())
}
var responses []struct {
Code int `json:"code"`
SimKey chanSimKey `json:"simKey"`
}
if err := json.Unmarshal(res.Body.Bytes(), &responses); err != nil {
t.Fatal(err)
}
if len(responses) != 1 || responses[0].Code != chanSuccessCode || responses[0].SimKey.ICCID != cards[0].ICCID {
t.Fatalf("responses = %#v", responses)
}
})
}
}
func TestChanBatchAndQueryStringCompatibility(t *testing.T) {
service, err := NewService(t.TempDir()+"/onelink_mock.db")
if err != nil {
t.Fatal(err)
}
if _, err := service.store.GenerateCards(2); err != nil {
t.Fatal(err)
}
cards := service.store.Snapshot().Cards
req := httptest.NewRequest(http.MethodGet, "/sim/query/sim-status-batch?iccids="+cards[0].ICCID+"_"+cards[1].ICCID, nil)
res := httptest.NewRecorder()
service.Router().ServeHTTP(res, req)
if res.Code != http.StatusOK {
t.Fatalf("status = %d body=%s", res.Code, res.Body.String())
}
var responses []chanResponse[chanCardStatus]
if err := json.Unmarshal(res.Body.Bytes(), &responses); err != nil {
t.Fatal(err)
}
if len(responses) != 2 || responses[0].Code != chanSuccessCode || responses[1].Code != chanSuccessCode {
t.Fatalf("responses = %#v", responses)
}
}
func TestChanChangesPersistAndCreateOrders(t *testing.T) {
service, err := NewService(t.TempDir()+"/onelink_mock.db")
if err != nil {
t.Fatal(err)
}
if _, err := service.store.GenerateCards(1); err != nil {
t.Fatal(err)
}
card := service.store.Snapshot().Cards[0]
statusBody := []byte(`{"simKeys":[{"iccid":"` + card.ICCID + `"}],"reqData":{"operType":"0"}}`)
res := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/sim/change/sim-status", bytes.NewReader(statusBody))
service.Router().ServeHTTP(res, req)
if res.Code != http.StatusOK {
t.Fatalf("status change status = %d body=%s", res.Code, res.Body.String())
}
if got := service.store.Snapshot().Cards[0].CardStatus; got != "4" {
t.Fatalf("card status = %s, want 4", got)
}
apnBody := []byte(`{"simKeys":[{"iccid":"` + card.ICCID + `"}],"reqData":{"apnName":"TEST_APN","operType":"0"}}`)
res = httptest.NewRecorder()
req = httptest.NewRequest(http.MethodPost, "/sim/change/sim-apn", bytes.NewReader(apnBody))
service.Router().ServeHTTP(res, req)
if res.Code != http.StatusOK {
t.Fatalf("apn change status = %d body=%s", res.Code, res.Body.String())
}
updated := service.store.Snapshot()
if len(updated.Orders) != 2 {
t.Fatalf("orders = %d, want 2", len(updated.Orders))
}
found := false
for _, apn := range updated.Cards[0].APNs {
if apn.Name == "TEST_APN" && apn.Status == "1" {
found = true
}
}
if !found {
t.Fatalf("updated APNs = %#v", updated.Cards[0].APNs)
}
}
@@ -0,0 +1,87 @@
package service
import (
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
)
func TestOneLinkDataDiagnosisUsesConfiguredAPNAndPCC(t *testing.T) {
service, err := NewService(t.TempDir()+"/mock.db")
if err != nil {
t.Fatal(err)
}
st := service.store.Snapshot()
st.Cards = GenerateSIMCards(time.Now(), 1)
card := &st.Cards[0]
card.APNs[0].DiagnosisStatus = "4"
card.APNs[0].ServiceUsageState = "96"
card.APNs[0].OverUseDetails = "16|17"
card.APNs[0].PCCs = []PCC{{ServiceCode: "2323232", Name: "APN 缺省流量", PolicyStatus: "4", ServiceUsageState: "1", ModifyTime: "20220812154700"}}
if err := service.store.SaveMutation(func(next *Store) error { *next = st; return nil }); err != nil {
t.Fatal(err)
}
st = service.store.Snapshot()
token := "diagnosis-test-token"
if err := service.store.SaveMutation(func(next *Store) error {
next.Tokens[st.AppIDs[0].AppID] = Token{Value: token, ExpiresAt: nowPlusHour()}
return nil
}); err != nil {
t.Fatal(err)
}
req := httptest.NewRequest(http.MethodGet, "/v5/ec/query/sim-data-diagnosis?token="+token+"&msisdn="+st.Cards[0].MSISDN, nil)
res := httptest.NewRecorder()
service.Router().ServeHTTP(res, req)
if res.Code != http.StatusOK {
t.Fatalf("status=%d body=%s", res.Code, res.Body.String())
}
var body Response
if err := json.Unmarshal(res.Body.Bytes(), &body); err != nil {
t.Fatal(err)
}
if body.Status != "0" {
t.Fatalf("response=%s", res.Body.String())
}
encoded, _ := json.Marshal(body.Result)
text := string(encoded)
for _, want := range []string{"\"apnStatus\":\"4\"", "\"apnServiceUsageState\":\"96\"", "\"overUseDetails\":\"16|17\"", "\"pccServiceCode\":\"2323232\"", "\"pccPolicyStatus\":\"4\""} {
if !contains(text, want) {
t.Fatalf("missing %s in %s", want, text)
}
}
}
func TestCardBindStatusRequiresMSISDNAndTestType(t *testing.T) {
service, err := NewService(t.TempDir()+"/mock.db")
if err != nil {
t.Fatal(err)
}
st := service.store.Snapshot()
st.Cards = GenerateSIMCards(time.Now(), 1)
token := "bind-test-token"
if err := service.store.SaveMutation(func(next *Store) error {
next.Tokens[st.AppIDs[0].AppID] = Token{Value: token, ExpiresAt: nowPlusHour()}
return nil
}); err != nil {
t.Fatal(err)
}
for _, query := range []string{"iccid=" + st.Cards[0].ICCID + "&testType=0", "msisdn=" + st.Cards[0].MSISDN} {
req := httptest.NewRequest(http.MethodGet, "/v5/ec/query/card-bind-status?token="+token+"&"+query, nil)
res := httptest.NewRecorder()
service.Router().ServeHTTP(res, req)
var body Response
if err := json.Unmarshal(res.Body.Bytes(), &body); err != nil {
t.Fatal(err)
}
if body.Status == "0" {
t.Fatalf("expected validation failure for %s", query)
}
}
}
func nowPlusHour() (value time.Time) { return time.Now().Add(time.Hour) }
func contains(value, want string) bool { return strings.Contains(value, want) }
@@ -0,0 +1,161 @@
package service
import (
"archive/zip"
"bytes"
"encoding/xml"
"fmt"
"strconv"
"strings"
)
func exportInboundCardsXLSX(cards []SIMCard) ([]byte, error) {
var buf bytes.Buffer
zw := zip.NewWriter(&buf)
files := map[string]string{
"[Content_Types].xml": contentTypesXML,
"_rels/.rels": rootRelsXML,
"xl/workbook.xml": workbookXML,
"xl/_rels/workbook.xml.rels": workbookRelsXML,
"xl/sharedStrings.xml": sharedStringsXML(),
"xl/styles.xml": stylesXML,
"xl/worksheets/sheet1.xml": sheetXML(cards),
}
for _, name := range []string{"[Content_Types].xml", "_rels/.rels", "xl/workbook.xml", "xl/_rels/workbook.xml.rels", "xl/sharedStrings.xml", "xl/styles.xml", "xl/worksheets/sheet1.xml"} {
w, err := zw.Create(name)
if err != nil {
return nil, err
}
if _, err := w.Write([]byte(files[name])); err != nil {
return nil, err
}
}
if err := zw.Close(); err != nil {
return nil, err
}
return buf.Bytes(), nil
}
func sheetXML(cards []SIMCard) string {
var b strings.Builder
b.WriteString(`<?xml version="1.0" encoding="UTF-8" standalone="yes"?>`)
b.WriteString(`<worksheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main">`)
b.WriteString(`<dimension ref="A1:R`)
b.WriteString(strconv.Itoa(len(cards) + 1))
b.WriteString(`"/><sheetViews><sheetView tabSelected="true" workbookViewId="0"/></sheetViews>`)
b.WriteString(`<sheetFormatPr defaultRowHeight="15"/><cols><col min="1" max="18" width="9.140625" style="1"/></cols><sheetData>`)
writeSharedStringRow(&b, 1, len(inboundExportHeaders))
for i, card := range cards {
row := inboundExportRow(card)
values := make([]string, 0, len(inboundExportColumns))
for _, column := range inboundExportColumns {
values = append(values, row[column.Key])
}
writeSheetRow(&b, i+2, values)
}
b.WriteString(`</sheetData></worksheet>`)
return b.String()
}
func writeSharedStringRow(b *strings.Builder, rowNum, count int) {
b.WriteString(`<row r="`)
b.WriteString(strconv.Itoa(rowNum))
b.WriteString(`">`)
for i := 0; i < count; i++ {
ref := fmt.Sprintf("%s%d", columnName(i+1), rowNum)
b.WriteString(`<c r="`)
b.WriteString(ref)
b.WriteString(`" s="1" t="s"><v>`)
b.WriteString(strconv.Itoa(i))
b.WriteString(`</v></c>`)
}
b.WriteString(`</row>`)
}
func writeSheetRow(b *strings.Builder, rowNum int, values []string) {
b.WriteString(`<row r="`)
b.WriteString(strconv.Itoa(rowNum))
b.WriteString(`">`)
for i, value := range values {
ref := fmt.Sprintf("%s%d", columnName(i+1), rowNum)
b.WriteString(`<c r="`)
b.WriteString(ref)
b.WriteString(`" s="1" t="inlineStr"><is><t>`)
b.WriteString(xmlEscape(value))
b.WriteString(`</t></is></c>`)
}
b.WriteString(`</row>`)
}
func sharedStringsXML() string {
var b strings.Builder
b.WriteString(`<?xml version="1.0" encoding="UTF-8" standalone="yes"?>`)
b.WriteString(`<sst xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main" count="`)
b.WriteString(strconv.Itoa(len(inboundExportHeaders)))
b.WriteString(`" uniqueCount="`)
b.WriteString(strconv.Itoa(len(inboundExportHeaders)))
b.WriteString(`">`)
for _, header := range inboundExportHeaders {
b.WriteString(`<si><t>`)
b.WriteString(xmlEscape(header))
b.WriteString(`</t></si>`)
}
b.WriteString(`</sst>`)
return b.String()
}
func columnName(index int) string {
var out []byte
for index > 0 {
index--
out = append([]byte{byte('A' + index%26)}, out...)
index /= 26
}
return string(out)
}
func xmlEscape(value string) string {
var b strings.Builder
_ = xml.EscapeText(&b, []byte(value))
return b.String()
}
const contentTypesXML = `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">
<Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/>
<Default Extension="xml" ContentType="application/xml"/>
<Override PartName="/xl/workbook.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet.main+xml"/>
<Override PartName="/xl/worksheets/sheet1.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml"/>
<Override PartName="/xl/sharedStrings.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.sharedStrings+xml"/>
<Override PartName="/xl/styles.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.styles+xml"/>
</Types>`
const rootRelsXML = `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="xl/workbook.xml"/>
</Relationships>`
const workbookXML = `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<workbook xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships">
<sheets>
<sheet name="Sheet1" sheetId="1" r:id="rId1"/>
</sheets>
</workbook>`
const workbookRelsXML = `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/worksheet" Target="worksheets/sheet1.xml"/>
<Relationship Id="rId2" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/styles" Target="styles.xml"/>
<Relationship Id="rId3" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/sharedStrings" Target="sharedStrings.xml"/>
</Relationships>`
const stylesXML = `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<styleSheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main">
<fonts count="1"><font><sz val="11"/><color theme="1"/><name val="Calibri"/><family val="2"/></font></fonts>
<fills count="2"><fill><patternFill patternType="none"/></fill><fill><patternFill patternType="gray125"/></fill></fills>
<borders count="1"><border><left/><right/><top/><bottom/><diagonal/></border></borders>
<cellStyleXfs count="1"><xf numFmtId="0" fontId="0" fillId="0" borderId="0"/></cellStyleXfs>
<cellXfs count="2"><xf numFmtId="0" fontId="0" fillId="0" borderId="0" xfId="0"/><xf numFmtId="49" fontId="0" fillId="0" borderId="0" xfId="0" applyNumberFormat="1"/></cellXfs>
<cellStyles count="1"><cellStyle name="Normal" xfId="0" builtinId="0"/></cellStyles>
<dxfs count="0"/><tableStyles count="0" defaultPivotStyle="PivotStyleLight16" defaultTableStyle="TableStyleMedium2"/>
</styleSheet>`
+134
View File
@@ -0,0 +1,134 @@
package service
import (
"fmt"
"strconv"
"time"
)
const (
generatedChanKey = "channel_yd_mock"
generatedCardType = "1"
generatedPackageCode = "CMIOT-SD-500M-24M-D01"
generatedSupplierCode = "SUP_CMCC"
generatedMonthlyUsage = 18432.0
generatedDailyUsage = 614.4
generatedPoolUsageByte = 18874368.0
)
type inboundExportColumn struct {
Header string
Key string
}
var inboundExportColumns = []inboundExportColumn{
{Header: "ICCID*", Key: "ICCID"},
{Header: "MSISDN*", Key: "MSISDN"},
{Header: "IMSI*", Key: "IMSI"},
{Header: "IMEI", Key: "IMEI"},
{Header: "通道编号*", Key: "chanKey"},
{Header: "卡类型", Key: "cardType"},
{Header: "上游套餐编码", Key: "upPkgCode"},
{Header: "供应商编码", Key: "suppliersCode"},
{Header: "上游激活时间", Key: "upstreamActivationTime"},
{Header: "是否贴片转插拔", Key: "insertToPlugEnabled"},
{Header: "是否运营商达量断网", Key: "carrierTrafficCutoffEnabled"},
{Header: "协议到期时间", Key: "agreementExpiryDate"},
{Header: "是否有测试期", Key: "hasTestPeriod"},
{Header: "测试期天数", Key: "testPeriodDays"},
{Header: "测试期最晚过期日期", Key: "testPeriodLatestExpiryDate"},
{Header: "是否有沉默期", Key: "hasSilentPeriod"},
{Header: "沉默期天数", Key: "silentPeriodDays"},
{Header: "沉默期最晚过期日期", Key: "silentPeriodLatestExpiryDate"},
}
var inboundExportHeaders = func() []string {
headers := make([]string, 0, len(inboundExportColumns))
for _, column := range inboundExportColumns {
headers = append(headers, column.Header)
}
return headers
}()
func GenerateSIMCards(batchTime time.Time, count int) []SIMCard {
if count <= 0 {
return []SIMCard{}
}
cards := make([]SIMCard, 0, count)
base := batchTime.UnixMilli()
dbTime := batchTime.Format("2006-01-02 15:04:05")
for i := 0; i < count; i++ {
seq := base + int64(i+1)
card := SIMCard{
MSISDN: fmt.Sprintf("141%08d", seq%100000000),
ICCID: fmt.Sprintf("898604%014d", seq%100000000000000),
IMSI: fmt.Sprintf("46004%010d", seq%10000000000),
IMEI: fmt.Sprintf("86%013d", seq%10000000000000),
Remark: sourceRemark("CMCC", generatedPackageCode, generatedPackageCode),
CardStatus: "2",
LastChangeDate: dbTime,
StopReason: "000000000000",
FrozenStatus: "0",
FrozenOperTime: batchTime.Format("20060102150405"),
OpenDate: dbTime,
ActiveDate: dbTime,
OnOffStatus: "1",
SessionStatus: "01",
IP: fmt.Sprintf("10.%d.%d.%d", (seq/10000)%255, (seq/100)%255, seq%255),
IPv6Prefix: "",
IPv6: "",
RAT: "6",
CardBindStatus: "1",
RegionLimitStatus: "0",
AreaRestrictType: "1",
RestrictedArea: "110000,310000,440000",
MonthUsageKB: generatedMonthlyUsage,
PoolUsageBytes: generatedPoolUsageByte,
DailyBaseKB: generatedDailyUsage,
MonthlyBaseKB: generatedMonthlyUsage,
GrowthPerMinuteKB: 0,
LastUsageUpdateTime: batchTime.Format(time.RFC3339),
APNs: []APN{
{Name: "CMIOT", Status: "1", UsageBytes: generatedPoolUsageByte, ModifyTime: batchTime.Format("20060102150405"), ServiceUsageState: "1", PCCs: []PCC{{ServiceCode: "default", Name: "APN 缺省流量", PolicyStatus: "2", ServiceUsageState: "1", ModifyTime: batchTime.Format("20060102150405")}}},
},
}
cards = append(cards, card)
}
return cards
}
func inboundExportRow(card SIMCard) map[string]string {
activation := parseCardTime(card.ActiveDate)
return map[string]string{
"ICCID": card.ICCID,
"MSISDN": card.MSISDN,
"IMSI": card.IMSI,
"IMEI": card.IMEI,
"chanKey": generatedChanKey,
"cardType": generatedCardType,
"upPkgCode": generatedPackageCode,
"suppliersCode": generatedSupplierCode,
"upstreamActivationTime": activation.Format("2006-01-02 15:04:05"),
"insertToPlugEnabled": "true",
"carrierTrafficCutoffEnabled": "false",
"agreementExpiryDate": activation.AddDate(1, 0, 0).Format("2006-01-02"),
"hasTestPeriod": "true",
"testPeriodDays": "30",
"testPeriodLatestExpiryDate": activation.AddDate(0, 0, 30).Format("2006-01-02"),
"hasSilentPeriod": "false",
"silentPeriodDays": "",
"silentPeriodLatestExpiryDate": "",
}
}
func parseCardTime(value string) time.Time {
for _, layout := range []string{"2006-01-02 15:04:05", time.RFC3339, time.RFC3339Nano} {
if t, err := time.ParseInLocation(layout, value, time.Local); err == nil {
return t
}
}
if unixMilli, err := strconv.ParseInt(value, 10, 64); err == nil {
return time.UnixMilli(unixMilli)
}
return time.Now()
}
@@ -0,0 +1,86 @@
package service
import (
"reflect"
"testing"
"time"
)
func TestInboundExportHeadersMatchLatestTemplate(t *testing.T) {
expected := []string{
"ICCID*", "MSISDN*", "IMSI*", "IMEI", "通道编号*", "卡类型", "上游套餐编码", "供应商编码",
"上游激活时间", "是否贴片转插拔", "是否运营商达量断网", "协议到期时间", "是否有测试期",
"测试期天数", "测试期最晚过期日期", "是否有沉默期", "沉默期天数", "沉默期最晚过期日期",
}
if !reflect.DeepEqual(inboundExportHeaders, expected) {
t.Fatalf("export headers = %#v, want %#v", inboundExportHeaders, expected)
}
}
func TestGeneratedCardsUseTimestampIdentifiersAndFixedTraffic(t *testing.T) {
batchTime := time.Date(2026, 7, 10, 10, 0, 0, 123000000, time.UTC)
cards := GenerateSIMCards(batchTime, 3)
if len(cards) != 3 {
t.Fatalf("generated card count = %d, want 3", len(cards))
}
first := cards[0]
second := cards[1]
if first.ICCID == second.ICCID || first.MSISDN == second.MSISDN || first.IMSI == second.IMSI || first.IMEI == second.IMEI {
t.Fatalf("generated identifiers should differ within a batch: first=%#v second=%#v", first, second)
}
if len(first.ICCID) != 20 || first.ICCID[:6] != "898604" {
t.Fatalf("iccid = %q, want 20 digits with 898604 prefix", first.ICCID)
}
if len(first.MSISDN) != 11 || first.MSISDN[:3] != "141" {
t.Fatalf("msisdn = %q, want 11 digits with 141 prefix", first.MSISDN)
}
if len(first.IMSI) != 15 || first.IMSI[:5] != "46004" {
t.Fatalf("imsi = %q, want 15 digits with 46004 prefix", first.IMSI)
}
if len(first.IMEI) != 15 || first.IMEI[:2] != "86" {
t.Fatalf("imei = %q, want 15 digits with 86 prefix", first.IMEI)
}
if first.MonthUsageKB != 18432 || first.MonthlyBaseKB != 18432 || first.DailyBaseKB != 614.4 {
t.Fatalf("fixed traffic not applied: month=%v monthly=%v daily=%v", first.MonthUsageKB, first.MonthlyBaseKB, first.DailyBaseKB)
}
if first.PoolUsageBytes != 18874368 {
t.Fatalf("pool usage bytes = %v, want 18874368", first.PoolUsageBytes)
}
if first.GrowthPerMinuteKB != 0 {
t.Fatalf("growth per minute = %v, want 0", first.GrowthPerMinuteKB)
}
if len(first.APNs) != 1 || first.APNs[0].Name != "CMIOT" || first.APNs[0].Status != "1" || first.APNs[0].UsageBytes != 18874368 {
t.Fatalf("apn not fixed to CMIOT usage: %#v", first.APNs)
}
}
func TestGeneratedExportRowMatchesInboundTemplateDefaults(t *testing.T) {
batchTime := time.Date(2026, 7, 10, 10, 0, 0, 0, time.UTC)
card := GenerateSIMCards(batchTime, 1)[0]
row := inboundExportRow(card)
if row["chanKey"] != "channel_yd_mock" {
t.Fatalf("chanKey = %q", row["chanKey"])
}
if row["cardType"] != "1" || row["upPkgCode"] != "CMIOT-SD-500M-24M-D01" || row["suppliersCode"] != "SUP_CMCC" {
t.Fatalf("package defaults not applied: %#v", row)
}
if row["upstreamActivationTime"] != "2026-07-10 10:00:00" {
t.Fatalf("activation time = %q", row["upstreamActivationTime"])
}
if row["insertToPlugEnabled"] != "true" || row["carrierTrafficCutoffEnabled"] != "false" {
t.Fatalf("boolean defaults not applied: %#v", row)
}
if row["agreementExpiryDate"] != "2027-07-10" {
t.Fatalf("agreement expiry = %q", row["agreementExpiryDate"])
}
if row["hasTestPeriod"] != "true" || row["testPeriodDays"] != "30" || row["testPeriodLatestExpiryDate"] != "2026-08-09" {
t.Fatalf("test period defaults not applied: %#v", row)
}
if row["hasSilentPeriod"] != "false" || row["silentPeriodDays"] != "" || row["silentPeriodLatestExpiryDate"] != "" {
t.Fatalf("silent period defaults not applied: %#v", row)
}
}
+26
View File
@@ -0,0 +1,26 @@
package service
import "net/http"
func (s *Service) Health(w http.ResponseWriter, r *http.Request) { s.health(w, r) }
func (s *Service) AdminState(w http.ResponseWriter, r *http.Request) { s.adminState(w, r) }
func (s *Service) AdminAppIDs(w http.ResponseWriter, r *http.Request) { s.adminAppIDs(w, r) }
func (s *Service) AdminCards(w http.ResponseWriter, r *http.Request) { s.adminCards(w, r) }
func (s *Service) AdminCardGroups(w http.ResponseWriter, r *http.Request) { s.adminCardGroups(w, r) }
func (s *Service) AdminCardsExport(w http.ResponseWriter, r *http.Request) { s.adminCardsExport(w, r) }
func (s *Service) AdminSettings(w http.ResponseWriter, r *http.Request) { s.adminSettings(w, r) }
func (s *Service) AdminOrders(w http.ResponseWriter, r *http.Request) { s.adminOrders(w, r) }
func (s *Service) AdminOperationLogs(w http.ResponseWriter, r *http.Request) {
s.adminOperationLogs(w, r)
}
func (s *Service) AdminRuntimeLogs(w http.ResponseWriter, r *http.Request) {
s.adminRuntimeLogs(w, r)
}
func (s *Service) AdminSync(w http.ResponseWriter, r *http.Request) { s.adminSync(w, r) }
func (s *Service) AdminGenerate(w http.ResponseWriter, r *http.Request) { s.adminGenerate(w, r) }
func (s *Service) OneLink(w http.ResponseWriter, r *http.Request) { s.oneLink(w, r) }
func (s *Service) Chan(w http.ResponseWriter, r *http.Request) { s.chanAPI(w, r) }
func (s *Service) WithOperationLogging(next http.Handler) http.Handler {
return s.withOperationLogging(next)
}
+140
View File
@@ -0,0 +1,140 @@
package service
import "time"
type Response struct {
Status string `json:"status"`
Message string `json:"message"`
Result []interface{} `json:"result"`
}
type Store struct {
AppIDs []Credential `json:"appids"`
Groups []CardGroup `json:"groups"`
Cards []SIMCard `json:"cards"`
Tokens map[string]Token `json:"tokens"`
Orders []Order `json:"orders"`
Settings SimulationSettings `json:"settings"`
}
type CardGroup struct {
ID int64 `json:"id"`
Name string `json:"name"`
CreatedAt string `json:"createdAt"`
UpdatedAt string `json:"updatedAt"`
CardCount int `json:"cardCount"`
}
type Credential struct {
AppID string `json:"appid"`
Password string `json:"password"`
Name string `json:"name"`
Enabled bool `json:"enabled"`
CreatedAt string `json:"createdAt"`
LastToken string `json:"lastToken,omitempty"`
TokenExpires string `json:"tokenExpires,omitempty"`
}
type Token struct {
AppID string `json:"appid"`
Value string `json:"value"`
ExpiresAt time.Time `json:"expiresAt"`
}
type APN struct {
Name string `json:"name"`
Status string `json:"status"`
DiagnosisStatus string `json:"diagnosisStatus,omitempty"`
UsageBytes float64 `json:"usageBytes"`
ModifyTime string `json:"modifyTime,omitempty"`
OverUseDetails string `json:"overUseDetails,omitempty"`
ServiceUsageState string `json:"serviceUsageState,omitempty"`
PCCs []PCC `json:"pccs,omitempty"`
}
type PCC struct {
ServiceCode string `json:"serviceCode"`
Name string `json:"name"`
PolicyStatus string `json:"policyStatus"`
ServiceUsageState string `json:"serviceUsageState"`
ModifyTime string `json:"modifyTime"`
}
type SIMCard struct {
GroupID int64 `json:"groupId,omitempty"`
GroupName string `json:"groupName,omitempty"`
MSISDN string `json:"msisdn"`
ICCID string `json:"iccid"`
IMSI string `json:"imsi"`
IMEI string `json:"imei"`
Remark string `json:"remark"`
CardStatus string `json:"cardStatus"`
LastChangeDate string `json:"lastChangeDate"`
StopReason string `json:"stopReason,omitempty"`
FrozenStatus string `json:"frozenStatus,omitempty"`
FrozenReason string `json:"frozenReason,omitempty"`
FrozenOperTime string `json:"frozenOperTime,omitempty"`
OpenDate string `json:"openDate"`
ActiveDate string `json:"activeDate"`
OnOffStatus string `json:"onOffStatus"`
SessionStatus string `json:"sessionStatus"`
SessionCreateDate string `json:"sessionCreateDate,omitempty"`
IP string `json:"ip"`
IPv6Prefix string `json:"ipv6Prefix"`
IPv6 string `json:"ipv6"`
RAT string `json:"rat"`
CardBindStatus string `json:"cardBindStatus"`
RegionLimitStatus string `json:"regionLimitStatus"`
AreaRestrictType string `json:"areaRestrictType"`
RestrictedArea string `json:"restrictedArea"`
MonthUsageKB float64 `json:"monthUsageKB"`
PoolUsageBytes float64 `json:"poolUsageBytes"`
DailyBaseKB float64 `json:"dailyBaseKB"`
MonthlyBaseKB float64 `json:"monthlyBaseKB"`
GrowthPerMinuteKB float64 `json:"growthPerMinuteKB"`
APNs []APN `json:"apns"`
LastUsageUpdateTime string `json:"lastUsageUpdateTime"`
Exported bool `json:"exported"`
ExportCheckedAt string `json:"exportCheckedAt"`
}
type Order struct {
OrderNum string `json:"orderNum"`
MSISDN string `json:"msisdn,omitempty"`
ICCID string `json:"iccid,omitempty"`
IMSI string `json:"imsi,omitempty"`
Type string `json:"type"`
OperType string `json:"operType,omitempty"`
APNName string `json:"apnName,omitempty"`
Status string `json:"status"`
CreatedAt string `json:"createdAt"`
}
type SimulationSettings struct {
AutoGrowthEnabled bool `json:"autoGrowthEnabled"`
DefaultGrowthKB float64 `json:"defaultGrowthKB"`
}
type SyncResult struct {
Scanned int `json:"scanned"`
Synced int `json:"synced"`
}
type GenerateResult struct {
Generated int `json:"generated"`
Total int `json:"total"`
}
type CardPage struct {
Items []SIMCard `json:"items"`
Total int `json:"total"`
Page int `json:"page"`
PageSize int `json:"pageSize"`
}
type OrderPage struct {
Items []Order `json:"items"`
Total int `json:"total"`
Page int `json:"page"`
PageSize int `json:"pageSize"`
}
@@ -0,0 +1,456 @@
package service
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net"
"net/http"
"net/url"
"sort"
"strconv"
"strings"
"sync"
"time"
)
const (
maxOperationDetailBytes = 2048
maxLoggedRequestBytes = 2 << 20
)
type OperationLog struct {
ID int64 `json:"id"`
CreatedAt string `json:"createdAt"`
Category string `json:"category"`
Action string `json:"action"`
Target string `json:"target,omitempty"`
Method string `json:"method"`
Path string `json:"path"`
Params string `json:"params,omitempty"`
Result string `json:"result"`
StatusCode int `json:"statusCode"`
Message string `json:"message,omitempty"`
DurationMS int64 `json:"durationMs"`
ClientIP string `json:"clientIp,omitempty"`
}
type OperationLogPage struct {
Items []OperationLog `json:"items"`
Total int `json:"total"`
Page int `json:"page"`
PageSize int `json:"pageSize"`
}
type OperationLogStore struct {
mu sync.RWMutex
logs []OperationLog
nextID int64
now func() time.Time
day string
}
func NewOperationLogStore() *OperationLogStore {
store := newOperationLogStore(time.Now)
store.scheduleMidnightClear()
return store
}
func newOperationLogStore(now func() time.Time) *OperationLogStore {
return &OperationLogStore{logs: make([]OperationLog, 0), now: now, day: logDay(now())}
}
func (s *OperationLogStore) Add(log OperationLog) {
s.mu.Lock()
defer s.mu.Unlock()
s.clearExpiredLocked()
s.nextID++
log.ID = s.nextID
log.CreatedAt = s.now().Format("2006-01-02 15:04:05")
s.logs = append(s.logs, log)
}
func (s *OperationLogStore) Query(keyword, category, result string, page, pageSize int) OperationLogPage {
s.mu.Lock()
defer s.mu.Unlock()
s.clearExpiredLocked()
keyword = strings.ToLower(strings.TrimSpace(keyword))
matched := make([]OperationLog, 0, len(s.logs))
for i := len(s.logs) - 1; i >= 0; i-- {
item := s.logs[i]
if category != "" && item.Category != category {
continue
}
if result != "" && item.Result != result {
continue
}
searchable := strings.ToLower(item.Action + " " + item.Target + " " + item.Path + " " + item.Params + " " + item.Message + " " + item.ClientIP)
if keyword != "" && !strings.Contains(searchable, keyword) {
continue
}
matched = append(matched, item)
}
total := len(matched)
start := (page - 1) * pageSize
if start > total {
start = total
}
end := start + pageSize
if end > total {
end = total
}
items := append([]OperationLog(nil), matched[start:end]...)
return OperationLogPage{Items: items, Total: total, Page: page, PageSize: pageSize}
}
func (s *OperationLogStore) Clear() {
s.mu.Lock()
defer s.mu.Unlock()
s.logs = s.logs[:0]
s.day = logDay(s.now())
}
func (s *OperationLogStore) clearExpiredLocked() {
today := logDay(s.now())
if s.day == today {
return
}
s.logs = s.logs[:0]
s.day = today
}
func (s *OperationLogStore) scheduleMidnightClear() {
now := s.now()
next := time.Date(now.Year(), now.Month(), now.Day()+1, 0, 0, 0, 0, now.Location())
time.AfterFunc(time.Until(next), func() {
s.Clear()
s.scheduleMidnightClear()
})
}
func logDay(value time.Time) string {
return value.Format("2006-01-02")
}
type operationResponseWriter struct {
http.ResponseWriter
status int
body bytes.Buffer
}
func (w *operationResponseWriter) WriteHeader(status int) {
if w.status == 0 {
w.status = status
}
w.ResponseWriter.WriteHeader(status)
}
func (w *operationResponseWriter) Write(p []byte) (int, error) {
if w.status == 0 {
w.status = http.StatusOK
}
if w.body.Len() < maxOperationDetailBytes {
remaining := maxOperationDetailBytes - w.body.Len()
if len(p) > remaining {
_, _ = w.body.Write(p[:remaining])
} else {
_, _ = w.body.Write(p)
}
}
return w.ResponseWriter.Write(p)
}
func (w *operationResponseWriter) Flush() {
if flusher, ok := w.ResponseWriter.(http.Flusher); ok {
flusher.Flush()
}
}
type replayReadCloser struct {
io.Reader
closer io.Closer
}
func (r *replayReadCloser) Close() error {
return r.closer.Close()
}
func (s *Service) withOperationLogging(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if !shouldLogOperation(r.URL.Path) {
next.ServeHTTP(w, r)
return
}
body := captureRequestBody(r)
category, action := s.describeOperation(r, body)
started := time.Now()
wrapped := &operationResponseWriter{ResponseWriter: w}
next.ServeHTTP(wrapped, r)
if wrapped.status == 0 {
wrapped.status = http.StatusOK
}
result, message := operationResult(wrapped.status, wrapped.body.Bytes())
s.operationLogs.Add(OperationLog{
Category: category, Action: action, Target: operationTarget(r.URL.Query(), body),
Method: r.Method, Path: r.URL.Path, Params: operationParams(r.URL.Query(), body),
Result: result, StatusCode: wrapped.status, Message: message,
DurationMS: time.Since(started).Milliseconds(), ClientIP: clientIP(r),
})
})
}
func shouldLogOperation(path string) bool {
if path == "/api/operation-logs" || path == "/api/runtime-logs" || path == "/healthz" {
return false
}
return strings.HasPrefix(path, "/api/") || strings.HasPrefix(path, "/v5/") || strings.HasPrefix(path, "/ec/") || strings.HasPrefix(path, "/sim/")
}
func captureRequestBody(r *http.Request) []byte {
if r.Body == nil {
return nil
}
original := r.Body
raw, err := io.ReadAll(io.LimitReader(original, maxLoggedRequestBytes+1))
if err != nil {
return nil
}
r.Body = &replayReadCloser{Reader: io.MultiReader(bytes.NewReader(raw), original), closer: original}
if len(raw) > maxLoggedRequestBytes {
return raw[:maxLoggedRequestBytes]
}
return raw
}
func (s *Service) describeOperation(r *http.Request, body []byte) (string, string) {
path := r.URL.Path
if strings.HasPrefix(path, "/api/") {
return "管理台", s.describeAdminOperation(r, body)
}
category := "OneLink"
endpoint := strings.TrimPrefix(strings.TrimPrefix(path, "/v5/"), "/")
if strings.HasPrefix(path, "/sim/") {
category = "Chan"
endpoint = strings.TrimPrefix(path, "/sim/")
}
actions := map[string]string{
"ec/get/token": "获取 Token", "ec/query/sim-basic-info": "查询卡基础信息",
"ec/query/sim-session": "查询在线状态", "ec/query/sim-status": "查询卡状态",
"ec/query/sim-imei": "查询 IMEI", "ec/query/sim-data-usage-inpool": "查询流量池用量",
"ec/query/sim-data-usage": "查询当月流量", "ec/query/sim-data-usage-monthly/batch": "批量查询月流量",
"ec/query/sim-data-usage-daily/batch": "批量查询日流量", "ec/query/apn-info": "查询 APN",
"ec/query/card-bind-status": "查询绑定状态", "ec/query/on-off-status": "查询开关机状态",
"ec/query/region-limit-status": "查询区域限制", "ec/change/sim-status": "变更 SIM 状态",
"ec/operate/sim-apn-function": "操作 APN",
"query/sim-basic-info": "查询卡基础信息", "query/sim-session": "查询在线状态",
"query/sim-status": "查询卡状态", "query/sim-status-batch": "批量查询卡状态",
"query/sim-card-bind-status": "查询绑定状态", "query/sim-device-status": "查询设备状态",
"query/sim-region-limit-status": "查询区域限制", "query/sim-imei": "查询 IMEI",
"query/sim-datausage": "查询当月流量", "query/sim-datausage-monthly": "查询月流量",
"query/sim-datausage-monthly-batch": "批量查询月流量", "query/sim-datausage-daily": "查询日流量",
"query/sim-datausage-daily-batch": "批量查询日流量", "query/sim-apn-info": "查询 APN",
"change/sim-status": "变更 SIM 状态", "change/sim-apn": "操作 APN",
}
if action := actions[endpoint]; action != "" {
return category, action
}
return category, "访问 " + endpoint
}
func (s *Service) describeAdminOperation(r *http.Request, body []byte) string {
key := r.Method + " " + r.URL.Path
actions := map[string]string{
"GET /api/state": "查看管理台", "GET /api/card-groups": "查询卡分组",
"POST /api/card-groups": "新增卡分组", "DELETE /api/card-groups": "删除卡分组",
"GET /api/cards": "查询 SIM 卡", "DELETE /api/cards": "删除 SIM 卡",
"GET /api/cards/export": "导出 SIM 卡", "POST /api/settings": "更新模拟设置",
"GET /api/orders": "查询订单", "DELETE /api/orders": "清空订单",
"POST /api/generate": "一键生成 SIM 卡", "POST /api/sync": "同步生成 SIM 卡",
"DELETE /api/appids": "删除 AppID",
}
if key == "POST /api/appids" {
appid := jsonStringField(body, "appid")
for _, item := range s.store.Snapshot().AppIDs {
if item.AppID == appid {
return "更新 AppID"
}
}
return "新增 AppID"
}
if key == "POST /api/cards" {
msisdn := jsonStringField(body, "msisdn")
for _, item := range s.store.Snapshot().Cards {
if item.MSISDN == msisdn {
return "编辑 SIM 卡"
}
}
return "新增 SIM 卡"
}
if action := actions[key]; action != "" {
return action
}
return "访问 " + r.URL.Path
}
func jsonStringField(body []byte, key string) string {
var value map[string]interface{}
if json.Unmarshal(body, &value) != nil {
return ""
}
return fmt.Sprint(value[key])
}
func operationTarget(query url.Values, body []byte) string {
keys := []string{"appid", "msisdn", "iccid", "imsi", "id"}
for _, key := range keys {
if value := strings.TrimSpace(query.Get(key)); value != "" {
return key + ": " + value
}
}
var value map[string]interface{}
if json.Unmarshal(body, &value) != nil {
return ""
}
for _, key := range keys {
if item := strings.TrimSpace(fmt.Sprint(value[key])); item != "" && item != "<nil>" {
return key + ": " + item
}
}
if simKeys, ok := value["simKeys"].([]interface{}); ok && len(simKeys) > 0 {
if first, ok := simKeys[0].(map[string]interface{}); ok {
for _, key := range []string{"msisdn", "iccid", "imsi"} {
if item := strings.TrimSpace(fmt.Sprint(first[key])); item != "" && item != "<nil>" {
return key + ": " + item
}
}
}
}
return ""
}
func operationParams(query url.Values, body []byte) string {
parts := make([]string, 0, 2)
if len(query) > 0 {
clean := make(url.Values, len(query))
keys := make([]string, 0, len(query))
for key := range query {
keys = append(keys, key)
}
sort.Strings(keys)
for _, key := range keys {
if sensitiveOperationKey(key) {
clean.Set(key, "***")
} else {
clean[key] = append([]string(nil), query[key]...)
}
}
parts = append(parts, clean.Encode())
}
if len(bytes.TrimSpace(body)) > 0 {
var value interface{}
if json.Unmarshal(body, &value) == nil {
if raw, err := json.Marshal(redactOperationValue(value)); err == nil {
parts = append(parts, string(raw))
}
} else {
parts = append(parts, "[non-JSON request body]")
}
}
result := strings.Join(parts, " ")
if len(result) > maxOperationDetailBytes {
return result[:maxOperationDetailBytes] + "..."
}
return result
}
func redactOperationValue(value interface{}) interface{} {
switch item := value.(type) {
case map[string]interface{}:
for key, child := range item {
if sensitiveOperationKey(key) {
item[key] = "***"
} else {
item[key] = redactOperationValue(child)
}
}
case []interface{}:
for i := range item {
item[i] = redactOperationValue(item[i])
}
}
return value
}
func sensitiveOperationKey(key string) bool {
key = strings.ToLower(key)
return strings.Contains(key, "password") || strings.Contains(key, "token") || strings.Contains(key, "authorization")
}
func operationResult(status int, body []byte) (string, string) {
if status >= http.StatusBadRequest {
return "failed", responseMessage(body, http.StatusText(status))
}
var value interface{}
if json.Unmarshal(body, &value) != nil {
return "success", ""
}
if item, ok := value.(map[string]interface{}); ok {
if message, ok := item["error"].(string); ok && message != "" {
return "failed", message
}
if code := fmt.Sprint(item["status"]); code != "<nil>" && code != "" && code != "0" {
return "failed", fmt.Sprint(item["message"])
}
}
if items, ok := value.([]interface{}); ok {
for _, raw := range items {
item, ok := raw.(map[string]interface{})
if !ok {
continue
}
if code, ok := item["code"].(float64); ok && code != chanSuccessCode {
return "failed", fmt.Sprint(item["message"])
}
}
}
return "success", ""
}
func responseMessage(body []byte, fallback string) string {
var value map[string]interface{}
if json.Unmarshal(body, &value) == nil {
for _, key := range []string{"error", "message"} {
if message := strings.TrimSpace(fmt.Sprint(value[key])); message != "" && message != "<nil>" {
return message
}
}
}
return fallback
}
func clientIP(r *http.Request) string {
if forwarded := strings.TrimSpace(strings.Split(r.Header.Get("X-Forwarded-For"), ",")[0]); forwarded != "" {
return forwarded
}
host, _, err := net.SplitHostPort(r.RemoteAddr)
if err == nil {
return host
}
return r.RemoteAddr
}
func (s *Service) adminOperationLogs(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
w.WriteHeader(http.StatusMethodNotAllowed)
return
}
page, pageSize := pagination(r)
writeJSON(w, http.StatusOK, s.operationLogs.Query(
r.URL.Query().Get("keyword"), r.URL.Query().Get("category"), r.URL.Query().Get("result"), page, pageSize,
))
}
func operationLogID(value int64) string {
return strconv.FormatInt(value, 10)
}
@@ -0,0 +1,41 @@
package service
import (
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
)
func TestOperationLogCapturesAdminMutationAndRedactsSecrets(t *testing.T) {
service, err := NewService(t.TempDir()+"/onelink_mock.db")
if err != nil {
t.Fatal(err)
}
req := httptest.NewRequest(http.MethodPost, "/api/appids", strings.NewReader(`{"appid":"demo","password":"plain-secret","name":"演示"}`))
req.RemoteAddr = "192.0.2.10:1234"
req.Header.Set("Content-Type", "application/json")
res := httptest.NewRecorder()
service.Router().ServeHTTP(res, req)
if res.Code != http.StatusOK {
t.Fatalf("status = %d body = %s", res.Code, res.Body.String())
}
page := service.operationLogs.Query("", "管理台", "success", 1, 20)
if page.Total != 1 || page.Items[0].Action != "新增 AppID" {
t.Fatalf("logs = %#v", page.Items)
}
if strings.Contains(page.Items[0].Params, "plain-secret") || !strings.Contains(page.Items[0].Params, "***") {
t.Fatalf("secret was not redacted: %s", page.Items[0].Params)
}
}
func TestOperationLogQueryReturnsNewestFirst(t *testing.T) {
store := newOperationLogStore(func() time.Time { return time.Date(2026, 8, 13, 10, 0, 0, 0, time.Local) })
store.Add(OperationLog{Category: "管理台", Action: "第一条", Result: "success"})
store.Add(OperationLog{Category: "管理台", Action: "第二条", Result: "success"})
page := store.Query("", "", "", 1, 1)
if page.Total != 2 || len(page.Items) != 1 || page.Items[0].Action != "第二条" {
t.Fatalf("page = %#v", page)
}
}
@@ -0,0 +1,129 @@
package service
import (
"bytes"
"net/http"
"strconv"
"strings"
"sync"
"time"
)
const defaultRuntimeLogCapacity = 500
type RuntimeLog struct {
ID int64 `json:"id"`
CreatedAt string `json:"createdAt"`
Level string `json:"level"`
Message string `json:"message"`
}
type RuntimeLogResult struct {
Items []RuntimeLog `json:"items"`
Total int `json:"total"`
}
type RuntimeLogStore struct {
mu sync.Mutex
logs []RuntimeLog
partial []byte
nextID int64
capacity int
now func() time.Time
}
func NewRuntimeLogStore(capacities ...int) *RuntimeLogStore {
capacity := defaultRuntimeLogCapacity
if len(capacities) > 0 {
capacity = capacities[0]
}
return newRuntimeLogStore(capacity, time.Now)
}
func newRuntimeLogStore(capacity int, now func() time.Time) *RuntimeLogStore {
if capacity < 1 {
capacity = 1
}
return &RuntimeLogStore{
logs: make([]RuntimeLog, 0, capacity),
capacity: capacity,
now: now,
}
}
func (s *RuntimeLogStore) Write(p []byte) (int, error) {
s.mu.Lock()
defer s.mu.Unlock()
written := len(p)
s.partial = append(s.partial, p...)
for {
index := bytes.IndexByte(s.partial, '\n')
if index < 0 {
break
}
s.addLineLocked(string(bytes.TrimSuffix(s.partial[:index], []byte{'\r'})))
s.partial = append(s.partial[:0], s.partial[index+1:]...)
}
return written, nil
}
func (s *RuntimeLogStore) Latest(limit int) RuntimeLogResult {
s.mu.Lock()
defer s.mu.Unlock()
if limit < 1 || limit > s.capacity {
limit = s.capacity
}
start := len(s.logs) - limit
if start < 0 {
start = 0
}
items := make([]RuntimeLog, len(s.logs)-start)
for i := range items {
items[i] = s.logs[len(s.logs)-1-i]
}
return RuntimeLogResult{Items: items, Total: len(s.logs)}
}
func (s *RuntimeLogStore) addLineLocked(message string) {
message = strings.TrimSpace(message)
if message == "" {
return
}
s.nextID++
item := RuntimeLog{
ID: s.nextID,
CreatedAt: s.now().Format("2006-01-02 15:04:05"),
Level: runtimeLogLevel(message),
Message: message,
}
if len(s.logs) == s.capacity {
copy(s.logs, s.logs[1:])
s.logs[len(s.logs)-1] = item
return
}
s.logs = append(s.logs, item)
}
func runtimeLogLevel(message string) string {
upper := strings.ToUpper(message)
switch {
case strings.Contains(upper, "FATAL"), strings.Contains(upper, "PANIC"), strings.Contains(upper, "ERROR"), strings.Contains(upper, "[ERRO]"):
return "ERROR"
case strings.Contains(upper, "WARN"), strings.Contains(upper, "[WARN]"):
return "WARN"
case strings.Contains(upper, "DEBUG"), strings.Contains(upper, "[DEBU]"):
return "DEBUG"
default:
return "INFO"
}
}
func (s *Service) adminRuntimeLogs(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
w.WriteHeader(http.StatusMethodNotAllowed)
return
}
limit, _ := strconv.Atoi(r.URL.Query().Get("limit"))
writeJSON(w, http.StatusOK, s.runtimeLogs.Latest(limit))
}
@@ -0,0 +1,56 @@
package service
import (
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"time"
)
func TestRuntimeLogStoreRetainsLatestLines(t *testing.T) {
now := func() time.Time { return time.Date(2026, 8, 13, 12, 30, 0, 0, time.Local) }
store := newRuntimeLogStore(2, now)
if _, err := store.Write([]byte("first\nsecond")); err != nil {
t.Fatal(err)
}
if _, err := store.Write([]byte(" line\nERROR third\n")); err != nil {
t.Fatal(err)
}
result := store.Latest(10)
if result.Total != 2 || len(result.Items) != 2 {
t.Fatalf("result = %#v", result)
}
if result.Items[0].Message != "ERROR third" || result.Items[0].Level != "ERROR" {
t.Fatalf("newest = %#v", result.Items[0])
}
if result.Items[1].Message != "second line" {
t.Fatalf("second = %#v", result.Items[1])
}
}
func TestRuntimeLogsEndpointIsNotRecordedAsOperation(t *testing.T) {
runtimeLogs := NewRuntimeLogStore()
service, err := NewService("sqlite::@file(:memory:)", runtimeLogs)
if err != nil {
t.Fatal(err)
}
_, _ = runtimeLogs.Write([]byte("service ready\n"))
req := httptest.NewRequest(http.MethodGet, "/api/runtime-logs?limit=10", nil)
res := httptest.NewRecorder()
service.Router().ServeHTTP(res, req)
if res.Code != http.StatusOK {
t.Fatalf("status = %d body = %s", res.Code, res.Body.String())
}
var result RuntimeLogResult
if err := json.Unmarshal(res.Body.Bytes(), &result); err != nil {
t.Fatal(err)
}
if len(result.Items) != 1 || result.Items[0].Message != "service ready" {
t.Fatalf("result = %#v", result)
}
if page := service.operationLogs.Query("", "", "", 1, 20); page.Total != 0 {
t.Fatalf("runtime log polling was recorded: %#v", page)
}
}
+746
View File
@@ -0,0 +1,746 @@
package service
import (
"encoding/json"
"fmt"
"math"
"net/http"
"strconv"
"strings"
"time"
)
type Service struct {
store *StoreManager
operationLogs *OperationLogStore
runtimeLogs *RuntimeLogStore
}
func NewService(dataPath string, runtimeLogStores ...*RuntimeLogStore) (*Service, error) {
store, err := NewStoreManager(dataPath)
if err != nil {
return nil, err
}
runtimeLogs := NewRuntimeLogStore()
if len(runtimeLogStores) > 0 && runtimeLogStores[0] != nil {
runtimeLogs = runtimeLogStores[0]
}
return &Service{store: store, operationLogs: NewOperationLogStore(), runtimeLogs: runtimeLogs}, nil
}
func (s *Service) Router() http.Handler {
mux := http.NewServeMux()
mux.HandleFunc("/healthz", s.health)
mux.HandleFunc("/api/state", s.adminState)
mux.HandleFunc("/api/appids", s.adminAppIDs)
mux.HandleFunc("/api/cards", s.adminCards)
mux.HandleFunc("/api/card-groups", s.adminCardGroups)
mux.HandleFunc("/api/cards/export", s.adminCardsExport)
mux.HandleFunc("/api/settings", s.adminSettings)
mux.HandleFunc("/api/orders", s.adminOrders)
mux.HandleFunc("/api/operation-logs", s.adminOperationLogs)
mux.HandleFunc("/api/runtime-logs", s.adminRuntimeLogs)
mux.HandleFunc("/api/sync", s.adminSync)
mux.HandleFunc("/api/generate", s.adminGenerate)
mux.HandleFunc("/sim/", s.chanAPI)
mux.HandleFunc("/v5/", s.oneLink)
mux.HandleFunc("/ec/", s.oneLink)
return withCORS(s.withOperationLogging(mux))
}
func withCORS(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Access-Control-Allow-Origin", "*")
w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization")
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
if r.Method == http.MethodOptions {
w.WriteHeader(http.StatusNoContent)
return
}
next.ServeHTTP(w, r)
})
}
func (s *Service) health(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
}
func (s *Service) oneLink(w http.ResponseWriter, r *http.Request) {
path := strings.TrimPrefix(r.URL.Path, "/v5/")
path = strings.TrimPrefix(path, "/")
switch path {
case "ec/get/token":
s.getToken(w, r)
case "ec/query/sim-basic-info":
s.withToken(w, r, s.queryBasicInfo)
case "ec/query/sim-session":
s.withToken(w, r, s.querySession)
case "ec/query/sim-status":
s.withToken(w, r, s.queryStatus)
case "ec/query/sim-stop-reason":
s.withToken(w, r, s.queryStopReason)
case "ec/query/sim-frozen-state-info":
s.withToken(w, r, s.queryFrozenState)
case "ec/query/sim-apn-session-info":
s.withToken(w, r, s.queryAPNSession)
case "ec/query/sim-activated-features-info":
s.withToken(w, r, s.queryActivatedFeatures)
case "ec/query/sim-operating-status":
s.withToken(w, r, s.queryOperatingStatus)
case "ec/query/sim-data-diagnosis":
s.withToken(w, r, s.queryDataDiagnosis)
case "ec/query/sim-imei":
s.withToken(w, r, s.queryIMEI)
case "ec/query/sim-data-usage-inpool":
s.withToken(w, r, s.queryDataUsageInPool)
case "ec/query/sim-data-usage":
s.withToken(w, r, s.queryDataUsage)
case "ec/query/sim-data-usage-monthly/batch":
s.withToken(w, r, s.queryDataUsageMonthlyBatch)
case "ec/query/sim-data-usage-daily/batch":
s.withToken(w, r, s.queryDataUsageDailyBatch)
case "ec/query/apn-info":
s.withToken(w, r, s.queryAPNInfo)
case "ec/query/card-bind-status":
s.withToken(w, r, s.queryCardBindStatus)
case "ec/query/on-off-status":
s.withToken(w, r, s.queryOnOffStatus)
case "ec/query/region-limit-status":
s.withToken(w, r, s.queryRegionLimitStatus)
case "ec/change/sim-status":
s.withToken(w, r, s.changeSIMStatus)
case "ec/operate/sim-apn-function":
s.withToken(w, r, s.operateSIMAPNFunction)
default:
writeOneLink(w, Response{Status: "14008", Message: "查询不到信息", Result: []interface{}{}})
}
}
func (s *Service) getToken(w http.ResponseWriter, r *http.Request) {
q := r.URL.Query()
appid := q.Get("appid")
password := q.Get("password")
refresh := q.Get("refresh")
var result Response
err := s.store.SaveMutation(func(st *Store) error {
for i := range st.AppIDs {
cred := &st.AppIDs[i]
if cred.AppID != appid || !cred.Enabled {
continue
}
if cred.Password != password {
result = Response{Status: "12022", Message: "PASSWORD 鉴权不通过", Result: []interface{}{}}
return nil
}
now := time.Now()
if refresh == "0" {
if token, ok := st.Tokens[appid]; ok && token.ExpiresAt.After(now) {
ttl := int(time.Until(token.ExpiresAt).Seconds())
result = okResp(map[string]string{"token": token.Value, "ttl": strconv.Itoa(ttl)})
return nil
}
}
token := Token{
AppID: appid,
Value: randomHex(32),
ExpiresAt: now.Add(time.Hour),
}
st.Tokens[appid] = token
cred.LastToken = token.Value
cred.TokenExpires = token.ExpiresAt.Format("2006-01-02 15:04:05")
result = okResp(map[string]string{"token": token.Value, "ttl": "3600"})
return nil
}
result = Response{Status: "12021", Message: "认证无效", Result: []interface{}{}}
return nil
})
if err != nil {
writeOneLink(w, Response{Status: "19000", Message: err.Error(), Result: []interface{}{}})
return
}
writeOneLink(w, result)
}
func (s *Service) withToken(w http.ResponseWriter, r *http.Request, next func(http.ResponseWriter, *http.Request, Store)) {
token := r.URL.Query().Get("token")
st := s.store.Snapshot()
for _, t := range st.Tokens {
if t.Value == token && t.ExpiresAt.After(time.Now()) {
next(w, r, st)
return
}
}
writeOneLink(w, Response{Status: "12021", Message: "TOKEN 不存在或已过期,请重新获取", Result: []interface{}{}})
}
func (s *Service) queryBasicInfo(w http.ResponseWriter, r *http.Request, st Store) {
card, err := findSingleCard(r, st)
if err != nil {
writeOneLink(w, paramErr(err.Error()))
return
}
writeOneLink(w, okResp(map[string]string{
"msisdn": card.MSISDN, "imsi": card.IMSI, "iccid": card.ICCID,
"activeDate": card.ActiveDate, "openDate": card.OpenDate, "remark": card.Remark,
}))
}
func (s *Service) querySession(w http.ResponseWriter, r *http.Request, st Store) {
card, err := findSingleCard(r, st)
if err != nil {
writeOneLink(w, paramErr(err.Error()))
return
}
writeOneLink(w, okResp(map[string]interface{}{
"simSessionList": []map[string]string{{
"apnId": "CMIOT", "status": card.SessionStatus, "ip": card.IP,
"ipv6Prefix": card.IPv6Prefix, "ipv6": card.IPv6,
"createDate": card.LastChangeDate, "rat": card.RAT,
}},
}))
}
func (s *Service) queryStatus(w http.ResponseWriter, r *http.Request, st Store) {
card, err := findSingleCard(r, st)
if err != nil {
writeOneLink(w, paramErr(err.Error()))
return
}
writeOneLink(w, okResp(map[string]string{"cardStatus": card.CardStatus, "lastChangeDate": card.LastChangeDate}))
}
func (s *Service) queryStopReason(w http.ResponseWriter, r *http.Request, st Store) {
card, err := findSingleCard(r, st)
if err != nil {
writeOneLink(w, paramErr(err.Error()))
return
}
stopReason := card.StopReason
if stopReason == "" {
stopReason = defaultStopReason(card.CardStatus)
}
writeOneLink(w, okResp(map[string]string{"platformType": "OneLink-CT", "stopReason": stopReason}))
}
func (s *Service) queryFrozenState(w http.ResponseWriter, r *http.Request, st Store) {
card, err := findSingleCard(r, st)
if err != nil {
writeOneLink(w, paramErr(err.Error()))
return
}
frozen := card.FrozenStatus
if frozen == "" {
frozen = "0"
}
operTime := card.FrozenOperTime
if operTime == "" {
operTime = diagnosticTime(parseCardTime(card.LastChangeDate))
}
data := map[string]string{"frozenStatus": frozen, "operTime": operTime}
if frozen == "1" && card.FrozenReason != "" {
data["frozenReason"] = card.FrozenReason
}
writeOneLink(w, okResp(data))
}
func (s *Service) queryAPNSession(w http.ResponseWriter, r *http.Request, st Store) {
card, err := findSingleCard(r, st)
if err != nil {
writeOneLink(w, paramErr(err.Error()))
return
}
items := make([]map[string]string, 0, len(card.APNs))
for _, apn := range card.APNs {
status := card.SessionStatus
if status != "00" && status != "01" {
status = "00"
}
createTime := card.SessionCreateDate
if createTime == "" {
createTime = card.LastChangeDate
}
items = append(items, map[string]string{"apn": apn.Name, "sessionStatus": status, "ipv4": card.IP, "ipv6Prefix": card.IPv6Prefix, "ipv6": card.IPv6, "createTime": createTime, "accessType": accessType(card.RAT)})
}
if len(items) == 0 {
status := card.SessionStatus
if status != "00" && status != "01" {
status = "00"
}
createTime := card.SessionCreateDate
if createTime == "" {
createTime = card.LastChangeDate
}
items = append(items, map[string]string{"apn": "CMIOT", "sessionStatus": status, "ipv4": card.IP, "ipv6Prefix": card.IPv6Prefix, "ipv6": card.IPv6, "createTime": createTime, "accessType": accessType(card.RAT)})
}
writeOneLink(w, okResp(map[string]interface{}{"infoList": items}))
}
func (s *Service) queryActivatedFeatures(w http.ResponseWriter, r *http.Request, st Store) {
card, err := findSingleCard(r, st)
if err != nil {
writeOneLink(w, paramErr(err.Error()))
return
}
items := make([]map[string]string, 0, len(card.APNs))
for _, apn := range card.APNs {
openStatus := "0"
if apn.Status == "1" {
openStatus = "1"
}
changeTime := apn.ModifyTime
if changeTime == "" {
changeTime = diagnosticTime(parseCardTime(card.LastChangeDate))
}
items = append(items, map[string]string{"functionType": "11", "openStatus": openStatus, "apn": apn.Name, "changeTime": changeTime})
}
if len(items) == 0 {
items = append(items, map[string]string{"functionType": "11", "openStatus": "1", "apn": "CMIOT", "changeTime": diagnosticTime(parseCardTime(card.LastChangeDate))})
}
writeOneLink(w, okResp(map[string]interface{}{"communicationFunctionList": items}))
}
func (s *Service) queryOperatingStatus(w http.ResponseWriter, r *http.Request, st Store) {
card, err := findSingleCard(r, st)
if err != nil {
writeOneLink(w, paramErr(err.Error()))
return
}
writeOneLink(w, okResp(map[string]string{"operatingStatus": card.OnOffStatus}))
}
func (s *Service) queryDataDiagnosis(w http.ResponseWriter, r *http.Request, st Store) {
card, err := findSingleCard(r, st)
if err != nil {
writeOneLink(w, paramErr(err.Error()))
return
}
apnInfo := make([]map[string]interface{}, 0, len(card.APNs))
for _, apn := range card.APNs {
status := apn.DiagnosisStatus
if status == "" {
status = "2"
}
modifyTime := apn.ModifyTime
if modifyTime == "" {
modifyTime = diagnosticTime(parseCardTime(card.LastChangeDate))
}
usageState := apn.ServiceUsageState
if usageState == "" {
usageState = "1"
}
item := map[string]interface{}{"apnName": apn.Name, "apnStatus": status, "modifyTime": modifyTime, "apnServiceUsageState": usageState}
if status == "4" && apn.OverUseDetails != "" {
item["overUseDetails"] = apn.OverUseDetails
}
pccInfo := make([]map[string]string, 0, len(apn.PCCs))
for _, pcc := range apn.PCCs {
pccTime := pcc.ModifyTime
if pccTime == "" {
pccTime = modifyTime
}
pccInfo = append(pccInfo, map[string]string{"pccServiceCode": pcc.ServiceCode, "pccName": pcc.Name, "pccPolicyStatus": pcc.PolicyStatus, "pccServiceUsageState": pcc.ServiceUsageState, "modifyTime": pccTime})
}
item["pccList"] = map[string]interface{}{"pccInfo": pccInfo}
apnInfo = append(apnInfo, item)
}
if len(apnInfo) == 0 {
apnInfo = append(apnInfo, map[string]interface{}{"apnName": "CMIOT", "apnStatus": "2", "modifyTime": diagnosticTime(parseCardTime(card.LastChangeDate)), "apnServiceUsageState": "1", "pccList": map[string]interface{}{"pccInfo": []map[string]string{}}})
}
writeOneLink(w, okResp(map[string]interface{}{"apnList": map[string]interface{}{"apnInfo": apnInfo}}))
}
func accessType(rat string) string {
switch strings.ToUpper(rat) {
case "5G", "51", "SA":
return "51"
case "NSA", "10":
return "10"
case "REDCAP", "58":
return "58"
case "NB", "8":
return "8"
case "2G", "2":
return "2"
case "3G", "3":
return "1"
default:
return "6"
}
}
func (s *Service) queryIMEI(w http.ResponseWriter, r *http.Request, st Store) {
card, err := findSingleCard(r, st)
if err != nil {
writeOneLink(w, paramErr(err.Error()))
return
}
writeOneLink(w, okResp(map[string]string{"imei": card.IMEI}))
}
func (s *Service) queryDataUsageInPool(w http.ResponseWriter, r *http.Request, st Store) {
card, err := findSingleCard(r, st)
if err != nil {
writeOneLink(w, paramErr(err.Error()))
return
}
apns := make([]map[string]string, 0, len(card.APNs))
for _, apn := range card.APNs {
apns = append(apns, map[string]string{"apnName": apn.Name, "apnUseAmount": fmtAmount(apn.UsageBytes)})
}
writeOneLink(w, okResp(map[string]interface{}{"apnList": apns}))
}
func (s *Service) queryDataUsage(w http.ResponseWriter, r *http.Request, st Store) {
card, err := findSingleCard(r, st)
if err != nil {
writeOneLink(w, paramErr(err.Error()))
return
}
writeOneLink(w, okResp(map[string]interface{}{
"dataAmount": fmt.Sprintf("%.2f", card.MonthUsageKB),
"apnUseAmountList": apnUseAmountList(card),
}))
}
func (s *Service) queryDataUsageMonthlyBatch(w http.ResponseWriter, r *http.Request, st Store) {
cards, key, err := findBatchCards(r, st)
if err != nil {
writeOneLink(w, paramErr(err.Error()))
return
}
items := make([]map[string]interface{}, 0, len(cards))
for _, card := range cards {
amount := card.MonthUsageKB
if q := r.URL.Query().Get("queryDate"); len(q) == 6 && q != time.Now().Format("200601") {
amount = card.MonthlyBaseKB
}
items = append(items, batchUsageItem(card, key, amount))
}
writeOneLink(w, okResp(map[string]interface{}{
"deadLine": time.Now().AddDate(0, 0, -1).Format("20060102"),
"dataAmountList": items,
}))
}
func (s *Service) queryDataUsageDailyBatch(w http.ResponseWriter, r *http.Request, st Store) {
cards, key, err := findBatchCards(r, st)
if err != nil {
writeOneLink(w, paramErr(err.Error()))
return
}
items := make([]map[string]interface{}, 0, len(cards))
for _, card := range cards {
items = append(items, batchUsageItem(card, key, card.DailyBaseKB))
}
writeOneLink(w, okResp(map[string]interface{}{"dataAmountList": items}))
}
func (s *Service) queryAPNInfo(w http.ResponseWriter, r *http.Request, st Store) {
card, err := findSingleCard(r, st)
if err != nil {
writeOneLink(w, paramErr(err.Error()))
return
}
apns := make([]map[string]string, 0, len(card.APNs))
for _, apn := range card.APNs {
apns = append(apns, map[string]string{"apnName": apn.Name, "status": apn.Status})
}
writeOneLink(w, okResp(map[string]interface{}{"apnList": apns}))
}
func (s *Service) queryCardBindStatus(w http.ResponseWriter, r *http.Request, st Store) {
if r.URL.Query().Get("msisdn") == "" || r.URL.Query().Get("iccid") != "" || r.URL.Query().Get("imsi") != "" {
writeOneLink(w, paramErr("card-bind-status requires msisdn only"))
return
}
testType := r.URL.Query().Get("testType")
if testType != "0" && testType != "1" {
writeOneLink(w, paramErr("testType must be 0 or 1"))
return
}
card, err := findSingleCard(r, st)
if err != nil {
writeOneLink(w, paramErr(err.Error()))
return
}
result := card.CardBindStatus
if result != "0" && result != "1" && result != "2" {
result = "1"
}
data := map[string]string{"result": result}
if result == "2" {
data["errorCode"] = "MOCK_BIND_CHECK_FAILED"
data["errorDes"] = "mock bind check failed"
}
writeOneLink(w, okResp(data))
}
func (s *Service) queryOnOffStatus(w http.ResponseWriter, r *http.Request, st Store) {
card, err := findSingleCard(r, st)
if err != nil {
writeOneLink(w, paramErr(err.Error()))
return
}
writeOneLink(w, okResp(map[string]string{"status": card.OnOffStatus}))
}
func (s *Service) queryRegionLimitStatus(w http.ResponseWriter, r *http.Request, st Store) {
card, err := findSingleCard(r, st)
if err != nil {
writeOneLink(w, paramErr(err.Error()))
return
}
writeOneLink(w, okResp(map[string]string{"regionLimitStatus": card.RegionLimitStatus}))
}
func (s *Service) changeSIMStatus(w http.ResponseWriter, r *http.Request, st Store) {
q := r.URL.Query()
idKey, idVal, err := singleIdentifier(q)
if err != nil {
writeOneLink(w, paramErr(err.Error()))
return
}
operType := q.Get("operType")
orderNum := "ORD" + time.Now().Format("20060102150405") + randomHex(3)
var result map[string]string
err = s.store.SaveMutation(func(store *Store) error {
idx := findCardIndex(*store, idKey, idVal)
if idx < 0 {
return fmt.Errorf("查询不到信息")
}
card := &store.Cards[idx]
card.CardStatus = statusByOperType(operType, card.CardStatus)
card.LastChangeDate = time.Now().Format("2006-01-02 15:04:05")
store.Orders = append(store.Orders, Order{
OrderNum: orderNum, MSISDN: card.MSISDN, ICCID: card.ICCID, IMSI: card.IMSI,
Type: "sim-status", OperType: operType, Status: "completed", CreatedAt: card.LastChangeDate,
})
result = map[string]string{idKey: idVal, "orderNum": orderNum}
return nil
})
if err != nil {
writeOneLink(w, paramErr(err.Error()))
return
}
writeOneLink(w, okResp(result))
}
func (s *Service) operateSIMAPNFunction(w http.ResponseWriter, r *http.Request, st Store) {
q := r.URL.Query()
idKey, idVal, err := singleIdentifier(q)
if err != nil {
writeOneLink(w, paramErr(err.Error()))
return
}
apnName := q.Get("apnName")
operType := q.Get("operType")
if apnName == "" || (operType != "0" && operType != "1") {
writeOneLink(w, paramErr("apnName 与 operType 必填"))
return
}
orderNum := "APN" + time.Now().Format("20060102150405") + randomHex(3)
var result map[string]string
err = s.store.SaveMutation(func(store *Store) error {
idx := findCardIndex(*store, idKey, idVal)
if idx < 0 {
return fmt.Errorf("查询不到信息")
}
card := &store.Cards[idx]
status := "0"
if operType == "0" {
status = "1"
}
found := false
for i := range card.APNs {
if strings.EqualFold(card.APNs[i].Name, apnName) {
card.APNs[i].Status = status
found = true
}
}
if !found {
card.APNs = append(card.APNs, APN{Name: apnName, Status: status})
}
now := time.Now().Format("2006-01-02 15:04:05")
store.Orders = append(store.Orders, Order{
OrderNum: orderNum, MSISDN: card.MSISDN, ICCID: card.ICCID, IMSI: card.IMSI,
Type: "sim-apn-function", OperType: operType, APNName: apnName, Status: "completed", CreatedAt: now,
})
result = map[string]string{idKey: idVal, "orderNum": orderNum}
return nil
})
if err != nil {
writeOneLink(w, paramErr(err.Error()))
return
}
writeOneLink(w, okResp(result))
}
func okResp(item interface{}) Response {
return Response{Status: "0", Message: "正确", Result: []interface{}{item}}
}
func paramErr(message string) Response {
if strings.Contains(message, "查询不到") {
return Response{Status: "14008", Message: "查询不到信息", Result: []interface{}{}}
}
return Response{Status: "12001", Message: message, Result: []interface{}{}}
}
func writeOneLink(w http.ResponseWriter, resp Response) {
writeJSON(w, http.StatusOK, resp)
}
func writeJSON(w http.ResponseWriter, code int, v interface{}) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(code)
_ = json.NewEncoder(w).Encode(v)
}
func singleIdentifier(q mapLike) (string, string, error) {
found := [][2]string{}
for _, key := range []string{"msisdn", "iccid", "imsi"} {
if v := q.Get(key); v != "" {
found = append(found, [2]string{key, v})
}
}
if len(found) != 1 {
return "", "", fmt.Errorf("msisdn、iccid、imsi 必须有且只有一项")
}
return found[0][0], found[0][1], nil
}
type mapLike interface {
Get(string) string
}
func findSingleCard(r *http.Request, st Store) (SIMCard, error) {
key, value, err := singleIdentifier(r.URL.Query())
if err != nil {
return SIMCard{}, err
}
for _, card := range st.Cards {
if matches(card, key, value) {
return card, nil
}
}
return SIMCard{}, fmt.Errorf("查询不到信息")
}
func findBatchCards(r *http.Request, st Store) ([]SIMCard, string, error) {
q := r.URL.Query()
keys := []string{"msisdns", "iccids", "imsis"}
foundKey := ""
foundValue := ""
for _, key := range keys {
if v := q.Get(key); v != "" {
if foundKey != "" {
return nil, "", fmt.Errorf("msisdns、iccids、imsis 必须有且只有一项")
}
foundKey = key
foundValue = v
}
}
if foundKey == "" {
return nil, "", fmt.Errorf("msisdns、iccids、imsis 必须有且只有一项")
}
singular := strings.TrimSuffix(foundKey, "s")
ids := strings.Split(foundValue, "_")
var cards []SIMCard
for _, id := range ids {
id = strings.TrimSpace(id)
for _, card := range st.Cards {
if matches(card, singular, id) {
cards = append(cards, card)
break
}
}
}
if len(cards) == 0 {
return nil, "", fmt.Errorf("查询不到信息")
}
return cards, singular, nil
}
func findCardIndex(st Store, key, value string) int {
for i, card := range st.Cards {
if matches(card, key, value) {
return i
}
}
return -1
}
func matches(card SIMCard, key, value string) bool {
switch key {
case "msisdn":
return card.MSISDN == value
case "iccid":
return card.ICCID == value
case "imsi":
return card.IMSI == value
}
return false
}
func apnUseAmountList(card SIMCard) []map[string]interface{} {
out := make([]map[string]interface{}, 0, len(card.APNs))
for _, apn := range card.APNs {
out = append(out, map[string]interface{}{
"apnName": apn.Name,
"apnUseAmount": fmtAmount(apn.UsageBytes / 1024),
"pccCodeUseAmountList": []map[string]string{{
"pccCode": "default",
"pccCodeUseAmount": fmtAmount(apn.UsageBytes / 1024),
}},
})
}
return out
}
func batchUsageItem(card SIMCard, key string, amount float64) map[string]interface{} {
item := map[string]interface{}{
key: identifierValue(card, key),
"dataAmount": fmtAmount(amount),
}
if amount > 0 {
item["apnDataAmountList"] = []map[string]string{
{"apnName": "CMIOT", "apnDataAmount": fmtAmount(amount * 0.7)},
{"apnName": "CMMTM", "apnDataAmount": fmtAmount(amount * 0.3)},
}
}
return item
}
func identifierValue(card SIMCard, key string) string {
switch key {
case "iccid":
return card.ICCID
case "imsi":
return card.IMSI
default:
return card.MSISDN
}
}
func fmtAmount(v float64) string {
if math.Abs(v-math.Round(v)) < 0.001 {
return strconv.FormatInt(int64(math.Round(v)), 10)
}
return fmt.Sprintf("%.2f", v)
}
func statusByOperType(operType, current string) string {
switch operType {
case "0":
return "4"
case "1", "2", "5", "6":
return "2"
case "3", "8":
return "7"
case "4", "9":
return "1"
default:
return current
}
}
+285
View File
@@ -0,0 +1,285 @@
package service
import (
"crypto/rand"
"encoding/hex"
"encoding/json"
"fmt"
"strings"
"sync"
"time"
)
type StoreManager struct {
mu sync.Mutex
backend storeBackend
data Store
initialized bool
}
type storeBackend interface {
Load() (Store, error)
Save(Store) error
}
type generatedCardBackend interface {
AppendCards([]SIMCard) error
}
func NewStoreManager(path string) (*StoreManager, error) {
backend, err := newStoreBackend(path)
if err != nil {
return nil, err
}
sm := &StoreManager{backend: backend}
if err := sm.load(); err != nil {
return nil, err
}
return sm, nil
}
func (s *StoreManager) Snapshot() Store {
s.mu.Lock()
defer s.mu.Unlock()
return cloneStore(s.data)
}
func (s *StoreManager) SaveMutation(fn func(*Store) error) error {
s.mu.Lock()
defer s.mu.Unlock()
if err := fn(&s.data); err != nil {
return err
}
return s.saveLocked()
}
func (s *StoreManager) WithStore(fn func(*Store) error) error {
s.mu.Lock()
defer s.mu.Unlock()
return fn(&s.data)
}
func (s *StoreManager) Persist() error {
s.mu.Lock()
defer s.mu.Unlock()
return s.saveLocked()
}
func (s *StoreManager) GenerateCards(count int) (GenerateResult, error) {
return s.GenerateCardsInGroup(count, 0)
}
func (s *StoreManager) GenerateCardsInGroup(count int, groupID int64) (GenerateResult, error) {
s.mu.Lock()
defer s.mu.Unlock()
if count <= 0 {
return GenerateResult{Total: len(s.data.Cards)}, nil
}
generator, ok := s.backend.(generatedCardBackend)
if !ok {
return GenerateResult{}, fmt.Errorf("current store backend does not support card generation")
}
if groupID != 0 {
found := false
for _, group := range s.data.Groups {
if group.ID == groupID {
found = true
break
}
}
if !found {
return GenerateResult{}, fmt.Errorf("card group not found")
}
}
cards := GenerateSIMCards(time.Now(), count)
for i := range cards {
cards[i].GroupID = groupID
for _, group := range s.data.Groups {
if group.ID == groupID {
cards[i].GroupName = group.Name
break
}
}
}
if err := generator.AppendCards(cards); err != nil {
return GenerateResult{}, err
}
s.data.Cards = append(s.data.Cards, cards...)
return GenerateResult{Generated: len(cards), Total: len(s.data.Cards)}, nil
}
func (s *StoreManager) QueryCards(keyword, exportStatus string, page, pageSize int) CardPage {
return s.QueryCardsByGroup(keyword, exportStatus, 0, false, page, pageSize)
}
func (s *StoreManager) QueryCardsByGroup(keyword, exportStatus string, groupID int64, filterGroup bool, page, pageSize int) CardPage {
s.mu.Lock()
defer s.mu.Unlock()
if page < 1 {
page = 1
}
if pageSize < 1 {
pageSize = 20
}
if pageSize > 100 {
pageSize = 100
}
matched := filterCards(s.data.Cards, keyword, exportStatus, groupID, filterGroup)
start := (page - 1) * pageSize
if start > len(matched) {
start = len(matched)
}
end := start + pageSize
if end > len(matched) {
end = len(matched)
}
return CardPage{Items: append([]SIMCard{}, matched[start:end]...), Total: len(matched), Page: page, PageSize: pageSize}
}
func (s *StoreManager) QueryAllCardsByGroup(keyword, exportStatus string, groupID int64, filterGroup bool) []SIMCard {
s.mu.Lock()
defer s.mu.Unlock()
return append([]SIMCard{}, filterCards(s.data.Cards, keyword, exportStatus, groupID, filterGroup)...)
}
func filterCards(cards []SIMCard, keyword, exportStatus string, groupID int64, filterGroup bool) []SIMCard {
keyword = strings.TrimSpace(keyword)
matched := make([]SIMCard, 0)
for i := len(cards) - 1; i >= 0; i-- {
card := cards[i]
groupMatch := !filterGroup || card.GroupID == groupID
exportMatch := exportStatus == "" || (exportStatus == "exported" && card.Exported) || (exportStatus == "not_exported" && !card.Exported)
if groupMatch && exportMatch && (keyword == "" || card.MSISDN == keyword || card.ICCID == keyword || card.IMSI == keyword) {
matched = append(matched, card)
}
}
return matched
}
func (s *StoreManager) QueryOrders(page, pageSize int) OrderPage {
s.mu.Lock()
defer s.mu.Unlock()
if page < 1 {
page = 1
}
if pageSize < 1 {
pageSize = 20
}
if pageSize > 100 {
pageSize = 100
}
total := len(s.data.Orders)
start := (page - 1) * pageSize
if start > total {
start = total
}
end := start + pageSize
if end > total {
end = total
}
items := make([]Order, 0, end-start)
for i := total - 1 - start; i >= total-end; i-- {
items = append(items, s.data.Orders[i])
}
return OrderPage{Items: items, Total: total, Page: page, PageSize: pageSize}
}
func (s *StoreManager) load() error {
return s.reloadLocked()
}
func (s *StoreManager) reloadLocked() error {
data, err := s.backend.Load()
if err != nil {
return err
}
if s.initialized {
data.AppIDs = s.data.AppIDs
data.Tokens = s.data.Tokens
data.Orders = s.data.Orders
data.Settings = s.data.Settings
}
s.data = data
if s.data.Tokens == nil {
s.data.Tokens = map[string]Token{}
}
s.initialized = true
return nil
}
func (s *StoreManager) saveLocked() error {
return s.backend.Save(s.data)
}
func defaultStore() Store {
now := time.Now()
return Store{
AppIDs: []Credential{
{
AppID: "demo_appid",
Password: "demo_password",
Name: "默认测试应用",
Enabled: true,
CreatedAt: now.Format("2006-01-02 15:04:05"),
},
},
Cards: []SIMCard{},
Groups: []CardGroup{},
Tokens: map[string]Token{},
Orders: []Order{},
Settings: SimulationSettings{
AutoGrowthEnabled: false,
DefaultGrowthKB: 0,
},
}
}
func sampleCard(msisdn, iccid, imsi, imei, remark string, monthKB float64) SIMCard {
now := time.Now()
return SIMCard{
MSISDN: msisdn,
ICCID: iccid,
IMSI: imsi,
IMEI: imei,
Remark: remark,
CardStatus: "2",
LastChangeDate: now.AddDate(0, -1, 0).Format("2006-01-02 15:04:05"),
OpenDate: now.AddDate(-1, 0, 0).Format("2006-01-02 15:04:05"),
ActiveDate: now.AddDate(-1, 0, 3).Format("2006-01-02 15:04:05"),
OnOffStatus: "1",
SessionStatus: "01",
IP: fmt.Sprintf("10.8.%d.%d", len(msisdn), now.Second()+20),
IPv6Prefix: "",
IPv6: "",
RAT: "6",
CardBindStatus: "1",
RegionLimitStatus: "0",
AreaRestrictType: "1",
RestrictedArea: "200",
MonthUsageKB: monthKB,
PoolUsageBytes: monthKB * 1024,
DailyBaseKB: monthKB / 30,
MonthlyBaseKB: monthKB,
GrowthPerMinuteKB: 1.5,
LastUsageUpdateTime: now.Format(time.RFC3339),
APNs: []APN{
{Name: "CMIOT", Status: "1", UsageBytes: monthKB * 1024 * 0.7},
{Name: "CMMTM", Status: "1", UsageBytes: monthKB * 1024 * 0.3},
},
}
}
func cloneStore(src Store) Store {
b, _ := json.Marshal(src)
var out Store
_ = json.Unmarshal(b, &out)
return out
}
func randomHex(n int) string {
b := make([]byte, n)
if _, err := rand.Read(b); err != nil {
return fmt.Sprintf("%d", time.Now().UnixNano())
}
return hex.EncodeToString(b)
}
@@ -0,0 +1,5 @@
package service
func newStoreBackend(path string) (storeBackend, error) {
return newSQLiteStoreBackend(path)
}
@@ -0,0 +1,143 @@
package service
import (
"encoding/json"
"fmt"
"strconv"
"strings"
"time"
)
type dbAPN struct {
APNName string `json:"apnName"`
Status string `json:"status"`
DiagnosisStatus string `json:"diagnosisStatus"`
UsageBytes json.Number `json:"usageBytes"`
ModifyTime string `json:"modifyTime"`
OverUseDetails string `json:"overUseDetails"`
ServiceUsageState string `json:"serviceUsageState"`
PCCs []dbPCC `json:"pccs"`
}
type dbPCC struct {
ServiceCode string `json:"serviceCode"`
Name string `json:"name"`
PolicyStatus string `json:"policyStatus"`
ServiceUsageState string `json:"serviceUsageState"`
ModifyTime string `json:"modifyTime"`
}
func parseAPNs(raw, fallbackName, fallbackStatus string, fallbackUsageKB float64, fallbackUsageBytes int64) []APN {
var rows []dbAPN
decoder := json.NewDecoder(strings.NewReader(raw))
decoder.UseNumber()
if raw != "" && decoder.Decode(&rows) == nil {
apns := make([]APN, 0, len(rows))
for _, row := range rows {
usageBytes, _ := strconv.ParseFloat(row.UsageBytes.String(), 64)
pccs := make([]PCC, 0, len(row.PCCs))
for _, pcc := range row.PCCs {
pccs = append(pccs, PCC{ServiceCode: pcc.ServiceCode, Name: pcc.Name, PolicyStatus: pcc.PolicyStatus, ServiceUsageState: pcc.ServiceUsageState, ModifyTime: pcc.ModifyTime})
}
apns = append(apns, APN{Name: row.APNName, Status: row.Status, DiagnosisStatus: row.DiagnosisStatus, UsageBytes: usageBytes, ModifyTime: row.ModifyTime, OverUseDetails: row.OverUseDetails, ServiceUsageState: row.ServiceUsageState, PCCs: pccs})
}
return apns
}
usageBytes := float64(fallbackUsageBytes)
if usageBytes == 0 && fallbackUsageKB > 0 {
usageBytes = fallbackUsageKB * 1024
}
if fallbackName == "" {
return nil
}
return []APN{{Name: fallbackName, Status: fallbackStatus, UsageBytes: usageBytes}}
}
func apnsForDB(apns []APN) []map[string]interface{} {
out := make([]map[string]interface{}, 0, len(apns))
for _, apn := range apns {
out = append(out, map[string]interface{}{
"apnName": apn.Name,
"status": apn.Status,
"diagnosisStatus": apn.DiagnosisStatus,
"usageBytes": int64(apn.UsageBytes),
"modifyTime": apn.ModifyTime,
"overUseDetails": apn.OverUseDetails,
"serviceUsageState": apn.ServiceUsageState,
"pccs": pccsForDB(apn.PCCs),
})
}
return out
}
func pccsForDB(pccs []PCC) []map[string]string {
out := make([]map[string]string, 0, len(pccs))
for _, pcc := range pccs {
out = append(out, map[string]string{
"serviceCode": pcc.ServiceCode, "name": pcc.Name, "policyStatus": pcc.PolicyStatus,
"serviceUsageState": pcc.ServiceUsageState, "modifyTime": pcc.ModifyTime,
})
}
return out
}
func primaryAPN(card SIMCard) APN {
for _, apn := range card.APNs {
if apn.Status == "1" {
return apn
}
}
if len(card.APNs) > 0 {
return card.APNs[0]
}
return APN{Name: "CMIOT", Status: "1", UsageBytes: card.MonthUsageKB * 1024}
}
func sourceRemark(carrier, packageCode, packageName string) string {
return fmt.Sprintf("carrier=%s; package=%s; name=%s; channel=%s; supplier=%s", carrier, packageCode, packageName, generatedChanKey, generatedSupplierCode)
}
func mockMetadataFromRemark(remark string) (string, string, string) {
values := map[string]string{}
for _, part := range strings.Split(remark, ";") {
key, value, ok := strings.Cut(strings.TrimSpace(part), "=")
if ok {
values[strings.ToLower(strings.TrimSpace(key))] = strings.TrimSpace(value)
}
}
carrier := values["carrier"]
if carrier == "" {
carrier = "CMCC"
}
packageCode := values["package"]
if packageCode == "" {
packageCode = generatedPackageCode
}
packageName := values["name"]
if packageName == "" {
packageName = generatedPackageCode
}
return carrier, packageCode, packageName
}
func dbTimeToRFC3339(value string) string {
if value == "" {
return time.Now().Format(time.RFC3339)
}
if t, err := time.ParseInLocation("2006-01-02 15:04:05", value, time.Local); err == nil {
return t.Format(time.RFC3339)
}
return value
}
func rfc3339ToDBTime(value string) string {
if value == "" {
return ""
}
for _, layout := range []string{time.RFC3339Nano, time.RFC3339, "2006-01-02 15:04:05"} {
if t, err := time.ParseInLocation(layout, value, time.Local); err == nil {
return t.Format("2006-01-02 15:04:05")
}
}
return value
}
@@ -0,0 +1,245 @@
package service
import (
"context"
"encoding/json"
"fmt"
"os"
"path/filepath"
"strings"
"sync/atomic"
"time"
"git.5iot.com/iotlcm-public/iotlcm-onelink-mock/internal/dao"
"git.5iot.com/iotlcm-public/iotlcm-onelink-mock/internal/model/do"
"git.5iot.com/iotlcm-public/iotlcm-onelink-mock/internal/model/entity"
"git.5iot.com/iotlcm-public/iotlcm-onelink-mock/manifest"
_ "github.com/gogf/gf/contrib/drivers/sqlite/v2"
"github.com/gogf/gf/v2/database/gdb"
"github.com/gogf/gf/v2/errors/gerror"
)
var sqliteStoreSequence atomic.Uint64
type sqliteStoreBackend struct {
db gdb.DB
ctx context.Context
keepOpen bool
}
func newSQLiteStoreBackend(databaseLink string) (storeBackend, error) {
if !strings.HasPrefix(databaseLink, "sqlite:") {
if strings.TrimSpace(databaseLink) == "" {
databaseLink = filepath.Join("data", "onelink_mock.db")
}
if databaseLink != ":memory:" {
if err := os.MkdirAll(filepath.Dir(databaseLink), 0o755); err != nil {
return nil, gerror.Wrap(err, "create sqlite directory")
}
}
databaseLink = fmt.Sprintf("sqlite::@file(%s)", filepath.ToSlash(databaseLink))
}
group := fmt.Sprintf("onelink_sqlite_%d", sqliteStoreSequence.Add(1))
if err := gdb.AddConfigNode(group, gdb.ConfigNode{
Type: "sqlite", Link: databaseLink,
MaxIdleConnCount: 1, MaxOpenConnCount: 1, MaxConnLifeTime: 24 * time.Hour,
}); err != nil {
return nil, gerror.Wrap(err, "configure sqlite")
}
db, err := gdb.NewByGroup(group)
if err != nil {
return nil, gerror.Wrap(err, "open sqlite")
}
backend := &sqliteStoreBackend{db: db, ctx: context.Background(), keepOpen: strings.Contains(databaseLink, ":memory:")}
if err = backend.ensureSchema(); err != nil {
return nil, err
}
if !backend.keepOpen {
if err = db.Close(backend.ctx); err != nil {
return nil, gerror.Wrap(err, "close sqlite after initialization")
}
}
return backend, nil
}
func (b *sqliteStoreBackend) close() error {
if b.keepOpen {
return nil
}
if err := b.db.Close(b.ctx); err != nil {
return gerror.Wrap(err, "close sqlite connection")
}
return nil
}
func (b *sqliteStoreBackend) ensureSchema() error {
if _, err := b.db.Exec(b.ctx, manifest.SchemaSQL); err != nil {
return gerror.Wrap(err, "initialize sqlite schema")
}
rows, err := b.db.GetAll(b.ctx, "PRAGMA table_info(mock_t_sim_card)")
if err != nil {
return gerror.Wrap(err, "inspect sqlite schema")
}
columns := make(map[string]struct{}, len(rows))
for _, row := range rows {
columns[row["name"].String()] = struct{}{}
}
migrations := map[string]string{
"group_id": "ALTER TABLE mock_t_sim_card ADD COLUMN group_id INTEGER",
"stop_reason": "ALTER TABLE mock_t_sim_card ADD COLUMN stop_reason TEXT",
"frozen_status": "ALTER TABLE mock_t_sim_card ADD COLUMN frozen_status TEXT",
"frozen_reason": "ALTER TABLE mock_t_sim_card ADD COLUMN frozen_reason TEXT",
"frozen_oper_time": "ALTER TABLE mock_t_sim_card ADD COLUMN frozen_oper_time TEXT",
"exported": "ALTER TABLE mock_t_sim_card ADD COLUMN exported INTEGER NOT NULL DEFAULT 0",
"export_checked_at": "ALTER TABLE mock_t_sim_card ADD COLUMN export_checked_at TEXT",
}
for column, statement := range migrations {
if _, ok := columns[column]; ok {
continue
}
if _, err = b.db.Exec(b.ctx, statement); err != nil {
return gerror.Wrapf(err, "add sqlite column %s", column)
}
}
return nil
}
func (b *sqliteStoreBackend) Load() (Store, error) {
defer b.close()
data := defaultStore()
var groups []entity.MockCardGroup
if err := b.db.Model(dao.MockCardGroup.Table()).Ctx(b.ctx).OrderAsc(dao.MockCardGroup.Columns().Id).Scan(&groups); err != nil {
return Store{}, gerror.Wrap(err, "load card groups")
}
data.Groups = make([]CardGroup, 0, len(groups))
groupNames := make(map[int64]string, len(groups))
for _, row := range groups {
group := CardGroup{ID: int64(row.Id), Name: row.Name, CreatedAt: row.CreatedAt, UpdatedAt: row.UpdatedAt}
data.Groups = append(data.Groups, group)
groupNames[group.ID] = group.Name
}
var rows []entity.MockTSimCard
if err := b.db.Model(dao.MockTSimCard.Table()).Ctx(b.ctx).OrderAsc(dao.MockTSimCard.Columns().Id).Scan(&rows); err != nil {
return Store{}, gerror.Wrap(err, "load sim cards")
}
data.Cards = make([]SIMCard, 0, len(rows))
for _, row := range rows {
card := SIMCard{
GroupID: int64(row.GroupId), GroupName: groupNames[int64(row.GroupId)],
MSISDN: row.Msisdn, ICCID: row.Iccid, IMSI: row.Imsi, IMEI: row.Imei, Remark: row.Remark,
CardStatus: row.CardStatus, LastChangeDate: row.LastChangeDate, StopReason: row.StopReason,
FrozenStatus: row.FrozenStatus, FrozenReason: row.FrozenReason, FrozenOperTime: row.FrozenOperTime,
OpenDate: row.OpenDate, ActiveDate: row.ActiveDate, OnOffStatus: row.OnOffStatus,
SessionStatus: row.SessionStatus, SessionCreateDate: row.SessionCreateDate,
IP: row.SessionIpv4, IPv6Prefix: row.SessionIpv6Prefix, IPv6: row.SessionIpv6, RAT: row.Rat,
CardBindStatus: row.CardBindStatus, RegionLimitStatus: row.RegionLimitStatus,
AreaRestrictType: row.AreaRestrictType, RestrictedArea: row.RestrictedArea,
MonthUsageKB: float64(row.CurrentMonthUsageKb), PoolUsageBytes: float64(row.PoolUsageBytes),
DailyBaseKB: float64(row.DailyUsageKb), MonthlyBaseKB: float64(row.MonthlyUsageKb),
GrowthPerMinuteKB: float64(row.GrowthPerMinuteKb), LastUsageUpdateTime: dbTimeToRFC3339(row.LastUsageUpdateTime),
Exported: row.Exported != 0, ExportCheckedAt: row.ExportCheckedAt,
}
card.APNs = parseAPNs(row.ApnListJson, row.PrimaryApnName, row.PrimaryApnStatus, float64(row.PrimaryApnUsageKb), int64(row.PrimaryApnUsageBytes))
data.Cards = append(data.Cards, card)
}
var orders []entity.MockOrder
if err := b.db.Model(dao.MockOrder.Table()).Ctx(b.ctx).OrderAsc(dao.MockOrder.Columns().Id).Scan(&orders); err != nil {
return Store{}, gerror.Wrap(err, "load orders")
}
data.Orders = make([]Order, 0, len(orders))
for _, row := range orders {
data.Orders = append(data.Orders, Order{OrderNum: row.OrderNum, MSISDN: row.Msisdn, ICCID: row.Iccid, IMSI: row.Imsi, Type: row.Type, OperType: row.OperType, APNName: row.ApnName, Status: row.Status, CreatedAt: row.CreatedAt})
}
return data, nil
}
func (b *sqliteStoreBackend) Save(data Store) error {
defer b.close()
return b.db.Transaction(b.ctx, func(ctx context.Context, tx gdb.TX) error {
if _, err := tx.Exec(manifest.SchemaSQL); err != nil {
return gerror.Wrap(err, "initialize sqlite schema")
}
for _, table := range []string{dao.MockOrder.Table(), dao.MockTSimCard.Table(), dao.MockCardGroup.Table()} {
if _, err := tx.Model(table).Ctx(ctx).Where("1=1").Delete(); err != nil {
return gerror.Wrap(err, "clear sqlite table")
}
}
if err := insertGroups(ctx, tx, data.Groups); err != nil {
return err
}
if err := insertCards(ctx, tx, data.Cards); err != nil {
return err
}
return insertOrders(ctx, tx, data.Orders)
})
}
func (b *sqliteStoreBackend) AppendCards(cards []SIMCard) error {
defer b.close()
return b.db.Transaction(b.ctx, func(ctx context.Context, tx gdb.TX) error {
if _, err := tx.Exec(manifest.SchemaSQL); err != nil {
return gerror.Wrap(err, "initialize sqlite schema")
}
return insertCards(ctx, tx, cards)
})
}
func insertGroups(ctx context.Context, tx gdb.TX, groups []CardGroup) error {
for _, item := range groups {
if _, err := tx.Model(dao.MockCardGroup.Table()).Ctx(ctx).Data(do.MockCardGroup{Id: item.ID, Name: item.Name, CreatedAt: item.CreatedAt, UpdatedAt: item.UpdatedAt}).Insert(); err != nil {
return gerror.Wrap(err, "insert card group")
}
}
return nil
}
func insertCards(ctx context.Context, tx gdb.TX, cards []SIMCard) error {
for _, item := range cards {
normalizeCard(&item)
primary := primaryAPN(item)
apnList, err := json.Marshal(apnsForDB(item.APNs))
if err != nil {
return gerror.Wrap(err, "encode apn list")
}
carrier, packageCode, packageName := mockMetadataFromRemark(item.Remark)
data := do.MockTSimCard{
GroupId: item.GroupID, Msisdn: item.MSISDN, Iccid: item.ICCID, Imsi: item.IMSI, Imei: item.IMEI,
Remark: item.Remark, Carrier: carrier, PackageCode: packageCode, PackageName: packageName,
PackageStatus: 1, PackageTotalFlowMb: item.MonthlyBaseKB / 1024, IsFlowPool: 1,
CardStatus: item.CardStatus, LastChangeDate: item.LastChangeDate, StopReason: item.StopReason,
FrozenStatus: item.FrozenStatus, FrozenReason: item.FrozenReason, FrozenOperTime: item.FrozenOperTime,
OpenDate: item.OpenDate, ActiveDate: item.ActiveDate, OnOffStatus: item.OnOffStatus, SessionStatus: item.SessionStatus,
ApnId: primary.Name, SessionIpv4: item.IP, SessionIpv6Prefix: item.IPv6Prefix, SessionIpv6: item.IPv6,
SessionCreateDate: item.SessionCreateDate, Rat: item.RAT, ApnListJson: string(apnList), ApnUsageJson: string(apnList),
PrimaryApnName: primary.Name, PrimaryApnStatus: primary.Status, PrimaryApnUsageKb: primary.UsageBytes / 1024,
PrimaryApnUsageBytes: int64(primary.UsageBytes), PccCode: "default", PccUsageKb: primary.UsageBytes / 1024,
CurrentMonthUsageKb: item.MonthUsageKB, PoolUsageBytes: int64(item.PoolUsageBytes), DailyUsageKb: item.DailyBaseKB,
MonthlyUsageKb: item.MonthlyBaseKB, DailyQueryDate: parseCardTime(item.ActiveDate).Format("20060102"),
MonthlyQueryMonth: parseCardTime(item.ActiveDate).Format("200601"), MonthlyDeadline: parseCardTime(item.ActiveDate).AddDate(0, 0, -1).Format("20060102"),
GrowthPerMinuteKb: item.GrowthPerMinuteKB, AutoGrowthEnabled: 0, LastUsageUpdateTime: rfc3339ToDBTime(item.LastUsageUpdateTime),
CardBindStatus: item.CardBindStatus, RegionLimitStatus: item.RegionLimitStatus, AreaRestrictType: item.AreaRestrictType,
RestrictedArea: item.RestrictedArea, Exported: boolInt(item.Exported), ExportCheckedAt: item.ExportCheckedAt,
CreatedAt: item.OpenDate, UpdatedAt: item.LastChangeDate,
}
if _, err = tx.Model(dao.MockTSimCard.Table()).Ctx(ctx).Data(data).Insert(); err != nil {
return gerror.Wrap(err, "insert sim card")
}
}
return nil
}
func insertOrders(ctx context.Context, tx gdb.TX, orders []Order) error {
for _, item := range orders {
if _, err := tx.Model(dao.MockOrder.Table()).Ctx(ctx).Data(do.MockOrder{OrderNum: item.OrderNum, Msisdn: item.MSISDN, Iccid: item.ICCID, Imsi: item.IMSI, Type: item.Type, OperType: item.OperType, ApnName: item.APNName, Status: item.Status, CreatedAt: item.CreatedAt}).Insert(); err != nil {
return gerror.Wrap(err, "insert order")
}
}
return nil
}
func boolInt(value bool) int {
if value {
return 1
}
return 0
}
@@ -0,0 +1,71 @@
package service
import "testing"
func TestStoreManagerUsesSQLiteWithoutExternalDatabase(t *testing.T) {
manager, err := NewStoreManager(t.TempDir() + "/onelink_mock.db")
if err != nil {
t.Fatal(err)
}
got := manager.Snapshot()
if len(got.Cards) != 0 {
t.Fatalf("new sqlite store should not contain sample cards, got %#v", got.Cards)
}
if len(got.AppIDs) == 0 || got.AppIDs[0].AppID != "demo_appid" {
t.Fatalf("default app id not preserved: %#v", got.AppIDs)
}
}
func TestStoreManagerGenerateCardsAppendsWithoutDeletingOldCards(t *testing.T) {
manager, err := NewStoreManager(t.TempDir() + "/onelink_mock.db")
if err != nil {
t.Fatal(err)
}
first, err := manager.GenerateCards(2)
if err != nil {
t.Fatal(err)
}
if first.Generated != 2 {
t.Fatalf("first generated = %d, want 2", first.Generated)
}
second, err := manager.GenerateCards(1)
if err != nil {
t.Fatal(err)
}
if second.Generated != 1 || second.Total != 3 {
t.Fatalf("second result = %#v, want generated 1 total 3", second)
}
got := manager.Snapshot()
if len(got.Cards) != 3 {
t.Fatalf("generated cards should append, got %d cards: %#v", len(got.Cards), got.Cards)
}
}
func TestOrdersPersistAcrossStoreRestart(t *testing.T) {
dbPath := t.TempDir() + "/onelink_mock.db"
manager, err := NewStoreManager(dbPath)
if err != nil {
t.Fatal(err)
}
want := Order{
OrderNum: "ORD202607270001", MSISDN: "14100000001", ICCID: "8986000000000000001",
IMSI: "460000000000001", Type: "sim-apn-function", OperType: "1",
APNName: "CMIOT", Status: "completed", CreatedAt: "2026-07-27 16:00:00",
}
if err := manager.SaveMutation(func(st *Store) error {
st.Orders = append(st.Orders, want)
return nil
}); err != nil {
t.Fatal(err)
}
reopened, err := NewStoreManager(dbPath)
if err != nil {
t.Fatal(err)
}
got := reopened.Snapshot().Orders
if len(got) != 1 || got[0] != want {
t.Fatalf("orders after restart = %#v, want %#v", got, want)
}
}