sync: 同步 v1.0.4 并脱敏配置与镜像
This commit is contained in:
@@ -42,11 +42,17 @@ func init() {
|
||||
}
|
||||
|
||||
func (s *sAgentDept) DeptList(ctx context.Context, in *model.AgentDeptListParam) (list []*model.AgentEnterpriseCustomerModel, total int, err error) {
|
||||
if in == nil {
|
||||
in = &model.AgentDeptListParam{}
|
||||
}
|
||||
in.NormalizePagination()
|
||||
list = make([]*model.AgentEnterpriseCustomerModel, 0)
|
||||
currentDeptId := contexts.GetDeptId(ctx)
|
||||
if currentDeptId == 0 {
|
||||
return nil, 0, gerror.New("当前用户未绑定组织")
|
||||
}
|
||||
m := s.deptModel(ctx).
|
||||
Where(dao.SysDept.Columns().TenantId, contexts.GetTenantId(ctx)).
|
||||
Where(dao.SysDept.Columns().ParentId, currentDeptId).
|
||||
Where(dao.SysDept.Columns().DeptType, 1)
|
||||
if in.DeptName != "" {
|
||||
@@ -56,14 +62,15 @@ func (s *sAgentDept) DeptList(ctx context.Context, in *model.AgentDeptListParam)
|
||||
m = m.Where(dao.SysDept.Columns().Status, in.Status)
|
||||
}
|
||||
err = m.Order(dao.SysDept.Columns().DeptId, "ASC").
|
||||
Scan(&list)
|
||||
Page(in.Page, in.PageSize).
|
||||
ScanAndCount(&list, &total, false)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
if err = s.fillEnterpriseDefaultUsers(ctx, list); err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
return list, len(list), nil
|
||||
return list, total, nil
|
||||
}
|
||||
|
||||
func (s *sAgentDept) DeptView(ctx context.Context, deptId int64) (res *model.AgentEnterpriseCustomerModel, err error) {
|
||||
@@ -124,63 +131,25 @@ func (s *sAgentDept) DeptSave(ctx context.Context, in *model.AgentDeptSaveParam)
|
||||
return deptId, nil
|
||||
}
|
||||
|
||||
// EnsureCurrentDeptDefaultRole upgrades legacy child enterprises that did not
|
||||
// receive an inherited administrator role when they were created.
|
||||
func (s *sAgentDept) EnsureCurrentDeptDefaultRole(ctx context.Context) error {
|
||||
user := contexts.GetUser(ctx)
|
||||
if user == nil {
|
||||
if contexts.GetUser(ctx) == nil {
|
||||
return gerror.New("登录已过期")
|
||||
}
|
||||
deptId := contexts.GetDeptId(ctx)
|
||||
if deptId == 0 {
|
||||
deptID := contexts.GetDeptId(ctx)
|
||||
if deptID <= 0 {
|
||||
return gerror.New("当前用户未绑定组织")
|
||||
}
|
||||
|
||||
var dept *entity.SysDept
|
||||
if err := s.deptModel(ctx).
|
||||
Where(dao.SysDept.Columns().DeptId, deptId).
|
||||
Scan(&dept); err != nil {
|
||||
if err := s.deptModel(ctx).Where(dao.SysDept.Columns().DeptId, deptID).Scan(&dept); err != nil {
|
||||
return err
|
||||
}
|
||||
if dept == nil || dept.DeptId == 0 {
|
||||
if dept == nil {
|
||||
return gerror.New("组织不存在")
|
||||
}
|
||||
if dept.DeptType != 1 || dept.DeptRole != 0 {
|
||||
return nil
|
||||
if dept.DeptType == 1 && dept.DeptRole <= 0 {
|
||||
return gerror.Newf("机构%d未关联默认角色,请联系平台管理员核对机构角色绑定", deptID)
|
||||
}
|
||||
|
||||
sourceRole, menuIds, err := s.passRoleByDept(ctx, dept.ParentId)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return g.DB().Transaction(ctx, func(ctx context.Context, tx gdb.TX) error {
|
||||
var current *entity.SysDept
|
||||
if err := tx.Ctx(ctx).Model(dao.SysDept.Table()).
|
||||
Where(dao.SysDept.Columns().DeptId, deptId).
|
||||
Scan(¤t); err != nil {
|
||||
return err
|
||||
}
|
||||
if current == nil || current.DeptId == 0 {
|
||||
return gerror.New("组织不存在")
|
||||
}
|
||||
if current.DeptRole != 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
roleId, err := s.createEnterpriseRole(ctx, tx, user, deptId, sourceRole, menuIds)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := s.updateDeptRole(ctx, tx, deptId, roleId, user.ID); err != nil {
|
||||
return err
|
||||
}
|
||||
// Keep the legacy role for the active session. After re-login, this role
|
||||
// becomes the department's selected default administrator role.
|
||||
_, err = tx.Ctx(ctx).Model(dao.SysUserRole.Table()).
|
||||
Data(do.SysUserRole{UserId: user.ID, RoleId: roleId}).
|
||||
OmitNil().Insert()
|
||||
return err
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *sAgentDept) validateEnterpriseSave(ctx context.Context, tenantId string, in *model.AgentDeptSaveParam) error {
|
||||
@@ -498,7 +467,10 @@ func (s *sAgentDept) passRoleByDept(ctx context.Context, deptId int64) (*entity.
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
if template != nil && contexts.GetTenantId(ctx) == template.TenantId {
|
||||
if template != nil {
|
||||
if contexts.GetTenantId(ctx) != template.TenantId {
|
||||
return nil, nil, gerror.New("当前租户不属于平台代理模板授权范围")
|
||||
}
|
||||
// 配置了平台模板后,下发来源不再依赖 is_pass=1:
|
||||
// 一级代理由平台模板推出,其默认角色是复制来的 is_pass=0 角色;
|
||||
// 各级代理再创建下级时,必须能从本机构默认角色(sys_dept.dept_role)继续传递,
|
||||
@@ -523,7 +495,7 @@ func (s *sAgentDept) passRoleByDept(ctx context.Context, deptId int64) (*entity.
|
||||
}
|
||||
}
|
||||
var menus []*entity.SysRoleMenu
|
||||
if err := dao.SysRoleMenu.Ctx(ctx).Where("role_id", source.RoleId).Scan(&menus); err != nil {
|
||||
if err := dao.SysRoleMenu.Ctx(ctx).Master().Where("tenant_id", template.TenantId).Where("role_id", source.RoleId).Scan(&menus); err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
ids := make([]int64, 0, len(menus))
|
||||
@@ -690,7 +662,11 @@ func (s *sAgentDept) childDeptAncestors(ctx context.Context, parentId int64) (st
|
||||
if ancestors == "" {
|
||||
ancestors = "0"
|
||||
}
|
||||
return fmt.Sprintf("%s,%d", ancestors, parent.DeptId), nil
|
||||
// ancestors 必须与平台约定格式一致:以本机构结尾并带尾逗号(如 "0,1,172,")。
|
||||
// 早期实现返回 "0,1,172"(缺尾逗号),而 GetDeptAndSub 按 '%,id,%' 展开
|
||||
// 「本机构及以下」数据权限,缺尾逗号时匹配不到,表现为上级机构看不到下级
|
||||
// (例:一级代理在卡片划拨里选不到下级客户)。
|
||||
return fmt.Sprintf("%s,%d,", ancestors, parent.DeptId), nil
|
||||
}
|
||||
|
||||
func (s *sAgentDept) nextCustomerDeptKey(ctx context.Context) (string, error) {
|
||||
@@ -744,11 +720,68 @@ func (s *sAgentDept) ensureUserNameUnique(ctx context.Context, tenantId, userNam
|
||||
}
|
||||
|
||||
func (s *sAgentDept) fillEnterpriseDefaultUsers(ctx context.Context, list []*model.AgentEnterpriseCustomerModel) error {
|
||||
if len(list) == 0 {
|
||||
return nil
|
||||
}
|
||||
var (
|
||||
deptIDs = make([]int64, 0, len(list))
|
||||
roleIDs = make([]int64, 0, len(list))
|
||||
bindings []struct {
|
||||
RoleId int64
|
||||
UserId int64
|
||||
}
|
||||
fallbacks []struct {
|
||||
DeptId int64
|
||||
UserId int64
|
||||
}
|
||||
)
|
||||
for _, dept := range list {
|
||||
user, err := s.defaultUserByDept(ctx, dept.DeptId, dept.DeptRole)
|
||||
if err != nil {
|
||||
deptIDs = append(deptIDs, dept.DeptId)
|
||||
if dept.DeptRole > 0 {
|
||||
roleIDs = append(roleIDs, dept.DeptRole)
|
||||
}
|
||||
}
|
||||
tenantID := contexts.GetTenantId(ctx)
|
||||
if len(roleIDs) > 0 {
|
||||
if err := dao.SysUserRole.Ctx(ctx).Where("tenant_id", tenantID).
|
||||
WhereIn("role_id", roleIDs).Fields("role_id", "MIN(user_id) AS user_id").Group("role_id").Scan(&bindings); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if err := s.userModel(ctx).Where("tenant_id", tenantID).WhereIn("dept_id", deptIDs).
|
||||
Fields("dept_id", "MIN(user_id) AS user_id").Group("dept_id").Scan(&fallbacks); err != nil {
|
||||
return err
|
||||
}
|
||||
var (
|
||||
roleUsers = make(map[int64]int64, len(bindings))
|
||||
deptUsers = make(map[int64]int64, len(fallbacks))
|
||||
userIDs = make([]int64, 0, len(bindings)+len(fallbacks))
|
||||
users []*entity.SysUser
|
||||
)
|
||||
for _, binding := range bindings {
|
||||
roleUsers[binding.RoleId] = binding.UserId
|
||||
userIDs = append(userIDs, binding.UserId)
|
||||
}
|
||||
for _, fallback := range fallbacks {
|
||||
deptUsers[fallback.DeptId] = fallback.UserId
|
||||
userIDs = append(userIDs, fallback.UserId)
|
||||
}
|
||||
if len(userIDs) == 0 {
|
||||
return nil
|
||||
}
|
||||
if err := s.userModel(ctx).Where("tenant_id", tenantID).WhereIn("dept_id", deptIDs).
|
||||
WhereIn("user_id", userIDs).Fields("user_id", "dept_id", "user_name", "nick_name").Scan(&users); err != nil {
|
||||
return err
|
||||
}
|
||||
usersByID := make(map[int64]*entity.SysUser, len(users))
|
||||
for _, user := range users {
|
||||
usersByID[user.UserId] = user
|
||||
}
|
||||
for _, dept := range list {
|
||||
user := usersByID[roleUsers[dept.DeptRole]]
|
||||
if user == nil || user.DeptId != dept.DeptId {
|
||||
user = usersByID[deptUsers[dept.DeptId]]
|
||||
}
|
||||
if user == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/contexts"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/xgorm/handler"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/model"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/service"
|
||||
|
||||
_ "github.com/gogf/gf/contrib/drivers/sqlite/v2"
|
||||
"github.com/gogf/gf/v2/database/gdb"
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
)
|
||||
|
||||
type paginationDeptService struct{ service.ISysDept }
|
||||
|
||||
func (paginationDeptService) Model(ctx context.Context, _ ...*handler.Option) *gdb.Model {
|
||||
return g.DB().Model("sys_dept").Ctx(ctx).Handler(handler.FilterTenant)
|
||||
}
|
||||
|
||||
type paginationUserService struct{ service.ISysUser }
|
||||
|
||||
func (paginationUserService) Model(ctx context.Context, _ ...*handler.Option) *gdb.Model {
|
||||
return g.DB().Model("sys_user").Ctx(ctx).Handler(handler.FilterTenant)
|
||||
}
|
||||
|
||||
func TestEnterpriseCustomerDatabasePaginationAndDefaultUsers(t *testing.T) {
|
||||
setupAgentRoleSyncTestDB(t)
|
||||
ctx := contexts.Set(context.Background(), &model.Context{User: &model.Identity{BaseClaims: model.BaseClaims{TenantId: "000000", DeptId: 1}}})
|
||||
service.RegisterSysDept(paginationDeptService{})
|
||||
service.RegisterSysUser(paginationUserService{})
|
||||
t.Cleanup(func() {
|
||||
service.RegisterSysDept(nil)
|
||||
service.RegisterSysUser(nil)
|
||||
})
|
||||
statements := []string{
|
||||
"ALTER TABLE sys_dept ADD COLUMN dept_name TEXT",
|
||||
"CREATE TABLE sys_user (user_id INTEGER PRIMARY KEY, tenant_id TEXT, dept_id INTEGER, user_name TEXT, nick_name TEXT, deleted_at DATETIME)",
|
||||
"INSERT INTO sys_dept (dept_id,tenant_id,parent_id,dept_type,dept_role,dept_name) VALUES (2,'000000',1,1,12,'企业A'),(3,'000000',1,1,13,'企业B'),(4,'000000',1,1,14,'企业C'),(5,'000000',2,1,15,'孙级企业'),(6,'other',1,1,16,'其他租户'),(7,'000000',1,0,17,'内部部门'),(8,'000000',1,1,18,'已删除企业'),(9,'000000',1,1,0,'无账号企业')",
|
||||
"UPDATE sys_dept SET deleted_at=CURRENT_TIMESTAMP WHERE dept_id=8",
|
||||
"INSERT INTO sys_user VALUES (20,'000000',2,'fallback-a','回退A',NULL),(21,'000000',2,'admin-a','管理员A',NULL),(30,'000000',3,'deleted-admin','已删除',CURRENT_TIMESTAMP),(31,'000000',3,'fallback-b','回退B',NULL),(40,'000000',4,'fallback-c','回退C',NULL),(50,'other',4,'other-user','跨租户',NULL)",
|
||||
"INSERT INTO sys_user_role (tenant_id,user_id,role_id) VALUES ('000000',21,12),('000000',30,13),('other',50,14)",
|
||||
}
|
||||
for _, statement := range statements {
|
||||
mustExecAgentRoleSyncTest(t, ctx, statement)
|
||||
}
|
||||
subject := NewAgentDept()
|
||||
params := &model.AgentDeptListParam{Page: 1, PageSize: 2}
|
||||
list, total, err := subject.DeptList(ctx, params)
|
||||
if err != nil || total != 4 || len(list) != 2 {
|
||||
t.Fatalf("page 1: total=%d list=%+v err=%v", total, list, err)
|
||||
}
|
||||
if list[0].DeptId != 2 || list[0].UserName != "admin-a" || list[1].DeptId != 3 || list[1].UserName != "fallback-b" {
|
||||
t.Fatalf("unexpected account selection: %+v %+v", list[0], list[1])
|
||||
}
|
||||
params.Page = 2
|
||||
list, total, err = subject.DeptList(ctx, params)
|
||||
if err != nil || total != 4 || len(list) != 2 || list[0].DeptId != 4 || list[0].UserName != "fallback-c" || list[1].DeptId != 9 || list[1].UserName != "" {
|
||||
t.Fatalf("page 2: total=%d list=%+v err=%v", total, list, err)
|
||||
}
|
||||
params.Page = 3
|
||||
list, total, err = subject.DeptList(ctx, params)
|
||||
if err != nil || total != 4 || len(list) != 0 || list == nil {
|
||||
t.Fatalf("out of range: total=%d list=%+v err=%v", total, list, err)
|
||||
}
|
||||
list, total, err = subject.DeptList(ctx, &model.AgentDeptListParam{DeptName: "企业A", Status: "0"})
|
||||
if err != nil || total != 1 || len(list) != 1 || list[0].DeptId != 2 {
|
||||
t.Fatalf("filtered list: total=%d list=%+v err=%v", total, list, err)
|
||||
}
|
||||
}
|
||||
@@ -12,9 +12,10 @@ import (
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/consts"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/contexts"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/model"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/service"
|
||||
|
||||
_ "git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/logic/memorydb"
|
||||
_ "git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/logic/system"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/logic/system"
|
||||
)
|
||||
|
||||
// 复现生产形态:模板角色已配置(role 18 @ dept 100,is_pass=1),
|
||||
@@ -56,6 +57,8 @@ func TestPassRoleByDeptWorksForChildAgentWithoutTemplateFlag(t *testing.T) {
|
||||
|
||||
func setupPassRoleTestDB(t *testing.T) {
|
||||
t.Helper()
|
||||
service.RegisterSysDept(system.NewSysDept())
|
||||
service.RegisterSysRole(system.NewSysRole())
|
||||
if err := gdb.SetConfig(gdb.Config{
|
||||
"default": gdb.ConfigGroup{
|
||||
{Type: "sqlite", Link: "sqlite::@file(:memory:)", Debug: false},
|
||||
@@ -69,7 +72,7 @@ func setupPassRoleTestDB(t *testing.T) {
|
||||
}
|
||||
ctx := context.Background()
|
||||
t.Cleanup(func() { _ = g.DB().Close(ctx) })
|
||||
mustExec(t, ctx, `CREATE TABLE sys_dept (dept_id INTEGER PRIMARY KEY, tenant_id TEXT, parent_id INTEGER, dept_type INTEGER, status TEXT DEFAULT '0', dept_role INTEGER, deleted_at DATETIME)`)
|
||||
mustExec(t, ctx, `CREATE TABLE sys_dept (dept_id INTEGER PRIMARY KEY, tenant_id TEXT, parent_id INTEGER, dept_name TEXT, dept_type INTEGER, status TEXT DEFAULT '0', dept_role INTEGER, deleted_at DATETIME)`)
|
||||
mustExec(t, ctx, `CREATE TABLE sys_config (config_id INTEGER PRIMARY KEY, tenant_id TEXT, config_name TEXT, config_key TEXT, config_value TEXT, config_type TEXT, created_dept INTEGER, created_by INTEGER, created_at DATETIME, updated_by INTEGER, updated_at DATETIME, remark TEXT)`)
|
||||
mustExec(t, ctx, `CREATE TABLE sys_role (
|
||||
role_id INTEGER PRIMARY KEY AUTOINCREMENT, tenant_id TEXT, dept_id INTEGER, role_name TEXT, role_key TEXT, role_sort INTEGER, data_scope TEXT,
|
||||
|
||||
@@ -0,0 +1,169 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/exportcenter"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/model"
|
||||
|
||||
"github.com/gogf/gf/v2/errors/gerror"
|
||||
)
|
||||
|
||||
// agentExportColumns 只允许列表字段,列标题和映射由服务端确定。
|
||||
func agentExportColumns(raw string, defaults []model.ExportColumn) ([]model.ExportColumn, error) {
|
||||
if strings.TrimSpace(raw) == "" {
|
||||
return nil, gerror.New("请选择导出列")
|
||||
}
|
||||
var requested []model.ExportColumn
|
||||
if err := json.Unmarshal([]byte(raw), &requested); err != nil {
|
||||
return nil, gerror.New("导出列格式错误")
|
||||
}
|
||||
allowed := make(map[string]model.ExportColumn, len(defaults))
|
||||
for _, col := range defaults {
|
||||
allowed[col.Field] = col
|
||||
}
|
||||
result := make([]model.ExportColumn, 0, len(requested))
|
||||
seen := make(map[string]bool)
|
||||
for _, col := range requested {
|
||||
safe, ok := allowed[col.Field]
|
||||
if !ok {
|
||||
return nil, gerror.New("包含不允许导出的字段")
|
||||
}
|
||||
if !seen[col.Field] {
|
||||
result = append(result, safe)
|
||||
seen[col.Field] = true
|
||||
}
|
||||
}
|
||||
if len(result) == 0 {
|
||||
return nil, gerror.New("请选择导出列")
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (s *sAgentUser) UserExport(ctx context.Context, in *model.AgentUserListParam) error {
|
||||
columns, err := agentExportColumns(in.ExportColumnsJson, []model.ExportColumn{
|
||||
{Field: "userName", Label: "用户名称"}, {Field: "nickName", Label: "用户昵称"},
|
||||
{Field: "roles", Label: "角色"}, {Field: "phonenumber", Label: "联系电话"},
|
||||
{Field: "email", Label: "邮箱"}, {Field: "status", Label: "状态"}, {Field: "createdAt", Label: "创建时间"},
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = exportcenter.Submit(ctx, &exportcenter.Request{
|
||||
BizKey: "agentUser", BizName: "用户管理", PermCode: "cpm:agent:user:export", QueryParam: in, Columns: columns,
|
||||
RowFields: agentExportFields(columns),
|
||||
ToRow: func(value interface{}) []interface{} {
|
||||
row := value.(map[string]interface{})
|
||||
values := make([]interface{}, 0, len(columns))
|
||||
for _, column := range columns {
|
||||
values = append(values, row[column.Field])
|
||||
}
|
||||
return values
|
||||
},
|
||||
Tags: func() ([]string, error) {
|
||||
labels := make([]string, 0, len(columns))
|
||||
for _, col := range columns {
|
||||
labels = append(labels, col.Label)
|
||||
}
|
||||
return labels, nil
|
||||
},
|
||||
Fetch: func(ctx context.Context, page, pageSize int) ([]interface{}, int, error) {
|
||||
query := *in
|
||||
query.Page = page
|
||||
query.PageSize = pageSize
|
||||
list, total, err := s.UserList(ctx, &query)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
rows := make([]interface{}, 0, len(list))
|
||||
for _, item := range list {
|
||||
roles := make([]string, 0, len(item.Roles))
|
||||
for _, role := range item.Roles {
|
||||
if role != nil && role.RoleName != "" {
|
||||
roles = append(roles, role.RoleName)
|
||||
}
|
||||
}
|
||||
roleNames := strings.Join(roles, "、")
|
||||
if roleNames == "" {
|
||||
roleNames = "-"
|
||||
}
|
||||
createdAt := ""
|
||||
if item.CreatedAt != nil {
|
||||
createdAt = item.CreatedAt.Format("Y-m-d H:i:s")
|
||||
}
|
||||
rows = append(rows, map[string]interface{}{"userName": item.UserName, "nickName": item.NickName, "roles": roleNames, "phonenumber": item.Phonenumber, "email": item.Email, "status": agentExportStatus(item.Status), "createdAt": createdAt})
|
||||
}
|
||||
return rows, total, nil
|
||||
},
|
||||
})
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *sAgentDept) DeptExport(ctx context.Context, in *model.AgentDeptListParam) error {
|
||||
columns, err := agentExportColumns(in.ExportColumnsJson, []model.ExportColumn{
|
||||
{Field: "deptName", Label: "企业名称"}, {Field: "userName", Label: "登录账号"}, {Field: "nickName", Label: "负责人"},
|
||||
{Field: "phone", Label: "联系电话"}, {Field: "email", Label: "联系人邮箱"}, {Field: "areaCode", Label: "组织所在地"},
|
||||
{Field: "address", Label: "详细地址"}, {Field: "createdAt", Label: "创建时间"}, {Field: "status", Label: "状态"},
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = exportcenter.Submit(ctx, &exportcenter.Request{
|
||||
BizKey: "agentDept", BizName: "企业客户", PermCode: "cpm:agent:dept:export", QueryParam: in, Columns: columns,
|
||||
RowFields: agentExportFields(columns),
|
||||
ToRow: func(value interface{}) []interface{} {
|
||||
row := value.(map[string]interface{})
|
||||
values := make([]interface{}, 0, len(columns))
|
||||
for _, column := range columns {
|
||||
values = append(values, row[column.Field])
|
||||
}
|
||||
return values
|
||||
},
|
||||
Tags: func() ([]string, error) {
|
||||
labels := make([]string, 0, len(columns))
|
||||
for _, col := range columns {
|
||||
labels = append(labels, col.Label)
|
||||
}
|
||||
return labels, nil
|
||||
},
|
||||
Fetch: func(ctx context.Context, page, pageSize int) ([]interface{}, int, error) {
|
||||
query := *in
|
||||
query.Page = page
|
||||
query.PageSize = pageSize
|
||||
list, total, err := s.DeptList(ctx, &query)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
rows := make([]interface{}, 0, len(list))
|
||||
for _, item := range list {
|
||||
createdAt := ""
|
||||
if item.CreatedAt != nil {
|
||||
createdAt = item.CreatedAt.Format("Y-m-d H:i:s")
|
||||
}
|
||||
rows = append(rows, map[string]interface{}{"deptName": item.DeptName, "userName": item.UserName, "nickName": item.NickName, "phone": item.Phone, "email": item.Email, "areaCode": item.AreaCode, "address": item.Address, "createdAt": createdAt, "status": agentExportStatus(item.Status)})
|
||||
}
|
||||
return rows, total, nil
|
||||
},
|
||||
})
|
||||
return err
|
||||
}
|
||||
|
||||
func agentExportFields(columns []model.ExportColumn) []string {
|
||||
fields := make([]string, 0, len(columns))
|
||||
for _, column := range columns {
|
||||
fields = append(fields, column.Field)
|
||||
}
|
||||
return fields
|
||||
}
|
||||
|
||||
func agentExportStatus(status string) string {
|
||||
if status == "0" {
|
||||
return "正常"
|
||||
}
|
||||
if status == "1" {
|
||||
return "停用"
|
||||
}
|
||||
return "未知"
|
||||
}
|
||||
@@ -46,17 +46,22 @@ func (s *sAgentMenu) AllowedMenuIdsByDeptRole(ctx context.Context, deptId int64)
|
||||
return []int64{}, nil
|
||||
}
|
||||
if dept.DeptRole == consts.SuperAdminRoleId {
|
||||
return s.allMenuIds(ctx)
|
||||
menuIds, err = s.allMenuIds(ctx)
|
||||
} else {
|
||||
var roleMenus []*entity.SysRoleMenu
|
||||
roleMenus, err = service.SysRole().GetRoleListMenu(ctx, []int64{dept.DeptRole})
|
||||
for _, item := range roleMenus {
|
||||
menuIds = append(menuIds, item.MenuId)
|
||||
}
|
||||
}
|
||||
roleMenus, err := service.SysRole().GetRoleListMenu(ctx, []int64{dept.DeptRole})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, item := range roleMenus {
|
||||
menuIds = append(menuIds, item.MenuId)
|
||||
}
|
||||
slices.Sort(menuIds)
|
||||
menuIds = slices.Compact(menuIds)
|
||||
if !contexts.IsSuperAdmin(ctx) {
|
||||
return rolepolicy.RestrictAssignableMenus(ctx, dept.TenantId, dept.DeptId, contexts.GetUserId(ctx), menuIds)
|
||||
}
|
||||
return rolepolicy.RestrictMenus(ctx, dept.TenantId, dept.DeptId, menuIds)
|
||||
}
|
||||
|
||||
@@ -83,32 +88,6 @@ func (s *sAgentMenu) getRoleById(ctx context.Context, roleId int64) (role *entit
|
||||
return role, err
|
||||
}
|
||||
|
||||
// filterMenuIdsByAllowed 筛选出当前机构允许的菜单ID
|
||||
func (s *sAgentMenu) filterMenuIdsByAllowed(ctx context.Context, deptId int64, menuIds []int64) (res []int64, err error) {
|
||||
if deptId == 0 {
|
||||
deptId = contexts.GetDeptId(ctx)
|
||||
}
|
||||
allowed, err := s.AllowedMenuIdsByDeptRole(ctx, deptId)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
allowed, err = s.withMenuAncestors(ctx, allowed)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
allowedMap := make(map[int64]struct{}, len(allowed))
|
||||
for _, menuId := range allowed {
|
||||
allowedMap[menuId] = struct{}{}
|
||||
}
|
||||
for _, menuId := range menuIds {
|
||||
if _, ok := allowedMap[menuId]; ok {
|
||||
res = append(res, menuId)
|
||||
}
|
||||
}
|
||||
slices.Sort(res)
|
||||
return slices.Compact(res), nil
|
||||
}
|
||||
|
||||
func (s *sAgentMenu) AllowedMenuTree(ctx context.Context, deptId int64) (tree []*model.SysMenuTreeModel, menuIds []int64, err error) {
|
||||
deptId = contexts.GetDeptId(ctx)
|
||||
menuIds, err = s.AllowedMenuIdsByDeptRole(ctx, deptId)
|
||||
@@ -118,13 +97,13 @@ func (s *sAgentMenu) AllowedMenuTree(ctx context.Context, deptId int64) (tree []
|
||||
if len(menuIds) == 0 && !contexts.IsSuperAdmin(ctx) {
|
||||
return []*model.SysMenuTreeModel{}, []int64{}, nil
|
||||
}
|
||||
menuIds, err = s.withMenuAncestors(ctx, menuIds)
|
||||
visibleMenuIds, err := rolepolicy.WithMenuAncestors(ctx, menuIds)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
mod := service.SysMenu().Model(ctx)
|
||||
if !contexts.IsSuperAdmin(ctx) {
|
||||
mod = mod.WhereIn(dao.SysMenu.Columns().MenuId, menuIds)
|
||||
mod = mod.WhereIn(dao.SysMenu.Columns().MenuId, visibleMenuIds)
|
||||
}
|
||||
var menus []*entity.SysMenu
|
||||
err = mod.Where(dao.SysMenu.Columns().Status, consts.SysMenuStatusNormal).
|
||||
@@ -134,43 +113,18 @@ func (s *sAgentMenu) AllowedMenuTree(ctx context.Context, deptId int64) (tree []
|
||||
return nil, nil, err
|
||||
}
|
||||
tree = s.buildMenuList(menus)
|
||||
activeMenuIds := make([]int64, 0, len(menus))
|
||||
for _, menu := range menus {
|
||||
activeMenuIds = append(activeMenuIds, menu.MenuId)
|
||||
}
|
||||
if contexts.IsSuperAdmin(ctx) {
|
||||
menuIds = activeMenuIds
|
||||
} else {
|
||||
menuIds = rolepolicy.Intersect(menuIds, activeMenuIds)
|
||||
}
|
||||
return tree, menuIds, nil
|
||||
}
|
||||
|
||||
func (s *sAgentMenu) withMenuAncestors(ctx context.Context, menuIds []int64) ([]int64, error) {
|
||||
seen := make(map[int64]struct{}, len(menuIds))
|
||||
queue := append([]int64{}, menuIds...)
|
||||
for _, menuId := range menuIds {
|
||||
seen[menuId] = struct{}{}
|
||||
}
|
||||
for len(queue) > 0 {
|
||||
var menus []*entity.SysMenu
|
||||
err := service.SysMenu().Model(ctx).
|
||||
WhereIn(dao.SysMenu.Columns().MenuId, queue).
|
||||
Scan(&menus)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
queue = queue[:0]
|
||||
for _, menu := range menus {
|
||||
if menu.ParentId == 0 {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[menu.ParentId]; ok {
|
||||
continue
|
||||
}
|
||||
seen[menu.ParentId] = struct{}{}
|
||||
queue = append(queue, menu.ParentId)
|
||||
}
|
||||
}
|
||||
res := make([]int64, 0, len(seen))
|
||||
for menuId := range seen {
|
||||
res = append(res, menuId)
|
||||
}
|
||||
slices.Sort(res)
|
||||
return res, nil
|
||||
}
|
||||
|
||||
func (s *sAgentMenu) buildMenuList(menus []*entity.SysMenu) (list []*model.SysMenuTreeModel) {
|
||||
for _, menu := range menus {
|
||||
list = append(list, &model.SysMenuTreeModel{
|
||||
|
||||
@@ -2,8 +2,6 @@ package agent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/consts"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/dao"
|
||||
@@ -112,7 +110,7 @@ func (s *sAgentRole) RoleView(ctx context.Context, roleId int64) (res *model.Age
|
||||
func (s *sAgentRole) RoleSave(ctx context.Context, in *model.AgentRoleSaveParam) (roleId int64, err error) {
|
||||
err = g.DB().Transaction(ctx, func(ctx context.Context, tx gdb.TX) error {
|
||||
var saveErr error
|
||||
roleId, saveErr = s.saveTemplateRole(ctx, in)
|
||||
roleId, saveErr = s.saveTemplateRole(rolesync.DeferRecording(ctx), in)
|
||||
if saveErr != nil {
|
||||
return saveErr
|
||||
}
|
||||
@@ -153,24 +151,17 @@ func (s *sAgentRole) saveTemplateRole(ctx context.Context, in *model.AgentRoleSa
|
||||
in.DataScope = consts.SysRoleDataScopeDeptAndBelow
|
||||
}
|
||||
if !contexts.IsSuperAdmin(ctx) {
|
||||
allowed, err := s.filterMenuIdsByAllowed(ctx, in.DeptId, in.MenuIds)
|
||||
allowed, err := rolepolicy.NormalizeAssignableMenus(ctx, user.TenantId, in.DeptId, user.ID, in.MenuIds)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
for _, id := range in.MenuIds {
|
||||
if !slices.Contains(allowed, id) {
|
||||
return 0, gerror.New("角色包含超出本机构权限范围的菜单或按钮")
|
||||
}
|
||||
}
|
||||
in.MenuIds = allowed
|
||||
switch in.DataScope {
|
||||
case consts.SysRoleDataScopeDept, consts.SysRoleDataScopeDeptAndBelow, consts.SysRoleDataScopePersonal, consts.SysRoleDataScopeDeptAndBelowOrPersonal, consts.SysRoleDataScopeOrgAndNextLevel, consts.SysRoleDataScopeOrgNextLevel:
|
||||
default:
|
||||
return 0, gerror.New("代理角色数据权限不能超出本机构及下级范围")
|
||||
}
|
||||
}
|
||||
// 只有本次保存的目标角色是平台代理模板时才走模板语义;
|
||||
// 普通角色(如「运营人员」)不能被强制改写成 is_pass=1,
|
||||
// 否则任何已配置模板的机构都无法再保存第二个角色。
|
||||
template, templateErr := rolepolicy.TemplateForRoles(ctx, in.RoleId)
|
||||
if templateErr != nil {
|
||||
return 0, templateErr
|
||||
@@ -181,7 +172,6 @@ func (s *sAgentRole) saveTemplateRole(ctx context.Context, in *model.AgentRoleSa
|
||||
if template != nil {
|
||||
in.DataScope = consts.SysRoleDataScopeDeptAndBelow
|
||||
}
|
||||
// 未配置平台模板时,保留原有「本级唯一 is_pass=1 角色」下发流程。
|
||||
legacyPass := in.IsPass == 1
|
||||
if legacyPass {
|
||||
configured, err := dao.SysConfig.Ctx(ctx).Master().Where("tenant_id", consts.DefaultSystemTenantCode).Where("config_key", rolepolicy.TemplateConfigKey).Count()
|
||||
@@ -399,188 +389,3 @@ func (s *sAgentRole) inheritedRoleModel(ctx context.Context) *gdb.Model {
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
func (s *sAgentRole) SyncInheritedRole(ctx context.Context, message *model.AgentRoleSyncMessage) error {
|
||||
if message == nil || message.SourceRoleId <= 0 {
|
||||
return gerror.New("invalid agent role sync message")
|
||||
}
|
||||
template, templateErr := rolepolicy.Template(ctx)
|
||||
if templateErr != nil {
|
||||
return templateErr
|
||||
}
|
||||
if template != nil && message.SourceDeptId == template.DeptId && (message.TenantId == template.TenantId || message.TenantId == "") {
|
||||
return syncTemplateRoles(ctx, template, message.ActorId)
|
||||
}
|
||||
var source *entity.SysRole
|
||||
sourceModel := dao.SysRole.Ctx(ctx).Where(dao.SysRole.Columns().RoleId, message.SourceRoleId)
|
||||
if message.TenantId != "" {
|
||||
sourceModel = sourceModel.Where(dao.SysRole.Columns().TenantId, message.TenantId)
|
||||
}
|
||||
if err := sourceModel.Scan(&source); err != nil {
|
||||
return err
|
||||
}
|
||||
if source == nil || source.IsPass != 1 || source.DeletedAt != nil {
|
||||
return nil
|
||||
}
|
||||
if message.SourceDeptId > 0 && message.SourceDeptId != source.DeptId {
|
||||
return gerror.New("role sync source department changed")
|
||||
}
|
||||
var children []*entity.SysDept
|
||||
if err := dao.SysDept.Ctx(ctx).
|
||||
Where(dao.SysDept.Columns().TenantId, source.TenantId).
|
||||
Where(dao.SysDept.Columns().ParentId, source.DeptId).
|
||||
Where(dao.SysDept.Columns().DeptType, 1).
|
||||
Scan(&children); err != nil {
|
||||
return err
|
||||
}
|
||||
failed := 0
|
||||
var firstErr error
|
||||
for _, child := range children {
|
||||
err := g.DB().Transaction(ctx, func(ctx context.Context, tx gdb.TX) error {
|
||||
return s.syncInheritedRoleChildrenTx(ctx, tx, message.SourceRoleId, source.TenantId, source.DeptId, message.ActorId, child.DeptId)
|
||||
})
|
||||
if err != nil {
|
||||
failed++
|
||||
if firstErr == nil {
|
||||
firstErr = err
|
||||
}
|
||||
g.Log().Warningf(ctx, "agent role sync child failed, sourceRoleId:%d, childDeptId:%d, err:%v", source.RoleId, child.DeptId, err)
|
||||
}
|
||||
}
|
||||
if firstErr != nil {
|
||||
return gerror.Wrapf(firstErr, "agent role sync failed for %d of %d children", failed, len(children))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *sAgentRole) syncInheritedRoleChildrenTx(ctx context.Context, tx gdb.TX, sourceRoleID int64, tenantId string, sourceDeptId, actorId, childDeptId int64) error {
|
||||
if sourceRoleID == 0 {
|
||||
return nil
|
||||
}
|
||||
var source *entity.SysRole
|
||||
sourceModel := tx.Ctx(ctx).Model(dao.SysRole.Table()).Where(dao.SysRole.Columns().RoleId, sourceRoleID)
|
||||
if tenantId != "" {
|
||||
sourceModel = sourceModel.Where(dao.SysRole.Columns().TenantId, tenantId)
|
||||
}
|
||||
if !strings.EqualFold(g.DB().GetConfig().Type, "sqlite") {
|
||||
sourceModel = sourceModel.LockUpdate()
|
||||
}
|
||||
if err := sourceModel.Scan(&source); err != nil {
|
||||
return err
|
||||
}
|
||||
if source == nil || source.RoleId == 0 || source.DeletedAt != nil || source.IsPass != 1 {
|
||||
return nil
|
||||
}
|
||||
if sourceDeptId > 0 && source.DeptId != sourceDeptId {
|
||||
return gerror.New("role sync source department changed")
|
||||
}
|
||||
var childDepts []*entity.SysDept
|
||||
childDeptModel := tx.Ctx(ctx).Model(dao.SysDept.Table()).Where(dao.SysDept.Columns().TenantId, source.TenantId).Where(dao.SysDept.Columns().ParentId, source.DeptId).Where(dao.SysDept.Columns().DeptType, 1).Where(dao.SysDept.Columns().DeptId, childDeptId)
|
||||
if !strings.EqualFold(g.DB().GetConfig().Type, "sqlite") {
|
||||
childDeptModel = childDeptModel.LockUpdate()
|
||||
}
|
||||
if err := childDeptModel.Scan(&childDepts); err != nil {
|
||||
return err
|
||||
}
|
||||
var menus []*entity.SysRoleMenu
|
||||
if err := tx.Ctx(ctx).Model(dao.SysRoleMenu.Table()).Where(dao.SysRoleMenu.Columns().RoleId, sourceRoleID).OrderAsc(dao.SysRoleMenu.Columns().MenuId).Scan(&menus); err != nil {
|
||||
return err
|
||||
}
|
||||
menuIDs := make([]int64, 0, len(menus))
|
||||
for _, menu := range menus {
|
||||
menuIDs = append(menuIDs, menu.MenuId)
|
||||
}
|
||||
allowed, err := rolepolicy.RestrictMenus(ctx, source.TenantId, source.DeptId, menuIDs)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
template, templateErr := rolepolicy.Template(ctx)
|
||||
if templateErr != nil {
|
||||
return templateErr
|
||||
}
|
||||
filtered := make([]*entity.SysRoleMenu, 0, len(menus))
|
||||
for _, menu := range menus {
|
||||
if slices.Contains(allowed, menu.MenuId) {
|
||||
copy := *menu
|
||||
if template != nil {
|
||||
copy.DataScope = consts.SysRoleMenuDataScopeRole
|
||||
}
|
||||
filtered = append(filtered, ©)
|
||||
}
|
||||
}
|
||||
menus = filtered
|
||||
for _, childDept := range childDepts {
|
||||
var children []*entity.SysRole
|
||||
if err := tx.Ctx(ctx).Model(dao.SysRole.Table()+" r").
|
||||
Fields("r.*").
|
||||
Where("r.tenant_id", source.TenantId).
|
||||
Where("r.dept_id", childDept.DeptId).
|
||||
Where("r.created_dept", source.DeptId).
|
||||
Where("r.role_id", childDept.DeptRole).
|
||||
Where("r.deleted_at IS NULL").
|
||||
Scan(&children); err != nil {
|
||||
return err
|
||||
}
|
||||
if len(children) > 1 {
|
||||
return gerror.Newf("multiple inherited roles in department %d for role %d", childDept.DeptId, source.RoleId)
|
||||
}
|
||||
if len(children) == 0 {
|
||||
var legacy *entity.SysRole
|
||||
if childDept.DeptRole > 0 {
|
||||
if err := tx.Ctx(ctx).Model(dao.SysRole.Table()).
|
||||
Where(dao.SysRole.Columns().RoleId, childDept.DeptRole).
|
||||
Where(dao.SysRole.Columns().DeptId, childDept.DeptId).
|
||||
Where(dao.SysRole.Columns().TenantId, source.TenantId).
|
||||
Scan(&legacy); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if legacy != nil && legacy.RoleId > 0 {
|
||||
if legacy.CreatedDept != source.DeptId || legacy.RoleKey != source.RoleKey {
|
||||
return gerror.Newf("cannot identify inherited role for department %d", childDept.DeptId)
|
||||
}
|
||||
children = []*entity.SysRole{legacy}
|
||||
} else {
|
||||
if childDept.DeptRole > 0 {
|
||||
return gerror.Newf("missing default role for department %d", childDept.DeptId)
|
||||
}
|
||||
roleId, err := s.createInheritedRoleTx(ctx, tx, source, childDept.DeptId, menus, actorId)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
children = []*entity.SysRole{{RoleId: roleId}}
|
||||
}
|
||||
}
|
||||
for _, child := range children {
|
||||
if _, err := tx.Ctx(ctx).Model(dao.SysRole.Table()).Where(dao.SysRole.Columns().RoleId, child.RoleId).Data(do.SysRole{RoleName: strings.TrimSpace(source.RoleName), RoleKey: source.RoleKey, RoleSort: source.RoleSort, DataScope: source.DataScope, MenuCheckStrictly: source.MenuCheckStrictly, DeptCheckStrictly: source.DeptCheckStrictly, Status: source.Status, Remark: source.Remark, UpdatedBy: actorId, UpdatedAt: gtime.Now()}).OmitNil().Update(); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := tx.Ctx(ctx).Model(dao.SysRoleMenu.Table()).Where(dao.SysRoleMenu.Columns().RoleId, child.RoleId).Delete(); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, menu := range menus {
|
||||
if _, err := tx.Ctx(ctx).Model(dao.SysRoleMenu.Table()).Data(do.SysRoleMenu{TenantId: source.TenantId, RoleId: child.RoleId, MenuId: menu.MenuId, DataScope: menu.DataScope}).OmitNil().Insert(); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *sAgentRole) createInheritedRoleTx(ctx context.Context, tx gdb.TX, source *entity.SysRole, deptId int64, menus []*entity.SysRoleMenu, actorId int64) (int64, error) {
|
||||
if actorId == 0 {
|
||||
actorId = source.UpdatedBy
|
||||
}
|
||||
roleId, err := tx.Ctx(ctx).Model(dao.SysRole.Table()).Data(do.SysRole{TenantId: source.TenantId, DeptId: deptId, RoleName: strings.TrimSpace(source.RoleName), RoleKey: source.RoleKey, RoleSort: source.RoleSort, DataScope: source.DataScope, MenuCheckStrictly: source.MenuCheckStrictly, DeptCheckStrictly: source.DeptCheckStrictly, IsPass: 0, Status: source.Status, CreatedDept: source.DeptId, CreatedBy: actorId, CreatedAt: gtime.Now(), UpdatedBy: actorId, UpdatedAt: gtime.Now(), Remark: source.Remark}).OmitNil().InsertAndGetId()
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
for _, menu := range menus {
|
||||
if _, err = tx.Ctx(ctx).Model(dao.SysRoleMenu.Table()).Data(do.SysRoleMenu{TenantId: source.TenantId, RoleId: roleId, MenuId: menu.MenuId, DataScope: menu.DataScope}).OmitNil().Insert(); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
}
|
||||
_, err = tx.Ctx(ctx).Model(dao.SysRoleDept.Table()).Data(do.SysRoleDept{RoleId: roleId, DeptId: deptId}).OmitNil().Insert()
|
||||
return roleId, err
|
||||
}
|
||||
|
||||
@@ -1,83 +0,0 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"time"
|
||||
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/cmd/inithttp"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/consts"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/rabbitmq"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/model"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/service"
|
||||
localqueue "git.5iot.com/iotlcm-public/iotlcm-admin-svr/utility/queue"
|
||||
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
"github.com/gogf/gf/v2/os/gctx"
|
||||
)
|
||||
|
||||
type agentRoleSyncQueue struct{}
|
||||
|
||||
func init() {
|
||||
inithttp.RegisterHttpInitFunc("agentRoleSyncQueue", func(ctx context.Context) error {
|
||||
if rabbitmq.Enabled(ctx) {
|
||||
if err := rabbitmq.DeclareQueue(ctx, consts.QueueAgentRoleSyncDeadLetter); err != nil {
|
||||
return err
|
||||
}
|
||||
return rabbitmq.ConsumeJSON[model.AgentRoleSyncMessage](ctx, consts.QueueAgentRoleSync, "iotlcm-agent-role-sync", handleAgentRoleSyncMessage)
|
||||
}
|
||||
if err := localqueue.NewConsumer(gctx.New(), &agentRoleSyncQueue{}, &localqueue.Config{Workers: 2}); err != nil {
|
||||
return err
|
||||
}
|
||||
return localqueue.NewProducer(gctx.New(), localqueue.QueueTypeMemory, consts.QueueAgentRoleSync, &localqueue.Config{})
|
||||
})
|
||||
}
|
||||
|
||||
func publishAgentRoleSync(ctx context.Context, message *model.AgentRoleSyncMessage) error {
|
||||
if rabbitmq.Enabled(ctx) {
|
||||
return rabbitmq.Publish(ctx, consts.QueueAgentRoleSync, message)
|
||||
}
|
||||
return localqueue.Push(ctx, consts.QueueAgentRoleSync, message)
|
||||
}
|
||||
|
||||
func handleAgentRoleSyncMessage(ctx context.Context, message model.AgentRoleSyncMessage) error {
|
||||
if err := service.AgentRole().SyncInheritedRole(ctx, &message); err != nil {
|
||||
g.Log().Errorf(ctx, "agent role sync failed, sourceRoleId:%d, retry:%d, err:%v", message.SourceRoleId, message.RetryCount, err)
|
||||
if message.RetryCount < 3 {
|
||||
message.RetryCount++
|
||||
return rabbitmq.PublishDelayed(ctx, consts.QueueAgentRoleSyncRetry, consts.QueueAgentRoleSync, time.Duration(message.RetryCount)*time.Second, message)
|
||||
}
|
||||
deadLetter := model.AgentRoleSyncDeadLetterMessage{Message: message, Error: err.Error(), FailedAt: time.Now().Format(time.RFC3339)}
|
||||
if publishErr := rabbitmq.Publish(ctx, consts.QueueAgentRoleSyncDeadLetter, deadLetter); publishErr != nil {
|
||||
return publishErr
|
||||
}
|
||||
g.Log().Errorf(ctx, "agent role sync moved to dead-letter queue, sourceRoleId:%d, err:%v", message.SourceRoleId, err)
|
||||
return nil
|
||||
}
|
||||
g.Log().Infof(ctx, "agent role sync completed, sourceRoleId:%d", message.SourceRoleId)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (*agentRoleSyncQueue) GetTopic() string { return consts.QueueAgentRoleSync }
|
||||
|
||||
func (*agentRoleSyncQueue) GetType() localqueue.QueueType { return localqueue.QueueTypeMemory }
|
||||
|
||||
func (*agentRoleSyncQueue) Handle(ctx context.Context, payload localqueue.Payload) error {
|
||||
var message model.AgentRoleSyncMessage
|
||||
if err := json.Unmarshal(payload.Data, &message); err != nil {
|
||||
return err
|
||||
}
|
||||
var err error
|
||||
for attempt := 0; attempt <= 3; attempt++ {
|
||||
err = service.AgentRole().SyncInheritedRole(ctx, &message)
|
||||
if err == nil {
|
||||
g.Log().Infof(ctx, "agent role sync completed, sourceRoleId:%d", message.SourceRoleId)
|
||||
return nil
|
||||
}
|
||||
if attempt < 3 {
|
||||
time.Sleep(time.Duration(attempt+1) * time.Second)
|
||||
}
|
||||
}
|
||||
g.Log().Errorf(ctx, "agent role sync failed after retries, sourceRoleId:%d, err:%v", message.SourceRoleId, err)
|
||||
return err
|
||||
}
|
||||
@@ -9,9 +9,9 @@ import (
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
"github.com/gogf/gf/v2/os/gcfg"
|
||||
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/contexts"
|
||||
_ "git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/logic/memorydb"
|
||||
_ "git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/logic/system"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/contexts"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/model"
|
||||
)
|
||||
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/consts"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/contexts"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/rolepolicy"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/xgorm/handler"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/model"
|
||||
|
||||
_ "github.com/gogf/gf/contrib/drivers/sqlite/v2"
|
||||
"github.com/gogf/gf/v2/database/gdb"
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
)
|
||||
|
||||
func TestEnsureRoleNotInheritedAllowsSuperAdmin(t *testing.T) {
|
||||
ctx := contexts.Set(context.Background(), &model.Context{
|
||||
User: &model.Identity{BaseClaims: model.BaseClaims{
|
||||
TenantId: "000000",
|
||||
Roles: []model.Role{{RoleId: consts.SuperAdminRoleId}},
|
||||
}},
|
||||
})
|
||||
|
||||
if err := NewAgentRole().ensureRoleNotInherited(ctx, 100); err != nil {
|
||||
t.Fatalf("super admin should edit inherited roles, got error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCurrentSuperAdminChecksLiveRoleAssignment(t *testing.T) {
|
||||
setupAgentRoleSyncTestDB(t)
|
||||
ctx := context.Background()
|
||||
mustExecAgentRoleSyncTest(t, ctx, "INSERT INTO sys_role (role_id,tenant_id,dept_id,status) VALUES (1,'000000',1,'0')")
|
||||
mustExecAgentRoleSyncTest(t, ctx, "INSERT INTO sys_user_role (tenant_id,user_id,role_id) VALUES ('000000',99,1)")
|
||||
isAdmin, err := rolepolicy.CurrentSuperAdmin(ctx, 99)
|
||||
if err != nil || !isAdmin {
|
||||
t.Fatalf("expected active administrator: %v %v", isAdmin, err)
|
||||
}
|
||||
mustExecAgentRoleSyncTest(t, ctx, "DELETE FROM sys_user_role WHERE user_id=99")
|
||||
isAdmin, err = rolepolicy.CurrentSuperAdmin(ctx, 99)
|
||||
if err != nil || isAdmin {
|
||||
t.Fatalf("revoked administrator must lose access: %v %v", isAdmin, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNoActiveRolesCannotReadFilteredData(t *testing.T) {
|
||||
setupAgentRoleSyncTestDB(t)
|
||||
ctx := contexts.Set(context.Background(), &model.Context{User: &model.Identity{BaseClaims: model.BaseClaims{ID: 99, TenantId: "000000", DeptId: 2}}})
|
||||
mustExecAgentRoleSyncTest(t, ctx, "INSERT INTO sys_role (role_id,tenant_id,dept_id,status) VALUES (21,'000000',2,'0')")
|
||||
count, err := g.DB().Model("sys_role").Ctx(ctx).Handler(handler.FilterAuth).Count()
|
||||
if err != nil || count != 0 {
|
||||
t.Fatalf("user without roles must not read filtered data: %d %v", count, err)
|
||||
}
|
||||
}
|
||||
|
||||
func setupAgentRoleSyncTestDB(t *testing.T) {
|
||||
t.Helper()
|
||||
if err := gdb.SetConfig(gdb.Config{
|
||||
"default": gdb.ConfigGroup{
|
||||
{Type: "sqlite", Link: "sqlite::@file(:memory:)", Debug: false},
|
||||
},
|
||||
}); err != nil {
|
||||
t.Fatalf("configure sqlite failed: %v", err)
|
||||
}
|
||||
ctx := context.Background()
|
||||
t.Cleanup(func() { _ = g.DB().Close(ctx) })
|
||||
mustExecAgentRoleSyncTest(t, ctx, `CREATE TABLE sys_dept (dept_id INTEGER PRIMARY KEY, tenant_id TEXT, parent_id INTEGER, dept_type INTEGER, status TEXT DEFAULT '0', dept_role INTEGER, deleted_at DATETIME)`)
|
||||
mustExecAgentRoleSyncTest(t, ctx, `CREATE TABLE sys_config (config_id INTEGER PRIMARY KEY, tenant_id TEXT, config_name TEXT, config_key TEXT, config_value TEXT, config_type TEXT, created_dept INTEGER, created_by INTEGER, created_at DATETIME, updated_by INTEGER, updated_at DATETIME, remark TEXT)`)
|
||||
mustExecAgentRoleSyncTest(t, ctx, `CREATE TABLE sys_role (
|
||||
role_id INTEGER PRIMARY KEY AUTOINCREMENT, tenant_id TEXT, dept_id INTEGER, role_name TEXT, role_key TEXT, role_sort INTEGER, data_scope TEXT,
|
||||
menu_check_strictly INTEGER, dept_check_strictly INTEGER, status TEXT, is_pass INTEGER, created_dept INTEGER, created_by INTEGER, created_at DATETIME, updated_by INTEGER, updated_at DATETIME, deleted_by INTEGER, deleted_at DATETIME, remark TEXT)`)
|
||||
mustExecAgentRoleSyncTest(t, ctx, `CREATE TABLE sys_role_menu (tenant_id TEXT, role_id INTEGER, menu_id INTEGER, data_scope TEXT)`)
|
||||
mustExecAgentRoleSyncTest(t, ctx, `CREATE TABLE sys_role_dept (tenant_id TEXT, role_id INTEGER, dept_id INTEGER)`)
|
||||
mustExecAgentRoleSyncTest(t, ctx, `CREATE TABLE sys_user_role (tenant_id TEXT DEFAULT '000000', user_id INTEGER, role_id INTEGER)`)
|
||||
}
|
||||
|
||||
func mustExecAgentRoleSyncTest(t *testing.T, ctx context.Context, sql string) {
|
||||
t.Helper()
|
||||
if _, err := g.DB().Exec(ctx, sql); err != nil {
|
||||
t.Fatalf("execute sql failed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func assertAgentRoleSyncCount(t *testing.T, ctx context.Context, sql string, expected int) {
|
||||
t.Helper()
|
||||
count, err := g.DB().GetValue(ctx, sql)
|
||||
if err != nil {
|
||||
t.Fatalf("query count failed: %v", err)
|
||||
}
|
||||
if count.Int() != expected {
|
||||
t.Fatalf("count = %d, want %d, sql=%s", count.Int(), expected, sql)
|
||||
}
|
||||
}
|
||||
@@ -1,113 +0,0 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/consts"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/dao"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/event"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/rolepolicy"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/model/do"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/model/entity"
|
||||
"github.com/gogf/gf/v2/database/gdb"
|
||||
"github.com/gogf/gf/v2/errors/gerror"
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func syncTemplateRoles(ctx context.Context, template *entity.SysRole, actorID int64) error {
|
||||
queue := []int64{template.DeptId}
|
||||
seen := make(map[int64]bool)
|
||||
failed := 0
|
||||
var firstErr error
|
||||
for len(queue) > 0 {
|
||||
parentID := queue[0]
|
||||
queue = queue[1:]
|
||||
if seen[parentID] {
|
||||
return gerror.New("机构上下级存在循环")
|
||||
}
|
||||
seen[parentID] = true
|
||||
var children []*entity.SysDept
|
||||
if err := dao.SysDept.Ctx(ctx).Where("tenant_id", template.TenantId).Where("parent_id", parentID).Where("dept_type", 1).Scan(&children); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, child := range children {
|
||||
if child.DeptRole <= 0 {
|
||||
g.Log().Warningf(ctx, "agent template sync skipped missing default role, deptId:%d", child.DeptId)
|
||||
continue
|
||||
}
|
||||
err := g.DB().Transaction(ctx, func(ctx context.Context, tx gdb.TX) error {
|
||||
lock := tx.Ctx(ctx).Model(dao.SysDept.Table()).Where("dept_id", child.DeptId).Where("tenant_id", template.TenantId)
|
||||
if !strings.EqualFold(g.DB().GetConfig().Type, "sqlite") {
|
||||
lock = lock.LockUpdate()
|
||||
}
|
||||
var locked *entity.SysDept
|
||||
if err := lock.Scan(&locked); err != nil {
|
||||
return err
|
||||
}
|
||||
if locked == nil || locked.DeptRole != child.DeptRole {
|
||||
return gerror.New("下级机构角色绑定已变化")
|
||||
}
|
||||
var current *entity.SysRole
|
||||
if err := tx.Ctx(ctx).Model(dao.SysRole.Table()).Where("role_id", child.DeptRole).Where("tenant_id", template.TenantId).Where("dept_id", child.DeptId).Scan(¤t); err != nil {
|
||||
return err
|
||||
}
|
||||
if current == nil {
|
||||
g.Log().Warningf(ctx, "agent template sync skipped missing or deleted role, deptId:%d, roleId:%d", child.DeptId, child.DeptRole)
|
||||
return nil
|
||||
}
|
||||
configLock := tx.Ctx(ctx).Model(dao.SysConfig.Table()).Where("tenant_id", template.TenantId).Where("config_key", rolepolicy.TemplateConfigKey)
|
||||
if !strings.EqualFold(g.DB().GetConfig().Type, "sqlite") {
|
||||
configLock = configLock.LockUpdate()
|
||||
}
|
||||
var config *entity.SysConfig
|
||||
if err := configLock.Scan(&config); err != nil {
|
||||
return err
|
||||
}
|
||||
if config == nil || strings.TrimSpace(config.ConfigValue) != strconv.FormatInt(template.RoleId, 10) {
|
||||
return gerror.New("模板已切换,请重新同步")
|
||||
}
|
||||
templateLock := tx.Ctx(ctx).Model(dao.SysRole.Table()).Where("role_id", template.RoleId)
|
||||
if !strings.EqualFold(g.DB().GetConfig().Type, "sqlite") {
|
||||
templateLock = templateLock.LockUpdate()
|
||||
}
|
||||
var latest *entity.SysRole
|
||||
if err := templateLock.Scan(&latest); err != nil {
|
||||
return err
|
||||
}
|
||||
if latest == nil || latest.Status != "0" {
|
||||
return gerror.New("模板不可用")
|
||||
}
|
||||
var menus []*entity.SysRoleMenu
|
||||
if err := tx.Ctx(ctx).Model(dao.SysRoleMenu.Table()).Where("tenant_id", template.TenantId).Where("role_id", template.RoleId).Scan(&menus); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := tx.Ctx(ctx).Model(dao.SysRole.Table()).Where("role_id", current.RoleId).Data(do.SysRole{CreatedDept: template.DeptId, DataScope: consts.SysRoleDataScopeDeptAndBelow, UpdatedBy: actorID}).Update(); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := tx.Ctx(ctx).Model(dao.SysRoleMenu.Table()).Where("role_id", current.RoleId).Delete(); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, menu := range menus {
|
||||
if _, err := tx.Ctx(ctx).Model(dao.SysRoleMenu.Table()).Data(do.SysRoleMenu{TenantId: template.TenantId, RoleId: current.RoleId, MenuId: menu.MenuId, DataScope: consts.SysRoleMenuDataScopeRole}).Insert(); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
failed++
|
||||
if firstErr == nil {
|
||||
firstErr = err
|
||||
}
|
||||
g.Log().Errorf(ctx, "agent template sync child failed, deptId:%d, err:%v", child.DeptId, err)
|
||||
continue
|
||||
}
|
||||
event.EventsInstance().Emit(ctx, consts.EventKeyDBSysRoleUpdate, child.DeptRole)
|
||||
}
|
||||
}
|
||||
if firstErr != nil {
|
||||
return gerror.Wrapf(firstErr, "代理模板同步失败企业数:%d", failed)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"slices"
|
||||
"testing"
|
||||
|
||||
"github.com/gogf/gf/v2/database/gdb"
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/contexts"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/xgorm/handler"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/model"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/service"
|
||||
)
|
||||
|
||||
type templateRoleService struct{ service.ISysRole }
|
||||
|
||||
func (templateRoleService) ModelQuery(ctx context.Context, _ ...*handler.Option) *gdb.Model {
|
||||
return g.DB().Model("sys_role").Ctx(ctx).Handler(handler.FilterTenant)
|
||||
}
|
||||
|
||||
func TestConfiguredTemplateUsesInheritedDefaultAtEveryLevel(t *testing.T) {
|
||||
setupAgentRoleSyncTestDB(t)
|
||||
service.RegisterSysDept(paginationDeptService{})
|
||||
service.RegisterSysRole(templateRoleService{})
|
||||
t.Cleanup(func() {
|
||||
service.RegisterSysDept(nil)
|
||||
service.RegisterSysRole(nil)
|
||||
})
|
||||
ctx := contexts.Set(context.Background(), &model.Context{User: &model.Identity{BaseClaims: model.BaseClaims{ID: 99, TenantId: "000000", DeptId: 2}}})
|
||||
for _, statement := range []string{
|
||||
"ALTER TABLE sys_dept ADD COLUMN dept_name TEXT",
|
||||
"INSERT INTO sys_dept(dept_id,tenant_id,parent_id,dept_type,dept_role) VALUES(1,'000000',0,1,0),(2,'000000',1,1,21),(3,'000000',2,1,31)",
|
||||
"INSERT INTO sys_role(role_id,tenant_id,dept_id,status,is_pass,created_dept,data_scope) VALUES(10,'000000',1,'0',0,1,'4'),(21,'000000',2,'0',0,1,'4'),(31,'000000',3,'0',0,2,'4')",
|
||||
"INSERT INTO sys_config(tenant_id,config_key,config_value) VALUES('000000','agent.role.template.role_id','10')",
|
||||
"INSERT INTO sys_role_menu(tenant_id,role_id,menu_id,data_scope) VALUES('000000',10,100,'0'),('000000',10,200,'0'),('000000',21,100,'0'),('000000',21,300,'0'),('000000',31,100,'0')",
|
||||
} {
|
||||
mustExecAgentRoleSyncTest(t, ctx, statement)
|
||||
}
|
||||
for _, deptID := range []int64{1, 2, 3} {
|
||||
source, menus, err := NewAgentDept().passRoleByDept(ctx, deptID)
|
||||
if err != nil || source == nil {
|
||||
t.Fatalf("source for department %d: %+v %v", deptID, source, err)
|
||||
}
|
||||
expected := map[int64][]int64{1: {100, 200}, 2: {100, 300}, 3: {100}}[deptID]
|
||||
got := make([]int64, 0, len(menus))
|
||||
for _, menu := range menus {
|
||||
got = append(got, menu.MenuId)
|
||||
}
|
||||
slices.Sort(got)
|
||||
if !slices.Equal(got, expected) {
|
||||
t.Fatalf("department %d must copy its current default role including platform expansion: %v, want %v", deptID, got, expected)
|
||||
}
|
||||
}
|
||||
mustExecAgentRoleSyncTest(t, ctx, "UPDATE sys_dept SET dept_role=0 WHERE dept_id=2")
|
||||
if _, _, err := NewAgentDept().passRoleByDept(ctx, 2); err == nil {
|
||||
t.Fatal("missing default role must not fall back to full platform permissions")
|
||||
}
|
||||
if err := NewAgentDept().EnsureCurrentDeptDefaultRole(ctx); err == nil {
|
||||
t.Fatal("read request must report missing default role")
|
||||
}
|
||||
assertAgentRoleSyncCount(t, ctx, "SELECT COUNT(*) FROM sys_role", 3)
|
||||
assertAgentRoleSyncCount(t, ctx, "SELECT COUNT(*) FROM sys_user_role", 0)
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
// Package iot
|
||||
// @Link https://github.com/xiujiecn/xiu-admin
|
||||
// @Copyright Copyright (c) 2026 LiXiujie
|
||||
// @Author Lxj <li@xiujie.cn>
|
||||
// @Author Lxj <xxx@example.com>
|
||||
// @License https://github.com/xiujiecn/xiu-admin/blob/master/LICENSE
|
||||
// @AutoGenerate Version
|
||||
package agent
|
||||
|
||||
@@ -1,29 +1,26 @@
|
||||
// Package iot
|
||||
// @Link https://github.com/xiujiecn/xiu-admin
|
||||
// @Copyright Copyright (c) 2026 LiXiujie
|
||||
// @Author Lxj <li@xiujie.cn>
|
||||
// @Author Lxj <xxx@example.com>
|
||||
// @License https://github.com/xiujiecn/xiu-admin/blob/master/LICENSE
|
||||
// @AutoGenerate Version
|
||||
package agent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/dao"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/contexts"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/exportcenter"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/xgorm/handler"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/xgorm/hook"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/model"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/service"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/utility/convert"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/utility/excel"
|
||||
|
||||
"github.com/gogf/gf/v2/database/gdb"
|
||||
"github.com/gogf/gf/v2/errors/gerror"
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
"github.com/gogf/gf/v2/os/gctx"
|
||||
"github.com/gogf/gf/v2/os/gtime"
|
||||
"github.com/gogf/gf/v2/util/gconv"
|
||||
)
|
||||
|
||||
type sAgentTApi struct{}
|
||||
@@ -86,31 +83,32 @@ func (s *sAgentTApi) List(ctx context.Context, in *model.TApiListParam) (list []
|
||||
return
|
||||
}
|
||||
|
||||
// Export 导出API集成
|
||||
// Export 导出API集成(异步:提交任务后到下载中心取文件)
|
||||
func (s *sAgentTApi) Export(ctx context.Context, in *model.TApiListParam) (err error) {
|
||||
list, totalCount, err := s.List(ctx, in)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
// 字段的排序是依据tags的字段顺序,如果你不想使用默认的排序方式,可以直接定义 tags = []string{"字段名称", "字段名称2", ...}
|
||||
tags, err := convert.GetEntityDescTags(model.TApiExportModel{})
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
var (
|
||||
fileName = "导出API集成-" + gctx.CtxId(ctx)
|
||||
sheetName = fmt.Sprintf("索引条件共%v行,共%v页,当前导出是第%v页,本页共%v行", totalCount, (totalCount+in.PageSize-1)/in.PageSize, in.Page, len(list))
|
||||
exports []model.TApiExportModel
|
||||
)
|
||||
|
||||
if err = gconv.Scan(list, &exports); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
err = excel.ExportByStructs(ctx, tags, exports, fileName, sheetName)
|
||||
return
|
||||
_, err = exportcenter.Submit(ctx, &exportcenter.Request{
|
||||
BizKey: "tApi",
|
||||
BizName: "API集成",
|
||||
PermCode: "cpm:agent:tApi:export",
|
||||
QueryParam: in,
|
||||
Tags: func() ([]string, error) {
|
||||
return convert.GetEntityDescTags(model.TApiListModel{})
|
||||
},
|
||||
Fetch: func(ctx context.Context, page, pageSize int) (rows []interface{}, total int, err error) {
|
||||
query := *in
|
||||
query.Page = page
|
||||
query.PageSize = pageSize
|
||||
list, count, err := s.List(ctx, &query)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
rows = make([]interface{}, 0, len(list))
|
||||
for _, item := range list {
|
||||
rows = append(rows, item)
|
||||
}
|
||||
return rows, count, nil
|
||||
},
|
||||
})
|
||||
return err
|
||||
}
|
||||
|
||||
// Edit 修改/新增API集成
|
||||
|
||||
@@ -2,21 +2,19 @@ package agent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/dao"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/contexts"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/exportcenter"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/xgorm"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/xgorm/handler"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/xgorm/hook"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/model"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/service"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/utility/convert"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/utility/excel"
|
||||
|
||||
"github.com/gogf/gf/v2/database/gdb"
|
||||
"github.com/gogf/gf/v2/errors/gerror"
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
"github.com/gogf/gf/v2/os/gctx"
|
||||
"github.com/gogf/gf/v2/os/gtime"
|
||||
"github.com/gogf/gf/v2/util/gconv"
|
||||
)
|
||||
@@ -71,31 +69,32 @@ func (s *sAgentTApiGroup) List(ctx context.Context, in *model.TApiGroupListParam
|
||||
return
|
||||
}
|
||||
|
||||
// Export 导出开放接口权限组列表。
|
||||
// Export 导出开放接口权限组(异步:提交任务后到下载中心取文件)
|
||||
func (s *sAgentTApiGroup) Export(ctx context.Context, in *model.TApiGroupListParam) (err error) {
|
||||
list, totalCount, err := s.List(ctx, in)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
tags, err := convert.GetEntityDescTags(model.TApiGroupExportModel{})
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
fileName := "导出开放接口权限组-" + gctx.CtxId(ctx)
|
||||
sheetName := fmt.Sprintf(
|
||||
"搜索条件共%v条 共%v页 当前导出第%v页 本页共%v条",
|
||||
totalCount,
|
||||
(totalCount+in.PageSize-1)/in.PageSize,
|
||||
in.Page,
|
||||
len(list),
|
||||
)
|
||||
var exports []model.TApiGroupExportModel
|
||||
if err = gconv.Scan(list, &exports); err != nil {
|
||||
return
|
||||
}
|
||||
return excel.ExportByStructs(ctx, tags, exports, fileName, sheetName)
|
||||
_, err = exportcenter.Submit(ctx, &exportcenter.Request{
|
||||
BizKey: "tApiGroup",
|
||||
BizName: "开放接口权限组",
|
||||
PermCode: "cpm:agent:tApiGroup:export",
|
||||
QueryParam: in,
|
||||
Tags: func() ([]string, error) {
|
||||
return convert.GetEntityDescTags(model.TApiGroupListModel{})
|
||||
},
|
||||
Fetch: func(ctx context.Context, page, pageSize int) (rows []interface{}, total int, err error) {
|
||||
query := *in
|
||||
query.Page = page
|
||||
query.PageSize = pageSize
|
||||
list, count, err := s.List(ctx, &query)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
rows = make([]interface{}, 0, len(list))
|
||||
for _, item := range list {
|
||||
rows = append(rows, item)
|
||||
}
|
||||
return rows, count, nil
|
||||
},
|
||||
})
|
||||
return err
|
||||
}
|
||||
|
||||
// Edit 新增或修改开放接口权限组,并同步维护权限组与接口资源的关联。
|
||||
|
||||
@@ -2,22 +2,20 @@ package agent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/dao"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/contexts"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/exportcenter"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/xgorm"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/xgorm/handler"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/xgorm/hook"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/model"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/service"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/utility/convert"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/utility/excel"
|
||||
|
||||
"github.com/gogf/gf/v2/database/gdb"
|
||||
"github.com/gogf/gf/v2/errors/gerror"
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
"github.com/gogf/gf/v2/os/gctx"
|
||||
"github.com/gogf/gf/v2/os/gtime"
|
||||
"github.com/gogf/gf/v2/text/gstr"
|
||||
"github.com/gogf/gf/v2/util/gconv"
|
||||
@@ -120,30 +118,32 @@ func (s *sAgentTApiResource) List(ctx context.Context, in *model.TApiResourceLis
|
||||
return
|
||||
}
|
||||
|
||||
// Export 导出开放接口资源列表。
|
||||
// Export 导出开放接口资源(异步:提交任务后到下载中心取文件)
|
||||
func (s *sAgentTApiResource) Export(ctx context.Context, in *model.TApiResourceListParam) (err error) {
|
||||
list, totalCount, err := s.List(ctx, in)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
tags, err := convert.GetEntityDescTags(model.TApiResourceExportModel{})
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
fileName := "export-open-api-resource-" + gctx.CtxId(ctx)
|
||||
totalPage := 0
|
||||
if in.PageSize > 0 {
|
||||
totalPage = (totalCount + in.PageSize - 1) / in.PageSize
|
||||
}
|
||||
sheetName := fmt.Sprintf("total %v pages %v current %v count %v", totalCount, totalPage, in.Page, len(list))
|
||||
|
||||
var exports []*model.TApiResourceExportModel
|
||||
if err = gconv.Scan(list, &exports); err != nil {
|
||||
return
|
||||
}
|
||||
return excel.ExportByStructs(ctx, tags, exports, fileName, sheetName)
|
||||
_, err = exportcenter.Submit(ctx, &exportcenter.Request{
|
||||
BizKey: "tApiResource",
|
||||
BizName: "开放接口资源",
|
||||
PermCode: "cpm:agent:tApiResource:export",
|
||||
QueryParam: in,
|
||||
Tags: func() ([]string, error) {
|
||||
return convert.GetEntityDescTags(model.TApiResourceListModel{})
|
||||
},
|
||||
Fetch: func(ctx context.Context, page, pageSize int) (rows []interface{}, total int, err error) {
|
||||
query := *in
|
||||
query.Page = page
|
||||
query.PageSize = pageSize
|
||||
list, count, err := s.List(ctx, &query)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
rows = make([]interface{}, 0, len(list))
|
||||
for _, item := range list {
|
||||
rows = append(rows, item)
|
||||
}
|
||||
return rows, count, nil
|
||||
},
|
||||
})
|
||||
return err
|
||||
}
|
||||
|
||||
// Edit 新增或修改开放接口资源。
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// Package iot
|
||||
// @Link https://github.com/xiujiecn/xiu-admin
|
||||
// @Copyright Copyright (c) 2026 LiXiujie
|
||||
// @Author Lxj <li@xiujie.cn>
|
||||
// @Author Lxj <xxx@example.com>
|
||||
// @License https://github.com/xiujiecn/xiu-admin/blob/master/LICENSE
|
||||
// @AutoGenerate Version
|
||||
package agent
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
package common
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"testing"
|
||||
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/consts"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/model"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/model/request"
|
||||
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
)
|
||||
|
||||
func TestBatchCardsFilterStatus(t *testing.T) {
|
||||
ctx := setupBatchExecutionLogListDB(t)
|
||||
for _, statement := range []string{
|
||||
"ALTER TABLE batch_main ADD target_dept_id INTEGER DEFAULT 0",
|
||||
"ALTER TABLE batch_main ADD dept_id INTEGER DEFAULT 0",
|
||||
"ALTER TABLE batch_main ADD updated_by INTEGER DEFAULT 0",
|
||||
"ALTER TABLE batch_main ADD batch_async_status INTEGER DEFAULT 3",
|
||||
} {
|
||||
if _, err := g.DB().Exec(ctx, statement); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
for _, batchType := range []int{consts.BatchTypeInbound, consts.BatchTypeOutbound, consts.BatchTypeAllocate} {
|
||||
batchNo := fmt.Sprintf("STATUS-%d", batchType)
|
||||
if _, err := g.DB().Exec(ctx, "INSERT INTO batch_main(batch_no,batch_type,batch_info) VALUES(?,?,'{}')", batchNo, batchType); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, status := range []int{0, 1, 2, 3, 4, 5} {
|
||||
if _, err := g.DB().Exec(ctx, "INSERT INTO batch_detail(batch_no,batch_type,detail_status,row_no,item_id,error_reason,snapshot) VALUES(?,?,?,?,?,?,'{}')", batchNo, batchType, status, status+1, fmt.Sprintf("card-%d", status), fmt.Sprintf("reason-%d", status)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
for _, status := range []int{0, 1, 2, 3, 4, 5} {
|
||||
rows, total, err := NewCommonBatchMain().ListCards(ctx, &model.BatchCardListParam{BatchNo: batchNo, DetailStatus: &status, PageInfo: request.PageInfo{Page: 1, PageSize: 20}})
|
||||
if err != nil || total != 1 || len(rows) != 1 {
|
||||
t.Fatalf("type=%d status=%d total=%d err=%v", batchType, status, total, err)
|
||||
}
|
||||
if rows[0].DetailStatus != status || rows[0].ErrorReason != fmt.Sprintf("reason-%d", status) {
|
||||
t.Fatalf("incorrect detail: %+v", rows[0])
|
||||
}
|
||||
}
|
||||
for page, wantStatus := range []int{3, 0, 1, 2, 4, 5} {
|
||||
rows, total, err := NewCommonBatchMain().ListCards(ctx, &model.BatchCardListParam{BatchNo: batchNo, PageInfo: request.PageInfo{Page: page + 1, PageSize: 1}})
|
||||
if err != nil || total != 6 || len(rows) != 1 || rows[0].DetailStatus != wantStatus {
|
||||
t.Fatalf("type=%d page=%d status=%d total=%d err=%v", batchType, page+1, wantStatus, total, err)
|
||||
}
|
||||
}
|
||||
if _, err := g.DB().Exec(ctx, "INSERT INTO batch_detail(batch_no,batch_type,detail_status,row_no,item_id,error_reason,snapshot) VALUES(?,?,?,?,?,?,'{}')", batchNo, batchType, consts.BatchDetailStatusFailed, 0, "cascade", "本批次存在失败明细,整批未执行"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rows, total, err := NewCommonBatchMain().ListCards(ctx, &model.BatchCardListParam{BatchNo: batchNo, PageInfo: request.PageInfo{Page: 1, PageSize: 1}})
|
||||
if err != nil || total != 7 || len(rows) != 1 || rows[0].Iccid != "card-3" {
|
||||
t.Fatalf("type=%d: real failure should precede legacy cascade: rows=%+v total=%d err=%v", batchType, rows, total, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,8 @@ package common
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
@@ -11,6 +13,7 @@ import (
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/dao"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/allocationunits"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/contexts"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/rabbitmq"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/xgorm/handler"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/xgorm/hook"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/model"
|
||||
@@ -20,6 +23,7 @@ import (
|
||||
"github.com/gogf/gf/v2/database/gdb"
|
||||
"github.com/gogf/gf/v2/encoding/gjson"
|
||||
"github.com/gogf/gf/v2/errors/gerror"
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
"github.com/gogf/gf/v2/os/gtime"
|
||||
)
|
||||
|
||||
@@ -94,6 +98,17 @@ func (s *sCommonBatchExecutionLog) ossModel(ctx context.Context, option ...*hand
|
||||
return handler.Model(dao.SysOss.Ctx(ctx), option...)
|
||||
}
|
||||
|
||||
// exportTaskModel 导出任务表;导出批次的结果文件只登记在这里。
|
||||
func (s *sCommonBatchExecutionLog) exportTaskModel(ctx context.Context, option ...*handler.Option) *gdb.Model {
|
||||
if len(option) == 0 {
|
||||
option = append(option, &handler.Option{
|
||||
FilterTenant: true,
|
||||
FilterAuth: false,
|
||||
})
|
||||
}
|
||||
return handler.Model(dao.TExportTask.Ctx(ctx), option...)
|
||||
}
|
||||
|
||||
func (s *sCommonBatchExecutionLog) List(ctx context.Context, in *model.BatchExecutionLogListParam) (list []*model.BatchExecutionLogListModel, totalCount int, err error) {
|
||||
mod := s.batchModel(ctx).
|
||||
Fields(model.BatchExecutionLogListModel{})
|
||||
@@ -142,6 +157,8 @@ func (s *sCommonBatchExecutionLog) List(ctx context.Context, in *model.BatchExec
|
||||
}
|
||||
fields := []*allocationunits.Fields{}
|
||||
for _, item := range list {
|
||||
item.UnknownCount = batchExecutionLogUnknownCount(item.UnitInfo)
|
||||
item.RestartPhase, item.RestartResumeAt = batchRestartPhase(item.UnitInfo)
|
||||
if item.BatchType == consts.BatchTypeAllocate {
|
||||
item.Fields = allocationunits.Resolve(item.UnitInfo, item.CreatedDept, item.StoredTargetDeptId)
|
||||
fields = append(fields, &item.Fields)
|
||||
@@ -187,9 +204,11 @@ func (s *sCommonBatchExecutionLog) Summary(ctx context.Context, in *model.BatchE
|
||||
RunResult: batch.RunResult,
|
||||
FailReason: batch.FailReason,
|
||||
TotalCount: batch.TotalCount,
|
||||
UnknownCount: batchExecutionLogUnknownCount(batch.BatchInfo),
|
||||
StartTime: batch.StartTime,
|
||||
EndTime: batch.EndTime,
|
||||
}
|
||||
res.RestartPhase, res.RestartResumeAt = batchRestartPhase(batch.BatchInfo)
|
||||
|
||||
if batch.BatchType == consts.BatchTypeAllocate {
|
||||
res.Fields = allocationunits.Resolve(batch.BatchInfo, batch.CreatedDept, batch.TargetDeptId)
|
||||
@@ -204,11 +223,17 @@ func (s *sCommonBatchExecutionLog) Summary(ctx context.Context, in *model.BatchE
|
||||
res.SourceFile = sourceFile
|
||||
}
|
||||
|
||||
if resultFile, err := s.getResultFile(ctx, batch.BatchNo, batch.ResultOssId, batch.BatchInfo); err != nil {
|
||||
return nil, err
|
||||
// 导出任务的成品文件落在 t_export_task,不写 t_resource_batch_download,须分开取。
|
||||
var resultFile *model.BatchExecutionLogResultFileModel
|
||||
if batch.BatchType == consts.BatchTypeExport {
|
||||
resultFile, err = s.getExportResultFile(ctx, batch.BatchNo, batch.ResultOssId)
|
||||
} else {
|
||||
res.ResultFile = resultFile
|
||||
resultFile, err = s.getResultFile(ctx, batch.BatchNo, batch.ResultOssId, batch.BatchInfo)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
res.ResultFile = resultFile
|
||||
|
||||
return res, nil
|
||||
}
|
||||
@@ -263,7 +288,7 @@ func (s *sCommonBatchExecutionLog) DetailList(ctx context.Context, in *model.Bat
|
||||
}
|
||||
|
||||
var details []*entity.BatchDetail
|
||||
mod = mod.Page(in.Page, in.PageSize).OrderAsc(columns.RowNo)
|
||||
mod = orderBatchCards(mod.Page(in.Page, in.PageSize), batch.BatchType)
|
||||
if err = mod.ScanAndCount(&details, &totalCount, false); err != nil {
|
||||
return nil, 0, gerror.Wrap(err, "获取批次执行明细列表失败,请稍后重试")
|
||||
}
|
||||
@@ -328,6 +353,65 @@ func (s *sCommonBatchExecutionLog) DetailList(ctx context.Context, in *model.Bat
|
||||
return
|
||||
}
|
||||
|
||||
// dataReviewQueryMessage 通知 Worker 对既有 operationId 做一次只读补查。
|
||||
type dataReviewQueryMessage struct {
|
||||
BatchNo string `json:"batchNo"`
|
||||
RequestedBy string `json:"requestedBy,omitempty"`
|
||||
}
|
||||
|
||||
var publishDataReviewQuery = rabbitmq.Publish
|
||||
|
||||
// SyncReview 手动同步待核查批次的上游执行结果。
|
||||
// 只重查既有受理号(jobId),不重发办理请求、不清零自动补查计数;
|
||||
// 上游仍无结论的明细继续保持待核查。
|
||||
func (s *sCommonBatchExecutionLog) SyncReview(ctx context.Context, in *model.BatchExecutionLogSyncReviewParam) (res *model.BatchExecutionLogSyncReviewModel, err error) {
|
||||
if in == nil {
|
||||
in = &model.BatchExecutionLogSyncReviewParam{}
|
||||
}
|
||||
batchNo := strings.TrimSpace(in.BatchNo)
|
||||
reason := strings.TrimSpace(in.Reason)
|
||||
if batchNo == "" {
|
||||
return nil, gerror.New("批次编号不能为空")
|
||||
}
|
||||
|
||||
columns := dao.BatchMain.Columns()
|
||||
batch := &entity.BatchMain{}
|
||||
// 沿用执行日志列表的可见性口径(部门权限过滤)。
|
||||
if err = s.batchModel(ctx).
|
||||
Fields(columns.Id, columns.BatchNo, columns.BatchType, columns.BatchAsyncStatus, columns.RunResult, columns.BatchInfo).
|
||||
Where(columns.BatchNo, batchNo).
|
||||
Scan(batch); err != nil {
|
||||
return nil, gerror.Wrap(err, "查询批次信息失败")
|
||||
}
|
||||
if batch.Id == 0 {
|
||||
return nil, gerror.New("批次不存在或无权限")
|
||||
}
|
||||
if batch.BatchAsyncStatus != consts.BatchAsyncStatusReview {
|
||||
return nil, gerror.New("仅待核查批次支持手动同步上游结果")
|
||||
}
|
||||
unknownCount := batchExecutionLogUnknownCount(batch.BatchInfo)
|
||||
if unknownCount <= 0 {
|
||||
return nil, gerror.New("该批次没有待核查明细")
|
||||
}
|
||||
|
||||
message := dataReviewQueryMessage{BatchNo: batch.BatchNo, RequestedBy: contexts.GetUserName(ctx)}
|
||||
if err = publishDataReviewQuery(ctx, consts.QueueDataReviewQuery, message); err != nil {
|
||||
g.Log().Errorf(ctx, "手动同步上游结果消息发布失败, batchNo=%s, tenantId=%s, userId=%d, reason=%s, err=%+v",
|
||||
batch.BatchNo, contexts.GetTenantId(ctx), contexts.GetUserId(ctx), reason, err)
|
||||
return nil, gerror.Wrap(err, "发布上游结果同步消息失败")
|
||||
}
|
||||
g.Log().Infof(ctx, "手动同步上游结果消息已发布, batchNo=%s, batchType=%d, unknownCount=%d, tenantId=%s, userId=%d, reason=%s",
|
||||
batch.BatchNo, batch.BatchType, unknownCount, contexts.GetTenantId(ctx), contexts.GetUserId(ctx), reason)
|
||||
|
||||
return &model.BatchExecutionLogSyncReviewModel{
|
||||
BatchNo: batch.BatchNo,
|
||||
BatchType: batch.BatchType,
|
||||
BatchAsyncStatus: batch.BatchAsyncStatus,
|
||||
UnknownCount: unknownCount,
|
||||
Queued: true,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *sCommonBatchExecutionLog) getDetailBatch(ctx context.Context, batchNo string, batchType *int) (*entity.BatchMain, error) {
|
||||
if batchNo == "" {
|
||||
return nil, gerror.New("批次号不能为空")
|
||||
@@ -471,11 +555,13 @@ func (s *sCommonBatchExecutionLog) getSourceFile(ctx context.Context, sourceOssI
|
||||
|
||||
func (s *sCommonBatchExecutionLog) getResultFile(ctx context.Context, batchNo string, resultOssId int64, batchInfo *gjson.Json) (*model.BatchExecutionLogResultFileModel, error) {
|
||||
download := &entity.TResourceBatchDownload{}
|
||||
// 出库/划拨/导出等批次不写 t_resource_batch_download,查不到记录按「无结果文件」处理,
|
||||
// 不能当异常抛给页面(否则详情弹窗整体报 sql: no rows in result set)。
|
||||
if err := s.downloadModel(ctx).
|
||||
Where(dao.TResourceBatchDownload.Columns().BatchNo, batchNo).
|
||||
OrderDesc(dao.TResourceBatchDownload.Columns().Id).
|
||||
Limit(1).
|
||||
Scan(download); err != nil {
|
||||
Scan(download); err != nil && !errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, gerror.Wrap(err, "获取结果文件信息失败,请稍后重试!")
|
||||
}
|
||||
ossId := resultOssId
|
||||
@@ -490,14 +576,16 @@ func (s *sCommonBatchExecutionLog) getResultFile(ctx context.Context, batchNo st
|
||||
}
|
||||
|
||||
url := batchExecutionLogFileURL(ctx, download.FileUrl, download.FilePath)
|
||||
originalName := ""
|
||||
if ossId > 0 {
|
||||
oss := &entity.SysOss{}
|
||||
if err := s.ossModel(ctx).
|
||||
if scanErr := s.ossModel(ctx).
|
||||
Where(dao.SysOss.Columns().OssId, ossId).
|
||||
Scan(oss); err != nil {
|
||||
return nil, gerror.Wrap(err, "获取结果文件OSS信息失败,请稍后重试!")
|
||||
Scan(oss); scanErr != nil && !errors.Is(scanErr, sql.ErrNoRows) {
|
||||
return nil, gerror.Wrap(scanErr, "获取结果文件OSS信息失败,请稍后重试!")
|
||||
}
|
||||
if oss.OssId > 0 {
|
||||
originalName = oss.OriginalName
|
||||
if ossUrl := batchExecutionLogFileURL(ctx, oss.Url, oss.Path); ossUrl != "" {
|
||||
url = ossUrl
|
||||
}
|
||||
@@ -508,10 +596,49 @@ func (s *sCommonBatchExecutionLog) getResultFile(ctx context.Context, batchNo st
|
||||
DownloadId: download.Id,
|
||||
OssId: ossId,
|
||||
Url: url,
|
||||
OriginalName: originalName,
|
||||
DownloadStatus: resultDownloadStatus(download),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// getExportResultFile 只暴露任务 ID;文件必须经下载中心重新校验本人、权限和有效期。
|
||||
func (s *sCommonBatchExecutionLog) getExportResultFile(ctx context.Context, taskNo string, _ int64) (*model.BatchExecutionLogResultFileModel, error) {
|
||||
task := &entity.TExportTask{}
|
||||
if err := s.exportTaskModel(ctx).
|
||||
Where(dao.TExportTask.Columns().TaskNo, taskNo).
|
||||
Where(dao.TExportTask.Columns().OwnerUserId, contexts.GetUserId(ctx)).
|
||||
Scan(task); err != nil && !errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, gerror.Wrap(err, "获取结果文件信息失败,请稍后重试!")
|
||||
}
|
||||
if task.Id <= 0 || task.DeletedTime != nil || task.Status != consts.DownloadStatusGenerated || task.OssId <= 0 ||
|
||||
(task.ExpireTime != nil && task.ExpireTime.Before(gtime.Now())) || !hasExportPermission(ctx, task.PermCode) {
|
||||
return nil, nil
|
||||
}
|
||||
return &model.BatchExecutionLogResultFileModel{
|
||||
ExportTaskId: task.Id,
|
||||
OriginalName: task.FileName,
|
||||
DownloadStatus: consts.DownloadStatusGenerated,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func batchExecutionLogUnknownCount(batchInfo *gjson.Json) int {
|
||||
if batchInfo == nil {
|
||||
return 0
|
||||
}
|
||||
return batchInfo.Get("apnUnknownCount").Int()
|
||||
}
|
||||
|
||||
// batchRestartPhase 从 batch_info 取出重启阶段与计划复机时间。
|
||||
// 重启批次停机完成后要等待若干分钟再复机,这段时间主状态仍是处理中,
|
||||
// 明细却已落终态;前端据此显示「等待复机」,避免误以为卡住或已办结。
|
||||
func batchRestartPhase(batchInfo *gjson.Json) (phase, resumeAt string) {
|
||||
if batchInfo == nil {
|
||||
return "", ""
|
||||
}
|
||||
return strings.TrimSpace(batchInfo.Get("restart.phase").String()),
|
||||
strings.TrimSpace(batchInfo.Get("restart.resumeAt").String())
|
||||
}
|
||||
|
||||
func legacySourceOssId(batchInfo *gjson.Json) int64 {
|
||||
if batchInfo == nil {
|
||||
return 0
|
||||
|
||||
@@ -0,0 +1,499 @@
|
||||
package common
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/consts"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/model"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/model/request"
|
||||
|
||||
_ "github.com/gogf/gf/contrib/drivers/sqlite/v2"
|
||||
"github.com/gogf/gf/v2/database/gdb"
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
)
|
||||
|
||||
func TestBatchExecutionLogDetailListPreservesUnknownStatusAndReason(t *testing.T) {
|
||||
ctx := setupBatchExecutionLogListDB(t)
|
||||
insertBatchExecutionLogDetail(t, ctx, "DATA-REVIEW", consts.BatchTypeDataStart, consts.BatchSourceTypeBatchInput, 1, "test-review-card")
|
||||
const reason = "上游已受理,补查超时,执行结果待核查"
|
||||
if _, err := g.DB().Exec(ctx, "UPDATE batch_detail SET detail_status = ?, error_reason = ? WHERE batch_no = ?", consts.BatchDetailStatusUnknown, reason, "DATA-REVIEW"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
status := consts.BatchDetailStatusUnknown
|
||||
list, total, err := NewCommonBatchExecutionLog().DetailList(ctx, &model.BatchDetailListParam{
|
||||
BatchNo: "DATA-REVIEW", DetailStatus: &status,
|
||||
})
|
||||
if err != nil || total != 1 || len(list) != 1 {
|
||||
t.Fatalf("读取待核查明细失败:total=%d,明细数=%d,错误:%v", total, len(list), err)
|
||||
}
|
||||
if list[0].DetailStatus != consts.BatchDetailStatusUnknown || list[0].Error == nil || list[0].Error.Reason != reason {
|
||||
t.Fatalf("待核查状态或具体原因未保留:%+v", list[0])
|
||||
}
|
||||
status = consts.BatchDetailStatusFailed
|
||||
list, total, err = NewCommonBatchExecutionLog().DetailList(ctx, &model.BatchDetailListParam{
|
||||
BatchNo: "DATA-REVIEW", DetailStatus: &status,
|
||||
})
|
||||
if err != nil || total != 0 || len(list) != 0 {
|
||||
t.Fatalf("待核查明细被计入失败筛选:total=%d,明细数=%d,错误:%v", total, len(list), err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBatchExecutionLogListPreservesReviewStatusAndResult(t *testing.T) {
|
||||
ctx := setupBatchExecutionLogListDB(t)
|
||||
if _, err := g.DB().Exec(ctx, "ALTER TABLE batch_main ADD batch_async_status INTEGER NOT NULL DEFAULT 0"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := g.DB().Exec(ctx, "INSERT INTO batch_main(batch_no, batch_type, batch_async_status, run_result) VALUES (?, ?, ?, ?), (?, ?, ?, ?)",
|
||||
"DATA-REVIEW", consts.BatchTypeDataStart, consts.BatchAsyncStatusReview, consts.BatchRunResultReview,
|
||||
"DATA-FAILED", consts.BatchTypeDataStop, consts.BatchAsyncStatusFailed, consts.BatchRunResultFailed,
|
||||
); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
status := consts.BatchAsyncStatusReview
|
||||
list, total, err := NewCommonBatchExecutionLog().List(ctx, &model.BatchExecutionLogListParam{
|
||||
BatchAsyncStatus: &status, PageInfo: request.PageInfo{Page: 1, PageSize: 20},
|
||||
})
|
||||
if err != nil || total != 1 || len(list) != 1 {
|
||||
t.Fatalf("读取待核查批次失败:total=%d,批次数=%d,错误:%v", total, len(list), err)
|
||||
}
|
||||
if list[0].BatchNo != "DATA-REVIEW" || list[0].BatchAsyncStatus != consts.BatchAsyncStatusReview || list[0].RunResult != consts.BatchRunResultReview {
|
||||
t.Fatalf("待核查父状态或运行结果未保留:%+v", list[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestBatchExecutionLogUnknownCountFromBatchInfo(t *testing.T) {
|
||||
ctx := setupBatchExecutionLogListDB(t)
|
||||
for _, statement := range []string{
|
||||
`ALTER TABLE batch_main ADD COLUMN target_dept_id INTEGER NOT NULL DEFAULT 0`,
|
||||
`ALTER TABLE batch_main ADD COLUMN batch_async_status INTEGER NOT NULL DEFAULT 0`,
|
||||
`ALTER TABLE batch_main ADD COLUMN fail_reason TEXT NOT NULL DEFAULT ''`,
|
||||
`ALTER TABLE batch_main ADD COLUMN source_oss_id INTEGER NOT NULL DEFAULT 0`,
|
||||
`ALTER TABLE batch_main ADD COLUMN result_oss_id INTEGER NOT NULL DEFAULT 0`,
|
||||
`CREATE TABLE t_resource_batch_download (id INTEGER PRIMARY KEY, batch_no TEXT)`,
|
||||
`INSERT INTO t_resource_batch_download(id, batch_no) VALUES (1, 'DATA-REVIEW'), (2, 'DATA-LEGACY')`,
|
||||
`INSERT INTO batch_main(batch_no, batch_type, batch_info, total_count) VALUES ('DATA-REVIEW', 109, '{"apnUnknownCount":2}', 3), ('DATA-LEGACY', 109, '{}', 1)`,
|
||||
} {
|
||||
if _, err := g.DB().Exec(ctx, statement); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
list, total, err := NewCommonBatchExecutionLog().List(ctx, &model.BatchExecutionLogListParam{PageInfo: request.PageInfo{Page: 1, PageSize: 20}})
|
||||
if err != nil || total != 2 || len(list) != 2 {
|
||||
t.Fatalf("读取未知计数列表失败: total=%d len=%d err=%v", total, len(list), err)
|
||||
}
|
||||
counts := map[string]int{}
|
||||
for _, item := range list {
|
||||
counts[item.BatchNo] = item.UnknownCount
|
||||
}
|
||||
if counts["DATA-REVIEW"] != 2 || counts["DATA-LEGACY"] != 0 {
|
||||
t.Fatalf("列表待核查数错误: %+v", counts)
|
||||
}
|
||||
for batchNo, want := range map[string]int{"DATA-REVIEW": 2, "DATA-LEGACY": 0} {
|
||||
summary, err := NewCommonBatchExecutionLog().Summary(ctx, &model.BatchExecutionLogDetailParam{BatchNo: batchNo})
|
||||
if err != nil || summary == nil || summary.UnknownCount != want {
|
||||
t.Fatalf("概要 %s 待核查数错误: summary=%+v err=%v", batchNo, summary, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestBatchExecutionLogResultFilePreservesOriginalName(t *testing.T) {
|
||||
ctx := setupBatchExecutionLogListDB(t)
|
||||
for _, statement := range []string{
|
||||
`CREATE TABLE t_resource_batch_download (id INTEGER PRIMARY KEY, batch_no TEXT, oss_id INTEGER, file_url TEXT, file_path TEXT)`,
|
||||
`CREATE TABLE sys_oss (oss_id INTEGER PRIMARY KEY, original_name TEXT, url TEXT, path TEXT)`,
|
||||
`INSERT INTO t_resource_batch_download(id, batch_no, oss_id, file_url, file_path) VALUES (1, 'DATA-REVIEW', 1, '/upload/DATA-REVIEW-result-nonce.xlsx', ''), (2, 'DATA-LEGACY', 2, '/upload/legacy.xlsx', '')`,
|
||||
`INSERT INTO sys_oss(oss_id, original_name, url, path) VALUES (1, 'DATA-REVIEW-result.xlsx', '/upload/DATA-REVIEW-result-nonce.xlsx', ''), (2, '', '/upload/legacy.xlsx', '')`,
|
||||
} {
|
||||
if _, err := g.DB().Exec(ctx, statement); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
for batchNo, want := range map[string]string{"DATA-REVIEW": "DATA-REVIEW-result.xlsx", "DATA-LEGACY": ""} {
|
||||
result, err := NewCommonBatchExecutionLog().getResultFile(ctx, batchNo, 0, nil)
|
||||
if err != nil || result == nil || result.OriginalName != want {
|
||||
t.Fatalf("原始下载名未透传: batch=%s result=%+v err=%v", batchNo, result, err)
|
||||
}
|
||||
encoded, err := json.Marshal(result)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var response map[string]any
|
||||
if err := json.Unmarshal(encoded, &response); err != nil || response["originalName"] != want {
|
||||
t.Fatalf("响应JSON丢失原始下载名: %s err=%v", encoded, err)
|
||||
}
|
||||
if batchNo == "DATA-REVIEW" && result.Url != "/upload/DATA-REVIEW-result-nonce.xlsx" {
|
||||
t.Fatalf("原始下载名影响了物理文件定位: %s", result.Url)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestBatchExecutionLogListReturnsTotalCount(t *testing.T) {
|
||||
ctx := setupBatchExecutionLogListDB(t)
|
||||
if _, err := g.DB().Exec(ctx, `
|
||||
INSERT INTO batch_main(
|
||||
batch_no, batch_type, source_type, total_count, run_result,
|
||||
created_dept, created_by, tenant_id
|
||||
) VALUES(?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`, "OUT-1", 2, 0, 7, 2, 0, 0, ""); err != nil {
|
||||
t.Fatalf("insert batch: %v", err)
|
||||
}
|
||||
|
||||
list, total, err := NewCommonBatchExecutionLog().List(ctx, &model.BatchExecutionLogListParam{
|
||||
PageInfo: request.PageInfo{Page: 1, PageSize: 20},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("list execution logs: %v", err)
|
||||
}
|
||||
if total != 1 {
|
||||
t.Fatalf("total rows = %d, want 1", total)
|
||||
}
|
||||
if len(list) != 1 {
|
||||
t.Fatalf("list length = %d, want 1", len(list))
|
||||
}
|
||||
if list[0].TotalCount != 7 {
|
||||
t.Fatalf("totalCount = %d, want 7", list[0].TotalCount)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAllocateDetailInheritsHistoricalUnits(t *testing.T) {
|
||||
ctx := setupBatchExecutionLogListDB(t)
|
||||
for _, statement := range []string{
|
||||
`ALTER TABLE batch_main ADD target_dept_id INTEGER DEFAULT 0`,
|
||||
`INSERT INTO batch_main(batch_no,batch_type,batch_info,created_dept,target_dept_id) VALUES('ALC-HISTORY',3,'{"sourceDeptId":10,"targetDeptId":20,"sourceDeptName":"历史划拨单位","targetDeptName":"历史接收单位"}',10,20)`,
|
||||
`INSERT INTO batch_detail(batch_no,batch_type,item_id,snapshot) VALUES('ALC-HISTORY',3,'card','{}')`,
|
||||
} {
|
||||
if _, err := g.DB().Exec(ctx, statement); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
rows, total, err := NewCommonBatchExecutionLog().DetailList(ctx, &model.BatchDetailListParam{BatchNo: "ALC-HISTORY"})
|
||||
if err != nil || total != 1 || len(rows) != 1 {
|
||||
t.Fatal("读取历史明细失败", total, err)
|
||||
}
|
||||
if rows[0].SourceDeptName != "历史划拨单位" || rows[0].TargetDeptName != "历史接收单位" {
|
||||
t.Fatal("历史明细未优先继承批次快照", rows[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestBatchExecutionLogDetailListNormalizesFileRowNo(t *testing.T) {
|
||||
ctx := setupBatchExecutionLogListDB(t)
|
||||
insertBatchExecutionLogDetail(t, ctx, "INB-1", consts.BatchTypeInbound, consts.BatchSourceTypeFile, 2, "89860000000000000001")
|
||||
|
||||
list, total, err := NewCommonBatchExecutionLog().DetailList(ctx, &model.BatchDetailListParam{
|
||||
BatchNo: "INB-1",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("detail list: %v", err)
|
||||
}
|
||||
if total != 1 {
|
||||
t.Fatalf("total rows = %d, want 1", total)
|
||||
}
|
||||
if len(list) != 1 {
|
||||
t.Fatalf("list length = %d, want 1", len(list))
|
||||
}
|
||||
if list[0].RowNo != 1 {
|
||||
t.Fatalf("file batch rowNo = %d, want 1", list[0].RowNo)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBatchExecutionLogDetailListNormalizesManualRowNo(t *testing.T) {
|
||||
ctx := setupBatchExecutionLogListDB(t)
|
||||
insertBatchExecutionLogDetail(t, ctx, "INB-2", consts.BatchTypeInbound, consts.BatchSourceTypeBatchInput, 2, "89860000000000000002")
|
||||
|
||||
list, total, err := NewCommonBatchExecutionLog().DetailList(ctx, &model.BatchDetailListParam{
|
||||
BatchNo: "INB-2",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("detail list: %v", err)
|
||||
}
|
||||
if total != 1 {
|
||||
t.Fatalf("total rows = %d, want 1", total)
|
||||
}
|
||||
if len(list) != 1 {
|
||||
t.Fatalf("list length = %d, want 1", len(list))
|
||||
}
|
||||
if list[0].RowNo != 1 {
|
||||
t.Fatalf("manual batch rowNo = %d, want 1", list[0].RowNo)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBatchExecutionLogDetailListNumbersPages(t *testing.T) {
|
||||
ctx := setupBatchExecutionLogListDB(t)
|
||||
insertBatchExecutionLogDetail(t, ctx, "ROWS", consts.BatchTypeInbound, consts.BatchSourceTypeBatchSelect, 2, "first")
|
||||
_, err := g.DB().Exec(ctx, "INSERT INTO batch_detail(batch_no, batch_type, detail_status, row_no, item_id) VALUES (?, ?, ?, ?, ?)", "ROWS", consts.BatchTypeInbound, consts.BatchDetailStatusFailed, 9, "second")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
list, total, err := NewCommonBatchExecutionLog().DetailList(ctx, &model.BatchDetailListParam{BatchNo: "ROWS", PageInfo: request.PageInfo{Page: 2, PageSize: 1}})
|
||||
if err != nil || total != 2 || len(list) != 1 || list[0].RowNo != 2 || list[0].Iccid != "first" {
|
||||
t.Fatalf("paged rows: list=%+v total=%d err=%v", list, total, err)
|
||||
}
|
||||
list, total, err = NewCommonBatchExecutionLog().DetailList(ctx, &model.BatchDetailListParam{BatchNo: "ROWS", PageInfo: request.PageInfo{Page: 1, PageSize: 1}})
|
||||
if err != nil || total != 2 || len(list) != 1 || list[0].Iccid != "second" {
|
||||
t.Fatalf("failed card not first: list=%+v total=%d err=%v", list, total, err)
|
||||
}
|
||||
failed := consts.BatchDetailStatusFailed
|
||||
list, total, err = NewCommonBatchExecutionLog().DetailList(ctx, &model.BatchDetailListParam{BatchNo: "ROWS", DetailStatus: &failed, PageInfo: request.PageInfo{Page: 1, PageSize: 1}})
|
||||
if err != nil || total != 1 || len(list) != 1 || list[0].RowNo != 1 {
|
||||
t.Fatalf("filtered rows: list=%+v total=%d err=%v", list, total, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBatchExecutionLogDetailListPrioritizesRealFailureOverCascade(t *testing.T) {
|
||||
ctx := setupBatchExecutionLogListDB(t)
|
||||
insertBatchExecutionLogDetail(t, ctx, "PRIORITY", consts.BatchTypeAllocate, consts.BatchSourceTypeBatchInput, 1, "cascade")
|
||||
if _, err := g.DB().Exec(ctx, "UPDATE batch_detail SET detail_status = ?, error_reason = ? WHERE batch_no = ?", consts.BatchDetailStatusFailed, "本批次存在失败明细,整批未执行", "PRIORITY"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := g.DB().Exec(ctx, "INSERT INTO batch_detail(batch_no,batch_type,detail_status,row_no,item_id,error_reason) VALUES(?,?,?,?,?,?)", "PRIORITY", consts.BatchTypeAllocate, consts.BatchDetailStatusFailed, 2, "actual-error", "ICCID不在当前操作员授权范围内"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for page, want := range []string{"actual-error", "cascade"} {
|
||||
rows, total, err := NewCommonBatchExecutionLog().DetailList(ctx, &model.BatchDetailListParam{BatchNo: "PRIORITY", PageInfo: request.PageInfo{Page: page + 1, PageSize: 1}})
|
||||
if err != nil || total != 2 || len(rows) != 1 || rows[0].Iccid != want {
|
||||
t.Fatalf("page=%d want=%s rows=%+v total=%d err=%v", page+1, want, rows, total, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func setupBatchExecutionLogListDB(t *testing.T) context.Context {
|
||||
t.Helper()
|
||||
ctx := context.Background()
|
||||
if err := gdb.SetConfig(gdb.Config{
|
||||
"default": gdb.ConfigGroup{{
|
||||
Type: "sqlite",
|
||||
Link: "sqlite::@file(:memory:)",
|
||||
MaxOpenConnCount: 1,
|
||||
}},
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
_ = g.DB().Close(ctx)
|
||||
})
|
||||
if err := g.DB().GetCore().ClearTableFieldsAll(ctx); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if _, err := g.DB().Exec(ctx, `
|
||||
CREATE TABLE batch_main (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
batch_no TEXT NOT NULL,
|
||||
batch_type INTEGER NOT NULL,
|
||||
source_batch_no TEXT NOT NULL DEFAULT '',
|
||||
source_type INTEGER,
|
||||
source_channel TEXT NOT NULL DEFAULT 'legacy', execution_mode INTEGER NOT NULL DEFAULT 0,
|
||||
batch_info TEXT,
|
||||
total_count INTEGER NOT NULL DEFAULT 0,
|
||||
run_result INTEGER NOT NULL DEFAULT 0,
|
||||
start_time TEXT,
|
||||
end_time TEXT,
|
||||
created_dept INTEGER NOT NULL DEFAULT 0,
|
||||
created_by INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TEXT,
|
||||
tenant_id TEXT NOT NULL DEFAULT ''
|
||||
)
|
||||
`); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := g.DB().Exec(ctx, `
|
||||
CREATE TABLE batch_detail (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
batch_no TEXT NOT NULL,
|
||||
batch_type INTEGER NOT NULL,
|
||||
detail_status INTEGER NOT NULL DEFAULT 0,
|
||||
row_no INTEGER NOT NULL DEFAULT 0,
|
||||
item_id TEXT NOT NULL DEFAULT '',
|
||||
error_reason TEXT NOT NULL DEFAULT '',
|
||||
snapshot TEXT,
|
||||
tenant_id TEXT NOT NULL DEFAULT '',
|
||||
created_dept INTEGER NOT NULL DEFAULT 0,
|
||||
created_by INTEGER NOT NULL DEFAULT 0
|
||||
)
|
||||
`); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return ctx
|
||||
}
|
||||
|
||||
func TestBatchExecutionLogListHidesAllocateChildBatches(t *testing.T) {
|
||||
ctx := setupBatchExecutionLogListDB(t)
|
||||
if _, err := g.DB().Exec(ctx, `
|
||||
INSERT INTO batch_main(
|
||||
batch_no, batch_type, source_batch_no, source_type, batch_info, total_count,
|
||||
run_result, created_dept, created_by, tenant_id
|
||||
) VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?), (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`,
|
||||
"ALC-PARENT", consts.BatchTypeAllocate, "", consts.BatchSourceTypeBatchInput, `{"jobRole":"allocate_parent"}`, 2, consts.BatchRunResultRunning, 0, 0, "",
|
||||
"ALC-CHILD", consts.BatchTypeAllocate, "ALC-PARENT", consts.BatchSourceTypeBatchInput, `{"jobRole":"allocate_chunk"}`, 2, consts.BatchRunResultRunning, 0, 0, "",
|
||||
); err != nil {
|
||||
t.Fatalf("insert allocate batches: %v", err)
|
||||
}
|
||||
|
||||
list, total, err := NewCommonBatchExecutionLog().List(ctx, &model.BatchExecutionLogListParam{
|
||||
PageInfo: request.PageInfo{Page: 1, PageSize: 20},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("list execution logs: %v", err)
|
||||
}
|
||||
if total != 1 || len(list) != 1 {
|
||||
t.Fatalf("allocate child batch was not hidden: total=%d list=%d", total, len(list))
|
||||
}
|
||||
if list[0].BatchNo != "ALC-PARENT" {
|
||||
t.Fatalf("visible batch = %q, want parent", list[0].BatchNo)
|
||||
}
|
||||
if _, err := NewCommonBatchExecutionLog().Summary(ctx, &model.BatchExecutionLogDetailParam{BatchNo: "ALC-CHILD"}); err == nil {
|
||||
t.Fatal("allocate child was exposed by summary")
|
||||
}
|
||||
if _, _, err := NewCommonBatchExecutionLog().DetailList(ctx, &model.BatchDetailListParam{BatchNo: "ALC-CHILD"}); err == nil {
|
||||
t.Fatal("allocate child was exposed by detail list")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBatchExecutionLogDetailListShowsAllocateChildFailureReason(t *testing.T) {
|
||||
ctx := setupBatchExecutionLogListDB(t)
|
||||
if _, err := g.DB().Exec(ctx, `
|
||||
INSERT INTO batch_main(batch_no, batch_type, source_batch_no, source_type, batch_info, total_count, run_result, created_dept, created_by, tenant_id)
|
||||
VALUES (?, ?, '', ?, ?, 2, ?, 0, 0, ''), (?, ?, ?, ?, ?, 2, ?, 0, 0, '')
|
||||
`, "ALC-PARENT", consts.BatchTypeAllocate, consts.BatchSourceTypeBatchInput, `{"jobRole":"allocate_parent"}`, consts.BatchRunResultFailed,
|
||||
"ALC-CHILD", consts.BatchTypeAllocate, "ALC-PARENT", consts.BatchSourceTypeBatchInput, `{"jobRole":"allocate_chunk"}`, consts.BatchRunResultFailed); err != nil {
|
||||
t.Fatalf("insert allocate batches: %v", err)
|
||||
}
|
||||
if _, err := g.DB().Exec(ctx, `
|
||||
INSERT INTO batch_detail(batch_no, batch_type, detail_status, row_no, item_id, error_reason, snapshot, tenant_id, created_dept, created_by)
|
||||
VALUES (?, ?, ?, 1, '89860000000000000001', ?, '{}', '', 0, 0), (?, ?, ?, 2, '89860000000000000002', '', '{}', '', 0, 0)
|
||||
`, "ALC-CHILD", consts.BatchTypeAllocate, consts.BatchDetailStatusFailed, "套餐已过期,不能划拨",
|
||||
"ALC-CHILD", consts.BatchTypeAllocate, consts.BatchDetailStatusSuccess); err != nil {
|
||||
t.Fatalf("insert allocate details: %v", err)
|
||||
}
|
||||
failed := consts.BatchDetailStatusFailed
|
||||
list, total, err := NewCommonBatchExecutionLog().DetailList(ctx, &model.BatchDetailListParam{
|
||||
PageInfo: request.PageInfo{Page: 1, PageSize: 20},
|
||||
BatchNo: "ALC-PARENT",
|
||||
DetailStatus: &failed,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("list parent failure details: %v", err)
|
||||
}
|
||||
if total != 1 || len(list) != 1 {
|
||||
t.Fatalf("failed detail count = %d, list length = %d, want 1", total, len(list))
|
||||
}
|
||||
if list[0].Iccid != "89860000000000000001" || list[0].Error == nil || list[0].Error.Reason != "套餐已过期,不能划拨" {
|
||||
t.Fatalf("unexpected failure detail: %+v", list[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestBatchExecutionLogDetailListAggregatesAllAllocateChildren(t *testing.T) {
|
||||
ctx := setupBatchExecutionLogListDB(t)
|
||||
if _, err := g.DB().Exec(ctx, `
|
||||
INSERT INTO batch_main(batch_no, batch_type, source_batch_no, source_type, batch_info, total_count, run_result, created_dept, created_by, tenant_id)
|
||||
VALUES (?, ?, '', ?, ?, 1, ?, 0, 0, ''), (?, ?, ?, ?, ?, 1, ?, 0, 0, ''), (?, ?, ?, ?, ?, 1, ?, 0, 0, '')
|
||||
`, "ALC-PARENT", consts.BatchTypeAllocate, consts.BatchSourceTypeBatchInput, `{"jobRole":"allocate_parent"}`, consts.BatchRunResultFailed,
|
||||
"ALC-CHILD", consts.BatchTypeAllocate, "ALC-PARENT", consts.BatchSourceTypeBatchInput, `{"jobRole":"allocate_chunk"}`, consts.BatchRunResultFailed,
|
||||
"ALC-RELATED", consts.BatchTypeAllocate, "ALC-PARENT", consts.BatchSourceTypeBatchInput, `{}`, consts.BatchRunResultFailed); err != nil {
|
||||
t.Fatalf("insert allocate batches: %v", err)
|
||||
}
|
||||
if _, err := g.DB().Exec(ctx, `
|
||||
INSERT INTO batch_detail(batch_no, batch_type, detail_status, row_no, item_id, error_reason, snapshot, tenant_id, created_dept, created_by)
|
||||
VALUES (?, ?, ?, 1, 'parent-card', '', '{}', '', 0, 0), (?, ?, ?, 1, 'chunk-card', '', '{}', '', 0, 0), (?, ?, ?, 1, 'related-card', '', '{}', '', 0, 0)
|
||||
`, "ALC-PARENT", consts.BatchTypeAllocate, consts.BatchDetailStatusSuccess,
|
||||
"ALC-CHILD", consts.BatchTypeAllocate, consts.BatchDetailStatusSuccess,
|
||||
"ALC-RELATED", consts.BatchTypeAllocate, consts.BatchDetailStatusSuccess); err != nil {
|
||||
t.Fatalf("insert batch details: %v", err)
|
||||
}
|
||||
|
||||
list, total, err := NewCommonBatchExecutionLog().DetailList(ctx, &model.BatchDetailListParam{BatchNo: "ALC-PARENT"})
|
||||
if err != nil || total != 3 || len(list) != 3 {
|
||||
t.Fatalf("unexpected parent details: list=%+v total=%d err=%v", list, total, err)
|
||||
}
|
||||
seen := map[string]bool{}
|
||||
for _, item := range list {
|
||||
seen[item.Iccid] = true
|
||||
}
|
||||
for _, iccid := range []string{"parent-card", "chunk-card", "related-card"} {
|
||||
if !seen[iccid] {
|
||||
t.Fatalf("parent details missing %s: %+v", iccid, list)
|
||||
}
|
||||
}
|
||||
}
|
||||
func TestBatchExecutionLogSelfReferencedParentAggregatesChildren(t *testing.T) {
|
||||
ctx := setupBatchExecutionLogListDB(t)
|
||||
if _, err := g.DB().Exec(ctx, `
|
||||
INSERT INTO batch_main(batch_no, batch_type, source_batch_no, batch_info)
|
||||
VALUES ('SELF-PARENT', 3, 'SELF-PARENT', '{}'), ('SELF-CHILD', 3, 'SELF-PARENT', '{}')
|
||||
`); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := g.DB().Exec(ctx, `
|
||||
INSERT INTO batch_detail(batch_no, batch_type, detail_status, row_no, item_id, snapshot)
|
||||
VALUES ('SELF-PARENT', 3, 2, 1, 'parent-card', '{}'), ('SELF-CHILD', 3, 2, 2, 'child-card', '{}')
|
||||
`); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
list, total, err := NewCommonBatchExecutionLog().DetailList(ctx, &model.BatchDetailListParam{BatchNo: "SELF-PARENT"})
|
||||
if err != nil || total != 2 || len(list) != 2 || list[0].Iccid != "parent-card" || list[1].Iccid != "child-card" {
|
||||
t.Fatalf("self-referenced parent aggregation: list=%+v total=%d err=%v", list, total, err)
|
||||
}
|
||||
}
|
||||
|
||||
func insertBatchExecutionLogDetail(t *testing.T, ctx context.Context, batchNo string, batchType, sourceType, rowNo int, itemID string) {
|
||||
t.Helper()
|
||||
if _, err := g.DB().Exec(ctx, `
|
||||
INSERT INTO batch_main(
|
||||
batch_no, batch_type, source_type, total_count, run_result,
|
||||
created_dept, created_by, tenant_id
|
||||
) VALUES(?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`, batchNo, batchType, sourceType, 1, 2, 0, 0, ""); err != nil {
|
||||
t.Fatalf("insert batch main: %v", err)
|
||||
}
|
||||
if _, err := g.DB().Exec(ctx, `
|
||||
INSERT INTO batch_detail(
|
||||
batch_no, batch_type, detail_status, row_no, item_id, snapshot,
|
||||
tenant_id, created_dept, created_by
|
||||
) VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`, batchNo, batchType, consts.BatchDetailStatusSuccess, rowNo, itemID, `{"ICCID":"`+itemID+`"}`, "", 0, 0); err != nil {
|
||||
t.Fatalf("insert batch detail: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// 出库等批次不写 t_resource_batch_download,详情查询查不到记录时
|
||||
// 必须按「无结果文件」返回,而不是把 sql: no rows in result set 抛给页面。
|
||||
func TestBatchExecutionLogSummaryTreatsMissingDownloadAsNoResultFile(t *testing.T) {
|
||||
ctx := setupBatchExecutionLogListDB(t)
|
||||
if _, err := g.DB().Exec(ctx, `
|
||||
CREATE TABLE t_resource_batch_download (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
batch_no TEXT NOT NULL DEFAULT '',
|
||||
oss_id INTEGER NOT NULL DEFAULT 0,
|
||||
file_url TEXT NOT NULL DEFAULT '',
|
||||
file_path TEXT NOT NULL DEFAULT '',
|
||||
created_at TEXT,
|
||||
tenant_id TEXT NOT NULL DEFAULT '',
|
||||
created_dept INTEGER NOT NULL DEFAULT 0,
|
||||
created_by INTEGER NOT NULL DEFAULT 0
|
||||
)
|
||||
`); err != nil {
|
||||
t.Fatalf("create download table: %v", err)
|
||||
}
|
||||
if _, err := g.DB().Exec(ctx, `
|
||||
INSERT INTO batch_main(
|
||||
batch_no, batch_type, source_type, batch_info, total_count,
|
||||
run_result, created_dept, created_by, tenant_id
|
||||
) VALUES(?, ?, ?, ?, 0, ?, 0, 0, '')
|
||||
`, "OUT-NOFILE", 2, 0, `{}`, consts.BatchRunResultSuccess); err != nil {
|
||||
t.Fatalf("insert outbound batch: %v", err)
|
||||
}
|
||||
|
||||
res, err := NewCommonBatchExecutionLog().Summary(ctx, &model.BatchExecutionLogDetailParam{BatchNo: "OUT-NOFILE"})
|
||||
if err != nil {
|
||||
t.Fatalf("summary should not fail when result file is absent: %v", err)
|
||||
}
|
||||
if res == nil {
|
||||
t.Fatal("summary returned nil result")
|
||||
}
|
||||
if res.ResultFile != nil {
|
||||
t.Fatalf("resultFile = %v, want nil when no download record exists", res.ResultFile)
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
// Package gen
|
||||
// @Link https://github.com/xiujiecn/xiu-admin
|
||||
// @Copyright Copyright (c) 2026 LiXiujie
|
||||
// @Author Lxj <li@xiujie.cn>
|
||||
// @Author Lxj <xxx@example.com>
|
||||
// @License https://github.com/xiujiecn/xiu-admin/blob/master/LICENSE
|
||||
// @AutoGenerate Version
|
||||
package common
|
||||
@@ -16,6 +16,7 @@ import (
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/allocationunits"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/batchmetadata"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/contexts"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/exportcenter"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/rabbitmq"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/xgorm"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/xgorm/handler"
|
||||
@@ -87,6 +88,8 @@ func validateRequeueBatch(batch *entity.BatchMain) error {
|
||||
return nil
|
||||
case consts.BatchAsyncStatusProcessing:
|
||||
return validateProcessingRequeueBatch(batch)
|
||||
case consts.BatchAsyncStatusReview:
|
||||
return gerror.New("待核查批次不支持安全重投,请先核对上游执行结果")
|
||||
case consts.BatchAsyncStatusFailed, consts.BatchAsyncStatusSuccess, consts.BatchAsyncStatusPartialSuccess:
|
||||
return gerror.New("终态批次不支持安全重投")
|
||||
default:
|
||||
@@ -248,7 +251,17 @@ func (s *sCommonBatchMain) SuccessList(ctx context.Context, in *model.BatchMainS
|
||||
if in.BatchType != 0 {
|
||||
requestedBatchType = &in.BatchType
|
||||
}
|
||||
mod = visibleBatchTypeModel(mod, columns.BatchType, requestedBatchType)
|
||||
mod = successListBatchTypeModel(mod, columns.BatchType, requestedBatchType)
|
||||
|
||||
if chanKey := strings.TrimSpace(in.ChanKey); chanKey != "" {
|
||||
extract := "JSON_UNQUOTE(JSON_EXTRACT(%s, '%s'))"
|
||||
if strings.EqualFold(dao.BatchMain.DB().GetConfig().Type, "sqlite") {
|
||||
extract = "JSON_EXTRACT(%s, '%s')"
|
||||
}
|
||||
nested := fmt.Sprintf(extract, columns.BatchInfo, "$.inboundOptions.chanKey")
|
||||
legacy := fmt.Sprintf(extract, columns.BatchInfo, "$.chanKey")
|
||||
mod = mod.Where(fmt.Sprintf("CASE WHEN JSON_VALID(%s) THEN COALESCE(NULLIF(TRIM(%s), ''), TRIM(%s)) END = ?", columns.BatchInfo, nested, legacy), chanKey)
|
||||
}
|
||||
|
||||
if in.BatchNo != "" {
|
||||
mod = mod.Where(columns.BatchNo+" LIKE ?", "%"+in.BatchNo+"%")
|
||||
@@ -343,6 +356,7 @@ func (s *sCommonBatchMain) ListCards(ctx context.Context, in *model.BatchCardLis
|
||||
}
|
||||
|
||||
details, totalCount, err := s.listBatchDetails(ctx, batch, batchDetailQuery{
|
||||
DetailStatus: in.DetailStatus,
|
||||
BatchNo: in.BatchNo,
|
||||
BatchType: in.BatchType,
|
||||
Iccid: in.Iccid,
|
||||
@@ -370,6 +384,7 @@ func (s *sCommonBatchMain) ListCards(ctx context.Context, in *model.BatchCardLis
|
||||
Msisdn: msisdn,
|
||||
Imsi: imsi,
|
||||
DetailStatus: detail.DetailStatus,
|
||||
ErrorReason: detail.ErrorReason,
|
||||
}
|
||||
if batch.BatchType == consts.BatchTypeAllocate {
|
||||
item.Fields = allocationunits.Resolve(detail.Snapshot, parentUnits.SourceDeptId, parentUnits.TargetDeptId)
|
||||
@@ -439,13 +454,22 @@ func (s *sCommonBatchMain) listBatchDetails(ctx context.Context, batch *entity.B
|
||||
mod = onlyCurrentStockDetails(mod)
|
||||
}
|
||||
|
||||
mod = mod.Page(query.Page, query.PageSize).OrderAsc(columns.RowNo)
|
||||
mod = orderBatchCards(mod.Page(query.Page, query.PageSize), batch.BatchType)
|
||||
if err = mod.ScanAndCount(&list, &totalCount, false); err != nil {
|
||||
return nil, 0, gerror.Wrap(err, "获取批次明细列表失败,请稍后重试")
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
func orderBatchCards(mod *gdb.Model, batchType int) *gdb.Model {
|
||||
columns := dao.BatchDetail.Columns()
|
||||
if batchType == consts.BatchTypeInbound || batchType == consts.BatchTypeOutbound || batchType == consts.BatchTypeAllocate {
|
||||
order := fmt.Sprintf("CASE WHEN %s = %d AND COALESCE(%s, '') NOT LIKE '本批%%整批未执行%%' AND COALESCE(%s, '') NOT LIKE '同批%%本行未执行%%' AND COALESCE(%s, '') NOT LIKE '同批%%整批未入库%%' THEN 0 ELSE 1 END, %s ASC, %s ASC", columns.DetailStatus, consts.BatchDetailStatusFailed, columns.ErrorReason, columns.ErrorReason, columns.ErrorReason, columns.RowNo, columns.Id)
|
||||
return mod.Order(gdb.Raw(order))
|
||||
}
|
||||
return mod.OrderAsc(columns.RowNo).OrderAsc(columns.Id)
|
||||
}
|
||||
|
||||
func onlyCurrentStockDetails(mod *gdb.Model) *gdb.Model {
|
||||
detailTable := dao.BatchDetail.Table()
|
||||
stockTable := dao.TSimCardStock.Table()
|
||||
@@ -508,6 +532,41 @@ func batchDetailSnapshotCards(detail *entity.BatchDetail) (iccid, msisdn, imsi s
|
||||
return
|
||||
}
|
||||
|
||||
// ExportWarehousing 导出入库批次列表(异步:提交任务后到下载中心取文件)。
|
||||
// 数据来源与入库管理页保持一致:成功批次列表(batch_type=入库)。
|
||||
func (s *sCommonBatchMain) ExportWarehousing(ctx context.Context, in *model.BatchMainSuccessListParam) (err error) {
|
||||
if in == nil {
|
||||
in = &model.BatchMainSuccessListParam{}
|
||||
}
|
||||
// 与页面 WarehousingList 一致:固定只导入库批次,不接受调用方传其它类型
|
||||
in.BatchType = consts.BatchTypeInbound
|
||||
|
||||
_, err = exportcenter.Submit(ctx, &exportcenter.Request{
|
||||
BizKey: "batchMainWarehousing",
|
||||
BizName: "入库管理",
|
||||
PermCode: "cpm:iot:tSimCardStock:export",
|
||||
QueryParam: in,
|
||||
Tags: func() ([]string, error) {
|
||||
return convert.GetEntityDescTags(model.BatchMainSuccessListModel{})
|
||||
},
|
||||
Fetch: func(ctx context.Context, page, pageSize int) (rows []interface{}, total int, err error) {
|
||||
query := *in
|
||||
query.Page = page
|
||||
query.PageSize = pageSize
|
||||
list, count, err := s.SuccessList(ctx, &query)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
rows = make([]interface{}, 0, len(list))
|
||||
for _, item := range list {
|
||||
rows = append(rows, item)
|
||||
}
|
||||
return rows, count, nil
|
||||
},
|
||||
})
|
||||
return err
|
||||
}
|
||||
|
||||
// Export 导出批次主表
|
||||
func (s *sCommonBatchMain) Export(ctx context.Context, in *model.BatchMainListParam) (err error) {
|
||||
list, totalCount, err := s.List(ctx, in)
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
package common
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/consts"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/model"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/model/request"
|
||||
)
|
||||
|
||||
func TestInboundBatchChannelFiltering(t *testing.T) {
|
||||
ctx := setupBatchExecutionLogListDB(t)
|
||||
for _, statement := range []string{
|
||||
"ALTER TABLE batch_main ADD target_dept_id INTEGER DEFAULT 0",
|
||||
"CREATE TABLE t_sim_card_stock(batch_no TEXT,tenant_id TEXT)",
|
||||
"INSERT INTO t_sim_card_stock VALUES('A-NESTED',''),('A-NESTED','')",
|
||||
} {
|
||||
if _, err := g.DB().Exec(ctx, statement); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
fixtures := []struct {
|
||||
name string
|
||||
info string
|
||||
batchType int
|
||||
runResult int
|
||||
}{
|
||||
{"A-NESTED", `{"inboundOptions":{"chanKey":"A"}}`, 1, consts.BatchRunResultSuccess},
|
||||
{"A-LEGACY", `{"chanKey":"A"}`, 1, consts.BatchRunResultSuccess},
|
||||
{"B-NESTED", `{"inboundOptions":{"chanKey":"B"},"chanKey":"A"}`, 1, consts.BatchRunResultSuccess},
|
||||
{"A-EMPTY-NESTED", `{"inboundOptions":{"chanKey":""},"chanKey":"A"}`, 1, consts.BatchRunResultSuccess},
|
||||
{"NO-CHANNEL", "{}", 1, consts.BatchRunResultSuccess},
|
||||
{"INVALID-JSON", "broken", 1, consts.BatchRunResultSuccess},
|
||||
{"OTHER-TYPE", `{"chanKey":"A"}`, 2, consts.BatchRunResultSuccess},
|
||||
{"NOT-SUCCESS", `{"chanKey":"A"}`, 1, consts.BatchRunResultFailed},
|
||||
}
|
||||
for _, fixture := range fixtures {
|
||||
if _, err := g.DB().Exec(ctx, "INSERT INTO batch_main(batch_no,batch_type,batch_info,total_count,run_result) VALUES(?,?,?,?,?)", fixture.name, fixture.batchType, fixture.info, 2, fixture.runResult); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
channel string
|
||||
page int
|
||||
size int
|
||||
wantTotal int
|
||||
wantRows int
|
||||
}{
|
||||
{"渠道A兼容新旧格式", "A", 1, 20, 3, 3},
|
||||
{"渠道B优先使用嵌套编码", "B", 1, 20, 1, 1},
|
||||
{"清空恢复全部入库批次", "", 1, 20, 6, 6},
|
||||
{"前后空白不影响筛选", " A ", 1, 20, 3, 3},
|
||||
{"总数按渠道过滤后分页", "A", 2, 2, 3, 1},
|
||||
{"不存在的渠道返回空", "missing", 1, 20, 0, 0},
|
||||
{"特殊字符作为参数而不是SQL", "A' OR 1=1 --", 1, 20, 0, 0},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
rows, total, err := NewCommonBatchMain().SuccessList(ctx, &model.BatchMainSuccessListParam{BatchType: consts.BatchTypeInbound, ChanKey: test.channel, PageInfo: request.PageInfo{Page: test.page, PageSize: test.size}})
|
||||
if err != nil || total != test.wantTotal || len(rows) != test.wantRows {
|
||||
t.Fatalf("total=%d rows=%d err=%v", total, len(rows), err)
|
||||
}
|
||||
for _, row := range rows {
|
||||
if row.BatchNo == "A-NESTED" && row.StockCount != 2 {
|
||||
t.Fatalf("stock count changed: %+v", row)
|
||||
}
|
||||
if test.channel == "B" && row.BatchNo != "B-NESTED" {
|
||||
t.Fatalf("nested channel priority violated: %+v", row)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
package common
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/consts"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/batchmetadata"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/contexts"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/model"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/model/entity"
|
||||
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
)
|
||||
|
||||
func TestBatchMainEditRejectsMissingCreationMetadata(t *testing.T) {
|
||||
for _, mode := range []int{batchmetadata.Unknown, -1, 3} {
|
||||
if err := NewCommonBatchMain().Edit(context.Background(), &model.BatchMainEditParam{ExecutionMode: &mode}); err == nil {
|
||||
t.Fatalf("accepted execution mode %d", mode)
|
||||
}
|
||||
}
|
||||
for _, input := range []*model.BatchMainEditParam{nil, {}} {
|
||||
if err := NewCommonBatchMain().Edit(context.Background(), input); err == nil {
|
||||
t.Fatal("accepted missing creation metadata")
|
||||
}
|
||||
}
|
||||
mode := batchmetadata.Single
|
||||
quantity := 2
|
||||
if err := NewCommonBatchMain().Edit(context.Background(), &model.BatchMainEditParam{ExecutionMode: &mode, TotalCount: &quantity}); err == nil {
|
||||
t.Fatal("accepted multiple cards as a single-card batch")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBatchMainEditWritesTrustedCreationMetadata(t *testing.T) {
|
||||
ctx := setupBatchExecutionLogListDB(t)
|
||||
for _, column := range []string{"updated_by INTEGER", "updated_at TEXT"} {
|
||||
if _, err := g.DB().Exec(ctx, "ALTER TABLE batch_main ADD COLUMN "+column); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if err := g.DB().GetCore().ClearTableFieldsAll(ctx); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
fields, err := g.Model("batch_main").Ctx(ctx).TableFields("batch_main")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
fields["id"].Key = "PRI"
|
||||
if _, err := g.DB().GetCore().GetTablesWithCache(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ctx = contexts.Set(ctx, &model.Context{User: &model.Identity{BaseClaims: model.BaseClaims{ID: 7, TenantId: "A"}}})
|
||||
batchType := consts.BatchTypeInbound
|
||||
quantity := 1
|
||||
claimedUser := int64(999)
|
||||
claimedTenant := "B"
|
||||
for _, mode := range []int{batchmetadata.Single, batchmetadata.Bulk} {
|
||||
batchNo := "META-EDIT-SINGLE"
|
||||
if mode == batchmetadata.Bulk {
|
||||
batchNo = "META-EDIT-BULK"
|
||||
}
|
||||
input := &model.BatchMainEditParam{BatchNo: &batchNo, BatchType: &batchType, ExecutionMode: &mode, TotalCount: &quantity, CreatedBy: &claimedUser, TenantId: &claimedTenant}
|
||||
if err := NewCommonBatchMain().Edit(ctx, input); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var batch *entity.BatchMain
|
||||
if err := g.Model("batch_main").Ctx(ctx).Where("batch_no", batchNo).Scan(&batch); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if batch == nil || batch.SourceChannel != batchmetadata.Console || batch.ExecutionMode != uint(mode) || batch.CreatedBy != 7 || batch.TenantId != "A" {
|
||||
t.Fatalf("unexpected batch: %+v", batch)
|
||||
}
|
||||
otherMode := 3 - mode
|
||||
if err := NewCommonBatchMain().Edit(ctx, &model.BatchMainEditParam{Id: batch.Id, BatchNo: &batchNo, ExecutionMode: &otherMode}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var updated *entity.BatchMain
|
||||
if err := g.Model("batch_main").Ctx(ctx).Where("id", batch.Id).Scan(&updated); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if updated.SourceChannel != batchmetadata.Console || updated.ExecutionMode != uint(mode) {
|
||||
t.Fatalf("editing changed attribution: %+v", updated)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
package common
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/consts"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/model"
|
||||
|
||||
_ "github.com/gogf/gf/contrib/drivers/sqlite/v2"
|
||||
"github.com/gogf/gf/v2/database/gdb"
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
"github.com/gogf/gf/v2/os/gtime"
|
||||
)
|
||||
|
||||
func TestBatchMainRequeuePublishesPendingBatch(t *testing.T) {
|
||||
ctx := setupBatchMainRequeueDB(t)
|
||||
insertRequeueBatch(t, ctx, "PENDING-1", consts.BatchTypeInbound, consts.BatchAsyncStatusPending, consts.BatchRunResultPending, `{}`)
|
||||
|
||||
var published []batchExecuteMessage
|
||||
restore := replaceBatchRequeuePublisher(func(_ context.Context, queue string, data any) error {
|
||||
if queue != consts.QueueBatchExecute {
|
||||
t.Fatalf("queue = %s, want %s", queue, consts.QueueBatchExecute)
|
||||
}
|
||||
message, ok := data.(batchExecuteMessage)
|
||||
if !ok {
|
||||
t.Fatalf("message type = %T, want batchExecuteMessage", data)
|
||||
}
|
||||
published = append(published, message)
|
||||
return nil
|
||||
})
|
||||
defer restore()
|
||||
|
||||
res, err := NewCommonBatchMain().Requeue(ctx, &model.BatchMainRequeueParam{BatchNo: "PENDING-1", Reason: "message lost"})
|
||||
if err != nil {
|
||||
t.Fatalf("Requeue() error = %v", err)
|
||||
}
|
||||
if res == nil || !res.Requeued || res.BatchNo != "PENDING-1" || res.BatchType != consts.BatchTypeInbound {
|
||||
t.Fatalf("unexpected response: %+v", res)
|
||||
}
|
||||
if len(published) != 1 || published[0].BatchNo != "PENDING-1" || published[0].BatchType != consts.BatchTypeInbound {
|
||||
t.Fatalf("unexpected published messages: %+v", published)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBatchMainRequeuePublishesDueRestartWaitingResume(t *testing.T) {
|
||||
ctx := setupBatchMainRequeueDB(t)
|
||||
resumeAt := gtime.Now().Add(-time.Minute).String()
|
||||
insertRequeueBatch(t, ctx, "RST-DUE", consts.BatchTypeRestart, consts.BatchAsyncStatusProcessing, consts.BatchRunResultRunning, `{"restart":{"phase":"waiting_resume","resumeAt":"`+resumeAt+`"}}`)
|
||||
|
||||
var published []batchExecuteMessage
|
||||
restore := replaceBatchRequeuePublisher(func(_ context.Context, _ string, data any) error {
|
||||
published = append(published, data.(batchExecuteMessage))
|
||||
return nil
|
||||
})
|
||||
defer restore()
|
||||
|
||||
res, err := NewCommonBatchMain().Requeue(ctx, &model.BatchMainRequeueParam{BatchNo: "RST-DUE", Reason: "delayed message lost"})
|
||||
if err != nil {
|
||||
t.Fatalf("Requeue() error = %v", err)
|
||||
}
|
||||
if res == nil || res.BatchNo != "RST-DUE" || res.BatchType != consts.BatchTypeRestart {
|
||||
t.Fatalf("unexpected response: %+v", res)
|
||||
}
|
||||
if len(published) != 1 || published[0].BatchNo != "RST-DUE" || published[0].BatchType != consts.BatchTypeRestart {
|
||||
t.Fatalf("unexpected published messages: %+v", published)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBatchMainRequeueRejectsUnsafeStatuses(t *testing.T) {
|
||||
ctx := setupBatchMainRequeueDB(t)
|
||||
future := gtime.Now().Add(time.Hour).String()
|
||||
cases := []struct {
|
||||
name string
|
||||
batchNo string
|
||||
batchType int
|
||||
status int
|
||||
runResult int
|
||||
info string
|
||||
}{
|
||||
{name: "failed", batchNo: "FAILED-1", batchType: consts.BatchTypeInbound, status: consts.BatchAsyncStatusFailed, runResult: consts.BatchRunResultFailed, info: `{}`},
|
||||
{name: "success", batchNo: "SUCCESS-1", batchType: consts.BatchTypeInbound, status: consts.BatchAsyncStatusSuccess, runResult: consts.BatchRunResultSuccess, info: `{}`},
|
||||
{name: "partial", batchNo: "PARTIAL-1", batchType: consts.BatchTypeInbound, status: consts.BatchAsyncStatusPartialSuccess, runResult: consts.BatchRunResultPartialSuccess, info: `{}`},
|
||||
{name: "review", batchNo: "REVIEW-1", batchType: consts.BatchTypeDataStart, status: consts.BatchAsyncStatusReview, runResult: consts.BatchRunResultReview, info: `{}`},
|
||||
{name: "normal processing", batchNo: "PROCESSING-1", batchType: consts.BatchTypeInbound, status: consts.BatchAsyncStatusProcessing, runResult: consts.BatchRunResultRunning, info: `{}`},
|
||||
{name: "restart future resume", batchNo: "RST-FUTURE", batchType: consts.BatchTypeRestart, status: consts.BatchAsyncStatusProcessing, runResult: consts.BatchRunResultRunning, info: `{"restart":{"phase":"waiting_resume","resumeAt":"` + future + `"}}`},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
insertRequeueBatch(t, ctx, tc.batchNo, tc.batchType, tc.status, tc.runResult, tc.info)
|
||||
}
|
||||
|
||||
var published int
|
||||
restore := replaceBatchRequeuePublisher(func(context.Context, string, any) error {
|
||||
published++
|
||||
return nil
|
||||
})
|
||||
defer restore()
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
_, err := NewCommonBatchMain().Requeue(ctx, &model.BatchMainRequeueParam{BatchNo: tc.batchNo, Reason: "operator retry"})
|
||||
if err == nil {
|
||||
t.Fatal("Requeue() error = nil, want error")
|
||||
}
|
||||
})
|
||||
}
|
||||
if published != 0 {
|
||||
t.Fatalf("published messages = %d, want 0", published)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBatchMainRequeueHandlesMissingBatchAndPublishFailure(t *testing.T) {
|
||||
ctx := setupBatchMainRequeueDB(t)
|
||||
insertRequeueBatch(t, ctx, "PENDING-FAIL-PUBLISH", consts.BatchTypeInbound, consts.BatchAsyncStatusPending, consts.BatchRunResultPending, `{}`)
|
||||
|
||||
restore := replaceBatchRequeuePublisher(func(context.Context, string, any) error {
|
||||
return errors.New("rabbitmq unavailable")
|
||||
})
|
||||
defer restore()
|
||||
|
||||
if _, err := NewCommonBatchMain().Requeue(ctx, &model.BatchMainRequeueParam{BatchNo: "PENDING-FAIL-PUBLISH", Reason: "message lost"}); err == nil {
|
||||
t.Fatal("Requeue() publish failure error = nil, want error")
|
||||
}
|
||||
if _, err := NewCommonBatchMain().Requeue(ctx, &model.BatchMainRequeueParam{BatchNo: "MISSING", Reason: "message lost"}); err == nil {
|
||||
t.Fatal("Requeue() missing batch error = nil, want error")
|
||||
}
|
||||
}
|
||||
|
||||
func setupBatchMainRequeueDB(t *testing.T) context.Context {
|
||||
t.Helper()
|
||||
ctx := context.Background()
|
||||
if err := gdb.SetConfig(gdb.Config{
|
||||
"default": gdb.ConfigGroup{{
|
||||
Type: "sqlite",
|
||||
Link: "sqlite::@file(:memory:)",
|
||||
MaxOpenConnCount: 1,
|
||||
}},
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
_ = g.DB().Close(ctx)
|
||||
})
|
||||
// GoFrame caches table fields by group and table, across SQLite instances.
|
||||
if err := g.DB().GetCore().ClearTableFieldsAll(ctx); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := g.DB().Exec(ctx, `CREATE TABLE batch_main (
|
||||
source_channel TEXT NOT NULL DEFAULT 'legacy',
|
||||
execution_mode INTEGER NOT NULL DEFAULT 0,
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
batch_no TEXT NOT NULL,
|
||||
batch_type INTEGER NOT NULL,
|
||||
batch_async_status INTEGER NOT NULL,
|
||||
run_result INTEGER NOT NULL,
|
||||
batch_info TEXT,
|
||||
tenant_id TEXT NOT NULL DEFAULT '',
|
||||
dept_id INTEGER NOT NULL DEFAULT 0,
|
||||
created_dept INTEGER NOT NULL DEFAULT 0,
|
||||
created_by INTEGER NOT NULL DEFAULT 0
|
||||
)`); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return ctx
|
||||
}
|
||||
|
||||
func insertRequeueBatch(t *testing.T, ctx context.Context, batchNo string, batchType, status, runResult int, batchInfo string) {
|
||||
t.Helper()
|
||||
if _, err := g.DB().Exec(ctx,
|
||||
`INSERT INTO batch_main(batch_no, batch_type, batch_async_status, run_result, batch_info, tenant_id) VALUES(?, ?, ?, ?, ?, '')`,
|
||||
batchNo, batchType, status, runResult, batchInfo,
|
||||
); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func replaceBatchRequeuePublisher(fn func(context.Context, string, any) error) func() {
|
||||
original := publishBatchExecuteMessage
|
||||
publishBatchExecuteMessage = fn
|
||||
return func() {
|
||||
publishBatchExecuteMessage = original
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
package common
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/consts"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/model"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/model/request"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestBatchManagementAllocationUnits(t *testing.T) {
|
||||
ctx := setupBatchExecutionLogListDB(t)
|
||||
for _, statement := range []string{
|
||||
"ALTER TABLE batch_main ADD target_dept_id INTEGER DEFAULT 0",
|
||||
"ALTER TABLE batch_main ADD dept_id INTEGER DEFAULT 0",
|
||||
"ALTER TABLE batch_main ADD updated_by INTEGER DEFAULT 0",
|
||||
"ALTER TABLE batch_main ADD batch_async_status INTEGER DEFAULT 3",
|
||||
"CREATE TABLE sys_dept(dept_id INTEGER,tenant_id TEXT,dept_key TEXT,dept_name TEXT,deleted_at TEXT)",
|
||||
"INSERT INTO sys_dept VALUES(10,'','source','当前划拨名称',NULL),(20,'','target','当前接收名称',NULL)",
|
||||
`INSERT INTO batch_main(batch_no,batch_type,batch_info,created_dept,target_dept_id,total_count,run_result) VALUES('ALC-PARENT',3,'{"jobRole":"allocate_parent","sourceDeptId":10,"targetDeptId":20,"sourceDeptName":"历史划拨单位","targetDeptName":"历史接收单位"}',10,20,1,2),('ALC-LEGACY',3,'{}',10,20,1,2),('ALC-PER-ROW',3,'{"sourceDeptId":10,"sourceDeptName":"历史划拨单位","targetDeptMode":"per_row"}',10,0,1,2)`,
|
||||
`INSERT INTO batch_main(batch_no,batch_type,source_batch_no,batch_info,created_dept,target_dept_id,total_count,run_result) VALUES('ALC-CHILD',3,'ALC-PARENT','{"jobRole":"allocate_chunk"}',10,20,1,2)`,
|
||||
`INSERT INTO batch_detail(batch_no,batch_type,item_id,snapshot,created_dept) VALUES('ALC-CHILD',3,'card','{}',10)`,
|
||||
} {
|
||||
if _, err := g.DB().Exec(ctx, statement); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
service := NewCommonBatchMain()
|
||||
rows, _, err := service.SuccessList(ctx, &model.BatchMainSuccessListParam{BatchType: consts.BatchTypeAllocate, PageInfo: request.PageInfo{Page: 1, PageSize: 20}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
seen := map[string]bool{}
|
||||
for _, row := range rows {
|
||||
seen[row.BatchNo] = true
|
||||
switch row.BatchNo {
|
||||
case "ALC-PARENT":
|
||||
if row.SourceDeptName != "历史划拨单位" || row.TargetDeptName != "历史接收单位" {
|
||||
t.Fatal(row)
|
||||
}
|
||||
encoded, err := json.Marshal(row)
|
||||
if err != nil || !strings.Contains(string(encoded), `"targetDeptId":20`) || strings.Contains(string(encoded), "StoredTargetDeptId") {
|
||||
t.Fatal("接口契约错误", string(encoded), err)
|
||||
}
|
||||
case "ALC-LEGACY":
|
||||
if row.SourceDeptName != "当前划拨名称" || row.TargetDeptName != "当前接收名称" {
|
||||
t.Fatal(row)
|
||||
}
|
||||
case "ALC-PER-ROW":
|
||||
if row.TargetDeptMode != "per_row" || row.TargetDeptName != "" {
|
||||
t.Fatal(row)
|
||||
}
|
||||
}
|
||||
}
|
||||
if !seen["ALC-PARENT"] || !seen["ALC-LEGACY"] || !seen["ALC-PER-ROW"] {
|
||||
t.Fatal(seen)
|
||||
}
|
||||
all, _, err := service.List(ctx, &model.BatchMainListParam{BatchNo: "ALC-PARENT", PageInfo: request.PageInfo{Page: 1, PageSize: 20}})
|
||||
if err != nil || len(all) != 1 || all[0].TargetDeptName != "历史接收单位" {
|
||||
t.Fatal("主表列表单位缺失", all, err)
|
||||
}
|
||||
view, err := service.View(ctx, &model.BatchMainViewParam{Id: all[0].Id})
|
||||
if err != nil || view.SourceDeptName != "历史划拨单位" || view.TargetDeptName != "历史接收单位" {
|
||||
t.Fatal("批次详情单位缺失", view, err)
|
||||
}
|
||||
cards, total, err := service.ListCards(ctx, &model.BatchCardListParam{BatchNo: "ALC-PARENT", PageInfo: request.PageInfo{Page: 1, PageSize: 20}})
|
||||
if err != nil || total != 1 || len(cards) != 1 {
|
||||
t.Fatal("父批次未展示子批次卡清单", total, err)
|
||||
}
|
||||
if cards[0].Iccid != "card" || cards[0].SourceDeptName != "历史划拨单位" || cards[0].TargetDeptName != "历史接收单位" {
|
||||
t.Fatal(cards[0])
|
||||
}
|
||||
if _, _, err := service.ListCards(context.Background(), &model.BatchCardListParam{BatchNo: "missing"}); err == nil {
|
||||
t.Fatal("不存在批次被放行")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
package common
|
||||
|
||||
import (
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/consts"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/model"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/model/request"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestExecutionLogMetadataFilters(t *testing.T) {
|
||||
ctx := setupBatchExecutionLogListDB(t)
|
||||
insertBatchExecutionLogDetail(t, ctx, "META-SINGLE", consts.BatchTypeInbound, 0, 1, "card1")
|
||||
insertBatchExecutionLogDetail(t, ctx, "META-BULK", consts.BatchTypeInbound, 3, 1, "card2")
|
||||
for _, sql := range []string{
|
||||
"UPDATE batch_main SET source_channel='mini_program',execution_mode=1 WHERE batch_no='META-SINGLE'",
|
||||
"UPDATE batch_main SET source_channel='console',execution_mode=2 WHERE batch_no='META-BULK'",
|
||||
} {
|
||||
if _, err := g.DB().Exec(ctx, sql); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
mode := 1
|
||||
list, total, err := NewCommonBatchExecutionLog().List(ctx, &model.BatchExecutionLogListParam{PageInfo: request.PageInfo{Page: 1, PageSize: 20}, SourceChannel: "mini_program", ExecutionMode: &mode})
|
||||
if err != nil || total != 1 || len(list) != 1 || list[0].BatchNo != "META-SINGLE" || list[0].SourceChannel != "mini_program" || list[0].ExecutionMode != 1 {
|
||||
t.Fatalf("%+v %d %v", list, total, err)
|
||||
}
|
||||
mode = 2
|
||||
_, total, err = NewCommonBatchExecutionLog().List(ctx, &model.BatchExecutionLogListParam{PageInfo: request.PageInfo{Page: 1, PageSize: 20}, SourceChannel: "mini_program", ExecutionMode: &mode})
|
||||
if err != nil || total != 0 {
|
||||
t.Fatal(total, err)
|
||||
}
|
||||
}
|
||||
@@ -22,3 +22,13 @@ func visibleBatchTypeMatches(actual int, requested *int) bool {
|
||||
}
|
||||
return requested == nil || actual == *requested
|
||||
}
|
||||
|
||||
// successListBatchTypeModel keeps export execution logs out of business batch selectors.
|
||||
func successListBatchTypeModel(mod *gdb.Model, column string, requested *int) *gdb.Model {
|
||||
mod = visibleBatchTypeModel(mod, column, requested)
|
||||
return mod.Where(column+" <> ?", consts.BatchTypeExport)
|
||||
}
|
||||
|
||||
func successListBatchTypeMatches(actual int, requested *int) bool {
|
||||
return actual != consts.BatchTypeExport && visibleBatchTypeMatches(actual, requested)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
package common
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/consts"
|
||||
)
|
||||
|
||||
func TestSuccessListBatchTypeMatches(t *testing.T) {
|
||||
prepare := consts.BatchTypePrepare
|
||||
renew := consts.BatchTypeRenew
|
||||
export := consts.BatchTypeExport
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
actual int
|
||||
requested *int
|
||||
want bool
|
||||
}{
|
||||
{name: "unfiltered business batch", actual: consts.BatchTypeChange, want: true},
|
||||
{name: "matching business filter", actual: consts.BatchTypeRenew, requested: &renew, want: true},
|
||||
{name: "different business filter", actual: consts.BatchTypeChange, requested: &renew, want: false},
|
||||
{name: "unfiltered prepare batch", actual: consts.BatchTypePrepare, want: false},
|
||||
{name: "explicit prepare filter remains hidden", actual: consts.BatchTypePrepare, requested: &prepare, want: false},
|
||||
{name: "unfiltered export batch remains hidden", actual: consts.BatchTypeExport, want: false},
|
||||
{name: "explicit export filter remains hidden", actual: consts.BatchTypeExport, requested: &export, want: false},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := successListBatchTypeMatches(tt.actual, tt.requested); got != tt.want {
|
||||
t.Fatalf("successListBatchTypeMatches(%d) = %v, want %v", tt.actual, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,341 @@
|
||||
package common
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/gogf/gf/v2/database/gdb"
|
||||
"github.com/gogf/gf/v2/errors/gerror"
|
||||
"github.com/gogf/gf/v2/os/gtime"
|
||||
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/consts"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/dao"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/contexts"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/exportcenter"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/model"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/model/entity"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/service"
|
||||
)
|
||||
|
||||
type sCommonDownloadCenter struct{}
|
||||
|
||||
func init() {
|
||||
service.RegisterCommonDownloadCenter(NewCommonDownloadCenter())
|
||||
}
|
||||
|
||||
func NewCommonDownloadCenter() *sCommonDownloadCenter {
|
||||
return &sCommonDownloadCenter{}
|
||||
}
|
||||
|
||||
// List 下载中心列表:K2 仅本人 + K1 业务权限过滤
|
||||
func (s *sCommonDownloadCenter) List(ctx context.Context, in *model.DownloadCenterListParam) (list []*model.DownloadCenterListModel, totalCount int, err error) {
|
||||
user := contexts.GetUser(ctx)
|
||||
if user == nil || user.ID <= 0 {
|
||||
return nil, 0, gerror.New("登录状态已失效,请重新登录")
|
||||
}
|
||||
|
||||
mod := dao.TExportTask.Ctx(ctx).
|
||||
Fields(model.DownloadCenterListModel{}).
|
||||
Where(dao.TExportTask.Columns().OwnerUserId, user.ID)
|
||||
mod = applyExportPermissionFilter(ctx, mod)
|
||||
|
||||
if keyword := strings.TrimSpace(in.Keyword); keyword != "" {
|
||||
like := "%" + keyword + "%"
|
||||
mod = mod.Where(
|
||||
"("+dao.TExportTask.Columns().TaskNo+" LIKE ? OR "+
|
||||
dao.TExportTask.Columns().FileName+" LIKE ? OR "+
|
||||
dao.TExportTask.Columns().BizName+" LIKE ?)",
|
||||
like, like, like,
|
||||
)
|
||||
}
|
||||
if in.BizKey != "" {
|
||||
mod = mod.Where(dao.TExportTask.Columns().BizKey, in.BizKey)
|
||||
}
|
||||
if statusIds := parseStatusIds(in.Status); len(statusIds) > 0 {
|
||||
mod = mod.WhereIn(dao.TExportTask.Columns().Status, statusIds)
|
||||
}
|
||||
if len(in.CreatedAt) == 2 {
|
||||
in.CreatedAt[1] = in.CreatedAt[1].EndOfDay()
|
||||
mod = mod.WhereBetween(dao.TExportTask.Columns().CreatedAt, in.CreatedAt[0], in.CreatedAt[1])
|
||||
}
|
||||
|
||||
err = mod.Page(in.Page, in.PageSize).
|
||||
OrderDesc(dao.TExportTask.Columns().Id).
|
||||
ScanAndCount(&list, &totalCount, false)
|
||||
if err != nil {
|
||||
return nil, 0, gerror.Wrap(err, "获取导出任务列表失败,请稍后重试")
|
||||
}
|
||||
if list == nil {
|
||||
list = []*model.DownloadCenterListModel{}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Recent 右上角全局入口:最近任务与进行中/已完成数量
|
||||
func (s *sCommonDownloadCenter) Recent(ctx context.Context, in *model.DownloadCenterRecentParam) (res *model.DownloadCenterRecentModel, err error) {
|
||||
user := contexts.GetUser(ctx)
|
||||
if user == nil || user.ID <= 0 {
|
||||
return nil, gerror.New("登录状态已失效,请重新登录")
|
||||
}
|
||||
limit := in.Limit
|
||||
if limit <= 0 || limit > 20 {
|
||||
limit = 5
|
||||
}
|
||||
|
||||
items := make([]*model.DownloadCenterListModel, 0, limit)
|
||||
mod := dao.TExportTask.Ctx(ctx).
|
||||
WhereNull(dao.TExportTask.Columns().DeletedTime).
|
||||
Fields(model.DownloadCenterListModel{}).
|
||||
Where(dao.TExportTask.Columns().OwnerUserId, user.ID)
|
||||
mod = applyExportPermissionFilter(ctx, mod)
|
||||
if err = mod.OrderDesc(dao.TExportTask.Columns().Id).Limit(limit).Scan(&items); err != nil {
|
||||
return nil, gerror.Wrap(err, "获取最近导出任务失败")
|
||||
}
|
||||
|
||||
running, err := s.countByStatus(ctx, user.ID, []int{consts.DownloadStatusPending, consts.DownloadStatusRunning})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
finished, err := s.countByStatus(ctx, user.ID, []int{consts.DownloadStatusGenerated})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &model.DownloadCenterRecentModel{
|
||||
Items: items,
|
||||
RunningCount: running,
|
||||
FinishedCount: finished,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *sCommonDownloadCenter) countByStatus(ctx context.Context, userId int64, status []int) (int, error) {
|
||||
mod := dao.TExportTask.Ctx(ctx).
|
||||
WhereNull(dao.TExportTask.Columns().DeletedTime).
|
||||
Where(dao.TExportTask.Columns().OwnerUserId, userId).
|
||||
WhereIn(dao.TExportTask.Columns().Status, status)
|
||||
mod = applyExportPermissionFilter(ctx, mod)
|
||||
count, err := mod.Count()
|
||||
if err != nil {
|
||||
return 0, gerror.Wrap(err, "统计导出任务数量失败")
|
||||
}
|
||||
return count, nil
|
||||
}
|
||||
|
||||
// Download 下载导出文件:重新校验 K1+K2,然后直接写文件流
|
||||
func (s *sCommonDownloadCenter) Download(ctx context.Context, in *model.DownloadCenterTaskParam) (err error) {
|
||||
task, err := s.loadOwnedTask(ctx, in.TaskId)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !hasExportPermission(ctx, task.PermCode) {
|
||||
return gerror.New("你的导出权限已变更,无法下载该文件")
|
||||
}
|
||||
if task.DeletedTime != nil {
|
||||
return gerror.New("文件已删除或已过期清理,请重新导出")
|
||||
}
|
||||
if task.Status != consts.DownloadStatusGenerated {
|
||||
return gerror.New("文件尚未生成完成,请稍后再试")
|
||||
}
|
||||
if task.ExpireTime == nil || !task.ExpireTime.After(gtime.Now()) {
|
||||
return gerror.New("文件已过期,请重新导出")
|
||||
}
|
||||
if task.OssId <= 0 {
|
||||
return gerror.New("文件不存在")
|
||||
}
|
||||
|
||||
oss := &entity.SysOss{}
|
||||
if err = dao.SysOss.Ctx(ctx).Where(dao.SysOss.Columns().OssId, task.OssId).Scan(oss); err != nil {
|
||||
return gerror.Wrap(err, "获取文件信息失败")
|
||||
}
|
||||
if oss.OssId <= 0 {
|
||||
return gerror.New("文件不存在或已被清理")
|
||||
}
|
||||
|
||||
return service.SysOss().DownloadLocal(ctx, oss, func() error {
|
||||
columns := dao.TExportTask.Columns()
|
||||
// 先计算有效期再递增次数,避免 MySQL 从左到右赋值影响首次下载判断。
|
||||
affected, updateErr := dao.TExportTask.Ctx(ctx).
|
||||
Data("expire_time = CASE WHEN download_count = 0 THEN LEAST(expire_time, DATE_ADD(NOW(), INTERVAL 1 HOUR)) ELSE expire_time END, last_download_time = NOW(), download_count = download_count + 1").
|
||||
Where(columns.Id, task.Id).
|
||||
WhereNull(columns.DeletedTime).
|
||||
Where(columns.OwnerUserId, task.OwnerUserId).
|
||||
Where(columns.Status, consts.DownloadStatusGenerated).
|
||||
Where(columns.OssId, oss.OssId).
|
||||
Where(columns.ExpireTime + " > NOW()").
|
||||
UpdateAndGetAffected()
|
||||
if updateErr != nil {
|
||||
return gerror.Wrap(updateErr, "更新下载有效期失败,请稍后重试")
|
||||
}
|
||||
if affected != 1 {
|
||||
return gerror.New("文件已过期或状态已变更,请刷新后重试")
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
// Remove 物理清理文件与OSS记录,软删除并保留导出任务历史
|
||||
func (s *sCommonDownloadCenter) Remove(ctx context.Context, in *model.DownloadCenterTaskParam) (err error) {
|
||||
task, err := s.loadOwnedTask(ctx, in.TaskId)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if task.Status == consts.DownloadStatusRunning {
|
||||
return gerror.New("文件正在生成中,暂时不能删除")
|
||||
}
|
||||
if !hasExportPermission(ctx, task.PermCode) {
|
||||
return gerror.New("你的导出权限已变更,无法删除该任务")
|
||||
}
|
||||
|
||||
userId := contexts.GetUserId(ctx)
|
||||
_, err = exportcenter.RemoveTaskFile(ctx, task.Id, "user", func(locked *entity.TExportTask) error {
|
||||
if locked.OwnerUserId != userId {
|
||||
return gerror.New("无权访问该导出任务")
|
||||
}
|
||||
if locked.Status == consts.DownloadStatusRunning {
|
||||
return gerror.New("任务已开始生成,暂时不能删除")
|
||||
}
|
||||
if !hasExportPermission(ctx, locked.PermCode) {
|
||||
return gerror.New("你的导出权限已变更,无法删除该任务")
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return gerror.Wrap(err, "删除导出任务失败")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// loadOwnedTask 取任务并校验归属(K2:仅本人)
|
||||
func (s *sCommonDownloadCenter) loadOwnedTask(ctx context.Context, taskId int64) (*entity.TExportTask, error) {
|
||||
if taskId <= 0 {
|
||||
return nil, gerror.New("任务ID不能为空")
|
||||
}
|
||||
task := &entity.TExportTask{}
|
||||
if err := dao.TExportTask.Ctx(ctx).
|
||||
Where(dao.TExportTask.Columns().Id, taskId).
|
||||
Scan(task); err != nil {
|
||||
return nil, gerror.Wrap(err, "获取导出任务失败")
|
||||
}
|
||||
if task.Id <= 0 {
|
||||
return nil, gerror.New("导出任务不存在")
|
||||
}
|
||||
|
||||
user := contexts.GetUser(ctx)
|
||||
if user == nil || user.ID <= 0 {
|
||||
return nil, gerror.New("登录状态已失效,请重新登录")
|
||||
}
|
||||
if task.OwnerUserId != user.ID {
|
||||
return nil, gerror.New("无权访问该导出任务")
|
||||
}
|
||||
return task, nil
|
||||
}
|
||||
|
||||
// applyExportPermissionFilter K1:只显示「我仍持有对应导出权限」的任务
|
||||
func applyExportPermissionFilter(ctx context.Context, mod *gdb.Model) *gdb.Model {
|
||||
if contexts.IsSuperAdmin(ctx) {
|
||||
return mod
|
||||
}
|
||||
codes := exportPermissionCodes(contexts.GetAccessCodeList(ctx))
|
||||
if len(codes) == 0 {
|
||||
return mod.Where("1 = 0")
|
||||
}
|
||||
|
||||
exact := make([]string, 0, len(codes))
|
||||
prefixes := make([]string, 0, len(codes))
|
||||
for _, code := range codes {
|
||||
if strings.HasSuffix(code, "*") {
|
||||
prefixes = append(prefixes, strings.TrimSuffix(code, "*"))
|
||||
continue
|
||||
}
|
||||
exact = append(exact, code)
|
||||
}
|
||||
|
||||
conditions := make([]string, 0, len(exact)+len(prefixes))
|
||||
args := make([]interface{}, 0, len(exact)+len(prefixes))
|
||||
column := dao.TExportTask.Columns().PermCode
|
||||
if len(exact) > 0 {
|
||||
conditions = append(conditions, column+" IN (?)")
|
||||
args = append(args, exact)
|
||||
}
|
||||
for _, prefix := range prefixes {
|
||||
conditions = append(conditions, column+" LIKE ?")
|
||||
args = append(args, prefix+"%")
|
||||
}
|
||||
if len(conditions) == 0 {
|
||||
return mod.Where("1 = 0")
|
||||
}
|
||||
return mod.Where("("+strings.Join(conditions, " OR ")+")", args...)
|
||||
}
|
||||
|
||||
// hasExportPermission 下载/删除时的二次校验
|
||||
func hasExportPermission(ctx context.Context, required string) bool {
|
||||
if contexts.IsSuperAdmin(ctx) {
|
||||
return true
|
||||
}
|
||||
if strings.TrimSpace(required) == "" {
|
||||
// 提交时接口未声明权限码时,退化为只校验归属,避免历史任务全部不可用
|
||||
return true
|
||||
}
|
||||
for _, code := range contexts.GetAccessCodeList(ctx) {
|
||||
if matchExportCode(code, required) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func matchExportCode(pattern, code string) bool {
|
||||
if pattern == code {
|
||||
return true
|
||||
}
|
||||
if strings.HasSuffix(pattern, "*") {
|
||||
return strings.HasPrefix(code, strings.TrimSuffix(pattern, "*"))
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// exportPermissionCodes 取「可用于判断导出任务可见性」的权限码。
|
||||
//
|
||||
// 历史实现只认 :export 结尾,但导出任务的 perm_code 是提交时声明的权限码,
|
||||
// 不同业务可能声明为 :view / :download / :list(例如卡基础信息查询、月历史用量查询
|
||||
// 把查询与导出合并成一个 :view 码)。只认 :export 会让这些任务对提交人自己也不可见。
|
||||
//
|
||||
// 放宽到常见后缀不影响安全:真正下载时 hasExportPermission 会拿任务记录的 perm_code
|
||||
// 再校验一次,用户失去权限后即使看得到记录也拿不到文件。
|
||||
func exportPermissionCodes(list []string) []string {
|
||||
suffixes := []string{":export", ":view", ":download", ":list"}
|
||||
out := make([]string, 0, len(list))
|
||||
for _, code := range list {
|
||||
if strings.HasSuffix(code, "*") {
|
||||
out = append(out, code)
|
||||
continue
|
||||
}
|
||||
for _, suffix := range suffixes {
|
||||
if strings.HasSuffix(code, suffix) {
|
||||
out = append(out, code)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// parseStatusIds 解析逗号分隔的状态筛选
|
||||
func parseStatusIds(raw string) []int {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" {
|
||||
return nil
|
||||
}
|
||||
ids := make([]int, 0, 4)
|
||||
for _, part := range strings.Split(raw, ",") {
|
||||
part = strings.TrimSpace(part)
|
||||
if part == "" {
|
||||
continue
|
||||
}
|
||||
if v, err := strconv.Atoi(part); err == nil {
|
||||
ids = append(ids, v)
|
||||
}
|
||||
}
|
||||
return ids
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
// Package gen
|
||||
// @Link https://github.com/xiujiecn/xiu-admin
|
||||
// @Copyright Copyright (c) 2026 LiXiujie
|
||||
// @Author Lxj <li@xiujie.cn>
|
||||
// @Author Lxj <xxx@example.com>
|
||||
// @License https://github.com/xiujiecn/xiu-admin/blob/master/LICENSE
|
||||
// @AutoGenerate Version
|
||||
package common
|
||||
|
||||
@@ -1,30 +1,27 @@
|
||||
// Package gen
|
||||
// @Link https://github.com/xiujiecn/xiu-admin
|
||||
// @Copyright Copyright (c) 2026 LiXiujie
|
||||
// @Author Lxj <li@xiujie.cn>
|
||||
// @Author Lxj <xxx@example.com>
|
||||
// @License https://github.com/xiujiecn/xiu-admin/blob/master/LICENSE
|
||||
// @AutoGenerate Version
|
||||
package common
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/dao"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/contexts"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/exportcenter"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/xgorm/handler"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/xgorm/hook"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/model"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/service"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/utility/convert"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/utility/excel"
|
||||
"time"
|
||||
|
||||
"github.com/gogf/gf/v2/database/gdb"
|
||||
"github.com/gogf/gf/v2/errors/gerror"
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
"github.com/gogf/gf/v2/os/gctx"
|
||||
"github.com/gogf/gf/v2/os/gtime"
|
||||
"github.com/gogf/gf/v2/util/gconv"
|
||||
)
|
||||
|
||||
type sCommonTBalanceDetail struct{}
|
||||
@@ -137,31 +134,38 @@ func (s *sCommonTBalanceDetail) List(ctx context.Context, in *model.TBalanceDeta
|
||||
return
|
||||
}
|
||||
|
||||
// Export 导出余额明细表
|
||||
// Export 导出余额明细(异步:提交任务后到下载中心取文件)
|
||||
func (s *sCommonTBalanceDetail) Export(ctx context.Context, in *model.TBalanceDetailListParam) (err error) {
|
||||
list, totalCount, err := s.List(ctx, in)
|
||||
if err != nil {
|
||||
return
|
||||
var req exportcenter.Request
|
||||
req = exportcenter.Request{
|
||||
BizKey: "tBalanceDetail",
|
||||
BizName: "余额明细",
|
||||
PermCode: "cpm:common:tBalanceDetail:export",
|
||||
QueryParam: in,
|
||||
Tags: func() ([]string, error) {
|
||||
return convert.GetEntityDescTags(model.TBalanceDetailListModel{})
|
||||
},
|
||||
Fetch: func(ctx context.Context, page, pageSize int) (rows []interface{}, total int, err error) {
|
||||
query := *in
|
||||
query.Page = page
|
||||
query.PageSize = pageSize
|
||||
list, count, err := s.List(ctx, &query)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
rows = make([]interface{}, 0, len(list))
|
||||
for _, item := range list {
|
||||
// 与列表 getDisplayTransactionChannel 保持一致,先归一化历史渠道值。
|
||||
if len(req.Columns) > 0 && (item.TransactionChannel == 4 || (item.TransactionChannel == 0 && item.IncomeExpenseType == 1 && item.TransactionType == 1)) {
|
||||
item.TransactionChannel = 3
|
||||
}
|
||||
rows = append(rows, item)
|
||||
}
|
||||
return rows, count, nil
|
||||
},
|
||||
}
|
||||
|
||||
// 字段的排序是依据tags的字段顺序,如果你不想使用默认的排序方式,可以直接定义 tags = []string{"字段名称", "字段名称2", ...}
|
||||
tags, err := convert.GetEntityDescTags(model.TBalanceDetailExportModel{})
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
var (
|
||||
fileName = "导出余额明细表-" + gctx.CtxId(ctx)
|
||||
sheetName = fmt.Sprintf("索引条件共%v行,共%v页,当前导出是第%v页,本页共%v行", totalCount, (totalCount+in.PageSize-1)/in.PageSize, in.Page, len(list))
|
||||
exports []model.TBalanceDetailExportModel
|
||||
)
|
||||
|
||||
if err = gconv.Scan(list, &exports); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
err = excel.ExportByStructs(ctx, tags, exports, fileName, sheetName)
|
||||
return
|
||||
_, err = exportcenter.Submit(ctx, &req)
|
||||
return err
|
||||
}
|
||||
|
||||
// Edit 修改/新增余额明细表
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// Package gen
|
||||
// @Link https://github.com/xiujiecn/xiu-admin
|
||||
// @Copyright Copyright (c) 2026 LiXiujie
|
||||
// @Author Lxj <li@xiujie.cn>
|
||||
// @Author Lxj <xxx@example.com>
|
||||
// @License https://github.com/xiujiecn/xiu-admin/blob/master/LICENSE
|
||||
// @AutoGenerate Version
|
||||
package common
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// Package gen
|
||||
// @Link https://github.com/xiujiecn/xiu-admin
|
||||
// @Copyright Copyright (c) 2026 LiXiujie
|
||||
// @Author Lxj <li@xiujie.cn>
|
||||
// @Author Lxj <xxx@example.com>
|
||||
// @License https://github.com/xiujiecn/xiu-admin/blob/master/LICENSE
|
||||
// @AutoGenerate Version
|
||||
package common
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// Package iot
|
||||
// @Link https://github.com/xiujiecn/xiu-admin
|
||||
// @Copyright Copyright (c) 2026 LiXiujie
|
||||
// @Author Lxj <li@xiujie.cn>
|
||||
// @Author Lxj <xxx@example.com>
|
||||
// @License https://github.com/xiujiecn/xiu-admin/blob/master/LICENSE
|
||||
// @AutoGenerate Version
|
||||
package common
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// Package gen
|
||||
// @Link https://github.com/xiujiecn/xiu-admin
|
||||
// @Copyright Copyright (c) 2026 LiXiujie
|
||||
// @Author Lxj <li@xiujie.cn>
|
||||
// @Author Lxj <xxx@example.com>
|
||||
// @License https://github.com/xiujiecn/xiu-admin/blob/master/LICENSE
|
||||
// @AutoGenerate Version
|
||||
package common
|
||||
|
||||
@@ -1,29 +1,26 @@
|
||||
// Package gen
|
||||
// @Link https://github.com/xiujiecn/xiu-admin
|
||||
// @Copyright Copyright (c) 2026 LiXiujie
|
||||
// @Author Lxj <li@xiujie.cn>
|
||||
// @Author Lxj <xxx@example.com>
|
||||
// @License https://github.com/xiujiecn/xiu-admin/blob/master/LICENSE
|
||||
// @AutoGenerate Version
|
||||
package common
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/dao"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/contexts"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/exportcenter"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/xgorm/handler"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/xgorm/hook"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/model"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/service"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/utility/convert"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/utility/excel"
|
||||
|
||||
"github.com/gogf/gf/v2/database/gdb"
|
||||
"github.com/gogf/gf/v2/errors/gerror"
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
"github.com/gogf/gf/v2/os/gctx"
|
||||
"github.com/gogf/gf/v2/os/gtime"
|
||||
"github.com/gogf/gf/v2/util/gconv"
|
||||
)
|
||||
|
||||
type sCommonTFinWithdrawableChange struct{}
|
||||
@@ -113,31 +110,32 @@ func (s *sCommonTFinWithdrawableChange) List(ctx context.Context, in *model.TFin
|
||||
return
|
||||
}
|
||||
|
||||
// Export 导出可提现变动明细表
|
||||
// Export 导出可提现变动明细(异步:提交任务后到下载中心取文件)
|
||||
func (s *sCommonTFinWithdrawableChange) Export(ctx context.Context, in *model.TFinWithdrawableChangeListParam) (err error) {
|
||||
list, totalCount, err := s.List(ctx, in)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
// 字段的排序是依据tags的字段顺序,如果你不想使用默认的排序方式,可以直接定义 tags = []string{"字段名称", "字段名称2", ...}
|
||||
tags, err := convert.GetEntityDescTags(model.TFinWithdrawableChangeExportModel{})
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
var (
|
||||
fileName = "导出可提现变动明细表-" + gctx.CtxId(ctx)
|
||||
sheetName = fmt.Sprintf("索引条件共%v行,共%v页,当前导出是第%v页,本页共%v行", totalCount, (totalCount+in.PageSize-1)/in.PageSize, in.Page, len(list))
|
||||
exports []model.TFinWithdrawableChangeExportModel
|
||||
)
|
||||
|
||||
if err = gconv.Scan(list, &exports); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
err = excel.ExportByStructs(ctx, tags, exports, fileName, sheetName)
|
||||
return
|
||||
_, err = exportcenter.Submit(ctx, &exportcenter.Request{
|
||||
BizKey: "tFinWithdrawableChange",
|
||||
BizName: "可提现变动明细",
|
||||
PermCode: "cpm:common:tFinWithdrawableChange:export",
|
||||
QueryParam: in,
|
||||
Tags: func() ([]string, error) {
|
||||
return convert.GetEntityDescTags(model.TFinWithdrawableChangeListModel{})
|
||||
},
|
||||
Fetch: func(ctx context.Context, page, pageSize int) (rows []interface{}, total int, err error) {
|
||||
query := *in
|
||||
query.Page = page
|
||||
query.PageSize = pageSize
|
||||
list, count, err := s.List(ctx, &query)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
rows = make([]interface{}, 0, len(list))
|
||||
for _, item := range list {
|
||||
rows = append(rows, item)
|
||||
}
|
||||
return rows, count, nil
|
||||
},
|
||||
})
|
||||
return err
|
||||
}
|
||||
|
||||
// Edit 修改/新增可提现变动明细表
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
// Package gen
|
||||
// @Link https://github.com/xiujiecn/xiu-admin
|
||||
// @Copyright Copyright (c) 2026 LiXiujie
|
||||
// @Author Lxj <li@xiujie.cn>
|
||||
// @Author Lxj <xxx@example.com>
|
||||
// @License https://github.com/xiujiecn/xiu-admin/blob/master/LICENSE
|
||||
// @AutoGenerate Version
|
||||
package common
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/dao"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/contexts"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/exportcenter"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/xgorm"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/xgorm/handler"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/xgorm/hook"
|
||||
@@ -18,14 +18,11 @@ import (
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/model/entity"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/service"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/utility/convert"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/utility/excel"
|
||||
|
||||
"github.com/gogf/gf/v2/database/gdb"
|
||||
"github.com/gogf/gf/v2/errors/gerror"
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
"github.com/gogf/gf/v2/os/gctx"
|
||||
"github.com/gogf/gf/v2/os/gtime"
|
||||
"github.com/gogf/gf/v2/util/gconv"
|
||||
)
|
||||
|
||||
type sCommonTFinanceApply struct{}
|
||||
@@ -105,31 +102,32 @@ func (s *sCommonTFinanceApply) List(ctx context.Context, in *model.TFinanceApply
|
||||
return
|
||||
}
|
||||
|
||||
// Export 导出财务申请表
|
||||
// Export 导出财务申请(异步:提交任务后到下载中心取文件)
|
||||
func (s *sCommonTFinanceApply) Export(ctx context.Context, in *model.TFinanceApplyListParam) (err error) {
|
||||
list, totalCount, err := s.List(ctx, in)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
// 字段的排序是依据tags的字段顺序,如果你不想使用默认的排序方式,可以直接定义 tags = []string{"字段名称", "字段名称2", ...}
|
||||
tags, err := convert.GetEntityDescTags(model.TFinanceApplyExportModel{})
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
var (
|
||||
fileName = "导出财务申请表-" + gctx.CtxId(ctx)
|
||||
sheetName = fmt.Sprintf("索引条件共%v行,共%v页,当前导出是第%v页,本页共%v行", totalCount, (totalCount+in.PageSize-1)/in.PageSize, in.Page, len(list))
|
||||
exports []model.TFinanceApplyExportModel
|
||||
)
|
||||
|
||||
if err = gconv.Scan(list, &exports); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
err = excel.ExportByStructs(ctx, tags, exports, fileName, sheetName)
|
||||
return
|
||||
_, err = exportcenter.Submit(ctx, &exportcenter.Request{
|
||||
BizKey: "tFinanceApply",
|
||||
BizName: "财务申请",
|
||||
PermCode: "cpm:common:tFinanceApply:export",
|
||||
QueryParam: in,
|
||||
Tags: func() ([]string, error) {
|
||||
return convert.GetEntityDescTags(model.TFinanceApplyListModel{})
|
||||
},
|
||||
Fetch: func(ctx context.Context, page, pageSize int) (rows []interface{}, total int, err error) {
|
||||
query := *in
|
||||
query.Page = page
|
||||
query.PageSize = pageSize
|
||||
list, count, err := s.List(ctx, &query)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
rows = make([]interface{}, 0, len(list))
|
||||
for _, item := range list {
|
||||
rows = append(rows, item)
|
||||
}
|
||||
return rows, count, nil
|
||||
},
|
||||
})
|
||||
return err
|
||||
}
|
||||
|
||||
// Edit 修改/新增财务申请表
|
||||
|
||||
@@ -1,30 +1,27 @@
|
||||
// Package gen
|
||||
// @Link https://github.com/xiujiecn/xiu-admin
|
||||
// @Copyright Copyright (c) 2026 LiXiujie
|
||||
// @Author Lxj <li@xiujie.cn>
|
||||
// @Author Lxj <xxx@example.com>
|
||||
// @License https://github.com/xiujiecn/xiu-admin/blob/master/LICENSE
|
||||
// @AutoGenerate Version
|
||||
package common
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/dao"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/contexts"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/exportcenter"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/xgorm"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/xgorm/handler"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/xgorm/hook"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/model"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/service"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/utility/convert"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/utility/excel"
|
||||
|
||||
"github.com/gogf/gf/v2/database/gdb"
|
||||
"github.com/gogf/gf/v2/errors/gerror"
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
"github.com/gogf/gf/v2/os/gctx"
|
||||
"github.com/gogf/gf/v2/os/gtime"
|
||||
"github.com/gogf/gf/v2/util/gconv"
|
||||
)
|
||||
|
||||
type sCommonTFinanceAuditRecord struct{}
|
||||
@@ -98,31 +95,32 @@ func (s *sCommonTFinanceAuditRecord) List(ctx context.Context, in *model.TFinanc
|
||||
return
|
||||
}
|
||||
|
||||
// Export 导出财务审核记录表
|
||||
// Export 导出财务审核记录(异步:提交任务后到下载中心取文件)
|
||||
func (s *sCommonTFinanceAuditRecord) Export(ctx context.Context, in *model.TFinanceAuditRecordListParam) (err error) {
|
||||
list, totalCount, err := s.List(ctx, in)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
// 字段的排序是依据tags的字段顺序,如果你不想使用默认的排序方式,可以直接定义 tags = []string{"字段名称", "字段名称2", ...}
|
||||
tags, err := convert.GetEntityDescTags(model.TFinanceAuditRecordExportModel{})
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
var (
|
||||
fileName = "导出财务审核记录表-" + gctx.CtxId(ctx)
|
||||
sheetName = fmt.Sprintf("索引条件共%v行,共%v页,当前导出是第%v页,本页共%v行", totalCount, (totalCount+in.PageSize-1)/in.PageSize, in.Page, len(list))
|
||||
exports []model.TFinanceAuditRecordExportModel
|
||||
)
|
||||
|
||||
if err = gconv.Scan(list, &exports); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
err = excel.ExportByStructs(ctx, tags, exports, fileName, sheetName)
|
||||
return
|
||||
_, err = exportcenter.Submit(ctx, &exportcenter.Request{
|
||||
BizKey: "tFinanceAuditRecord",
|
||||
BizName: "财务审核记录",
|
||||
PermCode: "cpm:common:tFinanceAuditRecord:export",
|
||||
QueryParam: in,
|
||||
Tags: func() ([]string, error) {
|
||||
return convert.GetEntityDescTags(model.TFinanceAuditRecordListModel{})
|
||||
},
|
||||
Fetch: func(ctx context.Context, page, pageSize int) (rows []interface{}, total int, err error) {
|
||||
query := *in
|
||||
query.Page = page
|
||||
query.PageSize = pageSize
|
||||
list, count, err := s.List(ctx, &query)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
rows = make([]interface{}, 0, len(list))
|
||||
for _, item := range list {
|
||||
rows = append(rows, item)
|
||||
}
|
||||
return rows, count, nil
|
||||
},
|
||||
})
|
||||
return err
|
||||
}
|
||||
|
||||
// Edit 修改/新增财务审核记录表
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// Package gen
|
||||
// @Link https://github.com/xiujiecn/xiu-admin
|
||||
// @Copyright Copyright (c) 2026 LiXiujie
|
||||
// @Author Lxj <li@xiujie.cn>
|
||||
// @Author Lxj <xxx@example.com>
|
||||
// @License https://github.com/xiujiecn/xiu-admin/blob/master/LICENSE
|
||||
// @AutoGenerate Version
|
||||
package common
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
"fmt"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/dao"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/contexts"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/exportcenter"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/websocket"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/xgorm/handler"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/xgorm/hook"
|
||||
@@ -18,13 +19,11 @@ import (
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/model/entity"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/service"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/utility/convert"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/utility/excel"
|
||||
"strings"
|
||||
|
||||
"github.com/gogf/gf/v2/database/gdb"
|
||||
"github.com/gogf/gf/v2/errors/gerror"
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
"github.com/gogf/gf/v2/os/gctx"
|
||||
"github.com/gogf/gf/v2/os/gtime"
|
||||
"github.com/gogf/gf/v2/util/gconv"
|
||||
)
|
||||
@@ -123,31 +122,32 @@ func (s *sCommonTMessageLog) List(ctx context.Context, in *model.TMessageLogList
|
||||
return
|
||||
}
|
||||
|
||||
// Export 导出消息日志
|
||||
// Export 导出消息日志(异步:提交任务后到下载中心取文件)
|
||||
func (s *sCommonTMessageLog) Export(ctx context.Context, in *model.TMessageLogListParam) (err error) {
|
||||
list, totalCount, err := s.List(ctx, in)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
// 字段的排序是依据tags的字段顺序,如果你不想使用默认的排序方式,可以直接定义 tags = []string{"字段名称", "字段名称2", ...}
|
||||
tags, err := convert.GetEntityDescTags(model.TMessageLogExportModel{})
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
var (
|
||||
fileName = "导出消息日志-" + gctx.CtxId(ctx)
|
||||
sheetName = fmt.Sprintf("索引条件共%v行,共%v页,当前导出是第%v页,本页共%v行", totalCount, (totalCount+in.PageSize-1)/in.PageSize, in.Page, len(list))
|
||||
exports []model.TMessageLogExportModel
|
||||
)
|
||||
|
||||
if err = gconv.Scan(list, &exports); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
err = excel.ExportByStructs(ctx, tags, exports, fileName, sheetName)
|
||||
return
|
||||
_, err = exportcenter.Submit(ctx, &exportcenter.Request{
|
||||
BizKey: "tMessageLog",
|
||||
BizName: "消息日志",
|
||||
PermCode: "cpm:common:tMessageLog:export",
|
||||
QueryParam: in,
|
||||
Tags: func() ([]string, error) {
|
||||
return convert.GetEntityDescTags(model.TMessageLogListModel{})
|
||||
},
|
||||
Fetch: func(ctx context.Context, page, pageSize int) (rows []interface{}, total int, err error) {
|
||||
query := *in
|
||||
query.Page = page
|
||||
query.PageSize = pageSize
|
||||
list, count, err := s.List(ctx, &query)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
rows = make([]interface{}, 0, len(list))
|
||||
for _, item := range list {
|
||||
rows = append(rows, item)
|
||||
}
|
||||
return rows, count, nil
|
||||
},
|
||||
})
|
||||
return err
|
||||
}
|
||||
|
||||
// Edit 修改/新增消息日志
|
||||
@@ -321,3 +321,119 @@ func (s *sCommonTMessageLog) View(ctx context.Context, in *model.TMessageLogView
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// 未读通知的默认与最大返回条数,避免一次拉取过多消息。
|
||||
const (
|
||||
messageUnreadDefaultLimit = 50
|
||||
messageUnreadMaxLimit = 200
|
||||
)
|
||||
|
||||
// unreadQuery 构造当前用户未读消息查询。
|
||||
// 以用户消息表为准:只查本人的未读记录,关联消息日志排除已撤回消息,
|
||||
// 消息日志已删除(关联为空)时也不提醒。
|
||||
// 这里不按 send_user_type 过滤:收件人 user_id 全部来自 sys_user 单表,
|
||||
// 该字段只表示消息面向哪个控制台,按其过滤会漏掉真正发给本人的消息。
|
||||
func (s *sCommonTMessageLog) unreadQuery(ctx context.Context, userId int64) *gdb.Model {
|
||||
var (
|
||||
userTable = dao.TUserMessage.Table()
|
||||
logTable = dao.TMessageLog.Table()
|
||||
userColumns = dao.TUserMessage.Columns()
|
||||
logColumns = dao.TMessageLog.Columns()
|
||||
)
|
||||
return dao.TUserMessage.Ctx(ctx).
|
||||
LeftJoin(logTable+" ON "+logTable+"."+logColumns.MessageLogId+" = "+userTable+"."+userColumns.MessageLogId).
|
||||
Where(userTable+"."+userColumns.UserId, userId).
|
||||
Where(userTable+"."+userColumns.IsRead, 0).
|
||||
Where(logTable+"."+logColumns.IsRecall, 0)
|
||||
}
|
||||
|
||||
// UnreadList 获取当前登录用户的未读消息通知。
|
||||
// 这是右上角消息提醒的数据来源:登录、刷新、断线重连后都必须重新拉取,
|
||||
// 否则离线期间的消息会因错过 websocket 推送而不再出现提醒。
|
||||
func (s *sCommonTMessageLog) UnreadList(ctx context.Context, in *model.TMessageLogUnreadListParam) (list []*model.TMessageLogUnreadListModel, totalCount int, err error) {
|
||||
userId := contexts.GetUserId(ctx)
|
||||
if userId <= 0 {
|
||||
err = gerror.New("未获取到当前登录用户信息")
|
||||
return
|
||||
}
|
||||
|
||||
limit := messageUnreadDefaultLimit
|
||||
if in != nil && in.Limit > 0 {
|
||||
limit = in.Limit
|
||||
}
|
||||
if limit > messageUnreadMaxLimit {
|
||||
limit = messageUnreadMaxLimit
|
||||
}
|
||||
|
||||
var (
|
||||
userTable = dao.TUserMessage.Table()
|
||||
logTable = dao.TMessageLog.Table()
|
||||
userColumn = dao.TUserMessage.Columns()
|
||||
logColumn = dao.TMessageLog.Columns()
|
||||
)
|
||||
if err = s.unreadQuery(ctx, userId).
|
||||
Fields(userTable + "." + userColumn.MessageLogId + "," +
|
||||
logTable + "." + logColumn.MessageType + "," +
|
||||
logTable + "." + logColumn.SendUserType + "," +
|
||||
logTable + "." + logColumn.MessageTitle + "," +
|
||||
logTable + "." + logColumn.MessageContent + "," +
|
||||
logTable + "." + logColumn.MessageLink + "," +
|
||||
logTable + "." + logColumn.SendTime + "," +
|
||||
userTable + "." + userColumn.IsRead).
|
||||
OrderDesc(userTable + "." + userColumn.MessageId).
|
||||
Limit(limit).
|
||||
Scan(&list); err != nil {
|
||||
err = gerror.Wrap(err, "获取未读消息失败,请稍后重试!")
|
||||
return
|
||||
}
|
||||
if list == nil {
|
||||
list = []*model.TMessageLogUnreadListModel{}
|
||||
}
|
||||
// 总数不受列表条数上限影响,用于判断是否还有更多未读。
|
||||
if totalCount, err = s.unreadQuery(ctx, userId).Count(); err != nil {
|
||||
err = gerror.Wrap(err, "获取未读消息数量失败,请稍后重试!")
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Read 标记当前登录用户的指定消息为已读。
|
||||
// 只允许操作本人的用户消息记录,避免越权修改他人已读状态。
|
||||
func (s *sCommonTMessageLog) Read(ctx context.Context, in *model.TMessageLogReadParam) (err error) {
|
||||
userId := contexts.GetUserId(ctx)
|
||||
if userId <= 0 {
|
||||
return gerror.New("未获取到当前登录用户信息")
|
||||
}
|
||||
if in == nil || in.MessageLogId <= 0 {
|
||||
return gerror.New("消息ID不能为空")
|
||||
}
|
||||
if _, err = dao.TUserMessage.Ctx(ctx).Where(g.Map{
|
||||
dao.TUserMessage.Columns().MessageLogId: in.MessageLogId,
|
||||
dao.TUserMessage.Columns().UserId: userId,
|
||||
}).Data(g.Map{
|
||||
dao.TUserMessage.Columns().IsRead: 1,
|
||||
dao.TUserMessage.Columns().UpdatedBy: userId,
|
||||
dao.TUserMessage.Columns().UpdatedAt: gtime.Now(),
|
||||
}).Update(); err != nil {
|
||||
err = gerror.Wrap(err, "标记消息已读失败,请稍后重试!")
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// ReadAll 标记当前登录用户的全部未读消息为已读。
|
||||
func (s *sCommonTMessageLog) ReadAll(ctx context.Context, in *model.TMessageLogReadAllParam) (err error) {
|
||||
userId := contexts.GetUserId(ctx)
|
||||
if userId <= 0 {
|
||||
return gerror.New("未获取到当前登录用户信息")
|
||||
}
|
||||
if _, err = dao.TUserMessage.Ctx(ctx).
|
||||
Where(dao.TUserMessage.Columns().UserId, userId).
|
||||
Where(dao.TUserMessage.Columns().IsRead, 0).
|
||||
Data(g.Map{
|
||||
dao.TUserMessage.Columns().IsRead: 1,
|
||||
dao.TUserMessage.Columns().UpdatedBy: userId,
|
||||
dao.TUserMessage.Columns().UpdatedAt: gtime.Now(),
|
||||
}).Update(); err != nil {
|
||||
err = gerror.Wrap(err, "标记全部消息已读失败,请稍后重试!")
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// Package gen
|
||||
// @Link https://github.com/xiujiecn/xiu-admin
|
||||
// @Copyright Copyright (c) 2026 LiXiujie
|
||||
// @Author Lxj <li@xiujie.cn>
|
||||
// @Author Lxj <xxx@example.com>
|
||||
// @License https://github.com/xiujiecn/xiu-admin/blob/master/LICENSE
|
||||
// @AutoGenerate Version
|
||||
package common
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
"fmt"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/dao"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/contexts"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/exportcenter"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/websocket"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/xgorm/handler"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/xgorm/hook"
|
||||
@@ -18,13 +19,11 @@ import (
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/model/entity"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/service"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/utility/convert"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/utility/excel"
|
||||
"strings"
|
||||
|
||||
"github.com/gogf/gf/v2/database/gdb"
|
||||
"github.com/gogf/gf/v2/errors/gerror"
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
"github.com/gogf/gf/v2/os/gctx"
|
||||
"github.com/gogf/gf/v2/os/gtime"
|
||||
"github.com/gogf/gf/v2/util/gconv"
|
||||
)
|
||||
@@ -120,31 +119,32 @@ func (s *sCommonTNotifyLog) List(ctx context.Context, in *model.TNotifyLogListPa
|
||||
return
|
||||
}
|
||||
|
||||
// Export 导出通知日志
|
||||
// Export 导出通知日志(异步:提交任务后到下载中心取文件)
|
||||
func (s *sCommonTNotifyLog) Export(ctx context.Context, in *model.TNotifyLogListParam) (err error) {
|
||||
list, totalCount, err := s.List(ctx, in)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
// 字段的排序是依据tags的字段顺序,如果你不想使用默认的排序方式,可以直接定义 tags = []string{"字段名称", "字段名称2", ...}
|
||||
tags, err := convert.GetEntityDescTags(model.TNotifyLogExportModel{})
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
var (
|
||||
fileName = "导出通知日志-" + gctx.CtxId(ctx)
|
||||
sheetName = fmt.Sprintf("索引条件共%v行,共%v页,当前导出是第%v页,本页共%v行", totalCount, (totalCount+in.PageSize-1)/in.PageSize, in.Page, len(list))
|
||||
exports []*model.TNotifyLogExportModel
|
||||
)
|
||||
|
||||
if err = gconv.Scan(list, &exports); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
err = excel.ExportByStructs(ctx, tags, exports, fileName, sheetName)
|
||||
return
|
||||
_, err = exportcenter.Submit(ctx, &exportcenter.Request{
|
||||
BizKey: "tNotifyLog",
|
||||
BizName: "通知日志",
|
||||
PermCode: "cpm:common:tNotifyLog:export",
|
||||
QueryParam: in,
|
||||
Tags: func() ([]string, error) {
|
||||
return convert.GetEntityDescTags(model.TNotifyLogListModel{})
|
||||
},
|
||||
Fetch: func(ctx context.Context, page, pageSize int) (rows []interface{}, total int, err error) {
|
||||
query := *in
|
||||
query.Page = page
|
||||
query.PageSize = pageSize
|
||||
list, count, err := s.List(ctx, &query)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
rows = make([]interface{}, 0, len(list))
|
||||
for _, item := range list {
|
||||
rows = append(rows, item)
|
||||
}
|
||||
return rows, count, nil
|
||||
},
|
||||
})
|
||||
return err
|
||||
}
|
||||
|
||||
// Edit 修改/新增通知日志
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// Package gen
|
||||
// @Link https://github.com/xiujiecn/xiu-admin
|
||||
// @Copyright Copyright (c) 2026 LiXiujie
|
||||
// @Author Lxj <li@xiujie.cn>
|
||||
// @Author Lxj <xxx@example.com>
|
||||
// @License https://github.com/xiujiecn/xiu-admin/blob/master/LICENSE
|
||||
// @AutoGenerate Version
|
||||
package common
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// Package gen
|
||||
// @Link https://github.com/xiujiecn/xiu-admin
|
||||
// @Copyright Copyright (c) 2026 LiXiujie
|
||||
// @Author Lxj <li@xiujie.cn>
|
||||
// @Author Lxj <xxx@example.com>
|
||||
// @License https://github.com/xiujiecn/xiu-admin/blob/master/LICENSE
|
||||
// @AutoGenerate Version
|
||||
package common
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// Package gen
|
||||
// @Link https://github.com/xiujiecn/xiu-admin
|
||||
// @Copyright Copyright (c) 2026 LiXiujie
|
||||
// @Author Lxj <li@xiujie.cn>
|
||||
// @Author Lxj <xxx@example.com>
|
||||
// @License https://github.com/xiujiecn/xiu-admin/blob/master/LICENSE
|
||||
// @AutoGenerate Version
|
||||
package common
|
||||
@@ -277,6 +277,7 @@ func (s *sCommonTWoMain) Edit(ctx context.Context, in *model.TWoMainEditParam) (
|
||||
return service.CommonTWoRecord().AddOperation(ctx, &model.TWoRecordAddOperationParam{
|
||||
WoNo: *in.WoNo,
|
||||
OperationType: workOrderOperationCreate,
|
||||
AfterStatus: currentStatus,
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,29 +1,26 @@
|
||||
// Package gen
|
||||
// @Link https://github.com/xiujiecn/xiu-admin
|
||||
// @Copyright Copyright (c) 2026 LiXiujie
|
||||
// @Author Lxj <li@xiujie.cn>
|
||||
// @Author Lxj <xxx@example.com>
|
||||
// @License https://github.com/xiujiecn/xiu-admin/blob/master/LICENSE
|
||||
// @AutoGenerate Version
|
||||
package common
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/dao"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/contexts"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/exportcenter"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/xgorm/handler"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/xgorm/hook"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/model"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/service"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/utility/convert"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/utility/excel"
|
||||
|
||||
"github.com/gogf/gf/v2/database/gdb"
|
||||
"github.com/gogf/gf/v2/errors/gerror"
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
"github.com/gogf/gf/v2/os/gctx"
|
||||
"github.com/gogf/gf/v2/os/gtime"
|
||||
"github.com/gogf/gf/v2/util/gconv"
|
||||
)
|
||||
|
||||
type sCommonTWoRecord struct{}
|
||||
@@ -135,31 +132,32 @@ func (s *sCommonTWoRecord) List(ctx context.Context, in *model.TWoRecordListPara
|
||||
return
|
||||
}
|
||||
|
||||
// Export 导出工单记录表
|
||||
// Export 导出工单记录(异步:提交任务后到下载中心取文件)
|
||||
func (s *sCommonTWoRecord) Export(ctx context.Context, in *model.TWoRecordListParam) (err error) {
|
||||
list, totalCount, err := s.List(ctx, in)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
// 字段的排序是依据tags的字段顺序,如果你不想使用默认的排序方式,可以直接定义 tags = []string{"字段名称", "字段名称2", ...}
|
||||
tags, err := convert.GetEntityDescTags(model.TWoRecordExportModel{})
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
var (
|
||||
fileName = "导出工单记录表-" + gctx.CtxId(ctx)
|
||||
sheetName = fmt.Sprintf("索引条件共%v行,共%v页,当前导出是第%v页,本页共%v行", totalCount, (totalCount+in.PageSize-1)/in.PageSize, in.Page, len(list))
|
||||
exports []model.TWoRecordExportModel
|
||||
)
|
||||
|
||||
if err = gconv.Scan(list, &exports); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
err = excel.ExportByStructs(ctx, tags, exports, fileName, sheetName)
|
||||
return
|
||||
_, err = exportcenter.Submit(ctx, &exportcenter.Request{
|
||||
BizKey: "tWoRecord",
|
||||
BizName: "工单记录",
|
||||
PermCode: "cpm:gen:tWoRecord:export",
|
||||
QueryParam: in,
|
||||
Tags: func() ([]string, error) {
|
||||
return convert.GetEntityDescTags(model.TWoRecordListModel{})
|
||||
},
|
||||
Fetch: func(ctx context.Context, page, pageSize int) (rows []interface{}, total int, err error) {
|
||||
query := *in
|
||||
query.Page = page
|
||||
query.PageSize = pageSize
|
||||
list, count, err := s.List(ctx, &query)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
rows = make([]interface{}, 0, len(list))
|
||||
for _, item := range list {
|
||||
rows = append(rows, item)
|
||||
}
|
||||
return rows, count, nil
|
||||
},
|
||||
})
|
||||
return err
|
||||
}
|
||||
|
||||
// Edit 修改/新增工单记录表
|
||||
@@ -205,17 +203,13 @@ func (s *sCommonTWoRecord) AddOperation(ctx context.Context, in *model.TWoRecord
|
||||
OperationType: &in.OperationType,
|
||||
OperationDeptId: &operationDeptId,
|
||||
OperationUserId: &operationUserId,
|
||||
BeforeStatus: &in.BeforeStatus,
|
||||
AfterStatus: &in.AfterStatus,
|
||||
}
|
||||
if in.TargetDeptId > 0 {
|
||||
param.TargetDeptId = &in.TargetDeptId
|
||||
param.TargetUserId = &in.TargetUserId
|
||||
}
|
||||
if in.BeforeStatus > 0 {
|
||||
param.BeforeStatus = &in.BeforeStatus
|
||||
}
|
||||
if in.AfterStatus > 0 {
|
||||
param.AfterStatus = &in.AfterStatus
|
||||
}
|
||||
return s.Edit(ctx, param)
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
package common
|
||||
|
||||
import (
|
||||
_ "github.com/gogf/gf/contrib/drivers/sqlite/v2"
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/contexts"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/model"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestWorkOrderAccess(t *testing.T) {
|
||||
ctx := setupBatchExecutionLogListDB(t)
|
||||
if _, err := g.DB().Exec(ctx, "CREATE TABLE t_wo_main (id INTEGER, wo_no TEXT, wo_type TEXT, related_id TEXT, title TEXT, content TEXT, attachment TEXT, creator_type TEXT, creator_dept_id TEXT, creator_user_id INTEGER, creator_name TEXT, creator_phone TEXT, assign_dept_id TEXT, assign_user_id TEXT, current_status TEXT, handler_dept_id INTEGER, handler_user_id INTEGER, first_response_time TEXT, resolve_time TEXT, close_time TEXT, satisfaction_level TEXT, eval_content TEXT, eval_time TEXT, remark TEXT, tenant_id TEXT, created_dept TEXT, created_by TEXT, created_at TEXT, updated_by TEXT, updated_at TEXT)"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := g.DB().Exec(ctx, "INSERT INTO t_wo_main (id,wo_no,tenant_id,creator_user_id,handler_user_id,handler_dept_id) VALUES (1, 'WO', 'A', 10, 20, 30)"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, item := range []struct {
|
||||
name, tenant string
|
||||
user, dept int64
|
||||
allowed bool
|
||||
}{
|
||||
{"creator", "A", 10, 40, true}, {"handler", "A", 20, 40, true}, {"handler department", "A", 21, 30, true}, {"unrelated", "A", 11, 40, false}, {"other tenant", "B", 10, 30, false}, {"anonymous", "A", 0, 30, false},
|
||||
} {
|
||||
t.Run(item.name, func(t *testing.T) {
|
||||
scoped := contexts.Set(ctx, &model.Context{User: &model.Identity{BaseClaims: model.BaseClaims{ID: item.user, DeptId: item.dept, TenantId: item.tenant}}})
|
||||
_, err := getWorkOrder(scoped, "WO")
|
||||
if (err == nil) != item.allowed {
|
||||
t.Fatalf("allowed=%v error=%v", item.allowed, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
t.Run("状态记录", func(t *testing.T) {
|
||||
testWorkOrderRecordStatuses(t, ctx)
|
||||
})
|
||||
}
|
||||
@@ -2,13 +2,13 @@ package common
|
||||
|
||||
import (
|
||||
"context"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/dao"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/model/do"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/model/entity"
|
||||
"github.com/gogf/gf/v2/database/gdb"
|
||||
"github.com/gogf/gf/v2/encoding/gjson"
|
||||
"github.com/gogf/gf/v2/errors/gerror"
|
||||
"github.com/gogf/gf/v2/os/gtime"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/dao"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/model/do"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/model/entity"
|
||||
"net/url"
|
||||
)
|
||||
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
package common
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/dao"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/contexts"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/model"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/model/do"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/model/entity"
|
||||
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
)
|
||||
|
||||
func testWorkOrderRecordStatuses(t *testing.T, ctx context.Context) {
|
||||
t.Helper()
|
||||
for _, statement := range []string{
|
||||
"CREATE TABLE sys_dept (dept_id INTEGER PRIMARY KEY, parent_id INTEGER, leader TEXT, tenant_id TEXT)",
|
||||
"CREATE TABLE t_wo_record (id INTEGER PRIMARY KEY, wo_no TEXT, operation_type INTEGER, operation_dept_id INTEGER, operation_user_id INTEGER, target_dept_id INTEGER, target_user_id INTEGER, before_status INTEGER, after_status INTEGER, tenant_id TEXT, created_dept INTEGER, created_by INTEGER, created_at TEXT, updated_by INTEGER, updated_at TEXT)",
|
||||
} {
|
||||
if _, err := g.DB().Exec(ctx, statement); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if err := g.DB().GetCore().ClearTableFieldsAll(ctx); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ctx = contexts.Set(ctx, &model.Context{User: &model.Identity{BaseClaims: model.BaseClaims{ID: 7, DeptId: 10, TenantId: "A"}}})
|
||||
if _, err := dao.SysDept.Ctx(ctx).Data(do.SysDept{DeptId: 10, ParentId: 0, Leader: "7", TenantId: "A"}).Insert(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
t.Run("创建记录与主表状态一致", func(t *testing.T) {
|
||||
title := "工单状态回归"
|
||||
input := &model.TWoMainEditParam{Title: &title}
|
||||
if err := NewCommonTWoMain().Edit(ctx, input); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var main entity.TWoMain
|
||||
if err := dao.TWoMain.Ctx(ctx).Where(dao.TWoMain.Columns().WoNo, *input.WoNo).Scan(&main); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var record entity.TWoRecord
|
||||
if err := dao.TWoRecord.Ctx(ctx).Where(dao.TWoRecord.Columns().WoNo, *input.WoNo).Scan(&record); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if main.CurrentStatus != workOrderStatusProcessing || record.AfterStatus != main.CurrentStatus || record.BeforeStatus != 0 || record.OperationType != workOrderOperationCreate {
|
||||
t.Fatalf("主表与创建记录状态不一致: main=%+v record=%+v", main, record)
|
||||
}
|
||||
if record.OperationUserId != 7 || record.OperationDeptId != 10 || record.TenantId != "A" {
|
||||
t.Fatalf("操作人或租户信息错误: %+v", record)
|
||||
}
|
||||
missing, err := dao.TWoRecord.Ctx(ctx).Where(dao.TWoRecord.Columns().Id, record.Id).WhereNull(dao.TWoRecord.Columns().BeforeStatus).Count()
|
||||
if err != nil || missing != 0 {
|
||||
t.Fatalf("创建前状态应显式写入0: missing=%d err=%v", missing, err)
|
||||
}
|
||||
})
|
||||
|
||||
for _, item := range []struct {
|
||||
name string
|
||||
operation, before, after int
|
||||
}{
|
||||
{"分派", workOrderOperationAssign, workOrderStatusAssigned, workOrderStatusProcessing},
|
||||
{"处理", workOrderOperationProcess, workOrderStatusResolved, workOrderStatusProcessing},
|
||||
{"完成", workOrderOperationFinish, workOrderStatusProcessing, workOrderStatusResolved},
|
||||
{"关单", workOrderOperationClose, workOrderStatusResolved, workOrderStatusClosed},
|
||||
{"取消", workOrderOperationCancel, workOrderStatusProcessing, workOrderStatusCanceled},
|
||||
} {
|
||||
t.Run(item.name, func(t *testing.T) {
|
||||
input := &model.TWoRecordAddOperationParam{WoNo: item.name, OperationType: item.operation, BeforeStatus: item.before, AfterStatus: item.after}
|
||||
if err := NewCommonTWoRecord().AddOperation(ctx, input); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var record entity.TWoRecord
|
||||
if err := dao.TWoRecord.Ctx(ctx).Where(dao.TWoRecord.Columns().WoNo, item.name).Scan(&record); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if record.OperationType != item.operation || record.BeforeStatus != item.before || record.AfterStatus != item.after {
|
||||
t.Fatalf("操作记录状态错误: %+v", record)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
// Package gen
|
||||
// @Link https://github.com/xiujiecn/xiu-admin
|
||||
// @Copyright Copyright (c) 2025 LiXiujie
|
||||
// @Author Lxj <li@xiujie.cn>
|
||||
// @Author Lxj <xxx@example.com>
|
||||
// @License https://github.com/xiujiecn/xiu-admin/blob/master/LICENSE
|
||||
// @AutoGenerate Version
|
||||
package gen
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// package gen_codes
|
||||
// @Link https://github.com/xiujiecn/xiu-admin
|
||||
// @Copyright Copyright (c) 2025 LiXiujie
|
||||
// @Author Lxj <li@xiujie.cn>
|
||||
// @Author Lxj <xxx@example.com>
|
||||
// @License https://github.com/xiujiecn/xiu-admin/blob/master/LICENSE
|
||||
package gen_codes
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// package gen_codes
|
||||
// @Link https://github.com/xiujiecn/xiu-admin
|
||||
// @Copyright Copyright (c) 2025 LiXiujie
|
||||
// @Author Lxj <li@xiujie.cn>
|
||||
// @Author Lxj <xxx@example.com>
|
||||
// @License https://github.com/xiujiecn/xiu-admin/blob/master/LICENSE
|
||||
package gen_codes
|
||||
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
package logic
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/service"
|
||||
)
|
||||
|
||||
func TestImeiQueueRegisteredAtApplicationStartup(t *testing.T) {
|
||||
if service.ImeiQueue() == nil {
|
||||
t.Fatal("IMEI queue implementation is not registered")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
package imeiqueue
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"reflect"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/consts"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/model"
|
||||
)
|
||||
|
||||
func stubImeiTrigger(t *testing.T) {
|
||||
t.Helper()
|
||||
var (
|
||||
now = imeiNow
|
||||
runID = imeiRunID
|
||||
enabled = imeiRabbitEnabled
|
||||
publish = imeiPublish
|
||||
)
|
||||
t.Cleanup(func() {
|
||||
imeiNow, imeiRunID, imeiRabbitEnabled, imeiPublish = now, runID, enabled, publish
|
||||
})
|
||||
imeiNow = func() time.Time { return time.Date(2026, 10, 13, 4, 0, 0, 0, time.FixedZone("UTC+8", 8*60*60)) }
|
||||
imeiRunID = func() string { return "test-run" }
|
||||
imeiRabbitEnabled = func(context.Context) bool { return true }
|
||||
}
|
||||
|
||||
func TestImeiTriggerPublishesOneRabbitMQStartWithScope(t *testing.T) {
|
||||
stubImeiTrigger(t)
|
||||
if consts.QueueImeiQueueStart != "iotlcm.imei.queue.start" {
|
||||
t.Fatal("IMEI start queue does not match Worker")
|
||||
}
|
||||
calls := 0
|
||||
var messages []model.ImeiQueueStartMessage
|
||||
imeiPublish = func(ctx context.Context, queue string, data any) error {
|
||||
calls++
|
||||
if ctx == nil || queue != consts.QueueImeiQueueStart {
|
||||
t.Errorf("queue=%q, context=%v", queue, ctx)
|
||||
}
|
||||
messages = append(messages, data.(model.ImeiQueueStartMessage))
|
||||
return nil
|
||||
}
|
||||
for _, scope := range []*model.SyncScope{nil, {Channels: []string{}}, {Channels: []string{"b", "a", "b"}, TriggerSource: model.TriggerSourceManual}} {
|
||||
runID, err := New().StartImeiRunScoped(nil, scope)
|
||||
if err != nil || runID != "imei-20261013040000-test-run" {
|
||||
t.Fatalf("run=%q, error=%v", runID, err)
|
||||
}
|
||||
}
|
||||
if calls != 3 || messages[0].Scope != nil || messages[1].Scope != nil {
|
||||
t.Fatalf("calls=%d, messages=%+v", calls, messages)
|
||||
}
|
||||
message := messages[2]
|
||||
if message.Operation != "imei_full_sync" || message.TriggeredAt != "2026-10-13T04:00:00+08:00" || !reflect.DeepEqual(message.Scope.Channels, []string{"a", "b"}) || message.Scope.TriggerSource != model.TriggerSourceManual {
|
||||
t.Fatalf("message=%+v", message)
|
||||
}
|
||||
for i, message := range messages {
|
||||
data, err := json.Marshal(message)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var body map[string]any
|
||||
if err := json.Unmarshal(data, &body); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if body["runId"] != message.RunID || body["operation"] != "imei_full_sync" || body["triggeredAt"] != message.TriggeredAt {
|
||||
t.Fatalf("Worker JSON contract: %s", data)
|
||||
}
|
||||
if _, present := body["scope"]; present != (i == 2) {
|
||||
t.Fatalf("full/scoped message compatibility: %s", data)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestImeiTriggerRejectsInvalidScopeDisabledMQAndPublishFailure(t *testing.T) {
|
||||
stubImeiTrigger(t)
|
||||
calls := 0
|
||||
publishErr := errors.New("broker unavailable")
|
||||
imeiPublish = func(context.Context, string, any) error { calls++; return publishErr }
|
||||
if _, err := New().StartImeiRunScoped(context.Background(), &model.SyncScope{Channels: []string{"bad/channel"}}); err == nil {
|
||||
t.Fatal("invalid scope accepted")
|
||||
}
|
||||
imeiRabbitEnabled = func(context.Context) bool { return false }
|
||||
if _, err := New().StartImeiRun(context.Background()); err == nil {
|
||||
t.Fatal("disabled RabbitMQ accepted")
|
||||
}
|
||||
if calls != 0 {
|
||||
t.Fatal("invalid scope or disabled RabbitMQ invoked publisher")
|
||||
}
|
||||
imeiRabbitEnabled = func(context.Context) bool { return true }
|
||||
if runID, err := New().StartImeiRun(context.Background()); runID != "" || !errors.Is(err, publishErr) {
|
||||
t.Fatalf("run=%q, error=%v", runID, err)
|
||||
}
|
||||
if calls != 1 {
|
||||
t.Fatalf("failed publish retried unexpectedly: %d", calls)
|
||||
}
|
||||
}
|
||||
@@ -25,7 +25,7 @@ func (s *Service) UpdateCardsAfterRenew(ctx context.Context, tx gdb.TX, renew *R
|
||||
if bind == nil {
|
||||
return gerror.Newf("ICCID续费订购记录不存在:%s", iccid)
|
||||
}
|
||||
packageStatus := CalcPackageStatus(now, card.FlowPkgBeginAt, bind.ExpectedExpireTime)
|
||||
packageStatus, _ := calcPackageStatuses(now, bind.EffectiveTime, bind.ExpectedExpireTime)
|
||||
result, err := tx.Ctx(ctx).Model(dao.TSimCardInfo.Table()).
|
||||
Where(dao.TSimCardInfo.Columns().Iccid, iccid).
|
||||
Data(do.TSimCardInfo{
|
||||
|
||||
@@ -187,6 +187,7 @@ func (s *Service) RenewCurrentPackage(ctx context.Context, tx gdb.TX, in *RenewC
|
||||
latestBind := latestBindsByIccid[iccid]
|
||||
periodBegin := renewPeriodBeginAt(renewBaseExpireTime(card, latestBind), in.PackageBeginTime)
|
||||
periodEnd := addMonths(periodBegin, durationMonths+giftDurationMonths)
|
||||
_, effectiveStatus := calcPackageStatuses(now, periodBegin, periodEnd)
|
||||
subscriptionNo, err := batchimport.NextRandomNo(subscriptionNoPrefix)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -201,7 +202,7 @@ func (s *Service) RenewCurrentPackage(ctx context.Context, tx gdb.TX, in *RenewC
|
||||
PackageCode: pkg.PackageCode,
|
||||
PreviousPackageCode: currentPackageCode,
|
||||
BusinessType: consts.PackageBusinessTypeRenew,
|
||||
EffectiveStatus: consts.EffectiveStatusPending,
|
||||
EffectiveStatus: effectiveStatus,
|
||||
HandleTime: now,
|
||||
EffectiveTime: periodBegin,
|
||||
ExpectedExpireTime: periodEnd,
|
||||
@@ -227,7 +228,7 @@ func (s *Service) RenewCurrentPackage(ctx context.Context, tx gdb.TX, in *RenewC
|
||||
PackageCode: pkg.PackageCode,
|
||||
PreviousPackageCode: currentPackageCode,
|
||||
BusinessType: consts.PackageBusinessTypeRenew,
|
||||
EffectiveStatus: consts.EffectiveStatusPending,
|
||||
EffectiveStatus: effectiveStatus,
|
||||
HandleTime: now,
|
||||
EffectiveTime: periodBegin,
|
||||
ExpectedExpireTime: periodEnd,
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
package cardpackage
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/consts"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/model/entity"
|
||||
)
|
||||
|
||||
func TestValidateRenewCardStatusRejectsStoppedCard(t *testing.T) {
|
||||
err := validateRenewCardStatus("89860401788404184188", &entity.TSimCardInfo{
|
||||
BossCardStatus: consts.V3ApiSimBossCardStatusStopped,
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("validateRenewCardStatus() error = nil, want stopped card error")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "当前为停机状态") {
|
||||
t.Fatalf("validateRenewCardStatus() error = %q, want stopped card error", err.Error())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
package cardpackage
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestCardPackageBindChunkSizeSupportsFiftyThousandCards(t *testing.T) {
|
||||
const totalCards = 50000
|
||||
if cardPackageBindInsertBatchSize <= 0 || cardPackageBindInsertBatchSize > 500 {
|
||||
t.Fatalf("cardPackageBindInsertBatchSize = %d, want 1..500", cardPackageBindInsertBatchSize)
|
||||
}
|
||||
if chunks := (totalCards + cardPackageBindInsertBatchSize - 1) / cardPackageBindInsertBatchSize; chunks != 100 {
|
||||
t.Fatalf("bind insert chunks = %d, want 100", chunks)
|
||||
}
|
||||
}
|
||||
@@ -506,6 +506,10 @@ func CalcPackageStatus(now, beginAt, endAt *gtime.Time) int {
|
||||
return consts.PackageStatusEffective
|
||||
}
|
||||
|
||||
func calcPackageStatuses(now, beginAt, endAt *gtime.Time) (packageStatus, effectiveStatus int) {
|
||||
return CalcPackageStatus(now, beginAt, endAt), CalcEffectiveStatus(now, beginAt, endAt)
|
||||
}
|
||||
|
||||
func CalcEffectiveStatus(now, beginAt, endAt *gtime.Time) int {
|
||||
if beginAt != nil && beginAt.After(now) {
|
||||
return consts.EffectiveStatusPending
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
package cardpackage
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/consts"
|
||||
|
||||
"github.com/gogf/gf/v2/os/gtime"
|
||||
)
|
||||
|
||||
func TestOrderTypeForBusinessType(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
businessType int
|
||||
want int
|
||||
}{
|
||||
{name: "subscribe", businessType: consts.PackageBusinessTypeSubscribe, want: consts.OrderTypeCardSubscribe},
|
||||
{name: "renew", businessType: consts.PackageBusinessTypeRenew, want: consts.OrderTypeCardRenew},
|
||||
{name: "change", businessType: consts.PackageBusinessTypeChange, want: consts.OrderTypeCardChange},
|
||||
{name: "unknown defaults to subscribe", businessType: 99, want: consts.OrderTypeCardSubscribe},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
if got := orderTypeForBusinessType(test.businessType); got != test.want {
|
||||
t.Fatalf("orderTypeForBusinessType(%d) = %d, want %d", test.businessType, got, test.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCalcPackageStatuses(t *testing.T) {
|
||||
now := gtime.NewFromStr("2026-10-27 12:00:00")
|
||||
tests := []struct {
|
||||
name string
|
||||
beginAt, endAt *gtime.Time
|
||||
wantPackage, wantBind int
|
||||
}{
|
||||
{
|
||||
name: "renew period already effective",
|
||||
beginAt: gtime.NewFromStr("2026-09-30 23:59:59"),
|
||||
endAt: gtime.NewFromStr("2027-09-30 23:59:59"),
|
||||
wantPackage: consts.PackageStatusEffective,
|
||||
wantBind: consts.EffectiveStatusEffective,
|
||||
},
|
||||
{
|
||||
name: "renew period starts in future",
|
||||
beginAt: gtime.NewFromStr("2026-11-01 23:59:59"),
|
||||
endAt: gtime.NewFromStr("2027-11-01 23:59:59"),
|
||||
wantPackage: consts.PackageStatusPending,
|
||||
wantBind: consts.EffectiveStatusPending,
|
||||
},
|
||||
{
|
||||
name: "renew period already expired",
|
||||
beginAt: gtime.NewFromStr("2025-09-30 23:59:59"),
|
||||
endAt: gtime.NewFromStr("2026-09-30 23:59:59"),
|
||||
wantPackage: consts.PackageStatusExpired,
|
||||
wantBind: consts.EffectiveStatusExpired,
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
packageStatus, effectiveStatus := calcPackageStatuses(now, test.beginAt, test.endAt)
|
||||
if packageStatus != test.wantPackage || effectiveStatus != test.wantBind {
|
||||
t.Fatalf("calcPackageStatuses() = (%d, %d), want (%d, %d)", packageStatus, effectiveStatus, test.wantPackage, test.wantBind)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
package iot
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/dao"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/contexts"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/model"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/model/entity"
|
||||
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
)
|
||||
|
||||
type channelDisplayScope struct {
|
||||
TenantID string
|
||||
DeptID int64
|
||||
FullName bool
|
||||
}
|
||||
|
||||
// Channel names are private to the tenant's top organization. A broad data
|
||||
// scope or an administrator role does not grant downstream organizations names.
|
||||
func canViewChannelFullName(ctx context.Context) bool {
|
||||
return resolveChannelDisplayScope(ctx, func() (*entity.SysDept, error) {
|
||||
var dept *entity.SysDept
|
||||
cols := dao.SysDept.Columns()
|
||||
err := dao.SysDept.Ctx(ctx).Fields(cols.DeptId, cols.ParentId, cols.Ancestors).
|
||||
Where(cols.TenantId, contexts.GetTenantId(ctx)).
|
||||
Where(cols.DeptId, contexts.GetDeptId(ctx)).Scan(&dept)
|
||||
return dept, err
|
||||
})
|
||||
}
|
||||
|
||||
func resolveChannelDisplayScope(ctx context.Context, load func() (*entity.SysDept, error)) bool {
|
||||
user := contexts.GetUser(ctx)
|
||||
if user == nil || user.DeptId <= 0 || strings.TrimSpace(user.TenantId) == "" {
|
||||
return false
|
||||
}
|
||||
const key = "iot.channelDisplayScope"
|
||||
if cached, ok := contexts.GetDataValue(ctx, key).(channelDisplayScope); ok && cached.TenantID == user.TenantId && cached.DeptID == user.DeptId {
|
||||
return cached.FullName
|
||||
}
|
||||
dept, err := load()
|
||||
fullName := err == nil && dept != nil && dept.DeptId == user.DeptId &&
|
||||
(dept.ParentId == 0 || strings.Trim(dept.Ancestors, ",") == "0")
|
||||
if err != nil {
|
||||
g.Log().Warningf(ctx, "resolve channel display organization failed: %v", err)
|
||||
}
|
||||
contexts.SetDataValue(ctx, key, channelDisplayScope{TenantID: user.TenantId, DeptID: user.DeptId, FullName: fullName})
|
||||
return fullName
|
||||
}
|
||||
|
||||
func channelDisplayNameColumn(ctx context.Context) string {
|
||||
if canViewChannelFullName(ctx) {
|
||||
return dao.TSimChannels.Columns().ChanName
|
||||
}
|
||||
return dao.TSimChannels.Columns().ChanAlias
|
||||
}
|
||||
|
||||
func channelDisplayNameSQL(ctx context.Context, table string) string {
|
||||
return fmt.Sprintf("COALESCE(NULLIF(TRIM(%s.%s), ''), '--')", table, channelDisplayNameColumn(ctx))
|
||||
}
|
||||
|
||||
func channelDisplayName(fullName bool, name, alias string) string {
|
||||
if fullName {
|
||||
return name
|
||||
}
|
||||
if alias = strings.TrimSpace(alias); alias != "" {
|
||||
return alias
|
||||
}
|
||||
return "--"
|
||||
}
|
||||
|
||||
func applyCardChannelDisplayNames(ctx context.Context, list []*model.TSimCardInfoListModel) {
|
||||
if len(list) == 0 {
|
||||
return
|
||||
}
|
||||
fullName := canViewChannelFullName(ctx)
|
||||
for _, item := range list {
|
||||
if item != nil {
|
||||
item.ChanName = channelDisplayName(fullName, item.ChanName, item.ChanAlias)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,232 @@
|
||||
package iot
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
_ "github.com/gogf/gf/contrib/drivers/sqlite/v2"
|
||||
"github.com/gogf/gf/v2/database/gdb"
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/consts"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/contexts"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/model"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/model/entity"
|
||||
)
|
||||
|
||||
func channelDisplayTestContext(tenant string, dept int64) context.Context {
|
||||
return contexts.Set(context.Background(), &model.Context{User: &model.Identity{BaseClaims: model.BaseClaims{
|
||||
ID: 1, TenantId: tenant, DeptId: dept,
|
||||
Roles: []model.Role{{RoleId: consts.SuperAdminRoleId, DataScope: consts.SysRoleDataScopeAll}},
|
||||
}}})
|
||||
}
|
||||
|
||||
func TestChannelDisplayScope(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
dept *entity.SysDept
|
||||
err error
|
||||
want bool
|
||||
}{
|
||||
{name: "top organization", dept: &entity.SysDept{DeptId: 100, ParentId: 0}, want: true},
|
||||
{name: "top organization legacy ancestors", dept: &entity.SysDept{DeptId: 100, ParentId: 1, Ancestors: "0"}, want: true},
|
||||
{name: "downstream super admin still uses alias", dept: &entity.SysDept{DeptId: 100, ParentId: 50, Ancestors: "0,50"}},
|
||||
{name: "missing organization"},
|
||||
{name: "different organization", dept: &entity.SysDept{DeptId: 999, ParentId: 0}},
|
||||
{name: "lookup failure", err: errors.New("lookup failed")},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
ctx := channelDisplayTestContext("tenant-A", 100)
|
||||
got := resolveChannelDisplayScope(ctx, func() (*entity.SysDept, error) { return tc.dept, tc.err })
|
||||
if got != tc.want {
|
||||
t.Fatalf("full name = %v, want %v", got, tc.want)
|
||||
}
|
||||
if got := canViewChannelFullName(ctx); got != tc.want {
|
||||
t.Fatalf("cached scope = %v", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
for _, ctx := range []context.Context{context.Background(), channelDisplayTestContext("", 100), channelDisplayTestContext("tenant-A", 0)} {
|
||||
if resolveChannelDisplayScope(ctx, func() (*entity.SysDept, error) {
|
||||
t.Fatal("invalid identity should not query organizations")
|
||||
return nil, nil
|
||||
}) {
|
||||
t.Fatal("invalid identity got full names")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestChannelDisplayScopeCacheIdentity(t *testing.T) {
|
||||
ctx := channelDisplayTestContext("tenant-A", 100)
|
||||
calls := 0
|
||||
load := func() (*entity.SysDept, error) {
|
||||
calls++
|
||||
return &entity.SysDept{DeptId: contexts.GetDeptId(ctx), ParentId: 0}, nil
|
||||
}
|
||||
if !resolveChannelDisplayScope(ctx, load) || !resolveChannelDisplayScope(ctx, load) || calls != 1 {
|
||||
t.Fatal("scope not cached for same identity")
|
||||
}
|
||||
contexts.GetUser(ctx).DeptId = 200
|
||||
resolveChannelDisplayScope(ctx, load)
|
||||
contexts.GetUser(ctx).TenantId = "tenant-B"
|
||||
resolveChannelDisplayScope(ctx, load)
|
||||
if calls != 3 {
|
||||
t.Fatalf("scope reused across organization or tenant: %d calls", calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestChannelDisplayAliasAndExport(t *testing.T) {
|
||||
ctx := channelDisplayTestContext("tenant-A", 200)
|
||||
resolveChannelDisplayScope(ctx, func() (*entity.SysDept, error) {
|
||||
return &entity.SysDept{DeptId: 200, ParentId: 100, Ancestors: "0,100"}, nil
|
||||
})
|
||||
list := []*model.TSimCardInfoListModel{
|
||||
{ChanName: "PRIVATE UPSTREAM NAME", ChanAlias: " 中国移动223 "},
|
||||
{ChanName: "PRIVATE UPSTREAM NAME", ChanAlias: " "},
|
||||
nil,
|
||||
}
|
||||
applyCardChannelDisplayNames(ctx, list)
|
||||
if list[0].ChanName != "中国移动223" || list[1].ChanName != "--" {
|
||||
t.Fatalf("name leak: %+v %+v", list[0], list[1])
|
||||
}
|
||||
for _, card := range list[:2] {
|
||||
row := cancellationExportRow(&model.TSimCardCancellationListModel{ChanName: card.ChanName})
|
||||
if row[7] != card.ChanName {
|
||||
t.Fatalf("export name differs: %v", row[7])
|
||||
}
|
||||
}
|
||||
if channelDisplayName(true, "full name", "alias") != "full name" {
|
||||
t.Fatal("top organization name changed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestChannelDisplaySQL(t *testing.T) {
|
||||
db, err := gdb.New(gdb.ConfigNode{Type: "sqlite", Link: "sqlite::@file(:memory:)", MaxOpenConnCount: 1})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = db.Close(context.Background()) })
|
||||
if _, err = db.Exec(context.Background(), "CREATE TABLE channels(chan_name TEXT,chan_alias TEXT)"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, alias := range []interface{}{"中国移动223", "", " ", nil} {
|
||||
if _, err = db.Exec(context.Background(), "INSERT INTO channels VALUES(?,?)", "PRIVATE UPSTREAM NAME", alias); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
for _, top := range []bool{false, true} {
|
||||
ctx := channelDisplayTestContext("tenant-A", 100)
|
||||
parent := int64(50)
|
||||
if top {
|
||||
parent = 0
|
||||
}
|
||||
resolveChannelDisplayScope(ctx, func() (*entity.SysDept, error) {
|
||||
return &entity.SysDept{DeptId: 100, ParentId: parent, Ancestors: "0,50"}, nil
|
||||
})
|
||||
values, err := db.GetArray(ctx, "SELECT "+channelDisplayNameSQL(ctx, "channels")+" FROM channels ORDER BY rowid")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for i, v := range values {
|
||||
want := "--"
|
||||
if i == 0 {
|
||||
want = "中国移动223"
|
||||
}
|
||||
if top {
|
||||
want = "PRIVATE UPSTREAM NAME"
|
||||
}
|
||||
if v.String() != want {
|
||||
t.Fatalf("top=%v row=%d got=%q want=%q", top, i, v.String(), want)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Exercise the real list query and export formatter against an isolated database.
|
||||
func TestChannelDisplayCancellationList(t *testing.T) {
|
||||
previous := gdb.GetConfig("default")
|
||||
if err := gdb.SetConfig(gdb.Config{"default": gdb.ConfigGroup{{Type: "sqlite", Link: "sqlite::@file(:memory:)", MaxOpenConnCount: 1}}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
db := g.DB()
|
||||
t.Cleanup(func() { _ = db.Close(context.Background()); _ = gdb.SetConfig(gdb.Config{"default": previous}) })
|
||||
statements := []string{
|
||||
"CREATE TABLE sys_dept(dept_id INTEGER,tenant_id TEXT,parent_id INTEGER,ancestors TEXT,dept_name TEXT,deleted_at TEXT)",
|
||||
"INSERT INTO sys_dept VALUES(100,'tenant-A',0,'0','Top',NULL),(200,'tenant-A',100,'0,100','Customer',NULL)",
|
||||
"CREATE TABLE t_sim_channels(id INTEGER,temp_key TEXT,chan_key TEXT,tenant_id TEXT,chan_name TEXT,chan_alias TEXT)",
|
||||
"CREATE TABLE t_sim_channel_template(temp_key TEXT,temp_name TEXT,temp_model TEXT)",
|
||||
"INSERT INTO t_sim_channels(id,chan_key,tenant_id,chan_name,chan_alias) VALUES(1,'SHARED','tenant-A','PRIVATE NAME A','中国移动223'),(2,'SHARED','tenant-B','PRIVATE NAME B','Other tenant alias')",
|
||||
"CREATE TABLE t_sim_flow_package_info(package_code TEXT,package_name TEXT,tenant_id TEXT)",
|
||||
"INSERT INTO t_sim_flow_package_info VALUES('PKG','套餐A','tenant-A'),('PKG','套餐B','tenant-B')",
|
||||
"CREATE TABLE t_sim_card_cancellation(id INTEGER,iccid TEXT,msisdn TEXT,imsi TEXT,imei TEXT,dept_id INTEGER,chan_key TEXT,cancel_batch_no TEXT,cancelled_at TEXT,boss_card_status INTEGER,current_package_code TEXT,inbound_batch_no TEXT,outbound_batch_no TEXT,allocation_batch_no TEXT,card_type INTEGER,net_model INTEGER,remark TEXT,created_by INTEGER,tenant_id TEXT)",
|
||||
"INSERT INTO t_sim_card_cancellation(id,iccid,dept_id,chan_key,created_by,tenant_id,current_package_code) VALUES(1,'CARD-A',200,'SHARED',0,'tenant-A','PKG'),(2,'CARD-B',200,'SHARED',0,'tenant-B','PKG')",
|
||||
}
|
||||
for _, sql := range statements {
|
||||
if _, err := db.Exec(context.Background(), sql); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
for _, dept := range []int64{200, 100} {
|
||||
ctx := channelDisplayTestContext("tenant-A", dept)
|
||||
contexts.GetUser(ctx).Roles = []model.Role{{RoleId: 99, DataScope: consts.SysRoleDataScopeAll}}
|
||||
in := &model.TSimCardCancellationListParam{}
|
||||
in.Page = 1
|
||||
in.PageSize = 20
|
||||
list, total, err := NewIotTSimCardCancellation().List(ctx, in)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if total != 1 || len(list) != 1 || list[0].Iccid != "CARD-A" {
|
||||
t.Fatalf("cross tenant rows: total=%d list=%+v", total, list)
|
||||
}
|
||||
want := "中国移动223"
|
||||
if dept == 100 {
|
||||
want = "PRIVATE NAME A"
|
||||
}
|
||||
if list[0].PackageName != "套餐A" {
|
||||
t.Fatalf("cross tenant package: %q", list[0].PackageName)
|
||||
}
|
||||
if list[0].ChanName != want {
|
||||
t.Fatalf("dept=%d name=%q want=%q", dept, list[0].ChanName, want)
|
||||
}
|
||||
if row := cancellationExportRow(list[0]); row[7] != want {
|
||||
t.Fatalf("export name=%v", row[7])
|
||||
}
|
||||
channelQuery := &model.TSimChannelsListParam{}
|
||||
channelQuery.Page = 1
|
||||
channelQuery.PageSize = 20
|
||||
channelQuery.ChanName = want
|
||||
channels, count, err := NewIotTSimChannels().List(ctx, channelQuery)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if count != 1 || len(channels) != 1 || channels[0].ChanName != want {
|
||||
t.Fatalf("channel options: count=%d rows=%+v", count, channels)
|
||||
}
|
||||
stockNames, err := loadStockChannelNames(ctx, []string{"SHARED"})
|
||||
if err != nil || stockNames["SHARED"] != want {
|
||||
t.Fatalf("stock channel name=%v err=%v", stockNames, err)
|
||||
}
|
||||
channelView, err := NewIotTSimChannels().View(ctx, &model.TSimChannelsViewParam{Id: 1})
|
||||
if err != nil || channelView == nil || channelView.ChanName != want {
|
||||
t.Fatalf("channel view=%+v err=%v", channelView, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestChannelDisplayCancellationMissingAlias(t *testing.T) {
|
||||
ctx := channelDisplayTestContext("tenant-A", 200)
|
||||
resolveChannelDisplayScope(ctx, func() (*entity.SysDept, error) { return &entity.SysDept{DeptId: 200, ParentId: 100}, nil })
|
||||
for _, alias := range []string{"", " "} {
|
||||
name := channelDisplayName(canViewChannelFullName(ctx), "PRIVATE NAME", alias)
|
||||
encoded, err := json.Marshal(&model.TSimCardCancellationListModel{ChanName: name})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if strings.Contains(string(encoded), "PRIVATE NAME") || name != "--" {
|
||||
t.Fatalf("response exposes full name: %s", encoded)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
package iot
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestResolvePackageEffectiveSalePriceUsesSystemDefaults(t *testing.T) {
|
||||
got, err := resolvePackageEffectiveSalePrice(
|
||||
context.Background(),
|
||||
nil,
|
||||
"tenant-1",
|
||||
0,
|
||||
0,
|
||||
packagePriceSource{
|
||||
PackageCode: "PKG-1",
|
||||
CostPrice: 10,
|
||||
TerminalCost: 20,
|
||||
SysDefaultRatio: 80,
|
||||
SysDefaultProfit: 1,
|
||||
SysDefaultTerminalRatio: 50,
|
||||
SysDefaultTerminalProfit: 2,
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("resolvePackageEffectiveSalePrice() error = %v", err)
|
||||
}
|
||||
if got.AgentShipmentPrice != 9 {
|
||||
t.Fatalf("agent shipment price = %v, want 9", got.AgentShipmentPrice)
|
||||
}
|
||||
if got.TerminalSalePrice != 12 {
|
||||
t.Fatalf("terminal sale price = %v, want 12", got.TerminalSalePrice)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
package packageprice
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/model/entity"
|
||||
)
|
||||
|
||||
type fakePriceStore struct {
|
||||
parents map[int64]int64
|
||||
prices map[priceKey]*entity.TSimFlowPackagePrice
|
||||
}
|
||||
|
||||
type priceKey struct {
|
||||
deptID int64
|
||||
scopeType int
|
||||
targetAgentID int64
|
||||
packageCode string
|
||||
}
|
||||
|
||||
func (s fakePriceStore) ParentDeptID(ctx context.Context, tenantID string, deptID int64) (int64, error) {
|
||||
return s.parents[deptID], nil
|
||||
}
|
||||
|
||||
func (s fakePriceStore) PriceOverride(ctx context.Context, tenantID string, deptID int64, scopeType int, targetAgentID int64, packageCode string) (*entity.TSimFlowPackagePrice, error) {
|
||||
return s.prices[priceKey{
|
||||
deptID: deptID,
|
||||
scopeType: scopeType,
|
||||
targetAgentID: targetAgentID,
|
||||
packageCode: packageCode,
|
||||
}], nil
|
||||
}
|
||||
|
||||
func TestResolveEffectiveSalePriceUsesRecursiveParentCost(t *testing.T) {
|
||||
store := fakePriceStore{
|
||||
parents: map[int64]int64{
|
||||
117: 100,
|
||||
131: 117,
|
||||
},
|
||||
prices: map[priceKey]*entity.TSimFlowPackagePrice{
|
||||
{deptID: 100, scopeType: 1, targetAgentID: 117, packageCode: "yd-100m-3y"}: {
|
||||
Id: 86,
|
||||
AgentCostRatio: 1,
|
||||
AgentCostProfit: 4,
|
||||
UserCostRatio: 1,
|
||||
UserCostProfit: 4,
|
||||
},
|
||||
{deptID: 117, scopeType: 0, targetAgentID: 0, packageCode: "yd-100m-3y"}: {
|
||||
Id: 91,
|
||||
AgentCostRatio: 1,
|
||||
AgentCostProfit: 1,
|
||||
UserCostRatio: 1,
|
||||
UserCostProfit: 1,
|
||||
},
|
||||
{deptID: 117, scopeType: 1, targetAgentID: 131, packageCode: "yd-100m-3y"}: {
|
||||
Id: 92,
|
||||
AgentCostRatio: 1,
|
||||
AgentCostProfit: 5,
|
||||
UserCostRatio: 1,
|
||||
UserCostProfit: 5,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
got, err := resolveEffectiveSalePrice(
|
||||
context.Background(),
|
||||
store,
|
||||
"000000",
|
||||
117,
|
||||
131,
|
||||
Source{
|
||||
PackageCode: "yd-100m-3y",
|
||||
CostPrice: 10,
|
||||
TerminalCost: 10,
|
||||
SysDefaultRatio: 1,
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("resolveEffectiveSalePrice() error = %v", err)
|
||||
}
|
||||
if got.Base.AgentCost != 14 {
|
||||
t.Fatalf("base agent cost = %.2f, want 14.00", got.Base.AgentCost)
|
||||
}
|
||||
if got.AgentShipmentPrice != 19 {
|
||||
t.Fatalf("agent shipment price = %.2f, want 19.00", got.AgentShipmentPrice)
|
||||
}
|
||||
if got.Rule.PriceSource != "target" || got.Rule.TargetPriceId != 92 {
|
||||
t.Fatalf("rule = %+v, want target price 92", got.Rule)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
package allocate
|
||||
|
||||
import (
|
||||
"context"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"testing"
|
||||
|
||||
"github.com/xuri/excelize/v2"
|
||||
)
|
||||
|
||||
func TestProcessorTemplateHeaders(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
expected := []string{"ICCID", "MSISDN", "IMSI", "目标客户名称"}
|
||||
if !reflect.DeepEqual(NewProcessor().Config().TemplateHeaders, expected) {
|
||||
t.Fatalf("unexpected headers: %#v", NewProcessor().Config().TemplateHeaders)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseExcelFileRowsCustomerFacingColumns(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
filePath := filepath.Join(t.TempDir(), "allocate.xlsx")
|
||||
f := excelize.NewFile()
|
||||
headers := []any{"ICCID", "MSISDN", "IMSI", "目标客户名称"}
|
||||
if err := f.SetSheetRow("Sheet1", "A1", &headers); err != nil {
|
||||
t.Fatalf("set headers failed: %v", err)
|
||||
}
|
||||
row := []any{
|
||||
"8986000000000000001",
|
||||
"13800008000",
|
||||
"460001234567890",
|
||||
"示例企业",
|
||||
}
|
||||
if err := f.SetSheetRow("Sheet1", "A2", &row); err != nil {
|
||||
t.Fatalf("set row failed: %v", err)
|
||||
}
|
||||
if err := f.SaveAs(filePath); err != nil {
|
||||
t.Fatalf("save excel failed: %v", err)
|
||||
}
|
||||
_ = f.Close()
|
||||
|
||||
rows, err := ParseExcelFileRows(context.Background(), filePath)
|
||||
if err != nil {
|
||||
t.Fatalf("parse excel failed: %v", err)
|
||||
}
|
||||
if len(rows) != 1 {
|
||||
t.Fatalf("unexpected row count: %d", len(rows))
|
||||
}
|
||||
rowModel, ok := rows[0].(*Row)
|
||||
if !ok || rowModel == nil {
|
||||
t.Fatalf("unexpected row type: %#v", rows[0])
|
||||
}
|
||||
if rowModel.Iccid != "8986000000000000001" {
|
||||
t.Fatalf("unexpected iccid: %s", rowModel.Iccid)
|
||||
}
|
||||
if rowModel.Msisdn != "13800008000" {
|
||||
t.Fatalf("unexpected msisdn: %s", rowModel.Msisdn)
|
||||
}
|
||||
if rowModel.Imsi != "460001234567890" {
|
||||
t.Fatalf("unexpected imsi: %s", rowModel.Imsi)
|
||||
}
|
||||
if rowModel.TargetDeptName != "示例企业" {
|
||||
t.Fatalf("unexpected target dept name: %s", rowModel.TargetDeptName)
|
||||
}
|
||||
if rowModel.Remark != "" {
|
||||
t.Fatalf("unexpected remark: %s", rowModel.Remark)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessorConfigLimitsAllocationRows(t *testing.T) {
|
||||
config := NewProcessor().Config()
|
||||
if config.MaxRows != 10000 {
|
||||
t.Fatalf("MaxRows = %d, want 10000", config.MaxRows)
|
||||
}
|
||||
if config.MaxFileSize != 50<<20 {
|
||||
t.Fatalf("MaxFileSize = %d, want %d", config.MaxFileSize, 50<<20)
|
||||
}
|
||||
}
|
||||
@@ -102,6 +102,7 @@ func createPendingSplit(
|
||||
if err := createAllocateParent(ctx, req, p, parentBatchNo, importOss, chunkSize); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
children := make([]*batchimport.Result, 0)
|
||||
|
||||
if _, err := stream(ctx, func(rows []batchimport.Row) error {
|
||||
if len(rows) == 0 {
|
||||
@@ -115,16 +116,20 @@ func createPendingSplit(
|
||||
return err
|
||||
}
|
||||
}
|
||||
if err := markAllocateCrossChunkDuplicates(ctx, parentBatchNo, req.Operator.TenantId, rows); err != nil {
|
||||
priorFailures, err := markAllocateCrossChunkDuplicates(ctx, parentBatchNo, req.Operator.TenantId, rows)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
state.Valid -= priorFailures
|
||||
state.Failed += priorFailures
|
||||
|
||||
state.ChunkCount++
|
||||
result, err := createAllocateChunk(ctx, req, p, parentBatchNo, state.ChunkCount, rows, importOss, state)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return common.PublishBatchMessageStrict(ctx, result, consts.QueueBatchExecute, "allocate")
|
||||
children = append(children, result)
|
||||
return nil
|
||||
}); err != nil {
|
||||
markAllocateParentFailed(ctx, parentBatchNo, err.Error())
|
||||
return nil, err
|
||||
@@ -138,6 +143,12 @@ func createPendingSplit(
|
||||
markAllocateParentFailed(ctx, parentBatchNo, err.Error())
|
||||
return nil, err
|
||||
}
|
||||
for _, child := range children {
|
||||
if err := common.PublishBatchMessageStrict(ctx, child, consts.QueueBatchExecute, "allocate"); err != nil {
|
||||
markAllocateParentFailed(ctx, parentBatchNo, err.Error())
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
parentMessage := &batchimport.Result{
|
||||
BatchNo: parentBatchNo,
|
||||
@@ -397,9 +408,9 @@ func countBatchRows(rows []batchimport.Row) (valid, failed int) {
|
||||
return
|
||||
}
|
||||
|
||||
func markAllocateCrossChunkDuplicates(ctx context.Context, parentBatchNo, tenantID string, rows []batchimport.Row) error {
|
||||
func markAllocateCrossChunkDuplicates(ctx context.Context, parentBatchNo, tenantID string, rows []batchimport.Row) (int, error) {
|
||||
if strings.TrimSpace(parentBatchNo) == "" || len(rows) == 0 {
|
||||
return nil
|
||||
return 0, nil
|
||||
}
|
||||
columns := dao.BatchDetail.Columns()
|
||||
itemIDs := make([]string, 0, len(rows))
|
||||
@@ -420,24 +431,65 @@ func markAllocateCrossChunkDuplicates(ctx context.Context, parentBatchNo, tenant
|
||||
itemIDs = append(itemIDs, itemID)
|
||||
}
|
||||
if len(itemIDs) == 0 {
|
||||
return nil
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
var existing []struct {
|
||||
ItemId string `orm:"item_id"`
|
||||
Id int64 `orm:"id"`
|
||||
BatchNo string `orm:"batch_no"`
|
||||
ItemId string `orm:"item_id"`
|
||||
DetailStatus int `orm:"detail_status"`
|
||||
}
|
||||
query := g.DB().Model(dao.BatchDetail.Table()).
|
||||
Fields(columns.ItemId).
|
||||
Fields(columns.Id, columns.BatchNo, columns.ItemId, columns.DetailStatus).
|
||||
Where(columns.BatchType, consts.BatchTypeAllocate).
|
||||
Where(columns.TenantId, tenantID).
|
||||
WhereIn(columns.ItemId, itemIDs).
|
||||
Where("batch_no IN (SELECT batch_no FROM batch_main WHERE source_batch_no = ? AND batch_type = ? AND tenant_id = ?)",
|
||||
parentBatchNo, consts.BatchTypeAllocate, tenantID)
|
||||
if err := query.Scan(&existing); err != nil {
|
||||
return gerror.Wrap(err, "check duplicate allocate ICCIDs across chunks failed")
|
||||
return 0, gerror.Wrap(err, "check duplicate allocate ICCIDs across chunks failed")
|
||||
}
|
||||
if len(existing) == 0 {
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
priorFailures := 0
|
||||
err := g.DB().Transaction(ctx, func(ctx context.Context, tx gdb.TX) error {
|
||||
for _, detail := range existing {
|
||||
if detail.DetailStatus != consts.BatchDetailStatusPending {
|
||||
continue
|
||||
}
|
||||
result, err := tx.Ctx(ctx).Model(dao.BatchDetail.Table()).
|
||||
Where(columns.Id, detail.Id).
|
||||
Where(columns.TenantId, tenantID).
|
||||
Where(columns.DetailStatus, consts.BatchDetailStatusPending).
|
||||
Data(do.BatchDetail{DetailStatus: consts.BatchDetailStatusFailed, ErrorReason: "ICCID在划拨批次内重复"}).Update()
|
||||
if err != nil {
|
||||
return gerror.Wrap(err, "mark prior duplicate allocation detail failed")
|
||||
}
|
||||
affected, err := result.RowsAffected()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if affected == 0 {
|
||||
continue
|
||||
}
|
||||
priorFailures++
|
||||
mainColumns := dao.BatchMain.Columns()
|
||||
for _, batchNo := range []string{detail.BatchNo, parentBatchNo} {
|
||||
if _, err := tx.Ctx(ctx).Model(dao.BatchMain.Table()).
|
||||
Where(mainColumns.BatchNo, batchNo).
|
||||
Where(mainColumns.TenantId, tenantID).
|
||||
Data(do.BatchMain{ValidCount: gdb.Raw("valid_count - 1"), FailCount: gdb.Raw("fail_count + 1")}).Update(); err != nil {
|
||||
return gerror.Wrap(err, "update cross-chunk duplicate counts failed")
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
duplicates := make(map[string]struct{}, len(existing))
|
||||
@@ -452,10 +504,10 @@ func markAllocateCrossChunkDuplicates(ctx context.Context, parentBatchNo, tenant
|
||||
continue
|
||||
}
|
||||
if _, exists := duplicates[strings.TrimSpace(row.Iccid)]; exists {
|
||||
row.AddErrorReason("ICCID appears in an earlier allocation chunk")
|
||||
row.AddErrorReason("ICCID在划拨批次内重复")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
return priorFailures, nil
|
||||
}
|
||||
|
||||
func withAllocateInfo(info *gjson.Json, values map[string]any) *gjson.Json {
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
package allocate
|
||||
|
||||
import (
|
||||
"context"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/consts"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/batchimport"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/batchmetadata"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/model/entity"
|
||||
|
||||
"github.com/gogf/gf/contrib/drivers/sqlite/v2"
|
||||
"github.com/gogf/gf/v2/database/gdb"
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
)
|
||||
|
||||
type allocateMetadataSQLiteDriver struct {
|
||||
*sqlite.Driver
|
||||
}
|
||||
|
||||
func (driver *allocateMetadataSQLiteDriver) New(core *gdb.Core, _ *gdb.ConfigNode) (gdb.DB, error) {
|
||||
return &allocateMetadataSQLiteDriver{Driver: &sqlite.Driver{Core: core}}, nil
|
||||
}
|
||||
|
||||
func (driver *allocateMetadataSQLiteDriver) DoSelect(ctx context.Context, link gdb.Link, query string, args ...any) (gdb.Result, error) {
|
||||
return driver.Core.DoSelect(ctx, link, strings.TrimSuffix(query, " FOR UPDATE"), args...)
|
||||
}
|
||||
|
||||
func TestAllocateSplitInheritsRequestMetadata(t *testing.T) {
|
||||
if err := gdb.Register("allocate_metadata_sqlite", &allocateMetadataSQLiteDriver{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ctx := context.Background()
|
||||
if err := gdb.SetConfig(gdb.Config{"default": gdb.ConfigGroup{{Type: "allocate_metadata_sqlite", Name: ":memory:", MaxOpenConnCount: 1}}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = g.DB().Close(ctx) })
|
||||
if err := g.DB().GetCore().ClearTableFieldsAll(ctx); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for table, record := range map[string]any{"batch_main": entity.BatchMain{}, "batch_detail": entity.BatchDetail{}} {
|
||||
recordType := reflect.TypeOf(record)
|
||||
columns := make([]string, 0, recordType.NumField())
|
||||
for fieldIndex := 0; fieldIndex < recordType.NumField(); fieldIndex++ {
|
||||
field := recordType.Field(fieldIndex)
|
||||
column := field.Tag.Get("orm")
|
||||
if column == "id" {
|
||||
columns = append(columns, "id INTEGER PRIMARY KEY AUTOINCREMENT")
|
||||
} else {
|
||||
columns = append(columns, column+" TEXT")
|
||||
}
|
||||
}
|
||||
if _, err := g.DB().Exec(ctx, "CREATE TABLE "+table+" ("+strings.Join(columns, ",")+")"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := g.Model(table).Ctx(ctx).TableFields(table); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if _, err := g.DB().GetCore().GetTablesWithCache(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, mode := range []int{batchmetadata.Single, batchmetadata.Bulk} {
|
||||
parentNo := "META-SPLIT-SINGLE"
|
||||
if mode == batchmetadata.Bulk {
|
||||
parentNo = "META-SPLIT-BULK"
|
||||
}
|
||||
req := batchimport.SubmitRequest{SourceChannel: batchmetadata.Console, ExecutionMode: mode, SourceType: consts.BatchSourceTypeBatchInput, Operator: batchimport.OperatorContext{TenantId: "A", UserId: 7}}
|
||||
processor := NewProcessor()
|
||||
if err := createAllocateParent(ctx, req, processor, parentNo, nil, 100); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rows := []batchimport.Row{&Row{RowNo: 1, Iccid: "card1"}}
|
||||
result, err := createAllocateChunk(ctx, req, processor, parentNo, 1, rows, nil, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, batchNo := range []string{parentNo, result.BatchNo} {
|
||||
var batch *entity.BatchMain
|
||||
if err := g.Model("batch_main").Ctx(ctx).Where("batch_no", batchNo).Scan(&batch); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if batch == nil || batch.SourceChannel != req.SourceChannel || batch.ExecutionMode != uint(mode) || batch.SourceType != req.SourceType || batch.TotalCount != 1 {
|
||||
t.Fatalf("metadata not inherited: %+v", batch)
|
||||
}
|
||||
if batchNo != parentNo && batch.SourceBatchNo != parentNo {
|
||||
t.Fatalf("child lost parent association: %+v", batch)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
package change
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/batchimport"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/model"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/model/entity"
|
||||
|
||||
"github.com/xuri/excelize/v2"
|
||||
)
|
||||
|
||||
func TestExcelTemplateHeaders(t *testing.T) {
|
||||
got := ExcelTemplateHeaders()
|
||||
want := []string{"ICCID", "MSISDN", "IMSI", "newPackageCode", "durationMonths", "giftDurationMonths", "effectiveMode", "offlineContractCompleted"}
|
||||
if len(got) != len(want) {
|
||||
t.Fatalf("headers len = %d, want %d", len(got), len(want))
|
||||
}
|
||||
for i := range want {
|
||||
if got[i] != want[i] {
|
||||
t.Fatalf("header[%d] = %q, want %q", i, got[i], want[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseExcelFileRows(t *testing.T) {
|
||||
path := buildTestExcelFile(t, [][]string{
|
||||
ExcelTemplateHeaders(),
|
||||
{"8986001", "", "", "PKG_NEW", "12", "", "immediate", "true"},
|
||||
})
|
||||
|
||||
rows, opts, err := ParseExcelFileRows(context.Background(), path)
|
||||
if err != nil {
|
||||
t.Fatalf("ParseExcelFileRows error = %v", err)
|
||||
}
|
||||
if len(rows) != 1 {
|
||||
t.Fatalf("rows len = %d, want 1", len(rows))
|
||||
}
|
||||
if rows[0].GiftDurationMonths != 0 {
|
||||
t.Fatalf("giftDurationMonths = %d, want 0", rows[0].GiftDurationMonths)
|
||||
}
|
||||
if opts == nil || opts.GiftDurationMonths != 0 || !opts.OfflineContractCompleted {
|
||||
t.Fatalf("opts = %#v", opts)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseExcelFileRowsRequireTrueOfflineContractCompleted(t *testing.T) {
|
||||
path := buildTestExcelFile(t, [][]string{
|
||||
ExcelTemplateHeaders(),
|
||||
{"8986001", "", "", "PKG_NEW", "12", "", "immediate", "false"},
|
||||
})
|
||||
|
||||
rows, _, err := ParseExcelFileRows(context.Background(), path)
|
||||
if err != nil {
|
||||
t.Fatalf("ParseExcelFileRows error = %v", err)
|
||||
}
|
||||
if len(rows) != 1 {
|
||||
t.Fatalf("rows len = %d, want 1", len(rows))
|
||||
}
|
||||
if rows[0].Reason == "" {
|
||||
t.Fatal("expected row error when offlineContractCompleted is false")
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateChangeRowSourceBatchOptional(t *testing.T) {
|
||||
operator := batchimport.OperatorContext{DeptId: 100}
|
||||
row := &Row{Iccid: "8986001"}
|
||||
card := &entity.TSimCardInfo{
|
||||
Iccid: "8986001",
|
||||
Msisdn: "13800008000",
|
||||
Imsi: "460001234567890",
|
||||
DeptId: 100,
|
||||
CurrentPackageCode: "PKG_OLD",
|
||||
PackageStatus: 1,
|
||||
}
|
||||
bind := &entity.TSimCardBindPkg{
|
||||
Iccid: "8986001",
|
||||
Msisdn: "13800008000",
|
||||
PackageCode: "PKG_OLD",
|
||||
BatchNo: "BATCH-OTHER",
|
||||
}
|
||||
|
||||
if reason := validateChangeRow("", operator, row, card, bind); reason != "" {
|
||||
t.Fatalf("validateChangeRow without sourceBatchNo = %q, want empty", reason)
|
||||
}
|
||||
if reason := validateChangeRow("BATCH-REQ", operator, row, card, bind); reason == "" {
|
||||
t.Fatal("expected sourceBatchNo mismatch error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateChangeRowIdentityMatch(t *testing.T) {
|
||||
operator := batchimport.OperatorContext{DeptId: 100}
|
||||
card := &entity.TSimCardInfo{
|
||||
Iccid: "8986001",
|
||||
Msisdn: "13800008000",
|
||||
Imsi: "460001234567890",
|
||||
DeptId: 100,
|
||||
CurrentPackageCode: "PKG_OLD",
|
||||
PackageStatus: 1,
|
||||
}
|
||||
bind := &entity.TSimCardBindPkg{
|
||||
Iccid: "8986001",
|
||||
Msisdn: "13800008000",
|
||||
PackageCode: "PKG_OLD",
|
||||
}
|
||||
|
||||
if reason := validateChangeRow("", operator, &Row{Iccid: "8986001", Msisdn: "bad"}, card, bind); reason == "" {
|
||||
t.Fatal("expected MSISDN mismatch error")
|
||||
}
|
||||
if reason := validateChangeRow("", operator, &Row{Iccid: "8986001", Imsi: "bad"}, card, bind); reason == "" {
|
||||
t.Fatal("expected IMSI mismatch error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildBatchInfoOmitsEmptySourceBatchNo(t *testing.T) {
|
||||
p := NewProcessor()
|
||||
info := p.BuildBatchInfo(context.Background(), batchimport.SubmitRequest{
|
||||
BizParams: &model.TSimCardChangeBatchSubmitParam{
|
||||
NewPackageCode: "PKG_NEW",
|
||||
DurationMonths: 12,
|
||||
GiftDurationMonths: 0,
|
||||
EffectiveMode: "immediate",
|
||||
OfflineContractCompleted: true,
|
||||
},
|
||||
ExecutionResult: &CompleteOutput{
|
||||
SourceBatchNo: "",
|
||||
SourceMode: 0,
|
||||
NewPackageCode: "PKG_NEW",
|
||||
NewPackageName: "New Package",
|
||||
OldPackageCode: "PKG_OLD",
|
||||
OldPackageName: "Old Package",
|
||||
DurationMonths: 12,
|
||||
GiftDurationMonths: 0,
|
||||
EffectiveMode: "immediate",
|
||||
OfflineContractCompleted: true,
|
||||
},
|
||||
}, nil, nil, nil, 0)
|
||||
|
||||
if info == nil {
|
||||
t.Fatal("info is nil")
|
||||
}
|
||||
data := info.Map()
|
||||
if _, ok := data["sourceBatchNo"]; ok {
|
||||
t.Fatalf("sourceBatchNo should be omitted when empty: %#v", data)
|
||||
}
|
||||
if _, ok := data["remark"]; ok {
|
||||
t.Fatalf("remark should not exist: %#v", data)
|
||||
}
|
||||
if _, ok := data["buyerNote"]; ok {
|
||||
t.Fatalf("buyerNote should not exist: %#v", data)
|
||||
}
|
||||
if _, ok := data["adminNote"]; ok {
|
||||
t.Fatalf("adminNote should not exist: %#v", data)
|
||||
}
|
||||
}
|
||||
|
||||
func buildTestExcelFile(t *testing.T, rows [][]string) string {
|
||||
t.Helper()
|
||||
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "change.xlsx")
|
||||
f := excelize.NewFile()
|
||||
for i, row := range rows {
|
||||
cell, err := excelize.CoordinatesToCellName(1, i+1)
|
||||
if err != nil {
|
||||
t.Fatalf("CoordinatesToCellName error = %v", err)
|
||||
}
|
||||
if err = f.SetSheetRow("Sheet1", cell, &row); err != nil {
|
||||
t.Fatalf("SetSheetRow error = %v", err)
|
||||
}
|
||||
}
|
||||
if err := f.SaveAs(path); err != nil {
|
||||
t.Fatalf("SaveAs error = %v", err)
|
||||
}
|
||||
_ = f.Close()
|
||||
if _, err := os.Stat(path); err != nil {
|
||||
t.Fatalf("Stat error = %v", err)
|
||||
}
|
||||
return path
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/batchimport"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/model"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/model/entity"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/service"
|
||||
@@ -135,7 +136,7 @@ func validateTask(task *Task, cards map[string]*entity.TSimCardInfo) bool {
|
||||
if hasFailure {
|
||||
for _, row := range task.Rows {
|
||||
if row != nil && row.Reason == "" {
|
||||
row.AddErrorReason("同批次存在校验失败卡,本卡未请求上游")
|
||||
row.AddErrorReason(batchimport.NotExecutedReasonPrefix + "本卡校验通过")
|
||||
}
|
||||
}
|
||||
task.FailureReason = "整批业务校验失败,未请求上游"
|
||||
|
||||
@@ -0,0 +1,290 @@
|
||||
package lifecycle
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/consts"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/model"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/model/entity"
|
||||
)
|
||||
|
||||
type fakeExecutorRepository struct {
|
||||
mu sync.Mutex
|
||||
claims []bool
|
||||
claimCalls int
|
||||
loadedBatchNos []string
|
||||
task *Task
|
||||
cards map[string]*entity.TSimCardInfo
|
||||
validationFailureCalls int
|
||||
outcomes []Outcome
|
||||
finalizeCalls int
|
||||
markFailedCalls int
|
||||
}
|
||||
|
||||
func (r *fakeExecutorRepository) ClaimBatch(context.Context, string) (bool, error) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
index := r.claimCalls
|
||||
r.claimCalls++
|
||||
if index >= len(r.claims) {
|
||||
return false, nil
|
||||
}
|
||||
return r.claims[index], nil
|
||||
}
|
||||
|
||||
func (r *fakeExecutorRepository) LoadTask(_ context.Context, batchNo string) (*Task, error) {
|
||||
r.loadedBatchNos = append(r.loadedBatchNos, batchNo)
|
||||
return r.task, nil
|
||||
}
|
||||
|
||||
func (r *fakeExecutorRepository) LoadCards(context.Context, *Task) (map[string]*entity.TSimCardInfo, error) {
|
||||
return r.cards, nil
|
||||
}
|
||||
|
||||
func (r *fakeExecutorRepository) SaveValidationFailure(context.Context, *Task) error {
|
||||
r.validationFailureCalls++
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *fakeExecutorRepository) SaveOutcome(_ context.Context, _ *Task, _ *Row, _ *entity.TSimCardInfo, outcome Outcome) error {
|
||||
r.outcomes = append(r.outcomes, outcome)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *fakeExecutorRepository) SaveDetailFailure(context.Context, *Task, *Row) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *fakeExecutorRepository) FinalizeBatch(context.Context, *Task) error {
|
||||
r.finalizeCalls++
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *fakeExecutorRepository) MarkBatchFailed(context.Context, string, string) error {
|
||||
r.markFailedCalls++
|
||||
return nil
|
||||
}
|
||||
|
||||
type fakeStatusChanger struct {
|
||||
calls int
|
||||
requests []*model.SimCardStatusChangeInput
|
||||
response []model.SimBaseRes[model.SimCardStatusChangeItem]
|
||||
err error
|
||||
}
|
||||
|
||||
func (c *fakeStatusChanger) ChangeSimStatus(_ context.Context, input *model.SimCardStatusChangeInput) ([]model.SimBaseRes[model.SimCardStatusChangeItem], error) {
|
||||
c.calls++
|
||||
c.requests = append(c.requests, input)
|
||||
return c.response, c.err
|
||||
}
|
||||
|
||||
func TestActionOperTypeMapping(t *testing.T) {
|
||||
stop, err := OperationSuspend.Config()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
start, err := OperationResume.Config()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if stop.OperType != "0" || stop.BatchType != consts.BatchTypeStop {
|
||||
t.Fatalf("unexpected stop mapping: %+v", stop)
|
||||
}
|
||||
if start.OperType != "1" || start.BatchType != consts.BatchTypeStart {
|
||||
t.Fatalf("unexpected start mapping: %+v", start)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecuteValidationFailureSkipsUpstream(t *testing.T) {
|
||||
task := newTestTask(t, OperationSuspend, []*Row{
|
||||
{RowNo: 1, Iccid: "missing", Authorized: true},
|
||||
{RowNo: 2, Iccid: "valid", Authorized: true},
|
||||
})
|
||||
repo := &fakeExecutorRepository{
|
||||
claims: []bool{true},
|
||||
task: task,
|
||||
cards: map[string]*entity.TSimCardInfo{
|
||||
"valid": {Id: 2, Iccid: "valid", ChanKey: "channel-a", BossCardStatus: consts.V3ApiSimBossCardStatusActivated},
|
||||
},
|
||||
}
|
||||
upstream := &fakeStatusChanger{}
|
||||
if err := newExecutor(repo, upstream).Execute(context.Background(), "STP-test"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if upstream.calls != 0 {
|
||||
t.Fatalf("upstream called %d times for invalid batch", upstream.calls)
|
||||
}
|
||||
if repo.validationFailureCalls != 1 || repo.finalizeCalls != 1 {
|
||||
t.Fatalf("validation/finalize calls = %d/%d", repo.validationFailureCalls, repo.finalizeCalls)
|
||||
}
|
||||
if task.Rows[0].Reason == "" || task.Rows[1].Reason == "" {
|
||||
t.Fatalf("expected all rows to retain failure reasons: %+v", task.Rows)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateStopStatus(t *testing.T) {
|
||||
t.Skip("TODO: 恢复卡状态限制后启用此测试")
|
||||
tests := []struct {
|
||||
name string
|
||||
status int
|
||||
valid bool
|
||||
}{
|
||||
{name: "activated", status: consts.V3ApiSimBossCardStatusActivated, valid: true},
|
||||
{name: "pending", status: consts.V3ApiSimBossCardStatusPendingActivate},
|
||||
{name: "testable", status: consts.V3ApiSimBossCardStatusTestable},
|
||||
{name: "stock", status: consts.V3ApiSimBossCardStatusStock},
|
||||
{name: "stopped", status: consts.V3ApiSimBossCardStatusStopped},
|
||||
{name: "pre-cancel", status: consts.V3ApiSimBossCardStatusPreCancel},
|
||||
{name: "canceled", status: consts.V3ApiSimBossCardStatusCanceled},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
row := &Row{Iccid: "card-1"}
|
||||
validateCardStatus(OperationSuspend, row, &entity.TSimCardInfo{BossCardStatus: tt.status})
|
||||
if got := row.Reason == ""; got != tt.valid {
|
||||
t.Fatalf("status %d valid=%v, reason=%q", tt.status, got, row.Reason)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecuteMixedStopStatusSkipsUpstream(t *testing.T) {
|
||||
t.Skip("TODO: 恢复卡状态限制后启用此测试")
|
||||
task := newTestTask(t, OperationSuspend, []*Row{
|
||||
{RowNo: 1, Iccid: "activated", Authorized: true},
|
||||
{RowNo: 2, Iccid: "stock", Authorized: true},
|
||||
})
|
||||
repo := &fakeExecutorRepository{
|
||||
claims: []bool{true},
|
||||
task: task,
|
||||
cards: map[string]*entity.TSimCardInfo{
|
||||
"activated": {Id: 1, Iccid: "activated", ChanKey: "channel-a", BossCardStatus: consts.V3ApiSimBossCardStatusActivated},
|
||||
"stock": {Id: 2, Iccid: "stock", ChanKey: "channel-a", BossCardStatus: consts.V3ApiSimBossCardStatusStock},
|
||||
},
|
||||
}
|
||||
upstream := &fakeStatusChanger{}
|
||||
if err := newExecutor(repo, upstream).Execute(context.Background(), "STP-mixed"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if upstream.calls != 0 {
|
||||
t.Fatalf("upstream called %d times for mixed status batch", upstream.calls)
|
||||
}
|
||||
if repo.validationFailureCalls != 1 || repo.finalizeCalls != 1 {
|
||||
t.Fatalf("validation/finalize calls = %d/%d", repo.validationFailureCalls, repo.finalizeCalls)
|
||||
}
|
||||
if task.Rows[0].Reason == "" || task.Rows[1].Reason == "" {
|
||||
t.Fatalf("expected both rows to be failed without upstream call: %+v", task.Rows)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecuteRestoresByBatchNoAndMapsPerCardResults(t *testing.T) {
|
||||
task := newTestTask(t, OperationSuspend, []*Row{
|
||||
{RowNo: 1, Iccid: "card-1", Authorized: true},
|
||||
{RowNo: 2, Iccid: "card-2", Authorized: true},
|
||||
})
|
||||
repo := &fakeExecutorRepository{
|
||||
claims: []bool{true},
|
||||
task: task,
|
||||
cards: map[string]*entity.TSimCardInfo{
|
||||
"card-1": {Id: 1, Iccid: "card-1", ChanKey: "channel-a", BossCardStatus: consts.V3ApiSimBossCardStatusActivated},
|
||||
"card-2": {Id: 2, Iccid: "card-2", ChanKey: "channel-a", BossCardStatus: consts.V3ApiSimBossCardStatusActivated},
|
||||
},
|
||||
}
|
||||
upstream := &fakeStatusChanger{response: []model.SimBaseRes[model.SimCardStatusChangeItem]{
|
||||
{Code: 0, SimKey: &model.SimKey{ICCID: "card-1"}, ResData: model.SimCardStatusChangeItem{OrderNum: "order-1"}},
|
||||
{Code: 500, Message: "operator rejected", SimKey: &model.SimKey{ICCID: "card-2"}},
|
||||
}}
|
||||
if err := newExecutor(repo, upstream).Execute(context.Background(), "STP-restore"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(repo.loadedBatchNos) != 1 || repo.loadedBatchNos[0] != "STP-restore" {
|
||||
t.Fatalf("executor did not restore by batchNo: %+v", repo.loadedBatchNos)
|
||||
}
|
||||
if upstream.calls != 1 || len(upstream.requests) != 1 {
|
||||
t.Fatalf("unexpected upstream calls: %d", upstream.calls)
|
||||
}
|
||||
if upstream.requests[0].ReqData.OperType != "0" {
|
||||
t.Fatalf("unexpected operType: %s", upstream.requests[0].ReqData.OperType)
|
||||
}
|
||||
if len(repo.outcomes) != 2 || !repo.outcomes[0].Success || repo.outcomes[1].Success {
|
||||
t.Fatalf("unexpected per-card outcomes: %+v", repo.outcomes)
|
||||
}
|
||||
if repo.outcomes[0].OrderNum != "order-1" || repo.outcomes[1].Message != "operator rejected" {
|
||||
t.Fatalf("upstream details were not preserved: %+v", repo.outcomes)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecuteDuplicateClaimRunsOnlyOnce(t *testing.T) {
|
||||
task := newTestTask(t, OperationResume, []*Row{{RowNo: 1, Iccid: "card-1", Authorized: true}})
|
||||
repo := &fakeExecutorRepository{
|
||||
claims: []bool{true, false},
|
||||
task: task,
|
||||
cards: map[string]*entity.TSimCardInfo{
|
||||
"card-1": {Id: 1, Iccid: "card-1", ChanKey: "channel-a", BossCardStatus: consts.V3ApiSimBossCardStatusStopped},
|
||||
},
|
||||
}
|
||||
upstream := &fakeStatusChanger{response: []model.SimBaseRes[model.SimCardStatusChangeItem]{
|
||||
{Code: 0, SimKey: &model.SimKey{ICCID: "card-1"}},
|
||||
}}
|
||||
executor := newExecutor(repo, upstream)
|
||||
if err := executor.Execute(context.Background(), "STR-once"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := executor.Execute(context.Background(), "STR-once"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if upstream.calls != 1 || len(repo.loadedBatchNos) != 1 || repo.finalizeCalls != 1 {
|
||||
t.Fatalf("duplicate claim executed twice: upstream=%d load=%d finalize=%d", upstream.calls, len(repo.loadedBatchNos), repo.finalizeCalls)
|
||||
}
|
||||
if upstream.requests[0].ReqData.OperType != "1" {
|
||||
t.Fatalf("unexpected start operType: %s", upstream.requests[0].ReqData.OperType)
|
||||
}
|
||||
}
|
||||
|
||||
type blockingExecutor struct {
|
||||
started chan struct{}
|
||||
release chan struct{}
|
||||
}
|
||||
|
||||
func (e *blockingExecutor) Execute(context.Context, string) error {
|
||||
close(e.started)
|
||||
<-e.release
|
||||
return nil
|
||||
}
|
||||
|
||||
func (e *blockingExecutor) MarkFailed(context.Context, string, string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestDispatchReturnsBeforeExecutorCompletes(t *testing.T) {
|
||||
executor := &blockingExecutor{started: make(chan struct{}), release: make(chan struct{})}
|
||||
Dispatch(executor, "STP-async")
|
||||
select {
|
||||
case <-executor.started:
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("background executor did not start")
|
||||
}
|
||||
select {
|
||||
case <-executor.release:
|
||||
t.Fatal("release channel should still be blocked")
|
||||
default:
|
||||
}
|
||||
close(executor.release)
|
||||
}
|
||||
|
||||
func newTestTask(t *testing.T, operation Operation, rows []*Row) *Task {
|
||||
t.Helper()
|
||||
config, err := operation.Config()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return &Task{
|
||||
Batch: &entity.BatchMain{BatchNo: "batch-test", BatchType: config.BatchType, TenantId: "tenant-1"},
|
||||
Operation: operation,
|
||||
Config: config,
|
||||
Rows: rows,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
package lifecycle
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestBatchExecuteMessageJSONContract(t *testing.T) {
|
||||
body, err := json.Marshal(BatchExecuteMessage{
|
||||
BatchNo: "STP202607170001",
|
||||
BatchType: 104,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("marshal batch execute message: %v", err)
|
||||
}
|
||||
const expected = `{"batchNo":"STP202607170001","batchType":104}`
|
||||
if string(body) != expected {
|
||||
t.Fatalf("unexpected batch execute message: %s", body)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
package lifecycle
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/batchimport"
|
||||
)
|
||||
|
||||
func TestValidateBasicRowsRejectsDuplicateIccid(t *testing.T) {
|
||||
rows := []batchimport.Row{
|
||||
&Row{RowNo: 2, Iccid: " 8986001 "},
|
||||
&Row{RowNo: 3, Iccid: "8986001"},
|
||||
}
|
||||
err := validateBasicRows(rows)
|
||||
if err == nil || !strings.Contains(err.Error(), "ICCID") || !strings.Contains(err.Error(), "重复") {
|
||||
t.Fatalf("expected readable duplicate ICCID error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateBasicRowsRejectsEveryEmptyIccid(t *testing.T) {
|
||||
rows := []batchimport.Row{
|
||||
&Row{RowNo: 2, Iccid: ""},
|
||||
&Row{RowNo: 5, Iccid: " "},
|
||||
}
|
||||
err := validateBasicRows(rows)
|
||||
if err == nil {
|
||||
t.Fatal("expected empty ICCID validation error")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "第2行") || !strings.Contains(err.Error(), "第5行") {
|
||||
t.Fatalf("expected all empty ICCID rows in error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateBasicRowsRejectsWrongFieldCount(t *testing.T) {
|
||||
rows, err := ParseInputRows(context.Background(), "8986001 13800008000")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
err = validateBasicRows(rows)
|
||||
if err == nil || !strings.Contains(err.Error(), "格式") {
|
||||
t.Fatalf("expected wrong field count error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInvalidBasicRowsStopBeforePendingPreparation(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
rows []batchimport.Row
|
||||
}{
|
||||
{
|
||||
name: "duplicate",
|
||||
rows: []batchimport.Row{
|
||||
&Row{RowNo: 2, Iccid: "8986001"},
|
||||
&Row{RowNo: 3, Iccid: "8986001"},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "multiple empty iccid",
|
||||
rows: []batchimport.Row{
|
||||
&Row{RowNo: 2},
|
||||
&Row{RowNo: 3, Iccid: " "},
|
||||
},
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
processor, err := NewProcessor(OperationSuspend)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
prepared := false
|
||||
result, err := batchimport.CreatePendingRows(
|
||||
context.Background(),
|
||||
batchimport.SubmitRequest{},
|
||||
processor,
|
||||
tt.rows,
|
||||
batchimport.PendingOptions{PrepareRows: func(context.Context, []batchimport.Row) error {
|
||||
prepared = true
|
||||
return nil
|
||||
}},
|
||||
)
|
||||
if err == nil || result != nil {
|
||||
t.Fatalf("expected pre-persistence validation error, result=%+v err=%v", result, err)
|
||||
}
|
||||
if prepared {
|
||||
t.Fatal("invalid rows reached pending preparation")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -155,11 +155,15 @@ func (r *Repository) SaveValidationFailure(ctx context.Context, task *Task) erro
|
||||
if row == nil {
|
||||
continue
|
||||
}
|
||||
status := consts.BatchDetailStatusFailed
|
||||
if batchimport.IsNotExecuted(row) {
|
||||
status = consts.BatchDetailStatusNotExecuted
|
||||
}
|
||||
if _, err := tx.Ctx(ctx).Model(dao.BatchDetail.Table()).
|
||||
Where(dao.BatchDetail.Columns().Id, row.DetailID).
|
||||
Where(dao.BatchDetail.Columns().BatchNo, task.Batch.BatchNo).
|
||||
Data(do.BatchDetail{
|
||||
DetailStatus: consts.BatchDetailStatusFailed,
|
||||
DetailStatus: status,
|
||||
ErrorReason: row.Reason,
|
||||
Snapshot: row.Snapshot(),
|
||||
UpdatedBy: task.Operator.UserId,
|
||||
@@ -255,21 +259,23 @@ func (r *Repository) FinalizeBatch(ctx context.Context, task *Task) error {
|
||||
}
|
||||
rows := make([]batchimport.Row, 0, len(task.Rows))
|
||||
successCount := 0
|
||||
failCount := 0
|
||||
for _, row := range task.Rows {
|
||||
rows = append(rows, row)
|
||||
if row != nil && row.Reason == "" {
|
||||
if row != nil && row.Reason == "" && task.ValidationOK {
|
||||
successCount++
|
||||
} else if row == nil || !batchimport.IsNotExecuted(row) {
|
||||
failCount++
|
||||
}
|
||||
}
|
||||
failCount := len(task.Rows) - successCount
|
||||
validCount := 0
|
||||
validCount := len(task.Rows) - failCount
|
||||
if task.ValidationOK {
|
||||
validCount = len(task.Rows)
|
||||
}
|
||||
importStatus := consts.ImportStatusSuccess
|
||||
batchStatus := consts.BatchAsyncStatusSuccess
|
||||
runResult := consts.BatchRunResultSuccess
|
||||
if failCount > 0 {
|
||||
if failCount > 0 || !task.ValidationOK {
|
||||
importStatus = consts.ImportStatusFailed
|
||||
batchStatus = consts.BatchAsyncStatusFailed
|
||||
runResult = consts.BatchRunResultFailed
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
package lifecycle
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/consts"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/batchimport"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/model/entity"
|
||||
|
||||
_ "github.com/gogf/gf/contrib/drivers/sqlite/v2"
|
||||
"github.com/gogf/gf/v2/database/gdb"
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
)
|
||||
|
||||
func TestRepositorySaveOutcomeUpdatesOnlySuccessfulCard(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
if err := gdb.SetConfig(gdb.Config{
|
||||
"default": gdb.ConfigGroup{{
|
||||
Type: "sqlite",
|
||||
Link: "sqlite::@file(:memory:)",
|
||||
MaxOpenConnCount: 1,
|
||||
}},
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer g.DB().Close(ctx)
|
||||
|
||||
statements := []string{
|
||||
`CREATE TABLE t_sim_card_info (
|
||||
id INTEGER PRIMARY KEY,
|
||||
iccid TEXT NOT NULL,
|
||||
boss_card_status INTEGER NOT NULL,
|
||||
status_sync_time DATETIME,
|
||||
tenant_id TEXT NOT NULL,
|
||||
updated_by INTEGER,
|
||||
updated_at DATETIME
|
||||
)`,
|
||||
`CREATE TABLE batch_detail (
|
||||
id INTEGER PRIMARY KEY,
|
||||
batch_no TEXT NOT NULL,
|
||||
detail_status INTEGER NOT NULL,
|
||||
error_reason TEXT,
|
||||
snapshot TEXT,
|
||||
updated_by INTEGER,
|
||||
updated_at DATETIME
|
||||
)`,
|
||||
`INSERT INTO t_sim_card_info(id, iccid, boss_card_status, tenant_id) VALUES
|
||||
(1, 'card-success', 2, 'tenant-1'),
|
||||
(2, 'card-failed', 2, 'tenant-1')`,
|
||||
`INSERT INTO batch_detail(id, batch_no, detail_status) VALUES
|
||||
(1, 'STP-test', 1),
|
||||
(2, 'STP-test', 1)`,
|
||||
}
|
||||
for _, statement := range statements {
|
||||
if _, err := g.DB().Exec(ctx, statement); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
config, err := OperationSuspend.Config()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
task := &Task{
|
||||
Batch: &entity.BatchMain{BatchNo: "STP-test", TenantId: "tenant-1"},
|
||||
Config: config,
|
||||
Operator: batchimport.OperatorContext{TenantId: "tenant-1", UserId: 10},
|
||||
}
|
||||
repository := NewRepository()
|
||||
successRow := &Row{DetailID: 1, Iccid: "card-success", StatusBefore: consts.V3ApiSimBossCardStatusActivated}
|
||||
failedRow := &Row{DetailID: 2, Iccid: "card-failed", StatusBefore: consts.V3ApiSimBossCardStatusActivated}
|
||||
if err = repository.SaveOutcome(ctx, task, successRow, &entity.TSimCardInfo{Id: 1}, Outcome{Success: true, OrderNum: "order-1"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err = repository.SaveOutcome(ctx, task, failedRow, &entity.TSimCardInfo{Id: 2}, Outcome{Code: 500, Message: "rejected"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
var cards []struct {
|
||||
Iccid string `orm:"iccid"`
|
||||
BossCardStatus int `orm:"boss_card_status"`
|
||||
}
|
||||
if err = g.DB().Model("t_sim_card_info").OrderAsc("id").Scan(&cards); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(cards) != 2 || cards[0].BossCardStatus != consts.V3ApiSimBossCardStatusStopped || cards[1].BossCardStatus != consts.V3ApiSimBossCardStatusActivated {
|
||||
t.Fatalf("unexpected local card statuses: %+v", cards)
|
||||
}
|
||||
|
||||
var details []struct {
|
||||
DetailStatus int `orm:"detail_status"`
|
||||
ErrorReason string `orm:"error_reason"`
|
||||
}
|
||||
if err = g.DB().Model("batch_detail").OrderAsc("id").Scan(&details); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(details) != 2 || details[0].DetailStatus != consts.BatchDetailStatusSuccess || details[1].DetailStatus != consts.BatchDetailStatusFailed || details[1].ErrorReason != "rejected" {
|
||||
t.Fatalf("unexpected detail results: %+v", details)
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@ package outbound
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/batchmetadata"
|
||||
"strings"
|
||||
|
||||
@@ -26,6 +27,10 @@ type AsyncMessage struct {
|
||||
BatchNo string `json:"batchNo"`
|
||||
}
|
||||
|
||||
var errOutboundValidationRejected = errors.New("存在不满足出库条件的卡,本次未执行")
|
||||
|
||||
const outboundNotExecutedReason = "校验通过,但本批次存在失败明细,整批未执行出库"
|
||||
|
||||
func SubmitExcelAsync(ctx context.Context, file *ghttp.UploadFile, in *model.TSimCardOutboundExcelSubmitParam) (*batchimport.Result, error) {
|
||||
operator := batchimport.OperatorContext{TenantId: contexts.GetTenantId(ctx), DeptId: contexts.GetDeptId(ctx), UserId: contexts.GetUserId(ctx)}
|
||||
processor := NewProcessor(PreviewOptions{DeptId: operator.DeptId})
|
||||
@@ -119,8 +124,9 @@ func handleAsyncMessage(ctx context.Context, message AsyncMessage) error {
|
||||
requiredSource = strings.TrimSpace(main.BatchInfo.Get("inboundBatchNo").String())
|
||||
}
|
||||
var (
|
||||
claimed bool
|
||||
output *CompleteOutput
|
||||
claimed bool
|
||||
output *CompleteOutput
|
||||
validationRejected bool
|
||||
)
|
||||
err = g.DB().Transaction(ctx, func(ctx context.Context, tx gdb.TX) error {
|
||||
claimResult, claimErr := tx.Ctx(ctx).Model(dao.BatchMain.Table()).
|
||||
@@ -153,6 +159,7 @@ func handleAsyncMessage(ctx context.Context, message AsyncMessage) error {
|
||||
var executeErr error
|
||||
output, executeErr = processor.ExecutePending(ctx, tx, message.BatchNo, param, rows, operator, requiredSource)
|
||||
if executeErr != nil {
|
||||
validationRejected = errors.Is(executeErr, errOutboundValidationRejected)
|
||||
return executeErr
|
||||
}
|
||||
if _, updateErr := tx.Ctx(ctx).Model(dao.BatchMain.Table()).Where(dao.BatchMain.Columns().BatchNo, message.BatchNo).Data(do.BatchMain{
|
||||
@@ -169,6 +176,9 @@ func handleAsyncMessage(ctx context.Context, message AsyncMessage) error {
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
if validationRejected {
|
||||
return markAsyncValidationFailed(ctx, message.BatchNo, main.TenantId, rows, err)
|
||||
}
|
||||
return markAsyncFailed(ctx, message.BatchNo, err)
|
||||
}
|
||||
if !claimed {
|
||||
@@ -186,6 +196,43 @@ func handleAsyncMessage(ctx context.Context, message AsyncMessage) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func markAsyncValidationFailed(ctx context.Context, batchNo, tenantID string, rows []*Row, cause error) error {
|
||||
validCount, failCount := 0, 0
|
||||
for _, row := range rows {
|
||||
if row == nil {
|
||||
continue
|
||||
}
|
||||
if row.Reason == outboundNotExecutedReason {
|
||||
validCount++
|
||||
} else {
|
||||
failCount++
|
||||
}
|
||||
}
|
||||
return g.DB().Transaction(ctx, func(ctx context.Context, tx gdb.TX) error {
|
||||
for _, row := range rows {
|
||||
if row == nil {
|
||||
continue
|
||||
}
|
||||
status := consts.BatchDetailStatusFailed
|
||||
if row.Reason == outboundNotExecutedReason {
|
||||
status = consts.BatchDetailStatusNotExecuted
|
||||
}
|
||||
if _, err := tx.Ctx(ctx).Model(dao.BatchDetail.Table()).
|
||||
Where(dao.BatchDetail.Columns().BatchNo, batchNo).
|
||||
Where(dao.BatchDetail.Columns().TenantId, tenantID).
|
||||
Where(dao.BatchDetail.Columns().RowNo, row.RowNo).
|
||||
Data(do.BatchDetail{DetailStatus: status, ErrorReason: row.Reason}).Update(); err != nil {
|
||||
return gerror.Wrap(err, "保存出库校验明细失败")
|
||||
}
|
||||
}
|
||||
_, err := tx.Ctx(ctx).Model(dao.BatchMain.Table()).
|
||||
Where(dao.BatchMain.Columns().BatchNo, batchNo).
|
||||
Where(dao.BatchMain.Columns().TenantId, tenantID).
|
||||
Data(do.BatchMain{BatchAsyncStatus: consts.BatchAsyncStatusFailed, RunResult: consts.BatchRunResultFailed, ValidCount: validCount, SuccessCount: 0, FailCount: failCount, FailReason: asyncFailureReason(cause), EndTime: gtime.Now()}).Update()
|
||||
return gerror.Wrap(err, "保存出库校验结果失败")
|
||||
})
|
||||
}
|
||||
|
||||
func markAsyncFailed(ctx context.Context, batchNo string, err error) error {
|
||||
reason := asyncFailureReason(err)
|
||||
if _, updateErr := g.DB().Model(dao.BatchMain.Table()).Ctx(ctx).Where(dao.BatchMain.Columns().BatchNo, batchNo).Data(do.BatchMain{
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
package outbound
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/batchimport"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/model/entity"
|
||||
)
|
||||
|
||||
func TestValidateOutboundRowCollectsIndependentFailures(t *testing.T) {
|
||||
row := &Row{Iccid: "card", Msisdn: "wrong", Imsi: "wrong"}
|
||||
stock := &entity.TSimCardStock{CreatedDept: 200, BatchNo: "other", Msisdn: "13800008000", Imsi: "460001234567890"}
|
||||
reason := validateOutboundRow(row, stock, true, batchimport.OperatorContext{DeptId: 100}, true, "required")
|
||||
for _, want := range []string{"不属于当前机构库存", "不属于指定库存批次", "已存在正式卡记录", "MSISDN与库存记录不一致", "IMSI与库存记录不一致"} {
|
||||
if !strings.Contains(reason, want) {
|
||||
t.Fatalf("missing %q in %q", want, reason)
|
||||
}
|
||||
}
|
||||
rows := []*Row{{Reason: reason}, {}, {Reason: "ICCID不存在库存记录,不能出库"}}
|
||||
markRemainingRowsSkipped(rows)
|
||||
if rows[0].Reason != reason || rows[1].Reason != outboundNotExecutedReason || rows[2].Reason != "ICCID不存在库存记录,不能出库" {
|
||||
t.Fatalf("per-card validation results were overwritten: %+v", rows)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOutboundChunkSizesSupportFiftyThousandCards(t *testing.T) {
|
||||
const totalCards = 50000
|
||||
if outboundDatabaseInsertChunkSize <= 0 || outboundDatabaseInsertChunkSize > 500 {
|
||||
t.Fatalf("outboundDatabaseInsertChunkSize = %d, want 1..500", outboundDatabaseInsertChunkSize)
|
||||
}
|
||||
if chunks := (totalCards + outboundDatabaseInsertChunkSize - 1) / outboundDatabaseInsertChunkSize; chunks != 100 {
|
||||
t.Fatalf("card insert chunks = %d, want 100", chunks)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAsyncFailureReasonSummarizesPlaceholderOverflow(t *testing.T) {
|
||||
reason := asyncFailureReason(errors.New("INSERT INTO t_sim_card_info (...) Error 1390: Prepared statement contains too many placeholders"))
|
||||
if reason != "新增正式卡失败:单次批量写入参数数量超过数据库限制" {
|
||||
t.Fatalf("unexpected reason: %q", reason)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAsyncFailureReasonFitsBatchDetailColumn(t *testing.T) {
|
||||
reason := asyncFailureReason(errors.New(strings.Repeat("失败原因", 300)))
|
||||
if len([]rune(reason)) > maxAsyncFailureReasonLength {
|
||||
t.Fatalf("reason length = %d, want <= %d", len([]rune(reason)), maxAsyncFailureReasonLength)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateOutboundIdentifiersReportsEveryDuplicate(t *testing.T) {
|
||||
rows := []*Row{
|
||||
{RowNo: 1, Iccid: " 8986000000000000001 "},
|
||||
{RowNo: 2, Iccid: "8986000000000000001"},
|
||||
{RowNo: 3, Iccid: ""},
|
||||
{RowNo: 4, Iccid: "8986000000000000002"},
|
||||
}
|
||||
iccids, err := validateOutboundIdentifiers(rows)
|
||||
if err != nil || len(iccids) != 1 || iccids[0] != rows[3].Iccid {
|
||||
t.Fatalf("valid identifiers = %v, err = %v", iccids, err)
|
||||
}
|
||||
for index := 0; index < 3; index++ {
|
||||
if rows[index].Reason == "" {
|
||||
t.Fatalf("row %d lost its validation reason", index+1)
|
||||
}
|
||||
}
|
||||
markRemainingRowsSkipped(rows)
|
||||
if rows[3].Reason != outboundNotExecutedReason {
|
||||
t.Fatalf("valid row reason = %q", rows[3].Reason)
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@ package outbound
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/consts"
|
||||
@@ -211,10 +212,13 @@ func (p *Processor) validateExecuteContext(ctx context.Context, tx gdb.TX, in *m
|
||||
if err := p.validateSelectablePackage(ctx, tx, in.PackageCode, operator.TenantId); err != nil {
|
||||
return err
|
||||
}
|
||||
iccids, err := strictRowsToIccids(rows)
|
||||
iccids, err := validateOutboundIdentifiers(rows)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(iccids) == 0 {
|
||||
return errOutboundValidationRejected
|
||||
}
|
||||
stockByIccid, err := p.repo.LockStockCardsByIccids(ctx, tx, iccids)
|
||||
if err != nil {
|
||||
return gerror.Wrap(err, "校验出库库存卡失败")
|
||||
@@ -237,11 +241,38 @@ func (p *Processor) validateExecuteContext(ctx context.Context, tx gdb.TX, in *m
|
||||
|
||||
if hasInvalidRows(rows) {
|
||||
markRemainingRowsSkipped(rows)
|
||||
return gerror.New("存在不满足出库条件的卡,本次未执行")
|
||||
return errOutboundValidationRejected
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateOutboundIdentifiers(rows []*Row) ([]string, error) {
|
||||
seen := make(map[string]*Row, len(rows))
|
||||
for _, row := range rows {
|
||||
if row == nil {
|
||||
return nil, gerror.New("出库行不能为空")
|
||||
}
|
||||
row.Iccid = strings.TrimSpace(row.Iccid)
|
||||
if row.Iccid == "" {
|
||||
row.AddReason("ICCID不能为空")
|
||||
continue
|
||||
}
|
||||
if first, exists := seen[row.Iccid]; exists {
|
||||
row.AddReason(fmt.Sprintf("ICCID与第%d行重复", first.RowNo))
|
||||
first.AddReason(fmt.Sprintf("ICCID与第%d行重复", row.RowNo))
|
||||
continue
|
||||
}
|
||||
seen[row.Iccid] = row
|
||||
}
|
||||
iccids := make([]string, 0, len(seen))
|
||||
for iccid, row := range seen {
|
||||
if row.Reason == "" {
|
||||
iccids = append(iccids, iccid)
|
||||
}
|
||||
}
|
||||
return iccids, nil
|
||||
}
|
||||
|
||||
func (p *Processor) validateSelectablePackage(ctx context.Context, tx gdb.TX, packageCode, tenantId string) error {
|
||||
pkg, err := p.repo.LoadPackage(ctx, tx, tenantId, packageCode)
|
||||
if err != nil {
|
||||
@@ -329,27 +360,28 @@ func (p *Processor) submitParams(params any) *model.TSimCardOutboundSubmitParam
|
||||
}
|
||||
|
||||
func validateOutboundRow(row *Row, stock *entity.TSimCardStock, formalExists bool, operator batchimport.OperatorContext, strictStockIdentity bool, requiredSourceBatchNo string) string {
|
||||
var reasons []string
|
||||
if stock == nil {
|
||||
return "ICCID不存在库存记录,不能出库"
|
||||
}
|
||||
if stock.CreatedDept != operator.DeptId {
|
||||
return "ICCID不属于当前机构库存,不能出库"
|
||||
reasons = append(reasons, "ICCID不属于当前机构库存,不能出库")
|
||||
}
|
||||
if requiredSourceBatchNo != "" && strings.TrimSpace(stock.BatchNo) != requiredSourceBatchNo {
|
||||
return "ICCID不属于指定库存批次,不能出库"
|
||||
reasons = append(reasons, "ICCID不属于指定库存批次,不能出库")
|
||||
}
|
||||
if formalExists {
|
||||
return "ICCID已存在正式卡记录,不能出库"
|
||||
reasons = append(reasons, "ICCID已存在正式卡记录,不能出库")
|
||||
}
|
||||
if strictStockIdentity {
|
||||
if strings.TrimSpace(row.Msisdn) != strings.TrimSpace(stock.Msisdn) {
|
||||
return "MSISDN与库存记录不一致,不能出库"
|
||||
reasons = append(reasons, "MSISDN与库存记录不一致,不能出库")
|
||||
}
|
||||
if strings.TrimSpace(row.Imsi) != strings.TrimSpace(stock.Imsi) {
|
||||
return "IMSI与库存记录不一致,不能出库"
|
||||
reasons = append(reasons, "IMSI与库存记录不一致,不能出库")
|
||||
}
|
||||
}
|
||||
return ""
|
||||
return strings.Join(reasons, ";")
|
||||
}
|
||||
|
||||
func hasInvalidRows(rows []*Row) bool {
|
||||
@@ -364,7 +396,7 @@ func hasInvalidRows(rows []*Row) bool {
|
||||
func markRemainingRowsSkipped(rows []*Row) {
|
||||
for _, row := range rows {
|
||||
if row != nil && row.Reason == "" {
|
||||
row.AddReason("同批存在不满足出库条件的卡,本行未执行")
|
||||
row.AddReason(outboundNotExecutedReason)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,384 @@
|
||||
package renew
|
||||
|
||||
import (
|
||||
"context"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"testing"
|
||||
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/consts"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/batchinput/text"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/model"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/model/entity"
|
||||
|
||||
"github.com/xuri/excelize/v2"
|
||||
)
|
||||
|
||||
func TestExcelTemplateHeaders(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
expected := []string{
|
||||
"ICCID",
|
||||
"MSISDN",
|
||||
"IMSI",
|
||||
"续费年限",
|
||||
"是否走线下",
|
||||
}
|
||||
if !reflect.DeepEqual(ExcelTemplateHeaders(), expected) {
|
||||
t.Fatalf("unexpected headers: %#v", ExcelTemplateHeaders())
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenewDurationValueToMonths(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
unit string
|
||||
value int
|
||||
want int
|
||||
}{
|
||||
{name: "one month", unit: consts.RenewDurationUnitMonth, value: 1, want: 1},
|
||||
{name: "one year", unit: consts.RenewDurationUnitYear, value: 1, want: 12},
|
||||
{name: "unknown unit defaults to year", unit: "unknown", value: 2, want: 24},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
test := test
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
if got := consts.DurationValueToMonths(test.unit, test.value); got != test.want {
|
||||
t.Fatalf("DurationValueToMonths(%q, %d) = %d, want %d", test.unit, test.value, got, test.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenewDisplayDurationAndAmount(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
yearSnapshot := &model.TSimCardRenewPriceSnapshot{
|
||||
RenewDurationUnit: consts.RenewDurationUnitYear,
|
||||
RenewUnitPrice: 10,
|
||||
}
|
||||
yearParam := &model.TSimCardRenewBatchSubmitParam{DurationYears: 1}
|
||||
if got := renewDisplayDurationValue(yearParam); got != 1 {
|
||||
t.Fatalf("renewDisplayDurationValue(year) = %d, want 1", got)
|
||||
}
|
||||
if got := renewActualDurationMonths(yearSnapshot, renewDisplayDurationValue(yearParam)); got != 12 {
|
||||
t.Fatalf("renewActualDurationMonths(year) = %d, want 12", got)
|
||||
}
|
||||
yearAmount, err := resolveRenewAmount(yearSnapshot, yearParam, 2)
|
||||
if err != nil {
|
||||
t.Fatalf("resolveRenewAmount(year) failed: %v", err)
|
||||
}
|
||||
if yearAmount != 20 {
|
||||
t.Fatalf("resolveRenewAmount(year) = %.2f, want 20.00", yearAmount)
|
||||
}
|
||||
|
||||
monthSnapshot := &model.TSimCardRenewPriceSnapshot{
|
||||
RenewDurationUnit: consts.RenewDurationUnitMonth,
|
||||
RenewUnitPrice: 10,
|
||||
}
|
||||
monthParam := &model.TSimCardRenewBatchSubmitParam{DurationMonths: 1}
|
||||
if got := renewActualDurationMonths(monthSnapshot, renewDisplayDurationValue(monthParam)); got != 12 {
|
||||
t.Fatalf("renewActualDurationMonths(month snapshot) = %d, want 12", got)
|
||||
}
|
||||
actualMonthParam := &model.TSimCardRenewBatchSubmitParam{DurationMonths: 12}
|
||||
if got := renewDisplayDurationValue(actualMonthParam); got != 1 {
|
||||
t.Fatalf("renewDisplayDurationValue(actual months legacy) = %d, want 1", got)
|
||||
}
|
||||
monthAmount, err := resolveRenewAmount(monthSnapshot, monthParam, 2)
|
||||
if err != nil {
|
||||
t.Fatalf("resolveRenewAmount(month) failed: %v", err)
|
||||
}
|
||||
if monthAmount != 20 {
|
||||
t.Fatalf("resolveRenewAmount(month) = %.2f, want 20.00", monthAmount)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyExecutionOptionsKeepsDisplayDurationValue(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
in := &model.TSimCardRenewBatchSubmitParam{}
|
||||
applyExecutionOptions(in, &executionOptions{
|
||||
DurationYears: 2,
|
||||
DurationMonths: 2,
|
||||
PaymentMode: renewPaymentModeBalance,
|
||||
})
|
||||
if in.DurationYears != 2 || in.DurationMonths != 2 {
|
||||
t.Fatalf("duration should keep display value: %#v", in)
|
||||
}
|
||||
if got := renewDisplayDurationValue(in); got != 2 {
|
||||
t.Fatalf("renewDisplayDurationValue = %d, want 2", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenewDeptAncestorsContain(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
ancestors string
|
||||
deptID int64
|
||||
want bool
|
||||
}{
|
||||
{name: "contains direct ancestor", ancestors: "0,100,200", deptID: 100, want: true},
|
||||
{name: "contains with spaces", ancestors: "0, 100 ,200", deptID: 100, want: true},
|
||||
{name: "does not match partial id", ancestors: "0,1001,200", deptID: 100, want: false},
|
||||
{name: "empty ancestors", ancestors: "", deptID: 100, want: false},
|
||||
{name: "invalid dept", ancestors: "0,100,200", deptID: 0, want: false},
|
||||
}
|
||||
for _, test := range tests {
|
||||
test := test
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
if got := renewDeptAncestorsContain(test.ancestors, test.deptID); got != test.want {
|
||||
t.Fatalf("renewDeptAncestorsContain(%q, %d) = %v, want %v", test.ancestors, test.deptID, got, test.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseExcelFileRowsAndOptions(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
filePath := filepath.Join(t.TempDir(), "renew.xlsx")
|
||||
f := excelize.NewFile()
|
||||
headers := ExcelTemplateHeaders()
|
||||
if err := f.SetSheetRow("Sheet1", "A1", &headers); err != nil {
|
||||
t.Fatalf("set headers failed: %v", err)
|
||||
}
|
||||
row := []any{
|
||||
"8986000000000000001",
|
||||
"13800008000",
|
||||
"460001234567890",
|
||||
"1",
|
||||
"是",
|
||||
}
|
||||
if err := f.SetSheetRow("Sheet1", "A2", &row); err != nil {
|
||||
t.Fatalf("set row failed: %v", err)
|
||||
}
|
||||
if err := f.SaveAs(filePath); err != nil {
|
||||
t.Fatalf("save excel failed: %v", err)
|
||||
}
|
||||
_ = f.Close()
|
||||
|
||||
rows, options, err := ParseExcelFileRows(context.Background(), filePath)
|
||||
if err != nil {
|
||||
t.Fatalf("parse excel failed: %v", err)
|
||||
}
|
||||
if len(rows) != 1 {
|
||||
t.Fatalf("unexpected row count: %d", len(rows))
|
||||
}
|
||||
if rows[0].Reason != "" {
|
||||
t.Fatalf("unexpected row reason: %s", rows[0].Reason)
|
||||
}
|
||||
if rows[0].Msisdn != "13800008000" {
|
||||
t.Fatalf("unexpected msisdn: %s", rows[0].Msisdn)
|
||||
}
|
||||
if rows[0].Imsi != "460001234567890" {
|
||||
t.Fatalf("unexpected imsi: %s", rows[0].Imsi)
|
||||
}
|
||||
if options == nil {
|
||||
t.Fatal("options should not be nil")
|
||||
}
|
||||
if options.DurationYears != 1 {
|
||||
t.Fatalf("unexpected durationYears: %d", options.DurationYears)
|
||||
}
|
||||
if options.PaymentMode != "offline" {
|
||||
t.Fatalf("unexpected paymentMode: %s", options.PaymentMode)
|
||||
}
|
||||
|
||||
submitParam := options.ToBatchSubmitParam()
|
||||
if submitParam.DurationYears != 1 || submitParam.DurationMonths != 12 {
|
||||
t.Fatalf("unexpected submit duration fields: %#v", submitParam)
|
||||
}
|
||||
if submitParam.PaymentMode != "offline" {
|
||||
t.Fatalf("unexpected submit paymentMode: %#v", submitParam)
|
||||
}
|
||||
if !submitParam.OfflineContractCompleted {
|
||||
t.Fatalf("excel submit should mark offline contract as confirmed: %#v", submitParam)
|
||||
}
|
||||
if submitParam.SourceBatchNo != "" || submitParam.BuyerNote != "" || submitParam.AdminNote != "" || submitParam.Remark != "" {
|
||||
t.Fatalf("excel submit param should not carry removed fields: %#v", submitParam)
|
||||
}
|
||||
|
||||
confirmParam := options.ToBatchConfirmParam("RB20260706001")
|
||||
if confirmParam.BatchNo != "RB20260706001" {
|
||||
t.Fatalf("unexpected batchNo: %s", confirmParam.BatchNo)
|
||||
}
|
||||
if confirmParam.DurationYears != 1 || confirmParam.DurationMonths != 12 {
|
||||
t.Fatalf("unexpected confirm duration fields: %#v", confirmParam)
|
||||
}
|
||||
if confirmParam.PaymentMode != "offline" {
|
||||
t.Fatalf("unexpected confirm paymentMode: %#v", confirmParam)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseExcelFileRowsRejectsMultipleSheetsWithData(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
filePath := filepath.Join(t.TempDir(), "renew-multi.xlsx")
|
||||
f := excelize.NewFile()
|
||||
headers := ExcelTemplateHeaders()
|
||||
if err := f.SetSheetRow("Sheet1", "A1", &headers); err != nil {
|
||||
t.Fatalf("set headers failed: %v", err)
|
||||
}
|
||||
row := []any{
|
||||
"8986000000000000001",
|
||||
"13800008000",
|
||||
"460001234567890",
|
||||
"1",
|
||||
"是",
|
||||
}
|
||||
if err := f.SetSheetRow("Sheet1", "A2", &row); err != nil {
|
||||
t.Fatalf("set row failed: %v", err)
|
||||
}
|
||||
_, err := f.NewSheet("Sheet2")
|
||||
if err != nil {
|
||||
t.Fatalf("new sheet failed: %v", err)
|
||||
}
|
||||
if err := f.SetSheetRow("Sheet2", "A1", &headers); err != nil {
|
||||
t.Fatalf("set sheet2 headers failed: %v", err)
|
||||
}
|
||||
if err := f.SetSheetRow("Sheet2", "A2", &row); err != nil {
|
||||
t.Fatalf("set sheet2 row failed: %v", err)
|
||||
}
|
||||
if err := f.SaveAs(filePath); err != nil {
|
||||
t.Fatalf("save excel failed: %v", err)
|
||||
}
|
||||
_ = f.Close()
|
||||
|
||||
if _, _, err := ParseExcelFileRows(context.Background(), filePath); err == nil {
|
||||
t.Fatal("expected multiple-sheet excel to be rejected")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrepareExcelProcessorUsesPrepareBatchType(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
processor := NewPrepareExcelProcessor()
|
||||
cfg := processor.Config()
|
||||
if cfg.BatchType != consts.BatchTypePrepare {
|
||||
t.Fatalf("prepare excel batch type = %d, want %d", cfg.BatchType, consts.BatchTypePrepare)
|
||||
}
|
||||
if cfg.BatchNoPrefix != consts.BatchNoPrefixRenew {
|
||||
t.Fatalf("prepare excel batch prefix = %q, want %q", cfg.BatchNoPrefix, consts.BatchNoPrefixRenew)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseRenewInputLineSupportsSingleAndTripleFields(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
line text.FieldLine
|
||||
wantCode string
|
||||
wantIccid string
|
||||
wantMsisdn string
|
||||
wantImsi string
|
||||
wantReason string
|
||||
}{
|
||||
{
|
||||
name: "single value",
|
||||
line: text.FieldLine{RowNo: 2, Fields: []string{"89860401786597927028"}},
|
||||
wantCode: "89860401786597927028",
|
||||
wantIccid: "89860401786597927028",
|
||||
},
|
||||
{
|
||||
name: "triple values",
|
||||
line: text.FieldLine{RowNo: 3, Fields: []string{"89860401786597927028", "14100007028", "460046597927028"}},
|
||||
wantCode: "89860401786597927028",
|
||||
wantIccid: "89860401786597927028",
|
||||
wantMsisdn: "14100007028",
|
||||
wantImsi: "460046597927028",
|
||||
},
|
||||
{
|
||||
name: "invalid format",
|
||||
line: text.FieldLine{RowNo: 4, Fields: []string{"a", "b"}},
|
||||
wantReason: "格式错误",
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
test := test
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
row := parseRenewInputLine(test.line)
|
||||
if row.InputCode != test.wantCode {
|
||||
t.Fatalf("input code = %q, want %q", row.InputCode, test.wantCode)
|
||||
}
|
||||
if row.Iccid != test.wantIccid {
|
||||
t.Fatalf("iccid = %q, want %q", row.Iccid, test.wantIccid)
|
||||
}
|
||||
if row.Msisdn != test.wantMsisdn {
|
||||
t.Fatalf("msisdn = %q, want %q", row.Msisdn, test.wantMsisdn)
|
||||
}
|
||||
if row.Imsi != test.wantImsi {
|
||||
t.Fatalf("imsi = %q, want %q", row.Imsi, test.wantImsi)
|
||||
}
|
||||
if test.wantReason != "" && row.Reason == "" {
|
||||
t.Fatal("expected row reason")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenewIdentityResolverResolvesAnyCardIdentity(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
resolver := newRenewIdentityResolverFromCards([]*entity.TSimCardInfo{
|
||||
{
|
||||
Id: 1,
|
||||
Iccid: "89860401786597927028",
|
||||
Msisdn: "14100007028",
|
||||
Imsi: "460046597927028",
|
||||
},
|
||||
{
|
||||
Id: 2,
|
||||
Iccid: "89860401786597927029",
|
||||
Msisdn: "14100007029",
|
||||
Imsi: "460046597927029",
|
||||
},
|
||||
})
|
||||
|
||||
row := &Row{RowNo: 2, InputCode: "14100007028", Msisdn: "14100007028"}
|
||||
resolver.resolveRow(row)
|
||||
if row.Reason != "" {
|
||||
t.Fatalf("unexpected reason: %s", row.Reason)
|
||||
}
|
||||
if row.Iccid != "89860401786597927028" || row.Msisdn != "14100007028" || row.Imsi != "460046597927028" {
|
||||
t.Fatalf("unexpected resolved row: %#v", row)
|
||||
}
|
||||
|
||||
row = &Row{RowNo: 3, InputCode: "460046597927029", Imsi: "460046597927029"}
|
||||
resolver.resolveRow(row)
|
||||
if row.Reason != "" {
|
||||
t.Fatalf("unexpected reason for imsi row: %s", row.Reason)
|
||||
}
|
||||
if row.Iccid != "89860401786597927029" || row.Msisdn != "14100007029" || row.Imsi != "460046597927029" {
|
||||
t.Fatalf("unexpected imsi resolved row: %#v", row)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenewIdentityResolverRejectsAmbiguousOrMismatchedIdentity(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
resolver := newRenewIdentityResolverFromCards([]*entity.TSimCardInfo{
|
||||
{Id: 1, Iccid: "89860401786597927028", Msisdn: "14100007028", Imsi: "460046597927028"},
|
||||
{Id: 2, Iccid: "89860401786597927029", Msisdn: "89860401786597927028", Imsi: "460046597927029"},
|
||||
})
|
||||
|
||||
ambiguous := &Row{RowNo: 2, InputCode: "89860401786597927028", Iccid: "89860401786597927028"}
|
||||
resolver.resolveRow(ambiguous)
|
||||
if ambiguous.Reason == "" {
|
||||
t.Fatal("expected ambiguous reason")
|
||||
}
|
||||
|
||||
mismatched := &Row{RowNo: 3, InputCode: "89860401786597927028", Iccid: "89860401786597927028", Msisdn: "00000000000"}
|
||||
resolver.resolveRow(mismatched)
|
||||
if mismatched.Reason == "" {
|
||||
t.Fatal("expected mismatch reason")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
package renew
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/consts"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/batchimport"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/model"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/model/entity"
|
||||
|
||||
"github.com/gogf/gf/v2/os/gtime"
|
||||
)
|
||||
|
||||
func TestBuildBatchInfoOmitsRemovedEmptyFields(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
info := NewProcessor().BuildBatchInfo(context.Background(), batchimport.SubmitRequest{
|
||||
BizParams: &model.TSimCardRenewBatchSubmitParam{
|
||||
DurationMonths: 12,
|
||||
GiftDurationMonths: 2,
|
||||
Amount: "100.00",
|
||||
OfflineContractCompleted: true,
|
||||
},
|
||||
ExecutionResult: &CompleteOutput{
|
||||
Quantity: 1,
|
||||
PackageCode: "PKG001",
|
||||
OrderSn: "ORDER001",
|
||||
},
|
||||
}, &batchimport.Result{}, nil, nil, 0)
|
||||
|
||||
data := info.Map()
|
||||
for _, key := range []string{"sourceBatchNo", "buyerNote", "adminNote", "remark"} {
|
||||
if _, ok := data[key]; ok {
|
||||
t.Fatalf("batch info should omit %s when empty: %#v", key, data)
|
||||
}
|
||||
}
|
||||
if data["durationMonths"] != 12 {
|
||||
t.Fatalf("unexpected durationMonths: %#v", data["durationMonths"])
|
||||
}
|
||||
if data["giftDurationMonths"] != 2 {
|
||||
t.Fatalf("unexpected giftDurationMonths: %#v", data["giftDurationMonths"])
|
||||
}
|
||||
if data["offlineContractCompleted"] != true {
|
||||
t.Fatalf("unexpected offlineContractCompleted: %#v", data["offlineContractCompleted"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestCurrentPackagePeriodKey(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
bind := &entity.TSimCardBindPkg{
|
||||
EffectiveTime: gtime.NewFromStr("2026-01-01"),
|
||||
ExpectedExpireTime: gtime.NewFromStr("2026-12-31"),
|
||||
}
|
||||
if got, want := currentPackagePeriodKey(bind), "2026-01-01|2026-12-31"; got != want {
|
||||
t.Fatalf("currentPackagePeriodKey() = %q, want %q", got, want)
|
||||
}
|
||||
if got := currentPackagePeriodKey(&entity.TSimCardBindPkg{}); got != "" {
|
||||
t.Fatalf("currentPackagePeriodKey() for empty bind = %q, want empty", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenewBaseExpireTimePrefersLaterCardExpire(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
card := &entity.TSimCardInfo{
|
||||
FlowPkgEndAt: gtime.NewFromStr("2026-10-31 00:00:00"),
|
||||
}
|
||||
bind := &entity.TSimCardBindPkg{
|
||||
ExpectedExpireTime: gtime.NewFromStr("2026-08-31 00:00:00"),
|
||||
}
|
||||
|
||||
if got, want := renewBaseExpireTime(card, bind).Format("Y-m-d"), "2026-10-31"; got != want {
|
||||
t.Fatalf("renewBaseExpireTime() = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenewBeginDateFromExpireUsesExpireDate(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
beginAt := renewBeginDateFromExpire(gtime.NewFromStr("2026-08-31 23:59:59"))
|
||||
if beginAt == nil {
|
||||
t.Fatal("renew begin date is nil")
|
||||
}
|
||||
if got, want := beginAt.Format("Y-m-d H:i:s"), "2026-08-31 23:59:59"; got != want {
|
||||
t.Fatalf("renewBeginDateFromExpire() = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateRenewRowRejectsExpireAfterSixMonths(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
card := &entity.TSimCardInfo{
|
||||
CurrentPackageCode: "PKG001",
|
||||
PackageStatus: consts.PackageStatusEffective,
|
||||
}
|
||||
bind := &entity.TSimCardBindPkg{
|
||||
PackageCode: "PKG001",
|
||||
ExpectedExpireTime: gtime.Now().AddDate(0, 6, 1),
|
||||
EffectiveTime: gtime.Now().AddDate(-1, 0, 0),
|
||||
EffectiveStatus: consts.EffectiveStatusEffective,
|
||||
}
|
||||
|
||||
if got, want := validateRenewRow("", batchimport.OperatorContext{}, card, bind), "距离到期超过6个月,不能续费"; got != want {
|
||||
t.Fatalf("validateRenewRow() = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateRenewRowAllowsExpireWithinSixMonths(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
card := &entity.TSimCardInfo{
|
||||
CurrentPackageCode: "PKG001",
|
||||
PackageStatus: consts.PackageStatusEffective,
|
||||
}
|
||||
bind := &entity.TSimCardBindPkg{
|
||||
PackageCode: "PKG001",
|
||||
ExpectedExpireTime: gtime.Now().AddDate(0, 6, 0),
|
||||
EffectiveTime: gtime.Now().AddDate(-1, 0, 0),
|
||||
EffectiveStatus: consts.EffectiveStatusEffective,
|
||||
}
|
||||
|
||||
if got := validateRenewRow("", batchimport.OperatorContext{}, card, bind); got != "" {
|
||||
t.Fatalf("validateRenewRow() = %q, want empty", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateRenewRowRejectsStoppedCard(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
card := &entity.TSimCardInfo{
|
||||
BossCardStatus: consts.V3ApiSimBossCardStatusStopped,
|
||||
CurrentPackageCode: "PKG001",
|
||||
PackageStatus: consts.PackageStatusEffective,
|
||||
}
|
||||
bind := &entity.TSimCardBindPkg{
|
||||
PackageCode: "PKG001",
|
||||
}
|
||||
|
||||
if got, want := validateRenewRow("", batchimport.OperatorContext{}, card, bind), "ICCID当前为停机状态,不能续费"; got != want {
|
||||
t.Fatalf("validateRenewRow() = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenewPrepareSubmitParamUsesPrepareSourceBatchNo(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
req := &batchimport.SubmitRequest{
|
||||
BizParams: &model.TSimCardRenewBatchPrepareParam{SourceBatchNo: " OUT20260807001 "},
|
||||
}
|
||||
got := renewPrepareSubmitParam(req)
|
||||
if got.SourceBatchNo != "OUT20260807001" {
|
||||
t.Fatalf("SourceBatchNo = %q, want OUT20260807001", got.SourceBatchNo)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenewPrepareSubmitParamFallsBackToRequestSourceBatchNo(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
req := &batchimport.SubmitRequest{
|
||||
BizParams: &model.TSimCardRenewBatchSubmitParam{},
|
||||
SourceBatchNo: " OUT20260807002 ",
|
||||
}
|
||||
got := renewPrepareSubmitParam(req)
|
||||
if got.SourceBatchNo != "OUT20260807002" {
|
||||
t.Fatalf("SourceBatchNo = %q, want OUT20260807002", got.SourceBatchNo)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrepareProcessorValidateRowsDoesNotBlockSubmit(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
rows := []batchimport.Row{
|
||||
&Row{RowNo: 1, Iccid: "8986001"},
|
||||
&Row{RowNo: 2, Iccid: "8986001"},
|
||||
}
|
||||
if err := NewPrepareProcessor().ValidateRows(context.Background(), rows); err != nil {
|
||||
t.Fatalf("ValidateRows() error = %v", err)
|
||||
}
|
||||
for _, item := range rows {
|
||||
row := item.(*Row)
|
||||
if row.Reason != "" {
|
||||
t.Fatalf("row %d reason = %q, want empty before worker prepare", row.RowNo, row.Reason)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,9 +2,9 @@ package unsub
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/batchimport"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/batchinput/excel"
|
||||
"io"
|
||||
)
|
||||
|
||||
func ParseExcelFileRows(ctx context.Context, filePath string) ([]batchimport.Row, error) {
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
package unsub
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/batchimport"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/model"
|
||||
)
|
||||
|
||||
func TestProcessorConfigTemplateHeaders(t *testing.T) {
|
||||
config := NewProcessor().Config()
|
||||
expected := []string{"ICCID", "MSISDN", "IMSI"}
|
||||
if len(config.TemplateHeaders) != len(expected) {
|
||||
t.Fatalf("unexpected template header count: got %d want %d", len(config.TemplateHeaders), len(expected))
|
||||
}
|
||||
for i, header := range expected {
|
||||
if config.TemplateHeaders[i] != header {
|
||||
t.Fatalf("unexpected template header at %d: got %q want %q", i, config.TemplateHeaders[i], header)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildBatchInfoExcelDoesNotIncludeRemarkOrSourceBatchNo(t *testing.T) {
|
||||
processor := NewProcessor()
|
||||
info := processor.BuildBatchInfo(context.Background(), batchimport.SubmitRequest{
|
||||
BizParams: &model.TSimCardUnsubExcelSubmitParam{},
|
||||
}, nil, nil, nil, 0)
|
||||
|
||||
if info.Get("remark").String() != "" {
|
||||
t.Fatalf("excel batch info should not include remark")
|
||||
}
|
||||
if info.Get("sourceBatchNo").String() != "" {
|
||||
t.Fatalf("excel batch info should not include sourceBatchNo")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildBatchInfoInputKeepsRemark(t *testing.T) {
|
||||
processor := NewProcessor()
|
||||
info := processor.BuildBatchInfo(context.Background(), batchimport.SubmitRequest{
|
||||
BizParams: &model.TSimCardUnsubInputSubmitParam{Remark: " note "},
|
||||
}, nil, nil, nil, 0)
|
||||
|
||||
if got := info.Get("remark").String(); got != "note" {
|
||||
t.Fatalf("unexpected remark: got %q", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,330 @@
|
||||
package simcardquery
|
||||
|
||||
import (
|
||||
"context"
|
||||
"math"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/model"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/utility/iot_excelize"
|
||||
|
||||
"github.com/gogf/gf/v2/encoding/gjson"
|
||||
"github.com/gogf/gf/v2/errors/gerror"
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
"github.com/xuri/excelize/v2"
|
||||
)
|
||||
|
||||
// 导出列白名单。
|
||||
//
|
||||
// 只放行白名单内的字段:前端传什么就导出什么,但传了名单外的字段一律忽略,
|
||||
// 避免误把内部字段(如 id、tenantId)带进对客户的导出文件。
|
||||
var baseColumnWhitelist = map[string]model.ExportColumn{
|
||||
"iccid": {
|
||||
Field: "iccid", Label: "ICCID",
|
||||
},
|
||||
"msisdn": {
|
||||
Field: "msisdn", Label: "MSISDN",
|
||||
},
|
||||
"imsi": {
|
||||
Field: "imsi", Label: "IMSI",
|
||||
},
|
||||
"imei": {
|
||||
Field: "imei", Label: "IMEI",
|
||||
},
|
||||
"usedFlowMb": {
|
||||
Field: "usedFlowMb", Label: "当月用量(MB)",
|
||||
},
|
||||
"remainFlowMb": {
|
||||
Field: "remainFlowMb", Label: "当月剩余用量(MB)",
|
||||
},
|
||||
"packageTotalMb": {
|
||||
Field: "packageTotalMb", Label: "套餐总流量(MB)",
|
||||
},
|
||||
"cardStatus": {
|
||||
Field: "cardStatus", Label: "卡状态", DictType: "t_boss_card_status",
|
||||
},
|
||||
"netModel": {
|
||||
Field: "netModel", Label: "网络类型", DictType: "t_net_model",
|
||||
},
|
||||
"operators": {
|
||||
Field: "operators", Label: "运营商",
|
||||
},
|
||||
"activateAt": {
|
||||
Field: "activateAt", Label: "激活时间",
|
||||
},
|
||||
"outStockTime": {
|
||||
Field: "outStockTime", Label: "出库时间",
|
||||
},
|
||||
"expireAt": {
|
||||
Field: "expireAt", Label: "到期时间",
|
||||
},
|
||||
"outboundBatch": {
|
||||
Field: "outboundBatch", Label: "出库批次",
|
||||
},
|
||||
"inboundBatch": {
|
||||
Field: "inboundBatch", Label: "入库批次",
|
||||
},
|
||||
"packageName": {
|
||||
Field: "packageName", Label: "当前套餐名称",
|
||||
},
|
||||
"deptName": {
|
||||
Field: "deptName", Label: "所属企业",
|
||||
},
|
||||
"remark": {
|
||||
Field: "remark", Label: "备注",
|
||||
},
|
||||
"extraRemark": {
|
||||
Field: "extraRemark", Label: "其他备注",
|
||||
},
|
||||
}
|
||||
|
||||
// baseDefaultOrder 未勾选列时的默认导出顺序,与卡信息列表的常用列保持一致。
|
||||
var baseDefaultOrder = []string{
|
||||
"iccid", "msisdn", "imsi", "imei", "usedFlowMb", "remainFlowMb", "packageTotalMb",
|
||||
"cardStatus", "netModel", "operators", "activateAt", "outStockTime", "expireAt",
|
||||
"outboundBatch", "inboundBatch", "packageName", "deptName", "remark", "extraRemark",
|
||||
}
|
||||
|
||||
// 查询结果与失败原因固定追加在末尾,保证「部分成功」时每条失败可追溯。
|
||||
const (
|
||||
queryResultField = "queryResult"
|
||||
failReasonField = "failReason"
|
||||
)
|
||||
|
||||
func baseDefaultTags() []string {
|
||||
tags := make([]string, 0, len(baseDefaultOrder)+2)
|
||||
for _, field := range baseDefaultOrder {
|
||||
tags = append(tags, baseColumnWhitelist[field].Label)
|
||||
}
|
||||
return append(tags, "查询结果", "失败原因")
|
||||
}
|
||||
|
||||
// baseMandatoryOrder 三码固定导出列。
|
||||
// 这三列是对账的锚点,文件里没有 ICCID/MSISDN/IMSI 就无法定位到卡,
|
||||
// 因此既不允许前端取消勾选,也不允许服务端被传参绕过。
|
||||
var baseMandatoryOrder = []string{"iccid", "msisdn", "imsi"}
|
||||
|
||||
// resolveBaseColumns 归一化前端传入的导出列:三码强制在前,其余去重后按白名单追加,并固定追加结果列。
|
||||
func resolveBaseColumns(in *model.SimCardQueryExportParam) ([]model.ExportColumn, error) {
|
||||
requested := decodeExportColumns(in)
|
||||
columns := make([]model.ExportColumn, 0, len(requested)+5)
|
||||
seen := make(map[string]struct{}, len(requested)+len(baseMandatoryOrder))
|
||||
for _, field := range baseMandatoryOrder {
|
||||
seen[field] = struct{}{}
|
||||
columns = append(columns, baseColumnWhitelist[field])
|
||||
}
|
||||
for _, column := range requested {
|
||||
field := strings.TrimSpace(column.Field)
|
||||
allowed, ok := baseColumnWhitelist[field]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if _, dup := seen[field]; dup {
|
||||
continue
|
||||
}
|
||||
seen[field] = struct{}{}
|
||||
// 表头以服务端白名单为准,忽略前端自定义文案,避免导出列与页面口径不一致。
|
||||
columns = append(columns, allowed)
|
||||
}
|
||||
// 未勾选任何可选列时,把剩余默认列补齐;三码已在前面,不会重复。
|
||||
if len(columns) == len(baseMandatoryOrder) {
|
||||
for _, field := range baseDefaultOrder {
|
||||
if _, dup := seen[field]; dup {
|
||||
continue
|
||||
}
|
||||
seen[field] = struct{}{}
|
||||
columns = append(columns, baseColumnWhitelist[field])
|
||||
}
|
||||
}
|
||||
// 结果列不允许被去掉:部分成功时用户必须能看到每条失败的原因。
|
||||
columns = append(columns,
|
||||
model.ExportColumn{Field: queryResultField, Label: "查询结果"},
|
||||
model.ExportColumn{Field: failReasonField, Label: "失败原因"},
|
||||
)
|
||||
return columns, nil
|
||||
}
|
||||
|
||||
// decodeExportColumns 解析前端传入的导出列。
|
||||
// 优先解析 JSON 字符串(表单提交时数组套对象会被拆散),失败再退回对象数组。
|
||||
func decodeExportColumns(in *model.SimCardQueryExportParam) []model.ExportColumn {
|
||||
if in == nil {
|
||||
return nil
|
||||
}
|
||||
if raw := strings.TrimSpace(in.ExportColumnsJson); raw != "" {
|
||||
cols := make([]model.ExportColumn, 0)
|
||||
if err := gjson.New(raw).Scan(&cols); err == nil && len(cols) > 0 {
|
||||
return cols
|
||||
}
|
||||
}
|
||||
return in.ExportColumns
|
||||
}
|
||||
|
||||
// baseQuerySnapshot 落库的筛选条件快照。
|
||||
// 只留模式、条数和前若干条样本,不存全量号码,避免 query_params 膨胀。
|
||||
func baseQuerySnapshot(in *model.SimCardQueryExportParam, inputRows int) g.Map {
|
||||
snapshot := g.Map{
|
||||
"mode": in.Mode,
|
||||
"inputRows": inputRows,
|
||||
}
|
||||
switch in.Mode {
|
||||
case model.SimCardQueryModeRange:
|
||||
snapshot["dimension"] = in.Dimension
|
||||
snapshot["startNum"] = in.StartNum
|
||||
snapshot["endNum"] = in.EndNum
|
||||
case model.SimCardQueryModeInput:
|
||||
snapshot["batchInputPreview"] = previewText(in.BatchInput, 5)
|
||||
}
|
||||
return snapshot
|
||||
}
|
||||
|
||||
func previewText(text string, limit int) []string {
|
||||
lines := strings.Split(strings.ReplaceAll(text, "\r\n", "\n"), "\n")
|
||||
out := make([]string, 0, limit)
|
||||
for _, line := range lines {
|
||||
line = strings.TrimSpace(line)
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
out = append(out, line)
|
||||
if len(out) >= limit {
|
||||
break
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// roundMb 统一保留 3 位小数,与库表 decimal(18,3) 精度一致。
|
||||
func roundMb(value float64) float64 {
|
||||
return math.Round(value*1000) / 1000
|
||||
}
|
||||
|
||||
// usageHistoryWhitelist 月历史用量查询允许勾选的固定列。
|
||||
// 月份列是动态生成的,直接按月份值作 Field,不走白名单。
|
||||
var usageHistoryWhitelist = map[string]model.ExportColumn{
|
||||
"iccid": {Field: "iccid", Label: "ICCID"},
|
||||
"msisdn": {Field: "msisdn", Label: "MSISDN"},
|
||||
"imsi": {Field: "imsi", Label: "IMSI"},
|
||||
"outStockTime": {Field: "outStockTime", Label: "出库时间"},
|
||||
"expireAt": {Field: "expireAt", Label: "到期时间"},
|
||||
}
|
||||
|
||||
// usageHistoryOptionalFields 月历史用量里可勾选的固定列(三码固定导出,不在此列)。
|
||||
var usageHistoryOptionalFields = []string{"outStockTime", "expireAt"}
|
||||
|
||||
// resolveUsageHistoryColumns 生成月历史用量导出列。
|
||||
// 三码固定在最前且不可取消;月份列固定跟随导出口径,不允许前端自行增删。
|
||||
func resolveUsageHistoryColumns(in *model.SimCardUsageHistoryExportParam, months []string) []model.ExportColumn {
|
||||
requested := decodeExportColumns(&in.SimCardQueryExportParam)
|
||||
allowed := make(map[string]struct{}, len(requested))
|
||||
for _, column := range requested {
|
||||
field := strings.TrimSpace(column.Field)
|
||||
if _, ok := usageHistoryWhitelist[field]; ok {
|
||||
allowed[field] = struct{}{}
|
||||
}
|
||||
}
|
||||
columns := make([]model.ExportColumn, 0, len(usageHistoryFixedFields)+len(months)+2)
|
||||
for _, field := range baseMandatoryOrder {
|
||||
columns = append(columns, usageHistoryWhitelist[field])
|
||||
}
|
||||
// 未勾选任何可选列时按默认全出,命中筛选时按固定顺序保留。
|
||||
if len(allowed) == 0 {
|
||||
for _, field := range usageHistoryOptionalFields {
|
||||
columns = append(columns, usageHistoryWhitelist[field])
|
||||
}
|
||||
} else {
|
||||
for _, field := range usageHistoryOptionalFields {
|
||||
if _, ok := allowed[field]; ok {
|
||||
columns = append(columns, usageHistoryWhitelist[field])
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, month := range months {
|
||||
columns = append(columns, model.ExportColumn{Field: month, Label: month})
|
||||
}
|
||||
columns = append(columns,
|
||||
model.ExportColumn{Field: queryResultField, Label: "查询结果"},
|
||||
model.ExportColumn{Field: failReasonField, Label: "失败原因"},
|
||||
)
|
||||
return columns
|
||||
}
|
||||
|
||||
func usageHistoryTags(months []string) []string {
|
||||
tags := make([]string, 0, len(usageHistoryFixedFields)+len(months)+2)
|
||||
for _, field := range usageHistoryFixedFields {
|
||||
tags = append(tags, usageHistoryWhitelist[field].Label)
|
||||
}
|
||||
tags = append(tags, months...)
|
||||
return append(tags, "查询结果", "失败原因")
|
||||
}
|
||||
|
||||
func usageHistorySnapshot(in *model.SimCardUsageHistoryExportParam, months []string, rows int) g.Map {
|
||||
snapshot := g.Map{
|
||||
"mode": in.Mode,
|
||||
"months": months,
|
||||
"inputRows": rows,
|
||||
}
|
||||
if in.Mode == model.SimCardQueryModeDept {
|
||||
snapshot["deptId"] = in.DeptId
|
||||
}
|
||||
return snapshot
|
||||
}
|
||||
|
||||
// UsageHistoryTemplate 生成月历史用量查询模板(与卡基础信息查询同一份模板口径)。
|
||||
func (s *Service) UsageHistoryTemplate(ctx context.Context) (*excelize.File, string, error) {
|
||||
return s.BaseTemplate(ctx)
|
||||
}
|
||||
|
||||
// BaseColumnOptions 卡基础信息查询可选导出列,供前端渲染勾选框。
|
||||
func (s *Service) BaseColumnOptions() []model.ExportColumn {
|
||||
options := make([]model.ExportColumn, 0, len(baseDefaultOrder))
|
||||
for _, field := range baseDefaultOrder {
|
||||
options = append(options, baseColumnWhitelist[field])
|
||||
}
|
||||
return options
|
||||
}
|
||||
|
||||
// UsageHistoryColumnOptions 月历史用量查询可选固定列(不含动态月份列)。
|
||||
func (s *Service) UsageHistoryColumnOptions() []model.ExportColumn {
|
||||
options := make([]model.ExportColumn, 0, len(usageHistoryFixedFields))
|
||||
for _, field := range usageHistoryFixedFields {
|
||||
options = append(options, usageHistoryWhitelist[field])
|
||||
}
|
||||
return options
|
||||
}
|
||||
|
||||
// UsageHistoryMonths 当前导出口径的月份清单,前端用它预览列顺序。
|
||||
func (s *Service) UsageHistoryMonths(now time.Time) []string {
|
||||
return LastCompleteMonths(now, usageHistoryMonths)
|
||||
}
|
||||
|
||||
// BaseTemplate 生成卡基础信息查询模板。
|
||||
// 三列都允许为空,但至少填一个;表头与导入解析的 RequiredHeaders 保持一致。
|
||||
func (s *Service) BaseTemplate(ctx context.Context) (*excelize.File, string, error) {
|
||||
f := excelize.NewFile()
|
||||
sheet := "卡基础信息查询"
|
||||
index, err := f.NewSheet(sheet)
|
||||
if err != nil {
|
||||
return nil, "", gerror.Wrap(err, "创建模板工作表失败")
|
||||
}
|
||||
f.SetActiveSheet(index)
|
||||
if err := f.DeleteSheet("Sheet1"); err != nil {
|
||||
return nil, "", gerror.Wrap(err, "清理默认工作表失败")
|
||||
}
|
||||
|
||||
headers := []string{"ICCID", "MSISDN", "IMSI"}
|
||||
for i, header := range headers {
|
||||
cell, _ := excelize.CoordinatesToCellName(i+1, 1)
|
||||
if err = f.SetCellValue(sheet, cell, header); err != nil {
|
||||
return nil, "", gerror.Wrap(err, "写入模板表头失败")
|
||||
}
|
||||
}
|
||||
if err = f.SetColWidth(sheet, "A", "C", 26); err != nil {
|
||||
return nil, "", gerror.Wrap(err, "设置模板列宽失败")
|
||||
}
|
||||
if err = iot_excelize.ApplyHeaderColumnsTextStyle(f); err != nil {
|
||||
g.Log().Warningf(ctx, "apply template style failed: %v", err)
|
||||
}
|
||||
|
||||
return f, "卡基础信息查询模板", nil
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
package simcardquery
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/dao"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/xgorm/handler"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/model"
|
||||
|
||||
"github.com/gogf/gf/v2/database/gdb"
|
||||
"github.com/gogf/gf/v2/errors/gerror"
|
||||
)
|
||||
|
||||
// DeptOptions 所属公司下拉项(树形)。
|
||||
//
|
||||
// 两点关键口径:
|
||||
// 1. 可见范围交给 handler.FilterAuth:顶级看全部,其他角色按自身数据范围。
|
||||
// 2. CardCount 是「含下级」的累计量,不是本部门直接挂的卡。
|
||||
// 原因:顶级公司(如示例企业)自身往往不挂卡,卡都在下级代理名下,
|
||||
// 只统计本部门会显示 0,看起来像没数据。
|
||||
func (s *Service) DeptOptions(ctx context.Context) ([]*model.SimCardQueryDeptOption, error) {
|
||||
deptColumns := dao.SysDept.Columns()
|
||||
depts := make([]*struct {
|
||||
DeptId int64 `orm:"dept_id"`
|
||||
DeptName string `orm:"dept_name"`
|
||||
ParentId int64 `orm:"parent_id"`
|
||||
}, 0, 256)
|
||||
|
||||
err := handler.Model(dao.SysDept.Ctx(ctx), &handler.Option{
|
||||
FilterTenant: true,
|
||||
FilterAuth: true,
|
||||
}).
|
||||
Fields(deptColumns.DeptId, deptColumns.DeptName, deptColumns.ParentId).
|
||||
Where(deptColumns.Status, 0).
|
||||
WhereNull(deptColumns.DeletedAt).
|
||||
OrderAsc(deptColumns.OrderNum).
|
||||
OrderAsc(deptColumns.DeptId).
|
||||
Scan(&depts)
|
||||
if err != nil {
|
||||
return nil, gerror.Wrap(err, "获取可选公司失败,请稍后重试")
|
||||
}
|
||||
if len(depts) == 0 {
|
||||
return []*model.SimCardQueryDeptOption{}, nil
|
||||
}
|
||||
|
||||
direct, err := directCardCountByDept(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
subtree := accumulateSubtreeCount(depts, direct)
|
||||
|
||||
options := make([]*model.SimCardQueryDeptOption, 0, len(depts))
|
||||
for _, dept := range depts {
|
||||
options = append(options, &model.SimCardQueryDeptOption{
|
||||
DeptId: dept.DeptId,
|
||||
DeptName: dept.DeptName,
|
||||
CardCount: subtree[dept.DeptId],
|
||||
ParentId: dept.ParentId,
|
||||
})
|
||||
}
|
||||
return options, nil
|
||||
}
|
||||
|
||||
// directCardCountByDept 按部门统计直接挂载的卡数量。
|
||||
// 卡表同样过数据权限,防止公司列表被收窄、但计数把无权卡的量带出来。
|
||||
func directCardCountByDept(ctx context.Context) (map[int64]int64, error) {
|
||||
cardColumns := dao.TSimCardInfo.Columns()
|
||||
rows := make([]*struct {
|
||||
DeptId int64 `orm:"dept_id"`
|
||||
Total int64 `orm:"total"`
|
||||
}, 0, 256)
|
||||
err := handler.Model(dao.TSimCardInfo.Ctx(ctx), &handler.Option{
|
||||
FilterTenant: true,
|
||||
FilterAuth: true,
|
||||
}).
|
||||
Fields(fmt.Sprintf("%s AS dept_id, COUNT(1) AS total", cardColumns.DeptId)).
|
||||
Group(cardColumns.DeptId).
|
||||
Scan(&rows)
|
||||
if err != nil {
|
||||
return nil, gerror.Wrap(err, "统计公司卡数量失败")
|
||||
}
|
||||
result := make(map[int64]int64, len(rows))
|
||||
for _, row := range rows {
|
||||
result[row.DeptId] = row.Total
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// accumulateSubtreeCount 把「本部门直接卡数」沿部门树向上累加,得到含下级的卡数。
|
||||
//
|
||||
// 只累加本次查询可见的部门:无权部门根本不在 depts 里,其卡数也不会被计入,
|
||||
// 因此不会通过父级汇总把无权数据带出来。
|
||||
func accumulateSubtreeCount(
|
||||
depts []*struct {
|
||||
DeptId int64 `orm:"dept_id"`
|
||||
DeptName string `orm:"dept_name"`
|
||||
ParentId int64 `orm:"parent_id"`
|
||||
},
|
||||
direct map[int64]int64,
|
||||
) map[int64]int64 {
|
||||
visible := make(map[int64]struct{}, len(depts))
|
||||
children := make(map[int64][]int64, len(depts))
|
||||
roots := make([]int64, 0, len(depts))
|
||||
for _, dept := range depts {
|
||||
visible[dept.DeptId] = struct{}{}
|
||||
}
|
||||
for _, dept := range depts {
|
||||
if _, ok := visible[dept.ParentId]; ok && dept.ParentId != dept.DeptId {
|
||||
children[dept.ParentId] = append(children[dept.ParentId], dept.DeptId)
|
||||
continue
|
||||
}
|
||||
roots = append(roots, dept.DeptId)
|
||||
}
|
||||
|
||||
subtree := make(map[int64]int64, len(depts))
|
||||
// 部门树很浅,递归深度可控;用显式栈避免深层树爆栈。
|
||||
type frame struct {
|
||||
id int64
|
||||
child int
|
||||
amount int64
|
||||
}
|
||||
for _, rootID := range roots {
|
||||
stack := []*frame{{id: rootID, amount: direct[rootID]}}
|
||||
for len(stack) > 0 {
|
||||
top := stack[len(stack)-1]
|
||||
if kids := children[top.id]; top.child < len(kids) {
|
||||
childID := kids[top.child]
|
||||
top.child++
|
||||
stack = append(stack, &frame{id: childID, amount: direct[childID]})
|
||||
continue
|
||||
}
|
||||
subtree[top.id] = top.amount
|
||||
stack = stack[:len(stack)-1]
|
||||
if len(stack) > 0 {
|
||||
stack[len(stack)-1].amount += top.amount
|
||||
}
|
||||
}
|
||||
}
|
||||
return subtree
|
||||
}
|
||||
|
||||
// deptSubtreeIDs 返回某部门及其全部下级的 dept_id 子查询。
|
||||
// 与 DeptOptions 的统计口径保持一致:选中公司即包含下级公司。
|
||||
func deptSubtreeIDs(ctx context.Context, deptId int64) *gdb.Model {
|
||||
deptColumns := dao.SysDept.Columns()
|
||||
return handler.Model(dao.SysDept.Ctx(ctx), &handler.Option{
|
||||
FilterTenant: true,
|
||||
}).Fields(deptColumns.DeptId).
|
||||
Where(fmt.Sprintf("%s = ? OR %s LIKE ?", deptColumns.DeptId, deptColumns.Ancestors),
|
||||
deptId, fmt.Sprintf("%%,%d,%%", deptId))
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
package simcardquery
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/xuri/excelize/v2"
|
||||
)
|
||||
|
||||
// buildRows 造 n 行「卡基础信息」级别的数据:22 列、字段长短接近真实。
|
||||
func buildRows(n int) [][]interface{} {
|
||||
rows := make([][]interface{}, n)
|
||||
for i := 0; i < n; i++ {
|
||||
id := strconv.Itoa(8986000000000000000 + i)
|
||||
rows[i] = []interface{}{
|
||||
id,
|
||||
"186" + strconv.Itoa(10000000+i),
|
||||
"46000" + strconv.Itoa(100000000+i),
|
||||
"86000000000000" + strconv.Itoa(i%100),
|
||||
float64(i % 5000),
|
||||
float64(5000 - i%5000),
|
||||
10240.0,
|
||||
2,
|
||||
4,
|
||||
"中国移动",
|
||||
"2026-01-01 00:00:00",
|
||||
"2025-12-01 00:00:00",
|
||||
"2027-12-31 23:59:59",
|
||||
"OUT20260101",
|
||||
"IN20251201",
|
||||
"标准套餐A",
|
||||
"示例企业",
|
||||
"备注内容示例",
|
||||
"",
|
||||
"成功",
|
||||
"",
|
||||
"",
|
||||
}
|
||||
}
|
||||
return rows
|
||||
}
|
||||
|
||||
// TestExportWriteCost 量不同行数下「写 Excel + 生成文件」的真实耗时与体积。
|
||||
// 用来判断 20000 这个上限是否偏离实际能力,而不是拍脑袋。
|
||||
func TestExportWriteCost(t *testing.T) {
|
||||
for _, n := range []int{20000, 50000, 100000} {
|
||||
rows := buildRows(n)
|
||||
start := time.Now()
|
||||
|
||||
f := excelize.NewFile()
|
||||
sheet := f.GetSheetName(0)
|
||||
stream, err := f.NewStreamWriter(sheet)
|
||||
if err != nil {
|
||||
t.Fatalf("new stream writer: %v", err)
|
||||
}
|
||||
header := make([]interface{}, 22)
|
||||
for i := range header {
|
||||
header[i] = "列" + strconv.Itoa(i)
|
||||
}
|
||||
if err = stream.SetRow("A1", header); err != nil {
|
||||
t.Fatalf("write header: %v", err)
|
||||
}
|
||||
for i, row := range rows {
|
||||
cell, _ := excelize.CoordinatesToCellName(1, i+2)
|
||||
if err = stream.SetRow(cell, row); err != nil {
|
||||
t.Fatalf("write row %d: %v", i, err)
|
||||
}
|
||||
}
|
||||
if err = stream.Flush(); err != nil {
|
||||
t.Fatalf("flush: %v", err)
|
||||
}
|
||||
buf, err := f.WriteToBuffer()
|
||||
if err != nil {
|
||||
t.Fatalf("write to buffer: %v", err)
|
||||
}
|
||||
_ = f.Close()
|
||||
|
||||
elapsed := time.Since(start)
|
||||
t.Logf("rows=%6d write=%8.2fs size=%6.1fMB", n, elapsed.Seconds(), float64(buf.Len())/(1<<20))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,857 @@
|
||||
// Package simcardquery 卡基础信息查询 与 月历史用量查询。
|
||||
//
|
||||
// 两个功能都是「按号码批量查卡 → 异步导出」,共用同一套输入解析与导出列机制:
|
||||
// - 输入来源:Excel 模板导入 / 多行文本批量输入 / 连号号段;
|
||||
// - 号码识别:每行 1 个值(ICCID、MSISDN、IMSI 任意一种)或 3 个值(ICCID MSISDN IMSI),
|
||||
// 单值行在三列之间做 OR 匹配,因此不需要用户先选「用三码还是只用 ICCID」;
|
||||
// - 导出走通用异步导出中心,文件末尾固定带「查询结果」「失败原因」两列,
|
||||
// 未命中的输入也会出现在文件里,便于对账。
|
||||
package simcardquery
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"math"
|
||||
"math/big"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/consts"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/dao"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/batchinput/excel"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/batchinput/text"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/exportcenter"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/xgorm/handler"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/logic/cardflow"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/model"
|
||||
|
||||
"github.com/gogf/gf/v2/errors/gerror"
|
||||
"github.com/gogf/gf/v2/os/gtime"
|
||||
)
|
||||
|
||||
// 查询结果列取值,导出文件与执行日志共用同一套文案。
|
||||
const (
|
||||
queryResultHit = "成功"
|
||||
queryResultMiss = "失败"
|
||||
)
|
||||
|
||||
// chunkSize 单次 IN 查询的号码条数。
|
||||
// 1000 是 MySQL 解析大量占位符仍能稳定走索引的量级,也和现有批量查询上限一致。
|
||||
const chunkSize = 1000
|
||||
|
||||
type Service struct{}
|
||||
|
||||
func New() *Service {
|
||||
return &Service{}
|
||||
}
|
||||
|
||||
// inputLine 一行输入:可能是 1 个号码,也可能是 ICCID/MSISDN/IMSI 三码。
|
||||
type inputLine struct {
|
||||
RowNo int
|
||||
Values []string
|
||||
}
|
||||
|
||||
// cardBrief 查询命中的卡基础字段,按号码索引后复用。
|
||||
type cardBrief struct {
|
||||
Id int64 `orm:"id"`
|
||||
Iccid string `orm:"iccid"`
|
||||
Msisdn string `orm:"msisdn"`
|
||||
Imsi string `orm:"imsi"`
|
||||
Imei string `orm:"imei"`
|
||||
UsedFlowMb float64 `orm:"used_flow_mb"`
|
||||
PackageTotalMb float64 `orm:"query_total_flow_mb"`
|
||||
CardStatus int `orm:"boss_card_status"`
|
||||
NetModel int `orm:"net_model"`
|
||||
ChanKey string `orm:"chan_key"`
|
||||
ActivateAt *gtime.Time `orm:"boss_activate_at"`
|
||||
OutStockTime *gtime.Time `orm:"out_stock_time"`
|
||||
ExpireAt *gtime.Time `orm:"flow_pkg_end_at"`
|
||||
OutboundBatch string `orm:"outbound_batch_no"`
|
||||
InboundBatch string `orm:"inbound_batch_no"`
|
||||
PackageCode string `orm:"current_package_code"`
|
||||
DeptId int64 `orm:"dept_id"`
|
||||
Remark string `orm:"remark"`
|
||||
}
|
||||
|
||||
// ExcelInput 上传文件的读取器工厂。
|
||||
// 每次调用返回新的读取器,保证后台解析时读到完整内容。
|
||||
type ExcelInput func() (io.Reader, error)
|
||||
|
||||
// ExportBase 卡基础信息查询导出。
|
||||
func (s *Service) ExportBase(ctx context.Context, in *model.SimCardQueryExportParam, input ExcelInput) (*exportcenter.SubmitResult, error) {
|
||||
lines, err := parseInputs(ctx, in, input)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
columns, err := resolveBaseColumns(in)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
stats := &resolveStats{}
|
||||
once := &sync.Once{}
|
||||
var cached []*model.SimCardBaseQueryRow
|
||||
var cacheErr error
|
||||
resolve := func(ctx context.Context) ([]*model.SimCardBaseQueryRow, error) {
|
||||
once.Do(func() {
|
||||
if in.Mode == model.SimCardQueryModeRange {
|
||||
cached, cacheErr = s.resolveRangeRows(ctx, in, stats)
|
||||
return
|
||||
}
|
||||
cached, cacheErr = s.resolveBaseRows(ctx, lines, stats)
|
||||
})
|
||||
return cached, cacheErr
|
||||
}
|
||||
|
||||
return exportcenter.Submit(ctx, &exportcenter.Request{
|
||||
BizKey: "tSimCardBaseQuery",
|
||||
BizName: "卡基础信息查询",
|
||||
PermCode: "cpm:iot:tSimCardBaseQuery:view",
|
||||
SheetName: "卡基础信息",
|
||||
QueryParam: baseQuerySnapshot(in, len(lines)),
|
||||
Columns: columns,
|
||||
Tags: func() ([]string, error) {
|
||||
return baseDefaultTags(), nil
|
||||
},
|
||||
Fetch: func(ctx context.Context, page, pageSize int) ([]interface{}, int, error) {
|
||||
// exportcenter 提交时会用 pageSize=1 做一次「总数探针」。
|
||||
// 直接返回输入行数(号段模式未知条数,返回 1 占位),让提交接口立即返回,
|
||||
// 真正的查库放到后台协程里做。
|
||||
if pageSize <= 1 && page <= 1 {
|
||||
if len(lines) > 0 {
|
||||
return nil, len(lines), nil
|
||||
}
|
||||
// 号段模式:号段包含多少个号码就是预计条数,与「查不查得到」无关。
|
||||
// 这样用户输入 10 个连续号,即使一条都查不到,页面也会显示「预计 10 条」。
|
||||
size, sizeErr := rangeSize(in)
|
||||
if sizeErr != nil {
|
||||
return nil, 0, sizeErr
|
||||
}
|
||||
return nil, int(size), nil
|
||||
}
|
||||
rows, err := resolve(ctx)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
total := len(rows)
|
||||
start := (page - 1) * pageSize
|
||||
if start >= total {
|
||||
return nil, total, nil
|
||||
}
|
||||
end := start + pageSize
|
||||
if end > total {
|
||||
end = total
|
||||
}
|
||||
out := make([]interface{}, 0, end-start)
|
||||
for _, row := range rows[start:end] {
|
||||
out = append(out, row)
|
||||
}
|
||||
return out, total, nil
|
||||
},
|
||||
Result: stats.resultSummary,
|
||||
})
|
||||
}
|
||||
|
||||
// parseInputs 按模式解析输入号码。
|
||||
func parseInputs(ctx context.Context, in *model.SimCardQueryExportParam, input ExcelInput) ([]inputLine, error) {
|
||||
switch in.Mode {
|
||||
case model.SimCardQueryModeInput:
|
||||
return parseTextInputs(ctx, in.BatchInput)
|
||||
case model.SimCardQueryModeExcel:
|
||||
if input == nil {
|
||||
return nil, gerror.New("请先上传 Excel 文件")
|
||||
}
|
||||
reader, err := input()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if reader == nil {
|
||||
return nil, gerror.New("请先上传 Excel 文件")
|
||||
}
|
||||
lines, err := streamExcelInputs(ctx, reader)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(lines) == 0 {
|
||||
return nil, gerror.New("Excel 中没有可解析的号码")
|
||||
}
|
||||
return lines, nil
|
||||
case model.SimCardQueryModeRange:
|
||||
// 号段模式不展开成逐个号码:连号可能跨上百万个号,展开会把内存打满。
|
||||
// 只做校验,实际取数交给 resolveRangeRows 用 BETWEEN 条件分页查。
|
||||
return nil, parseRangeInputs(in)
|
||||
default:
|
||||
return nil, gerror.New("不支持的查询方式")
|
||||
}
|
||||
}
|
||||
|
||||
// parseRangeInputs 校验连号号段参数。
|
||||
func parseRangeInputs(in *model.SimCardQueryExportParam) error {
|
||||
start := strings.TrimSpace(in.StartNum)
|
||||
end := strings.TrimSpace(in.EndNum)
|
||||
if start == "" || end == "" {
|
||||
return gerror.New("连号查询需同时填写起始和结束号码")
|
||||
}
|
||||
if len(start) != len(end) || !isDigits(start) || !isDigits(end) {
|
||||
return gerror.New("起止号码必须为等长数字")
|
||||
}
|
||||
if start > end {
|
||||
return gerror.New("起始号码不能大于结束号码")
|
||||
}
|
||||
_, err := rangeColumn(in.Dimension)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// 号段按号码逐个输出(查到出行数据、没查到出失败行),条数必须与号段大小一致。
|
||||
// 因此号段不能超过单次上限,否则无法完整展开。
|
||||
size, err := rangeSize(in)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if size > model.SimCardQueryMaxRows {
|
||||
return gerror.Newf("号段内号码数量超过单次上限 %d 个,请缩小范围或分批查询", model.SimCardQueryMaxRows)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// parseTextInputs 解析多行文本。每行 1~3 个空白分隔的号码值。
|
||||
func isDigits(value string) bool {
|
||||
if value == "" {
|
||||
return false
|
||||
}
|
||||
for _, r := range value {
|
||||
if r < '0' || r > '9' {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func parseTextInputs(ctx context.Context, input string) ([]inputLine, error) {
|
||||
lines, err := text.ParseWhitespaceFields(ctx, input)
|
||||
if err != nil {
|
||||
if errors.Is(err, text.ErrEmptyInput) {
|
||||
return nil, gerror.New("批量输入内容不能为空")
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
if len(lines) > model.SimCardQueryMaxRows {
|
||||
return nil, gerror.Newf("批量输入 %d 行,超过单次上限 %d 行,请拆分后分批查询", len(lines), model.SimCardQueryMaxRows)
|
||||
}
|
||||
out := make([]inputLine, 0, len(lines))
|
||||
for _, line := range lines {
|
||||
if len(line.Fields) > 3 {
|
||||
return nil, gerror.Newf("第 %d 行格式错误:每行支持 1 至 3 个号码值", line.RowNo)
|
||||
}
|
||||
out = append(out, inputLine{RowNo: line.RowNo, Values: line.Fields})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// streamExcelInputs 流式解析 Excel 上传。ICCID / MSISDN / IMSI 三列任填其一即可。
|
||||
func streamExcelInputs(ctx context.Context, reader io.Reader) ([]inputLine, error) {
|
||||
out := make([]inputLine, 0)
|
||||
_, err := excel.StreamReader(ctx, reader, excel.ParseOptions{
|
||||
SingleSheet: true,
|
||||
MaxDataRows: model.SimCardQueryMaxRows,
|
||||
}, func(row excel.ParsedRow) error {
|
||||
values := make([]string, 0, 3)
|
||||
for _, name := range []string{"ICCID", "MSISDN", "IMSI"} {
|
||||
if value := excel.Field(row, name); value != "" {
|
||||
values = append(values, value)
|
||||
}
|
||||
}
|
||||
if len(values) == 0 {
|
||||
// 空行由解析库跳过;这里保留为失败行,便于用户知道模板里哪一行没填。
|
||||
values = append(values, "")
|
||||
}
|
||||
out = append(out, inputLine{RowNo: row.RowNo, Values: values})
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(out) == 0 {
|
||||
return nil, gerror.New("Excel 中没有可解析的号码")
|
||||
}
|
||||
if len(out) > model.SimCardQueryMaxRows {
|
||||
return nil, gerror.Newf("Excel 有效数据 %d 行,超过单次上限 %d 行,请拆分后分批查询", len(out), model.SimCardQueryMaxRows)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// resolveStats 记录命中/未命中/去重数量,用于导出结束后的执行日志汇总。
|
||||
type resolveStats struct {
|
||||
mu sync.Mutex
|
||||
hit int
|
||||
miss int
|
||||
dup int
|
||||
}
|
||||
|
||||
func (s *resolveStats) add(hit bool) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if hit {
|
||||
s.hit++
|
||||
return
|
||||
}
|
||||
s.miss++
|
||||
}
|
||||
|
||||
// addDup 记录一条因重复而合并的输入。
|
||||
// 同一张卡可能是同一号码写了两遍,也可能一行 ICCID、另一行 MSISDN 指向同一张卡。
|
||||
func (s *resolveStats) addDup() {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.dup++
|
||||
}
|
||||
|
||||
func (s *resolveStats) resultSummary(_ int) (int, string) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
total := s.hit + s.miss
|
||||
if total == 0 {
|
||||
return consts.BatchRunResultFailed, "查询结果:没有可导出的数据"
|
||||
}
|
||||
dupText := ""
|
||||
if s.dup > 0 {
|
||||
dupText = fmt.Sprintf(",重复 %d 条已合并", s.dup)
|
||||
}
|
||||
if s.miss == 0 {
|
||||
return consts.BatchRunResultSuccess,
|
||||
fmt.Sprintf("查询完成:成功 %d 条,失败 0 条%s", s.hit, dupText)
|
||||
}
|
||||
if s.hit == 0 {
|
||||
return consts.BatchRunResultFailed,
|
||||
fmt.Sprintf("查询失败:成功 0 条,失败 %d 条%s;失败原因见导出文件「失败原因」列", s.miss, dupText)
|
||||
}
|
||||
return consts.BatchRunResultPartialSuccess,
|
||||
fmt.Sprintf("部分成功:成功 %d 条,失败 %d 条%s;失败原因见导出文件「失败原因」列", s.hit, s.miss, dupText)
|
||||
}
|
||||
|
||||
// resolveBaseRows 把输入号码解析成导出行,未命中的行也保留并写明原因。
|
||||
func (s *Service) resolveBaseRows(ctx context.Context, lines []inputLine, stats *resolveStats) ([]*model.SimCardBaseQueryRow, error) {
|
||||
values := collectValues(lines)
|
||||
cards, err := queryCardsByValues(ctx, values)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
byIccid := indexCards(cards, func(card *cardBrief) string { return card.Iccid })
|
||||
byMsisdn := indexCards(cards, func(card *cardBrief) string { return card.Msisdn })
|
||||
byImsi := indexCards(cards, func(card *cardBrief) string { return card.Imsi })
|
||||
|
||||
display := newDisplayResolver(ctx, cards)
|
||||
rows := make([]*model.SimCardBaseQueryRow, 0, len(lines))
|
||||
// 同一张卡在输入里出现多次时只导出一次:
|
||||
// 可能是同一号码重复填写,也可能一行是 ICCID、另一行是它的 MSISDN。
|
||||
exported := make(map[int64]struct{}, len(lines))
|
||||
for _, line := range lines {
|
||||
row := &model.SimCardBaseQueryRow{}
|
||||
card, reason := matchCard(line, byIccid, byMsisdn, byImsi)
|
||||
if card == nil {
|
||||
// 未命中时把输入的第一列原样回填,方便用户定位是哪一条没查到。
|
||||
row.Iccid = firstValue(line.Values)
|
||||
row.QueryResult = queryResultMiss
|
||||
row.FailReason = inputFailure(line, reason)
|
||||
stats.add(false)
|
||||
rows = append(rows, row)
|
||||
continue
|
||||
}
|
||||
if _, ok := exported[card.Id]; ok {
|
||||
stats.addDup()
|
||||
continue
|
||||
}
|
||||
exported[card.Id] = struct{}{}
|
||||
display.fill(row, card)
|
||||
row.QueryResult = queryResultHit
|
||||
stats.add(true)
|
||||
rows = append(rows, row)
|
||||
}
|
||||
return rows, nil
|
||||
}
|
||||
|
||||
// resolveRangeRows 号段模式:按起止号段分页取卡,取到的卡全部视为命中。
|
||||
// 取满上限即停,避免上百万号的号段把导出任务拖死。
|
||||
func (s *Service) resolveRangeRows(ctx context.Context, in *model.SimCardQueryExportParam, stats *resolveStats) ([]*model.SimCardBaseQueryRow, error) {
|
||||
cards, err := queryCardsByRange(ctx, in)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return buildRangeRows(ctx, in, cards, stats), nil
|
||||
}
|
||||
|
||||
// setRangeNumber 按号段维度把号码写进对应列。
|
||||
func setRangeNumber(row *model.SimCardBaseQueryRow, dimension, number string) {
|
||||
switch dimension {
|
||||
case "msisdn":
|
||||
row.Msisdn = number
|
||||
case "imsi":
|
||||
row.Imsi = number
|
||||
default:
|
||||
row.Iccid = number
|
||||
}
|
||||
}
|
||||
|
||||
// rangeCardIndex 按号段维度值索引命中的卡。
|
||||
func rangeCardIndex(cards []*cardBrief, dimension string) map[string]*cardBrief {
|
||||
index := make(map[string]*cardBrief, len(cards))
|
||||
for _, card := range cards {
|
||||
var value string
|
||||
switch dimension {
|
||||
case "msisdn":
|
||||
value = card.Msisdn
|
||||
case "imsi":
|
||||
value = card.Imsi
|
||||
default:
|
||||
value = card.Iccid
|
||||
}
|
||||
if value != "" {
|
||||
index[value] = card
|
||||
}
|
||||
}
|
||||
return index
|
||||
}
|
||||
|
||||
// buildRangeRows 号段模式:按号段逐个号码输出一行,命中出行数据、未命中出失败行。
|
||||
//
|
||||
// 不能只输出命中的卡:号段里可能一部分在当前账号可见范围内、一部分不在,
|
||||
// 只输出命中部分会让导出条数与号段大小对不上,客户无法按号对账。
|
||||
func buildRangeRows(ctx context.Context, in *model.SimCardQueryExportParam, cards []*cardBrief, stats *resolveStats) []*model.SimCardBaseQueryRow {
|
||||
dimension := strings.TrimSpace(in.Dimension)
|
||||
index := rangeCardIndex(cards, dimension)
|
||||
size, err := rangeSize(in)
|
||||
if err != nil {
|
||||
size = int64(len(cards))
|
||||
}
|
||||
numbers := rangeNumbers(in, size)
|
||||
display := newDisplayResolver(ctx, cards)
|
||||
rows := make([]*model.SimCardBaseQueryRow, 0, len(numbers))
|
||||
for _, number := range numbers {
|
||||
if card, ok := index[number]; ok {
|
||||
row := &model.SimCardBaseQueryRow{QueryResult: queryResultHit}
|
||||
display.fill(row, card)
|
||||
stats.add(true)
|
||||
rows = append(rows, row)
|
||||
continue
|
||||
}
|
||||
row := &model.SimCardBaseQueryRow{
|
||||
QueryResult: queryResultMiss,
|
||||
FailReason: rangeMissReason,
|
||||
}
|
||||
setRangeNumber(row, dimension, number)
|
||||
stats.add(false)
|
||||
rows = append(rows, row)
|
||||
}
|
||||
return rows
|
||||
}
|
||||
|
||||
// rangeMissReason 号段/号码查不到数据时的统一说明。
|
||||
// 不能写「无权访问」:是否因为权限被过滤属于内部口径,不应暴露给客户。
|
||||
const rangeMissReason = "未查询到数据"
|
||||
|
||||
// rangeSize 号段包含的号码个数,用于提交时给出「预计 N 条」。
|
||||
// ICCID 有 20 位,超过 int64 表示范围,用 big.Int 计算。
|
||||
func rangeSize(in *model.SimCardQueryExportParam) (int64, error) {
|
||||
start := strings.TrimSpace(in.StartNum)
|
||||
end := strings.TrimSpace(in.EndNum)
|
||||
s, ok := new(big.Int).SetString(start, 10)
|
||||
if !ok {
|
||||
return 0, gerror.New("起始号码格式不正确")
|
||||
}
|
||||
e, ok := new(big.Int).SetString(end, 10)
|
||||
if !ok {
|
||||
return 0, gerror.New("结束号码格式不正确")
|
||||
}
|
||||
if e.Cmp(s) < 0 {
|
||||
return 0, gerror.New("起始号码不能大于结束号码")
|
||||
}
|
||||
span := new(big.Int).Sub(e, s)
|
||||
span.Add(span, big.NewInt(1))
|
||||
if !span.IsInt64() {
|
||||
return math.MaxInt64, nil
|
||||
}
|
||||
return span.Int64(), nil
|
||||
}
|
||||
|
||||
// padNumber 保持与输入等长,避免前导零被 big.Int 吃掉后与用户输入对不上。
|
||||
func padNumber(num string, width int) string {
|
||||
if len(num) >= width {
|
||||
return num
|
||||
}
|
||||
return strings.Repeat("0", width-len(num)) + num
|
||||
}
|
||||
|
||||
// rangeNumbers 按号段依次产出号码,最多 limit 个。
|
||||
func rangeNumbers(in *model.SimCardQueryExportParam, limit int64) []string {
|
||||
start := strings.TrimSpace(in.StartNum)
|
||||
s, ok := new(big.Int).SetString(start, 10)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
out := make([]string, 0, limit)
|
||||
cur := new(big.Int).Set(s)
|
||||
for i := int64(0); i < limit; i++ {
|
||||
out = append(out, padNumber(cur.String(), len(start)))
|
||||
cur.Add(cur, big.NewInt(1))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// rangeColumn 号段维度对应的表字段。
|
||||
// 只允许三码维度,避免把任意字段拼进 SQL。
|
||||
func rangeColumn(dimension string) (string, error) {
|
||||
columns := dao.TSimCardInfo.Columns()
|
||||
switch strings.TrimSpace(dimension) {
|
||||
case "iccid":
|
||||
return columns.Iccid, nil
|
||||
case "msisdn":
|
||||
return columns.Msisdn, nil
|
||||
case "imsi":
|
||||
return columns.Imsi, nil
|
||||
default:
|
||||
return "", gerror.New("连号维度只能选 ICCID、MSISDN 或 IMSI")
|
||||
}
|
||||
}
|
||||
|
||||
// queryCardsByRange 按号段取卡;命中超过上限时拒绝,避免静默截断。
|
||||
func queryCardsByRange(ctx context.Context, in *model.SimCardQueryExportParam) ([]*cardBrief, error) {
|
||||
column, err := rangeColumn(in.Dimension)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
columns := dao.TSimCardInfo.Columns()
|
||||
result := make([]*cardBrief, 0)
|
||||
for start := 0; start < model.SimCardQueryMaxRows; start += chunkSize {
|
||||
list := make([]*cardBrief, 0, chunkSize)
|
||||
err := handler.Model(dao.TSimCardInfo.Ctx(ctx), &handler.Option{
|
||||
FilterTenant: true,
|
||||
FilterAuth: true,
|
||||
}).
|
||||
Fields(
|
||||
columns.Id, columns.Iccid, columns.Msisdn, columns.Imsi, columns.Imei,
|
||||
columns.UsedFlowMb, columns.PackageTotalFlowMb, columns.BossCardStatus,
|
||||
columns.NetModel, columns.ChanKey, columns.BossActivateAt, columns.OutStockTime,
|
||||
columns.FlowPkgEndAt, columns.OutboundBatchNo, columns.InboundBatchNo,
|
||||
columns.CurrentPackageCode, columns.DeptId, columns.Remark,
|
||||
).
|
||||
Fields(cardflow.TotalFlowExpr(dao.TSimCardInfo.Table(), "")+" AS query_total_flow_mb").
|
||||
Where(column+" >= ?", strings.TrimSpace(in.StartNum)).
|
||||
Where(column+" <= ?", strings.TrimSpace(in.EndNum)).
|
||||
Where(column+" != ?", "").
|
||||
OrderAsc(columns.Id).
|
||||
Limit(chunkSize).Offset(start).
|
||||
Scan(&list)
|
||||
if err != nil {
|
||||
return nil, gerror.Wrap(err, "查询卡信息失败,请稍后重试")
|
||||
}
|
||||
result = append(result, list...)
|
||||
if len(list) < chunkSize {
|
||||
break
|
||||
}
|
||||
if len(result) == model.SimCardQueryMaxRows {
|
||||
var extra int
|
||||
extra, err = handler.Model(dao.TSimCardInfo.Ctx(ctx), &handler.Option{
|
||||
FilterTenant: true,
|
||||
FilterAuth: true,
|
||||
}).Where(column+" >= ?", strings.TrimSpace(in.StartNum)).
|
||||
Where(column+" <= ?", strings.TrimSpace(in.EndNum)).
|
||||
Where(column+" != ?", "").Count()
|
||||
if err != nil {
|
||||
return nil, gerror.Wrap(err, "校验号段查询上限失败")
|
||||
}
|
||||
if extra > model.SimCardQueryMaxRows {
|
||||
return nil, gerror.Newf("号段命中超过单次上限 %d 张卡,请缩小范围", model.SimCardQueryMaxRows)
|
||||
}
|
||||
}
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
type cardIndex map[string][]*cardBrief
|
||||
|
||||
func indexCards(cards []*cardBrief, field func(*cardBrief) string) cardIndex {
|
||||
index := make(cardIndex, len(cards))
|
||||
for _, card := range cards {
|
||||
if card != nil {
|
||||
if value := field(card); value != "" {
|
||||
index[value] = append(index[value], card)
|
||||
}
|
||||
}
|
||||
}
|
||||
return index
|
||||
}
|
||||
|
||||
// matchCard 对每个号码保留全部候选卡,再求交集;重复 IMSI 和跨字段冲突不按顺序择卡。
|
||||
func matchCard(line inputLine, byIccid, byMsisdn, byImsi cardIndex) (*cardBrief, string) {
|
||||
var candidates map[int64]*cardBrief
|
||||
for _, value := range line.Values {
|
||||
value = strings.TrimSpace(value)
|
||||
matches := make(map[int64]*cardBrief)
|
||||
for _, index := range []cardIndex{byIccid, byMsisdn, byImsi} {
|
||||
for _, card := range index[value] {
|
||||
matches[card.Id] = card
|
||||
}
|
||||
}
|
||||
if value == "" || len(matches) == 0 {
|
||||
return nil, "未查询到数据:" + value
|
||||
}
|
||||
if candidates == nil {
|
||||
candidates = matches
|
||||
continue
|
||||
}
|
||||
for id := range candidates {
|
||||
if _, ok := matches[id]; !ok {
|
||||
delete(candidates, id)
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(candidates) == 0 {
|
||||
return nil, "同一行内的号码未指向同一张卡"
|
||||
}
|
||||
if len(candidates) > 1 {
|
||||
return nil, "号码匹配到多张卡,请补充同一卡的 ICCID/MSISDN/IMSI 消除歧义"
|
||||
}
|
||||
for _, card := range candidates {
|
||||
return card, ""
|
||||
}
|
||||
return nil, rangeMissReason
|
||||
}
|
||||
|
||||
func inputFailure(line inputLine, reason string) string {
|
||||
return fmt.Sprintf("第 %d 行,原始输入 [%s]:%s", line.RowNo, strings.Join(line.Values, " | "), reason)
|
||||
}
|
||||
|
||||
func firstValue(values []string) string {
|
||||
if len(values) == 0 {
|
||||
return ""
|
||||
}
|
||||
return values[0]
|
||||
}
|
||||
|
||||
// collectValues 汇总需要查库的号码值,去重后返回。
|
||||
func collectValues(lines []inputLine) []string {
|
||||
seen := make(map[string]struct{}, len(lines)*3)
|
||||
out := make([]string, 0, len(lines)*3)
|
||||
for _, line := range lines {
|
||||
for _, value := range line.Values {
|
||||
value = strings.TrimSpace(value)
|
||||
if value == "" {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[value]; ok {
|
||||
continue
|
||||
}
|
||||
seen[value] = struct{}{}
|
||||
out = append(out, value)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// queryCardsByValues 分批按号码值查卡。
|
||||
// 单值在三列之间 OR 匹配(iccid 唯一索引 + msisdn 唯一索引 + imsi 索引),
|
||||
// 因此用户不需要提前声明用的是 ICCID 还是三码。
|
||||
func queryCardsByValues(ctx context.Context, values []string) ([]*cardBrief, error) {
|
||||
if len(values) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
columns := dao.TSimCardInfo.Columns()
|
||||
cardTable := dao.TSimCardInfo.Table()
|
||||
result := make([]*cardBrief, 0, len(values))
|
||||
seen := make(map[int64]struct{}, len(values))
|
||||
|
||||
for start := 0; start < len(values); start += chunkSize {
|
||||
end := start + chunkSize
|
||||
if end > len(values) {
|
||||
end = len(values)
|
||||
}
|
||||
chunk := values[start:end]
|
||||
placeholders := strings.TrimSuffix(strings.Repeat("?,", len(chunk)), ",")
|
||||
condition := fmt.Sprintf("(%s.%s IN (%s) OR %s.%s IN (%s) OR %s.%s IN (%s))",
|
||||
cardTable, columns.Iccid, placeholders,
|
||||
cardTable, columns.Msisdn, placeholders,
|
||||
cardTable, columns.Imsi, placeholders,
|
||||
)
|
||||
args := make([]interface{}, 0, len(chunk)*3)
|
||||
for repeat := 0; repeat < 3; repeat++ {
|
||||
for _, value := range chunk {
|
||||
args = append(args, value)
|
||||
}
|
||||
}
|
||||
|
||||
list := make([]*cardBrief, 0, len(chunk))
|
||||
err := handler.Model(dao.TSimCardInfo.Ctx(ctx), &handler.Option{
|
||||
FilterTenant: true,
|
||||
FilterAuth: true,
|
||||
}).
|
||||
Fields(
|
||||
columns.Id,
|
||||
columns.Iccid,
|
||||
columns.Msisdn,
|
||||
columns.Imsi,
|
||||
columns.Imei,
|
||||
columns.UsedFlowMb,
|
||||
columns.PackageTotalFlowMb,
|
||||
columns.BossCardStatus,
|
||||
columns.NetModel,
|
||||
columns.ChanKey,
|
||||
columns.BossActivateAt,
|
||||
columns.OutStockTime,
|
||||
columns.FlowPkgEndAt,
|
||||
columns.OutboundBatchNo,
|
||||
columns.InboundBatchNo,
|
||||
columns.CurrentPackageCode,
|
||||
columns.DeptId,
|
||||
columns.Remark,
|
||||
).
|
||||
Fields(cardflow.TotalFlowExpr(cardTable, "")+" AS query_total_flow_mb").
|
||||
Where(condition, args...).
|
||||
Scan(&list)
|
||||
if err != nil {
|
||||
return nil, gerror.Wrap(err, "查询卡信息失败,请稍后重试")
|
||||
}
|
||||
for _, card := range list {
|
||||
if _, ok := seen[card.Id]; ok {
|
||||
continue
|
||||
}
|
||||
seen[card.Id] = struct{}{}
|
||||
result = append(result, card)
|
||||
}
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// displayResolver 把 chan_key / package_code / dept_id 批量翻译成展示名,避免逐行子查询。
|
||||
type displayResolver struct {
|
||||
chanNames map[string]string
|
||||
packageNames map[string]string
|
||||
packageFlows map[string]float64
|
||||
deptNames map[string]string
|
||||
}
|
||||
|
||||
func newDisplayResolver(ctx context.Context, cards []*cardBrief) *displayResolver {
|
||||
r := &displayResolver{
|
||||
chanNames: make(map[string]string),
|
||||
packageNames: make(map[string]string),
|
||||
packageFlows: make(map[string]float64),
|
||||
deptNames: make(map[string]string),
|
||||
}
|
||||
if len(cards) == 0 {
|
||||
return r
|
||||
}
|
||||
chanKeys := make([]string, 0)
|
||||
packageCodes := make([]string, 0)
|
||||
deptIds := make([]int64, 0)
|
||||
seenChan := map[string]struct{}{}
|
||||
seenPackage := map[string]struct{}{}
|
||||
seenDept := map[int64]struct{}{}
|
||||
for _, card := range cards {
|
||||
if card.ChanKey != "" {
|
||||
if _, ok := seenChan[card.ChanKey]; !ok {
|
||||
seenChan[card.ChanKey] = struct{}{}
|
||||
chanKeys = append(chanKeys, card.ChanKey)
|
||||
}
|
||||
}
|
||||
if card.PackageCode != "" {
|
||||
if _, ok := seenPackage[card.PackageCode]; !ok {
|
||||
seenPackage[card.PackageCode] = struct{}{}
|
||||
packageCodes = append(packageCodes, card.PackageCode)
|
||||
}
|
||||
}
|
||||
if card.DeptId > 0 {
|
||||
if _, ok := seenDept[card.DeptId]; !ok {
|
||||
seenDept[card.DeptId] = struct{}{}
|
||||
deptIds = append(deptIds, card.DeptId)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if len(chanKeys) > 0 {
|
||||
type chanRow struct {
|
||||
ChanKey string `orm:"chan_key"`
|
||||
Operator int `orm:"operator"`
|
||||
}
|
||||
rows := make([]*chanRow, 0, len(chanKeys))
|
||||
if err := dao.TSimChannels.Ctx(ctx).
|
||||
Fields("chan_key, operator").
|
||||
WhereIn("chan_key", chanKeys).
|
||||
Scan(&rows); err == nil {
|
||||
for _, row := range rows {
|
||||
switch row.Operator {
|
||||
case 1:
|
||||
r.chanNames[row.ChanKey] = "中国移动"
|
||||
case 2:
|
||||
r.chanNames[row.ChanKey] = "中国联通"
|
||||
case 3:
|
||||
r.chanNames[row.ChanKey] = "中国电信"
|
||||
default:
|
||||
r.chanNames[row.ChanKey] = "未知"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(packageCodes) > 0 {
|
||||
type pkgRow struct {
|
||||
PackageCode string `orm:"package_code"`
|
||||
PackageName string `orm:"package_name"`
|
||||
FlowMb float64 `orm:"flow_mb"`
|
||||
}
|
||||
rows := make([]*pkgRow, 0, len(packageCodes))
|
||||
if err := dao.TSimFlowPackageInfo.Ctx(ctx).
|
||||
Fields("package_code, package_name, flow_mb").
|
||||
WhereIn("package_code", packageCodes).
|
||||
Scan(&rows); err == nil {
|
||||
for _, row := range rows {
|
||||
r.packageNames[row.PackageCode] = row.PackageName
|
||||
r.packageFlows[row.PackageCode] = row.FlowMb
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(deptIds) > 0 {
|
||||
type deptRow struct {
|
||||
DeptId int64 `orm:"dept_id"`
|
||||
DeptName string `orm:"dept_name"`
|
||||
}
|
||||
rows := make([]*deptRow, 0, len(deptIds))
|
||||
if err := dao.SysDept.Ctx(ctx).
|
||||
Fields("dept_id, dept_name").
|
||||
WhereIn("dept_id", deptIds).
|
||||
WhereNull("deleted_at").
|
||||
Scan(&rows); err == nil {
|
||||
for _, row := range rows {
|
||||
r.deptNames[fmt.Sprintf("%d", row.DeptId)] = row.DeptName
|
||||
}
|
||||
}
|
||||
}
|
||||
return r
|
||||
}
|
||||
|
||||
// fill 把库里字段落到导出行。
|
||||
func (r *displayResolver) fill(row *model.SimCardBaseQueryRow, card *cardBrief) {
|
||||
row.Iccid = card.Iccid
|
||||
row.Msisdn = card.Msisdn
|
||||
row.Imsi = card.Imsi
|
||||
row.Imei = card.Imei
|
||||
row.UsedFlowMb = roundMb(card.UsedFlowMb)
|
||||
row.CardStatus = card.CardStatus
|
||||
row.NetModel = card.NetModel
|
||||
row.Operators = r.chanNames[card.ChanKey]
|
||||
row.OutboundBatch = card.OutboundBatch
|
||||
row.InboundBatch = card.InboundBatch
|
||||
row.PackageName = r.packageNames[card.PackageCode]
|
||||
row.DeptName = r.deptNames[fmt.Sprintf("%d", card.DeptId)]
|
||||
row.Remark = card.Remark
|
||||
row.ActivateAt = card.ActivateAt
|
||||
row.OutStockTime = card.OutStockTime
|
||||
row.ExpireAt = card.ExpireAt
|
||||
|
||||
// 复用 cardflow.TotalFlowExpr:当前主套餐(或卡冗余值)加生效中的单卡加油包。
|
||||
row.PackageTotalMb = roundMb(card.PackageTotalMb)
|
||||
remain := card.PackageTotalMb - card.UsedFlowMb
|
||||
if remain < 0 {
|
||||
remain = 0
|
||||
}
|
||||
row.RemainFlowMb = roundMb(remain)
|
||||
}
|
||||
@@ -0,0 +1,271 @@
|
||||
package simcardquery
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/xuri/excelize/v2"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/consts"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/model"
|
||||
)
|
||||
|
||||
func TestTemplateRoundTrip(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
f, _, err := New().BaseTemplate(ctx)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer f.Close()
|
||||
sheet := f.GetSheetName(0)
|
||||
if err := f.SetCellStr(sheet, "A2", "89860000000000000001"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
b, err := f.WriteToBuffer()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
lines, err := streamExcelInputs(ctx, b)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(lines) != 1 || lines[0].Values[0] != "89860000000000000001" {
|
||||
t.Fatalf("unexpected lines: %+v", lines)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExcelInputLimit(t *testing.T) {
|
||||
f := excelize.NewFile()
|
||||
defer f.Close()
|
||||
sheet := f.GetSheetName(0)
|
||||
if err := f.SetCellStr(sheet, "A1", "ICCID"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for i := 2; i <= model.SimCardQueryMaxRows+2; i++ {
|
||||
cell, _ := excelize.CoordinatesToCellName(1, i)
|
||||
if err := f.SetCellStr(sheet, cell, "89860000000000000001"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
b, err := f.WriteToBuffer()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := streamExcelInputs(context.Background(), b); err == nil {
|
||||
t.Fatal("expected over-limit error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMatchCardAmbiguity(t *testing.T) {
|
||||
cards := []*cardBrief{
|
||||
{Id: 1, Iccid: "89860001", Msisdn: "10001", Imsi: "46000001"},
|
||||
{Id: 2, Iccid: "89860002", Msisdn: "10002", Imsi: "46000001"},
|
||||
}
|
||||
a := indexCards(cards, func(c *cardBrief) string { return c.Iccid })
|
||||
b := indexCards(cards, func(c *cardBrief) string { return c.Msisdn })
|
||||
c := indexCards(cards, func(c *cardBrief) string { return c.Imsi })
|
||||
if card, _ := matchCard(inputLine{Values: []string{"46000001"}}, a, b, c); card != nil {
|
||||
t.Fatal("ambiguous IMSI must fail")
|
||||
}
|
||||
if card, reason := matchCard(inputLine{Values: []string{"89860002", "46000001"}}, a, b, c); card == nil || card.Id != 2 {
|
||||
t.Fatalf("expected second card, got %+v %s", card, reason)
|
||||
}
|
||||
if card, _ := matchCard(inputLine{Values: []string{"89860001", "10002"}}, a, b, c); card != nil {
|
||||
t.Fatal("mixed card values must fail")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRangeValidation(t *testing.T) {
|
||||
for _, pair := range [][3]string{{"1", "22", "iccid"}, {"aa", "bb", "iccid"}, {"22", "11", "iccid"}, {"11", "22", "imei"}} {
|
||||
if parseRangeInputs(&model.SimCardQueryExportParam{StartNum: pair[0], EndNum: pair[1], Dimension: pair[2]}) == nil {
|
||||
t.Fatalf("expected error: %v", pair)
|
||||
}
|
||||
}
|
||||
if err := parseRangeInputs(&model.SimCardQueryExportParam{StartNum: "0011", EndNum: "0022", Dimension: "iccid"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsSafeIccid(t *testing.T) {
|
||||
ok := []string{"89860000000000000001", "8986abc001", "ABC123"}
|
||||
for _, v := range ok {
|
||||
if !isSafeIccid(v) {
|
||||
t.Fatalf("expected safe: %s", v)
|
||||
}
|
||||
}
|
||||
// 单引号、空格、分号、括号都必须拒绝,否则拼进 SQL 会破坏语句结构。
|
||||
bad := []string{"", "8986'001", "8986 001", "8986;001", "8986)001", strings.Repeat("1", 33)}
|
||||
for _, v := range bad {
|
||||
if isSafeIccid(v) {
|
||||
t.Fatalf("expected unsafe: %q", v)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatMonthValuesThreeStates(t *testing.T) {
|
||||
months := []string{"2026-09", "2026-08", "2026-07"}
|
||||
|
||||
// 查询成功:9 月有量、8 月记录为 0、7 月无记录 → 数值 / 0 / "-"
|
||||
ok := &model.SimCardUsageHistoryRow{
|
||||
MonthValues: map[string]float64{"2026-09": 12.3456, "2026-08": 0},
|
||||
}
|
||||
got := formatMonthValues(ok, months)
|
||||
if got[0] != 12.346 {
|
||||
t.Fatalf("9 月应导出数值,实际 %v", got[0])
|
||||
}
|
||||
if got[1] != float64(0) {
|
||||
t.Fatalf("8 月零用量应导出 0,实际 %v", got[1])
|
||||
}
|
||||
if got[2] != usageNoDataText {
|
||||
t.Fatalf("7 月无记录应导出 %q,实际 %v", usageNoDataText, got[2])
|
||||
}
|
||||
|
||||
// 整卡查询失败:月份列一律留空(保证该列是纯数值),原因由「失败原因」列承载
|
||||
failed := &model.SimCardUsageHistoryRow{
|
||||
MonthValues: map[string]float64{"2026-09": 99},
|
||||
UsageQueryFailed: true,
|
||||
}
|
||||
got = formatMonthValues(failed, months)
|
||||
for i, v := range got {
|
||||
if v != "" {
|
||||
t.Fatalf("第 %d 列应留空,实际 %v", i+1, v)
|
||||
}
|
||||
}
|
||||
if !strings.Contains(usageQueryFailedNote, "查询失败") {
|
||||
t.Fatalf("失败原因文案应说明查询失败:%s", usageQueryFailedNote)
|
||||
}
|
||||
}
|
||||
|
||||
// TestFormatMonthValuesPartialHistory 覆盖「平台只同步了几个月」和「新卡没历史」两种常见情况:
|
||||
// 这类卡不是查询失败,缺的月份必须显示 "-",不能整行留空或判失败。
|
||||
func TestFormatMonthValuesPartialHistory(t *testing.T) {
|
||||
months := []string{"2026-09", "2026-08", "2026-07", "2026-06", "2026-05", "2026-04"}
|
||||
|
||||
// 只有最近 3 个月有数据(新卡 / 平台同步只跑了 3 个月)
|
||||
partial := &model.SimCardUsageHistoryRow{
|
||||
MonthValues: map[string]float64{
|
||||
"2026-09": 120.5,
|
||||
"2026-08": 98,
|
||||
"2026-07": 0,
|
||||
},
|
||||
}
|
||||
got := formatMonthValues(partial, months)
|
||||
if len(got) != len(months) {
|
||||
t.Fatalf("列数应与月份数一致,实际 %d", len(got))
|
||||
}
|
||||
if got[0] != 120.5 {
|
||||
t.Fatalf("有数据的月份应导出数值,实际 %v", got[0])
|
||||
}
|
||||
if got[2] != float64(0) {
|
||||
t.Fatalf("有记录但用量为 0 应导出 0,实际 %v", got[2])
|
||||
}
|
||||
for i := 3; i < len(got); i++ {
|
||||
if got[i] != usageNoDataText {
|
||||
t.Fatalf("第 %d 列无记录应为 %q,实际 %v", i+1, usageNoDataText, got[i])
|
||||
}
|
||||
}
|
||||
// 部分缺失不能被当成查询失败
|
||||
if partial.UsageQueryFailed {
|
||||
t.Fatal("缺月份不等于查询失败")
|
||||
}
|
||||
|
||||
// 全新卡:12 个月一条记录都没有 → 全部 "-",仍然算查询成功
|
||||
brandNew := &model.SimCardUsageHistoryRow{MonthValues: map[string]float64{}}
|
||||
got = formatMonthValues(brandNew, months)
|
||||
for i, v := range got {
|
||||
if v != usageNoDataText {
|
||||
t.Fatalf("新卡第 %d 列应为 %q,实际 %v", i+1, usageNoDataText, v)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestBuildRangeRowsKeepsOneRowPerNumber 号段里只有部分号码可见时,
|
||||
// 导出必须仍然每个号码一行:可见的出行数据,不可见的出「未查询到数据」,
|
||||
// 总条数等于号段大小,客户才能按号对账。
|
||||
func TestBuildRangeRowsKeepsOneRowPerNumber(t *testing.T) {
|
||||
in := &model.SimCardQueryExportParam{
|
||||
Dimension: "iccid",
|
||||
StartNum: "89860000000000000001",
|
||||
EndNum: "89860000000000000010",
|
||||
}
|
||||
// 10 个号里只有第 3、5、8 个在可见范围内
|
||||
cards := []*cardBrief{
|
||||
{Id: 1, Iccid: "89860000000000000003"},
|
||||
{Id: 2, Iccid: "89860000000000000005"},
|
||||
{Id: 3, Iccid: "89860000000000000008"},
|
||||
}
|
||||
stats := &resolveStats{}
|
||||
rows := buildRangeRows(context.Background(), in, cards, stats)
|
||||
|
||||
if len(rows) != 10 {
|
||||
t.Fatalf("应输出 10 行(与号段大小一致),实际 %d", len(rows))
|
||||
}
|
||||
hit, miss := 0, 0
|
||||
seen := make(map[string]struct{}, len(rows))
|
||||
for _, row := range rows {
|
||||
if _, dup := seen[row.Iccid]; dup {
|
||||
t.Fatalf("号码重复输出:%s", row.Iccid)
|
||||
}
|
||||
seen[row.Iccid] = struct{}{}
|
||||
switch row.QueryResult {
|
||||
case queryResultHit:
|
||||
hit++
|
||||
case queryResultMiss:
|
||||
miss++
|
||||
// 不能透露是因为权限查不到
|
||||
if row.FailReason != rangeMissReason {
|
||||
t.Fatalf("失败原因应为 %q,实际 %q", rangeMissReason, row.FailReason)
|
||||
}
|
||||
if strings.Contains(row.FailReason, "权限") {
|
||||
t.Fatalf("失败原因不能提及权限:%s", row.FailReason)
|
||||
}
|
||||
}
|
||||
}
|
||||
if hit != 3 || miss != 7 {
|
||||
t.Fatalf("成功/失败应为 3/7,实际 %d/%d", hit, miss)
|
||||
}
|
||||
if stats.hit != 3 || stats.miss != 7 {
|
||||
t.Fatalf("统计应为 3/7,实际 %d/%d", stats.hit, stats.miss)
|
||||
}
|
||||
// 首尾号码要对得上,且保持等长(前导零不丢)
|
||||
if _, ok := seen["89860000000000000001"]; !ok {
|
||||
t.Fatal("缺少起始号码")
|
||||
}
|
||||
if _, ok := seen["89860000000000000010"]; !ok {
|
||||
t.Fatal("缺少结束号码")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRangeSizeAndLimit(t *testing.T) {
|
||||
in := &model.SimCardQueryExportParam{StartNum: "89860000000000000001", EndNum: "89860000000000000010"}
|
||||
size, err := rangeSize(in)
|
||||
if err != nil || size != 10 {
|
||||
t.Fatalf("号段大小应为 10,实际 %d err=%v", size, err)
|
||||
}
|
||||
// 超过单次上限的号段要在提交阶段直接拒绝,否则无法完整展开
|
||||
big := &model.SimCardQueryExportParam{
|
||||
Dimension: "iccid",
|
||||
StartNum: "89860000000000000001",
|
||||
EndNum: "89860000000000099999",
|
||||
}
|
||||
if err := parseRangeInputs(big); err == nil {
|
||||
t.Fatal("超限号段应被拒绝")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMonthsAndStats(t *testing.T) {
|
||||
months := LastCompleteMonths(time.Date(2026, 10, 23, 14, 0, 0, 0, time.FixedZone("CST", 8*3600)), 12)
|
||||
if len(months) != 12 || months[0] != "2026-09" || months[11] != "2025-10" {
|
||||
t.Fatalf("unexpected months: %v", months)
|
||||
}
|
||||
stats := &resolveStats{}
|
||||
stats.add(true)
|
||||
if result, summary := stats.resultSummary(1); result != consts.BatchRunResultSuccess || !strings.Contains(summary, "成功 1 条") {
|
||||
t.Fatalf("bad success: %d %s", result, summary)
|
||||
}
|
||||
stats.add(false)
|
||||
if result, _ := stats.resultSummary(2); result != consts.BatchRunResultPartialSuccess {
|
||||
t.Fatal("expected partial success")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,622 @@
|
||||
package simcardquery
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/dao"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/exportcenter"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/tdengine"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/xgorm/handler"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/model"
|
||||
|
||||
"github.com/gogf/gf/v2/database/gdb"
|
||||
"github.com/gogf/gf/v2/errors/gerror"
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
"github.com/gogf/gf/v2/os/gtime"
|
||||
)
|
||||
|
||||
// usageHistoryMonths 近一年月流水的月份个数(不含当月)。
|
||||
// 例:当前 2026-10,导出 2026-09 ~ 2025-10 共 12 个月。
|
||||
const usageHistoryMonths = 12
|
||||
|
||||
// usageHistoryTDEngineChunk 单次向 TDengine 查询的 ICCID 条数。
|
||||
// 父表按 iccid 标签过滤会剪枝到对应子表,500 条一批可把单次查询控制在秒级。
|
||||
const usageHistoryTDEngineChunk = 500
|
||||
|
||||
// dept 模式没有输入号码,靠公司范围圈卡,必须设上限,否则顶级账号一次拖全库。
|
||||
// deptExportMaxRows 公司模式(含下级)单次导出上限。
|
||||
//
|
||||
// 与号码模式的 20000 分开取值:号码模式受「用户手上有多少号码」约束,
|
||||
// 公司模式是整批圈卡,按 2 万设限只能靠不断切分下级公司,反而更容易出错。
|
||||
//
|
||||
// 取值依据(实测,见 export_cost_test.go):5 万行写 Excel 约 3.2s、文件 4.5MB,
|
||||
// 远低于导出中心 10 分钟超时 / 100MB 上限;真正的约束是当前实现会把整批行
|
||||
// 先构造成内存切片再分页写出,5 万行内存占用约 50MB 量级,10 万行开始偏高。
|
||||
// 若要支持 10 万以上,需要把公司模式改成「按页直接查库」的流式导出。
|
||||
const deptExportMaxRows = 50000
|
||||
|
||||
var usageHistoryFixedFields = []string{"iccid", "msisdn", "imsi", "outStockTime", "expireAt"}
|
||||
|
||||
// 月用量列的取值口径:
|
||||
//
|
||||
// 查询失败(服务异常)→ 留空,原因写进「失败原因」列
|
||||
// 查到了但该月无记录 → "-"
|
||||
// 该月有记录且用量为 0 → 0
|
||||
//
|
||||
// 失败格留空而不是写文字:这样整列是「数字 + 空 + 横杠」,Excel 求和/透视不会被文本干扰;
|
||||
// 同时「失败原因」列会写明该卡流量查询失败,信息不会丢。
|
||||
const usageNoDataText = "-"
|
||||
|
||||
// usageQueryFailedNote 流量查询失败时写入「失败原因」列的说明。
|
||||
const usageQueryFailedNote = "该卡近12个月流量数据本次查询失败,月份列留空,可稍后重新导出"
|
||||
|
||||
// cardUsageResult 单张卡的历史用量查询结果。
|
||||
type cardUsageResult struct {
|
||||
// Months 键为 "YYYY-MM";缺失表示查询成功但该月无记录。
|
||||
Months map[string]float64
|
||||
// Failed 表示该卡所属批次的流量查询失败,数据不可信。
|
||||
Failed bool
|
||||
}
|
||||
|
||||
// ExportUsageHistory 月历史用量查询导出。
|
||||
func (s *Service) ExportUsageHistory(ctx context.Context, in *model.SimCardUsageHistoryExportParam, input ExcelInput) (*exportcenter.SubmitResult, error) {
|
||||
months := LastCompleteMonths(time.Now(), usageHistoryMonths)
|
||||
columns := resolveUsageHistoryColumns(in, months)
|
||||
|
||||
// 公司模式的卡列表必须在后台任务中解析,避免提交请求同步读取最多 2 万张卡。
|
||||
// 号码模式仍在提交阶段校验输入格式,实际查库统一延迟到导出 runner。
|
||||
var lines []inputLine
|
||||
var err error
|
||||
if in.Mode != model.SimCardQueryModeDept {
|
||||
lines, err = parseInputs(ctx, &in.SimCardQueryExportParam, input)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
stats := &resolveStats{}
|
||||
once := &sync.Once{}
|
||||
var cached []*model.SimCardUsageHistoryRow
|
||||
var cacheErr error
|
||||
resolve := func(ctx context.Context) ([]*model.SimCardUsageHistoryRow, error) {
|
||||
once.Do(func() {
|
||||
switch in.Mode {
|
||||
case model.SimCardQueryModeDept:
|
||||
cached, cacheErr = s.resolveDeptRows(ctx, in, months, stats)
|
||||
case model.SimCardQueryModeRange:
|
||||
cached, cacheErr = s.resolveUsageRangeRows(ctx, &in.SimCardQueryExportParam, months, stats)
|
||||
default:
|
||||
cached, cacheErr = s.resolveUsageHistoryRows(ctx, lines, months, stats)
|
||||
}
|
||||
})
|
||||
return cached, cacheErr
|
||||
}
|
||||
probeTotal := len(lines)
|
||||
switch in.Mode {
|
||||
case model.SimCardQueryModeRange:
|
||||
// 号段模式:号段包含多少个号码就是预计条数,与是否查到无关。
|
||||
if size, sizeErr := rangeSize(&in.SimCardQueryExportParam); sizeErr == nil {
|
||||
probeTotal = int(size)
|
||||
} else {
|
||||
probeTotal = -1
|
||||
}
|
||||
case model.SimCardQueryModeDept:
|
||||
// 公司卡数只在后台 runner 中查询;提交探针不扫描业务表,条数未知。
|
||||
probeTotal = -1
|
||||
}
|
||||
|
||||
return exportcenter.Submit(ctx, &exportcenter.Request{
|
||||
BizKey: "tSimCardUsageHistory",
|
||||
BizName: "月历史用量查询",
|
||||
PermCode: "cpm:iot:tSimCardUsageHistory:view",
|
||||
SheetName: "月历史用量",
|
||||
QueryParam: usageHistorySnapshot(in, months, probeTotal),
|
||||
Columns: columns,
|
||||
RowFields: append(append([]string{}, usageHistoryFixedFields...), append(months, queryResultField, failReasonField)...),
|
||||
Tags: func() ([]string, error) {
|
||||
return usageHistoryTags(months), nil
|
||||
},
|
||||
ToRow: func(v interface{}) []interface{} {
|
||||
row, ok := v.(*model.SimCardUsageHistoryRow)
|
||||
if !ok || row == nil {
|
||||
return nil
|
||||
}
|
||||
values := make([]interface{}, 0, len(usageHistoryFixedFields)+len(months)+2)
|
||||
values = append(values, row.Iccid, row.Msisdn, row.Imsi, formatTime(row.OutStockTime), formatTime(row.ExpireAt))
|
||||
values = append(values, formatMonthValues(row, months)...)
|
||||
return append(values, row.QueryResult, row.FailReason)
|
||||
},
|
||||
Fetch: func(ctx context.Context, page, pageSize int) ([]interface{}, int, error) {
|
||||
if pageSize <= 1 && page <= 1 {
|
||||
// probeTotal 为 -1 表示提交阶段无法确定条数(按公司模式),放行由后台实际取数。
|
||||
return nil, probeTotal, nil
|
||||
}
|
||||
rows, err := resolve(ctx)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
total := len(rows)
|
||||
start := (page - 1) * pageSize
|
||||
if start >= total {
|
||||
return nil, total, nil
|
||||
}
|
||||
end := start + pageSize
|
||||
if end > total {
|
||||
end = total
|
||||
}
|
||||
out := make([]interface{}, 0, end-start)
|
||||
for _, row := range rows[start:end] {
|
||||
out = append(out, row)
|
||||
}
|
||||
return out, total, nil
|
||||
},
|
||||
Result: stats.resultSummary,
|
||||
})
|
||||
}
|
||||
|
||||
// resolveUsageHistoryRows 号码模式:解析输入 → 查卡 → 查近 12 个月用量。
|
||||
func (s *Service) resolveUsageHistoryRows(ctx context.Context, lines []inputLine, months []string, stats *resolveStats) ([]*model.SimCardUsageHistoryRow, error) {
|
||||
values := collectValues(lines)
|
||||
cards, err := queryCardsByValues(ctx, values)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
byIccid := indexCards(cards, func(card *cardBrief) string { return card.Iccid })
|
||||
byMsisdn := indexCards(cards, func(card *cardBrief) string { return card.Msisdn })
|
||||
byImsi := indexCards(cards, func(card *cardBrief) string { return card.Imsi })
|
||||
iccids := make([]string, 0, len(cards))
|
||||
for _, card := range cards {
|
||||
if card.Iccid != "" {
|
||||
iccids = append(iccids, card.Iccid)
|
||||
}
|
||||
}
|
||||
|
||||
usage, err := queryMonthlyUsage(ctx, iccids, months)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
rows := make([]*model.SimCardUsageHistoryRow, 0, len(lines))
|
||||
// 同一张卡重复出现只导出一次(同号码重复填写,或 ICCID 与其 MSISDN 各写一行)。
|
||||
exported := make(map[int64]struct{}, len(lines))
|
||||
for _, line := range lines {
|
||||
row := &model.SimCardUsageHistoryRow{MonthValues: map[string]float64{}}
|
||||
card, reason := matchCard(line, byIccid, byMsisdn, byImsi)
|
||||
if card == nil {
|
||||
row.Iccid = firstValue(line.Values)
|
||||
row.QueryResult = queryResultMiss
|
||||
row.FailReason = inputFailure(line, reason)
|
||||
stats.add(false)
|
||||
rows = append(rows, row)
|
||||
continue
|
||||
}
|
||||
if _, ok := exported[card.Id]; ok {
|
||||
stats.addDup()
|
||||
continue
|
||||
}
|
||||
exported[card.Id] = struct{}{}
|
||||
rows = append(rows, newUsageRow(card, usage))
|
||||
stats.add(true)
|
||||
}
|
||||
return rows, nil
|
||||
}
|
||||
|
||||
// resolveUsageRangeRows 号段模式:按起止号段取卡,再补近 12 个月用量。
|
||||
func (s *Service) resolveUsageRangeRows(ctx context.Context, in *model.SimCardQueryExportParam, months []string, stats *resolveStats) ([]*model.SimCardUsageHistoryRow, error) {
|
||||
cards, err := queryCardsByRange(ctx, in)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
iccids := make([]string, 0, len(cards))
|
||||
for _, card := range cards {
|
||||
if card.Iccid != "" {
|
||||
iccids = append(iccids, card.Iccid)
|
||||
}
|
||||
}
|
||||
usage, err := queryMonthlyUsage(ctx, iccids, months)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// 按号段逐个号码输出:命中出行数据,未命中出「未查询到数据」的失败行,
|
||||
// 保证导出条数与号段大小一致(号段里可能一部分可见、一部分不可见)。
|
||||
dimension := strings.TrimSpace(in.Dimension)
|
||||
index := rangeCardIndex(cards, dimension)
|
||||
size, sizeErr := rangeSize(in)
|
||||
if sizeErr != nil {
|
||||
size = int64(len(cards))
|
||||
}
|
||||
numbers := rangeNumbers(in, size)
|
||||
rows := make([]*model.SimCardUsageHistoryRow, 0, len(numbers))
|
||||
for _, number := range numbers {
|
||||
if card, ok := index[number]; ok {
|
||||
rows = append(rows, newUsageRow(card, usage))
|
||||
stats.add(true)
|
||||
continue
|
||||
}
|
||||
row := &model.SimCardUsageHistoryRow{
|
||||
MonthValues: map[string]float64{},
|
||||
QueryResult: queryResultMiss,
|
||||
FailReason: rangeMissReason,
|
||||
}
|
||||
switch dimension {
|
||||
case "msisdn":
|
||||
row.Msisdn = number
|
||||
case "imsi":
|
||||
row.Imsi = number
|
||||
default:
|
||||
row.Iccid = number
|
||||
}
|
||||
stats.add(false)
|
||||
rows = append(rows, row)
|
||||
}
|
||||
return rows, nil
|
||||
}
|
||||
|
||||
// resolveDeptRows 在后台按提交人的数据权限重新解析公司(含下级)卡列表。
|
||||
func (s *Service) resolveDeptRows(ctx context.Context, in *model.SimCardUsageHistoryExportParam, months []string, stats *resolveStats) ([]*model.SimCardUsageHistoryRow, error) {
|
||||
iccids, err := s.cardsByDept(ctx, in)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
cards, err := queryCardsByIccids(ctx, iccids)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
usage, err := queryMonthlyUsage(ctx, iccids, months)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
rows := make([]*model.SimCardUsageHistoryRow, 0, len(cards))
|
||||
for _, card := range cards {
|
||||
stats.add(true)
|
||||
rows = append(rows, newUsageRow(card, usage))
|
||||
}
|
||||
return rows, nil
|
||||
}
|
||||
|
||||
// cardsByDept 取公司(含全部下级公司)名下的 ICCID 列表。
|
||||
//
|
||||
// 选中公司即包含下级,与 DeptOptions 的卡数统计口径一致:
|
||||
// 顶级公司自身通常不挂卡,只查本级会得到 0 条。
|
||||
func (s *Service) cardsByDept(ctx context.Context, in *model.SimCardUsageHistoryExportParam) ([]string, error) {
|
||||
if in.DeptId <= 0 {
|
||||
return nil, gerror.New("请选择所属公司")
|
||||
}
|
||||
columns := dao.TSimCardInfo.Columns()
|
||||
base := func() *gdb.Model {
|
||||
mod := handler.Model(dao.TSimCardInfo.Ctx(ctx), &handler.Option{
|
||||
FilterTenant: true,
|
||||
FilterAuth: true,
|
||||
}).WhereIn(columns.DeptId, deptSubtreeIDs(ctx, in.DeptId))
|
||||
if start := strings.TrimSpace(in.ExpireStartTime); start != "" {
|
||||
mod = mod.WhereGTE(columns.FlowPkgEndAt, start)
|
||||
}
|
||||
if end := strings.TrimSpace(in.ExpireEndTime); end != "" {
|
||||
mod = mod.WhereLTE(columns.FlowPkgEndAt, end)
|
||||
}
|
||||
return mod
|
||||
}
|
||||
|
||||
count, err := base().Fields(columns.Iccid).Count()
|
||||
if err != nil {
|
||||
return nil, gerror.Wrap(err, "统计公司名下卡数量失败")
|
||||
}
|
||||
if count <= 0 {
|
||||
return nil, gerror.New("该公司(含下级)下没有符合条件可导出的卡")
|
||||
}
|
||||
if count > deptExportMaxRows {
|
||||
return nil, gerror.Newf(
|
||||
"该公司(含下级)共 %d 张卡,超过单次导出上限 %d 张,请改选下级公司或改用号码导入",
|
||||
count, deptExportMaxRows,
|
||||
)
|
||||
}
|
||||
values, err := base().Fields(columns.Iccid).OrderAsc(columns.Id).Limit(deptExportMaxRows).Array()
|
||||
if err != nil {
|
||||
return nil, gerror.Wrap(err, "查询公司名下卡失败")
|
||||
}
|
||||
out := make([]string, 0, len(values))
|
||||
for _, value := range values {
|
||||
if iccid := strings.TrimSpace(value.String()); iccid != "" {
|
||||
out = append(out, iccid)
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// queryCardsByIccids 按 ICCID 批量取导出所需字段。
|
||||
func queryCardsByIccids(ctx context.Context, iccids []string) ([]*cardBrief, error) {
|
||||
if len(iccids) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
columns := dao.TSimCardInfo.Columns()
|
||||
out := make([]*cardBrief, 0, len(iccids))
|
||||
for start := 0; start < len(iccids); start += chunkSize {
|
||||
end := start + chunkSize
|
||||
if end > len(iccids) {
|
||||
end = len(iccids)
|
||||
}
|
||||
list := make([]*cardBrief, 0, end-start)
|
||||
err := handler.Model(dao.TSimCardInfo.Ctx(ctx), &handler.Option{
|
||||
FilterTenant: true,
|
||||
FilterAuth: true,
|
||||
}).
|
||||
Fields(
|
||||
columns.Id, columns.Iccid, columns.Msisdn, columns.Imsi,
|
||||
columns.OutStockTime, columns.FlowPkgEndAt,
|
||||
).
|
||||
WhereIn(columns.Iccid, iccids[start:end]).
|
||||
Scan(&list)
|
||||
if err != nil {
|
||||
return nil, gerror.Wrap(err, "查询卡信息失败,请稍后重试")
|
||||
}
|
||||
out = append(out, list...)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// queryMonthlyUsage 从 TDengine 取多卡的多月用量。
|
||||
//
|
||||
// 每张卡带上自己的查询状态,三种情况必须在导出里可区分:
|
||||
// - Failed=true:该批查询异常,月份格写「查询失败」
|
||||
// - Months 里缺该月:查询成功但没有该月记录,月份格写 "-"
|
||||
// - Months 里有该月且为 0:查询成功、零用量,月份格写 0
|
||||
//
|
||||
// 部分批次失败不拖垮整单,只有全部批次失败(服务整体不可用)才返回错误。
|
||||
func queryMonthlyUsage(ctx context.Context, iccids []string, months []string) (map[string]*cardUsageResult, error) {
|
||||
result := make(map[string]*cardUsageResult, len(iccids))
|
||||
if len(iccids) == 0 || len(months) == 0 {
|
||||
return result, nil
|
||||
}
|
||||
if !tdengine.Enabled(ctx) {
|
||||
g.Log().Warning(ctx, "[simcardquery] 流量库未启用")
|
||||
return nil, gerror.New("流量数据服务暂时不可用,本次导出未能完成。请稍后重试,若持续出现可联系技术支持。")
|
||||
}
|
||||
cfg := tdengine.GetConfig(ctx)
|
||||
if err := validateTDEngineNames(cfg.DbName, cfg.MonthlyStable); err != nil {
|
||||
g.Log().Errorf(ctx, "[simcardquery] 流量库配置异常: %v", err)
|
||||
return nil, gerror.New("流量数据服务暂时不可用,本次导出未能完成。请稍后重试,若持续出现可联系技术支持。")
|
||||
}
|
||||
|
||||
start, end, err := monthRange(months)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
table := fmt.Sprintf("%s.%s", cfg.DbName, cfg.MonthlyStable)
|
||||
// 用秒级时间字面量:月表写入点是当月最后一天 23:59:59,秒级精度已能覆盖。
|
||||
startText := start.Format("2006-01-02 15:04:05")
|
||||
endText := end.Format("2006-01-02 15:04:05")
|
||||
|
||||
batches, failedBatches := 0, 0
|
||||
for chunkStart := 0; chunkStart < len(iccids); chunkStart += usageHistoryTDEngineChunk {
|
||||
chunkEnd := chunkStart + usageHistoryTDEngineChunk
|
||||
if chunkEnd > len(iccids) {
|
||||
chunkEnd = len(iccids)
|
||||
}
|
||||
chunk := iccids[chunkStart:chunkEnd]
|
||||
// TDengine 驱动不会给字符串参数补引号,用 ? 绑定会让时间条件变成
|
||||
// `ts >= 2025-10-01 00:00:00` 这种非法表达式(报 syntax error)。
|
||||
// 因此这里把 ICCID 与时间都拼成带引号的字面量,
|
||||
// ICCID 必须经过字符白名单校验,杜绝拼接注入。
|
||||
quoted := make([]string, 0, len(chunk))
|
||||
safe := make([]string, 0, len(chunk))
|
||||
for _, iccid := range chunk {
|
||||
if !isSafeIccid(iccid) {
|
||||
continue
|
||||
}
|
||||
safe = append(safe, iccid)
|
||||
quoted = append(quoted, "'"+iccid+"'")
|
||||
}
|
||||
if len(quoted) == 0 {
|
||||
continue
|
||||
}
|
||||
batches++
|
||||
// 先按「查询成功、暂无记录」建条目;一旦本批出错再整体标记为查询失败。
|
||||
for _, iccid := range safe {
|
||||
if result[iccid] == nil {
|
||||
result[iccid] = &cardUsageResult{Months: make(map[string]float64, len(months))}
|
||||
}
|
||||
}
|
||||
markFailedBatch := func() {
|
||||
for _, iccid := range safe {
|
||||
if entry := result[iccid]; entry != nil {
|
||||
entry.Failed = true
|
||||
entry.Months = map[string]float64{}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
sql := fmt.Sprintf(
|
||||
"SELECT iccid, ts, usage_mb FROM %s WHERE iccid IN (%s) AND ts >= '%s' AND ts <= '%s'",
|
||||
table, strings.Join(quoted, ","), startText, endText,
|
||||
)
|
||||
rows, queryErr := tdengine.Query(ctx, sql)
|
||||
if queryErr != nil {
|
||||
// 单批失败不拖垮整个导出:本批卡片标记为「查询失败」,其余批次照常。
|
||||
failedBatches++
|
||||
markFailedBatch()
|
||||
g.Log().Errorf(ctx, "[simcardquery] 查询月流量失败 cards=%d range=%s~%s err=%v",
|
||||
len(chunk), startText, endText, queryErr)
|
||||
continue
|
||||
}
|
||||
latest := make(map[string]map[string]time.Time, len(chunk))
|
||||
scanFailed := false
|
||||
for rows.Next() {
|
||||
var iccid string
|
||||
var ts time.Time
|
||||
var usage float64
|
||||
if scanErr := rows.Scan(&iccid, &ts, &usage); scanErr != nil {
|
||||
scanFailed = true
|
||||
g.Log().Errorf(ctx, "[simcardquery] 解析月流量结果失败 err=%v", scanErr)
|
||||
break
|
||||
}
|
||||
key := ts.Format("2006-01")
|
||||
if latest[iccid] == nil {
|
||||
latest[iccid] = make(map[string]time.Time, len(months))
|
||||
}
|
||||
previous, exists := latest[iccid][key]
|
||||
if exists && !ts.After(previous) {
|
||||
continue
|
||||
}
|
||||
entry := result[iccid]
|
||||
if entry == nil {
|
||||
entry = &cardUsageResult{Months: make(map[string]float64, len(months))}
|
||||
result[iccid] = entry
|
||||
}
|
||||
latest[iccid][key] = ts
|
||||
entry.Months[key] = usage
|
||||
}
|
||||
if !scanFailed {
|
||||
if rowsErr := rows.Err(); rowsErr != nil {
|
||||
scanFailed = true
|
||||
g.Log().Errorf(ctx, "[simcardquery] 遍历月流量结果失败 err=%v", rowsErr)
|
||||
}
|
||||
}
|
||||
rows.Close()
|
||||
if scanFailed {
|
||||
failedBatches++
|
||||
markFailedBatch()
|
||||
}
|
||||
}
|
||||
// 全部批次都失败才判定服务不可用。
|
||||
if batches > 0 && failedBatches == batches {
|
||||
g.Log().Errorf(ctx, "[simcardquery] 流量服务全部批次查询失败 batches=%d", batches)
|
||||
return nil, gerror.New("流量数据服务暂时不可用,本次导出未能完成。请稍后重试,若持续出现可联系技术支持。")
|
||||
}
|
||||
if failedBatches > 0 {
|
||||
g.Log().Warningf(ctx, "[simcardquery] 流量服务部分批次失败 batches=%d failed=%d", batches, failedBatches)
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// formatMonthValues 把某行的月度用量格式化成导出的单元格值,三种状态严格区分:
|
||||
//
|
||||
// 查询失败(服务异常)→ 留空(原因见「失败原因」列)
|
||||
// 查询正常、该月无记录 → "-"
|
||||
// 查询正常、有记录 → 数值(零用量即 0)
|
||||
func formatMonthValues(row *model.SimCardUsageHistoryRow, months []string) []interface{} {
|
||||
values := make([]interface{}, 0, len(months))
|
||||
for _, month := range months {
|
||||
if row.UsageQueryFailed || row.QueryResult == queryResultMiss {
|
||||
// 整卡流量查询失败,或这张卡本身没查到:月份列留空,
|
||||
// 原因由「失败原因」列说明,保证月份列始终是「数字 + 半角横杠 + 空」。
|
||||
values = append(values, "")
|
||||
continue
|
||||
}
|
||||
usage, exists := row.MonthValues[month]
|
||||
if !exists {
|
||||
values = append(values, usageNoDataText)
|
||||
continue
|
||||
}
|
||||
values = append(values, roundMb(usage))
|
||||
}
|
||||
return values
|
||||
}
|
||||
|
||||
// newUsageRow 组装月历史导出行,并统一处理「流量查询失败」的说明。
|
||||
func newUsageRow(card *cardBrief, usage map[string]*cardUsageResult) *model.SimCardUsageHistoryRow {
|
||||
row := &model.SimCardUsageHistoryRow{
|
||||
Iccid: card.Iccid,
|
||||
Msisdn: card.Msisdn,
|
||||
Imsi: card.Imsi,
|
||||
OutStockTime: card.OutStockTime,
|
||||
ExpireAt: card.ExpireAt,
|
||||
MonthValues: usageMonths(usage, card.Iccid),
|
||||
UsageQueryFailed: usageFailed(usage, card.Iccid),
|
||||
QueryResult: queryResultHit,
|
||||
}
|
||||
if row.UsageQueryFailed {
|
||||
row.FailReason = usageQueryFailedNote
|
||||
}
|
||||
return row
|
||||
}
|
||||
|
||||
// usageMonths 取某卡的月度用量;该卡查询失败或没有条目时返回空 map。
|
||||
func usageMonths(usage map[string]*cardUsageResult, iccid string) map[string]float64 {
|
||||
if entry := usage[iccid]; entry != nil {
|
||||
return entry.Months
|
||||
}
|
||||
return map[string]float64{}
|
||||
}
|
||||
|
||||
// usageFailed 该卡的流量数据是否查询失败。
|
||||
func usageFailed(usage map[string]*cardUsageResult, iccid string) bool {
|
||||
if entry := usage[iccid]; entry != nil {
|
||||
return entry.Failed
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// LastCompleteMonths 返回最近 N 个「已结束的月份」,按时间倒序(最近的在前)。
|
||||
// 当月不计入:当月数据仍在累积,导出口径会和下个月对不上。
|
||||
func LastCompleteMonths(now time.Time, count int) []string {
|
||||
months := make([]string, 0, count)
|
||||
cursor := time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, now.Location()).AddDate(0, -1, 0)
|
||||
for i := 0; i < count; i++ {
|
||||
months = append(months, cursor.Format("2006-01"))
|
||||
cursor = cursor.AddDate(0, -1, 0)
|
||||
}
|
||||
return months
|
||||
}
|
||||
|
||||
// monthRange 把月份清单换算成查询时间范围(含首尾)。
|
||||
func monthRange(months []string) (time.Time, time.Time, error) {
|
||||
if len(months) == 0 {
|
||||
return time.Time{}, time.Time{}, gerror.New("月份清单为空")
|
||||
}
|
||||
parsed := make([]time.Time, 0, len(months))
|
||||
for _, month := range months {
|
||||
value, err := time.ParseInLocation("2006-01", month, time.Local)
|
||||
if err != nil {
|
||||
return time.Time{}, time.Time{}, gerror.Wrapf(err, "月份格式错误:%s", month)
|
||||
}
|
||||
parsed = append(parsed, value)
|
||||
}
|
||||
sort.Slice(parsed, func(i, j int) bool { return parsed[i].Before(parsed[j]) })
|
||||
start := parsed[0]
|
||||
end := parsed[len(parsed)-1].AddDate(0, 1, 0).Add(-time.Second)
|
||||
return start, end, nil
|
||||
}
|
||||
|
||||
// isSafeIccid ICCID 拼进 SQL 字面量前的白名单校验。
|
||||
// ICCID 正常只含数字,个别批次可能带字母;除此之外一律拒绝,避免拼接注入。
|
||||
func isSafeIccid(value string) bool {
|
||||
if value == "" || len(value) > 32 {
|
||||
return false
|
||||
}
|
||||
for _, r := range value {
|
||||
switch {
|
||||
case r >= '0' && r <= '9':
|
||||
case r >= 'a' && r <= 'z':
|
||||
case r >= 'A' && r <= 'Z':
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func validateTDEngineNames(database, stable string) error {
|
||||
|
||||
if database == "" || stable == "" {
|
||||
return gerror.New("流量库配置不完整")
|
||||
}
|
||||
for _, value := range []string{database, stable} {
|
||||
for _, r := range value {
|
||||
if !(r == '_' || (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9')) {
|
||||
return gerror.Newf("流量库标识非法:%s", value)
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func formatTime(value *gtime.Time) string {
|
||||
if value == nil || value.IsZero() {
|
||||
return ""
|
||||
}
|
||||
return value.Format("Y-m-d H:i:s")
|
||||
}
|
||||
@@ -51,7 +51,7 @@ func applyStockBusinessFilters(ctx context.Context, mod *gdb.Model, filter stock
|
||||
stockTable,
|
||||
stockColumns.ChanKey,
|
||||
channelColumns.TenantId,
|
||||
channelColumns.ChanName,
|
||||
channelDisplayNameColumn(ctx),
|
||||
channelColumns.ChanKey,
|
||||
),
|
||||
contexts.GetTenantId(ctx),
|
||||
@@ -144,12 +144,16 @@ func enrichStockListBusinessFields(ctx context.Context, list []*model.TSimCardSt
|
||||
|
||||
batchNos := make([]string, 0, len(list))
|
||||
chanKeys := make([]string, 0, len(list))
|
||||
deptIds := make([]int64, 0, len(list))
|
||||
for _, item := range list {
|
||||
if item == nil {
|
||||
continue
|
||||
}
|
||||
batchNos = appendUniqueNonBlank(batchNos, item.BatchNo)
|
||||
chanKeys = appendUniqueNonBlank(chanKeys, item.ChanKey)
|
||||
if item.CreatedDept > 0 {
|
||||
deptIds = append(deptIds, item.CreatedDept)
|
||||
}
|
||||
}
|
||||
|
||||
batchMap, err := loadStockBatchBusinessFields(ctx, batchNos)
|
||||
@@ -167,6 +171,10 @@ func enrichStockListBusinessFields(ctx context.Context, list []*model.TSimCardSt
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
deptMap, err := loadStockDeptNames(ctx, deptIds)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, item := range list {
|
||||
if item == nil {
|
||||
@@ -178,6 +186,7 @@ func enrichStockListBusinessFields(ctx context.Context, list []*model.TSimCardSt
|
||||
item.UpPkgCode = fields.UpPkgCode
|
||||
item.UpPkgName = fields.UpPkgName
|
||||
item.AgreementExpiryDate = fields.AgreementExpiryDate
|
||||
item.CreatedDeptName = deptMap[item.CreatedDept]
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -419,7 +428,7 @@ func loadStockChannelNames(ctx context.Context, chanKeys []string) (map[string]s
|
||||
var rows []*channelRow
|
||||
columns := dao.TSimChannels.Columns()
|
||||
if err := dao.TSimChannels.Ctx(ctx).
|
||||
Fields(columns.ChanKey, columns.ChanName).
|
||||
Fields(columns.ChanKey, channelDisplayNameSQL(ctx, dao.TSimChannels.Table())+" AS chan_name").
|
||||
Where(columns.TenantId, contexts.GetTenantId(ctx)).
|
||||
WhereIn(columns.ChanKey, chanKeys).
|
||||
Scan(&rows); err != nil {
|
||||
@@ -433,6 +442,51 @@ func loadStockChannelNames(ctx context.Context, chanKeys []string) (map[string]s
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// loadStockDeptNames 批量补充创建部门名称(sys_dept.dept_id -> dept_name)。
|
||||
func loadStockDeptNames(ctx context.Context, deptIds []int64) (map[int64]string, error) {
|
||||
result := make(map[int64]string)
|
||||
deptIds = uniqueInt64(deptIds)
|
||||
if len(deptIds) == 0 {
|
||||
return result, nil
|
||||
}
|
||||
|
||||
type deptRow struct {
|
||||
DeptId int64 `orm:"dept_id"`
|
||||
DeptName string `orm:"dept_name"`
|
||||
}
|
||||
var rows []*deptRow
|
||||
columns := dao.SysDept.Columns()
|
||||
mod := dao.SysDept.Ctx(ctx).
|
||||
Fields(columns.DeptId, columns.DeptName).
|
||||
WhereIn(columns.DeptId, deptIds)
|
||||
if tenantId := strings.TrimSpace(contexts.GetTenantId(ctx)); tenantId != "" {
|
||||
mod = mod.Where(columns.TenantId, tenantId)
|
||||
}
|
||||
if err := mod.Scan(&rows); err != nil {
|
||||
return nil, gerror.Wrap(err, "补充创建部门名称失败")
|
||||
}
|
||||
for _, row := range rows {
|
||||
if row != nil && row.DeptId > 0 {
|
||||
result[row.DeptId] = row.DeptName
|
||||
}
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// uniqueInt64 去重并剔除非正的部门ID。
|
||||
func uniqueInt64(values []int64) []int64 {
|
||||
seen := make(map[int64]bool, len(values))
|
||||
result := make([]int64, 0, len(values))
|
||||
for _, value := range values {
|
||||
if value <= 0 || seen[value] {
|
||||
continue
|
||||
}
|
||||
seen[value] = true
|
||||
result = append(result, value)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func loadStockUpstreamPackageNames(ctx context.Context, upPkgCodes []string) (map[string]string, error) {
|
||||
result := make(map[string]string)
|
||||
upPkgCodes = uniqueNonBlank(upPkgCodes)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// Package gen
|
||||
// @Link https://github.com/xiujiecn/xiu-admin
|
||||
// @Copyright Copyright (c) 2026 LiXiujie
|
||||
// @Author Lxj <li@xiujie.cn>
|
||||
// @Author Lxj <xxx@example.com>
|
||||
// @License https://github.com/xiujiecn/xiu-admin/blob/master/LICENSE
|
||||
// @AutoGenerate Version
|
||||
package iot
|
||||
@@ -11,22 +11,20 @@ import (
|
||||
"fmt"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/dao"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/contexts"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/exportcenter"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/xgorm/handler"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/xgorm/hook"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/model"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/model/entity"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/service"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/utility/convert"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/utility/excel"
|
||||
"strings"
|
||||
|
||||
"github.com/gogf/gf/v2/database/gdb"
|
||||
"github.com/gogf/gf/v2/encoding/gjson"
|
||||
"github.com/gogf/gf/v2/errors/gerror"
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
"github.com/gogf/gf/v2/os/gctx"
|
||||
"github.com/gogf/gf/v2/os/gtime"
|
||||
"github.com/gogf/gf/v2/util/gconv"
|
||||
)
|
||||
|
||||
type sIotTFlowPackageConfig struct{}
|
||||
@@ -171,31 +169,32 @@ func (s *sIotTFlowPackageConfig) List(ctx context.Context, in *model.TFlowPackag
|
||||
return
|
||||
}
|
||||
|
||||
// Export 导出SIM流量池套餐配置表
|
||||
// Export 导出流量池套餐配置(异步:提交任务后到下载中心取文件)
|
||||
func (s *sIotTFlowPackageConfig) Export(ctx context.Context, in *model.TFlowPackageConfigListParam) (err error) {
|
||||
list, totalCount, err := s.List(ctx, in)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
// 字段的排序是依据tags的字段顺序,如果你不想使用默认的排序方式,可以直接定义 tags = []string{"字段名称", "字段名称2", ...}
|
||||
tags, err := convert.GetEntityDescTags(model.TFlowPackageConfigExportModel{})
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
var (
|
||||
fileName = "导出SIM流量池套餐配置表-" + gctx.CtxId(ctx)
|
||||
sheetName = fmt.Sprintf("索引条件共%v行,共%v页,当前导出是第%v页,本页共%v行", totalCount, (totalCount+in.PageSize-1)/in.PageSize, in.Page, len(list))
|
||||
exports []model.TFlowPackageConfigExportModel
|
||||
)
|
||||
|
||||
if err = gconv.Scan(list, &exports); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
err = excel.ExportByStructs(ctx, tags, exports, fileName, sheetName)
|
||||
return
|
||||
_, err = exportcenter.Submit(ctx, &exportcenter.Request{
|
||||
BizKey: "tFlowPackageConfig",
|
||||
BizName: "流量池套餐配置",
|
||||
PermCode: "cpm:iot:tFlowPackageConfig:export",
|
||||
QueryParam: in,
|
||||
Tags: func() ([]string, error) {
|
||||
return convert.GetEntityDescTags(model.TFlowPackageConfigListModel{})
|
||||
},
|
||||
Fetch: func(ctx context.Context, page, pageSize int) (rows []interface{}, total int, err error) {
|
||||
query := *in
|
||||
query.Page = page
|
||||
query.PageSize = pageSize
|
||||
list, count, err := s.List(ctx, &query)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
rows = make([]interface{}, 0, len(list))
|
||||
for _, item := range list {
|
||||
rows = append(rows, item)
|
||||
}
|
||||
return rows, count, nil
|
||||
},
|
||||
})
|
||||
return err
|
||||
}
|
||||
|
||||
// Edit 修改/新增SIM流量池套餐配置表
|
||||
|
||||
@@ -1,31 +1,28 @@
|
||||
// Package gen
|
||||
// @Link https://github.com/xiujiecn/xiu-admin
|
||||
// @Copyright Copyright (c) 2026 LiXiujie
|
||||
// @Author Lxj <li@xiujie.cn>
|
||||
// @Author Lxj <xxx@example.com>
|
||||
// @License https://github.com/xiujiecn/xiu-admin/blob/master/LICENSE
|
||||
// @AutoGenerate Version
|
||||
package iot
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/consts"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/dao"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/contexts"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/exportcenter"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/xgorm/handler"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/xgorm/hook"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/model"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/model/entity"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/service"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/utility/convert"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/utility/excel"
|
||||
"strings"
|
||||
|
||||
"github.com/gogf/gf/v2/database/gdb"
|
||||
"github.com/gogf/gf/v2/encoding/gjson"
|
||||
"github.com/gogf/gf/v2/errors/gerror"
|
||||
"github.com/gogf/gf/v2/os/gctx"
|
||||
"github.com/gogf/gf/v2/util/gconv"
|
||||
)
|
||||
|
||||
type sIotTOrder struct{}
|
||||
@@ -357,51 +354,38 @@ func orderItemView(item *entity.TOrderItem) *model.TOrderItemViewModel {
|
||||
return view
|
||||
}
|
||||
|
||||
// Export 导出订单(异步:提交任务后到下载中心取文件)
|
||||
func (s *sIotTOrder) Export(ctx context.Context, in *model.TOrderListParam) (err error) {
|
||||
query := *in
|
||||
query.Page = 1
|
||||
query.PageSize = orderExportPageSize
|
||||
list := make([]*model.TOrderListModel, 0)
|
||||
totalCount := 0
|
||||
for {
|
||||
var (
|
||||
pageList []*model.TOrderListModel
|
||||
pageTotal int
|
||||
)
|
||||
if query.Perspective == orderPerspectiveSell {
|
||||
pageList, pageTotal, err = s.SellList(ctx, &query)
|
||||
} else {
|
||||
pageList, pageTotal, err = s.List(ctx, &query)
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
totalCount = pageTotal
|
||||
list = append(list, pageList...)
|
||||
if len(pageList) == 0 || len(list) >= totalCount {
|
||||
break
|
||||
}
|
||||
query.Page++
|
||||
}
|
||||
|
||||
// 字段的排序是依据tags的字段顺序,如果你不想使用默认的排序方式,可以直接定义 tags = []string{"字段名称", "字段名称2", ...}
|
||||
tags, err := convert.GetEntityDescTags(model.TOrderExportModel{})
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
var (
|
||||
fileName = "导出订单主表-" + gctx.CtxId(ctx)
|
||||
sheetName = fmt.Sprintf("筛选结果共%v行", totalCount)
|
||||
exports []model.TOrderExportModel
|
||||
)
|
||||
|
||||
if err = gconv.Scan(list, &exports); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
err = excel.ExportByStructs(ctx, tags, exports, fileName, sheetName)
|
||||
return
|
||||
_, err = exportcenter.Submit(ctx, &exportcenter.Request{
|
||||
BizKey: "tOrder",
|
||||
BizName: "订单",
|
||||
PermCode: "cpm:iot:tOrder:export",
|
||||
QueryParam: in,
|
||||
Tags: func() ([]string, error) {
|
||||
return convert.GetEntityDescTags(model.TOrderListModel{})
|
||||
},
|
||||
Fetch: func(ctx context.Context, page, pageSize int) (rows []interface{}, total int, err error) {
|
||||
query := *in
|
||||
query.Page = page
|
||||
query.PageSize = pageSize
|
||||
var list []*model.TOrderListModel
|
||||
var count int
|
||||
if query.Perspective == orderPerspectiveSell {
|
||||
list, count, err = s.SellList(ctx, &query)
|
||||
} else {
|
||||
list, count, err = s.List(ctx, &query)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
rows = make([]interface{}, 0, len(list))
|
||||
for _, item := range list {
|
||||
rows = append(rows, item)
|
||||
}
|
||||
return rows, count, nil
|
||||
},
|
||||
})
|
||||
return err
|
||||
}
|
||||
|
||||
// Edit 订单由业务流程生成,不允许后台手工修改。
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
package iot
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/consts"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/model"
|
||||
)
|
||||
|
||||
func TestNormalizeOrderItemProduct(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
orderType int
|
||||
want string
|
||||
}{
|
||||
{name: "renew", orderType: consts.OrderTypeCardRenew, want: "卡续费"},
|
||||
{name: "change", orderType: consts.OrderTypeCardChange, want: "卡变更"},
|
||||
{name: "subscribe", orderType: consts.OrderTypeCardSubscribe, want: "卡订购"},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
view := &model.TOrderItemViewModel{
|
||||
ProductName: "余额充值",
|
||||
ProductSn: "BALANCE_RECHARGE",
|
||||
}
|
||||
normalizeOrderItemProduct(test.orderType, view)
|
||||
if view.ProductName != test.want || view.ProductSn != "" {
|
||||
t.Fatalf("got name=%q sn=%q, want name=%q and empty sn", view.ProductName, view.ProductSn, test.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
// Package gen
|
||||
// @Link https://github.com/xiujiecn/xiu-admin
|
||||
// @Copyright Copyright (c) 2026 LiXiujie
|
||||
// @Author Lxj <li@xiujie.cn>
|
||||
// @Author Lxj <xxx@example.com>
|
||||
// @License https://github.com/xiujiecn/xiu-admin/blob/master/LICENSE
|
||||
// @AutoGenerate Version
|
||||
package iot
|
||||
|
||||
@@ -2,24 +2,22 @@ package iot
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/consts"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/dao"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/exportcenter"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/xgorm/handler"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/xgorm/hook"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/model"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/service"
|
||||
|
||||
"github.com/gogf/gf/v2/util/gconv"
|
||||
|
||||
"github.com/gogf/gf/v2/database/gdb"
|
||||
"github.com/gogf/gf/v2/errors/gerror"
|
||||
"github.com/gogf/gf/v2/net/ghttp"
|
||||
"github.com/gogf/gf/v2/os/gtime"
|
||||
excelize "github.com/xuri/excelize/v2"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -34,6 +32,16 @@ var cancellationExportHeaders = []interface{}{
|
||||
"入库批次", "出库批次", "划拨批次", "操作人", "备注",
|
||||
}
|
||||
|
||||
// cancellationExportFields 与 cancellationExportHeaders 一一对应(json tag),
|
||||
// 供「按页面显示列导出」在保留 ToRow 格式化的前提下按字段取列。
|
||||
var cancellationExportFields = []string{
|
||||
"cancelBatchNo", "cancelledAt", "iccid", "msisdn", "imsi", "imei",
|
||||
"chanKey", "chanName", "deptId", "deptName",
|
||||
"currentPackageCode", "packageName", "bossCardStatus",
|
||||
// 字段名必须与列表页列的 field 一致(操作人页面用 createdBy,显示的是 createdBySumma 摘要)
|
||||
"inboundBatchNo", "outboundBatchNo", "allocationBatchNo", "createdBy", "remark",
|
||||
}
|
||||
|
||||
type sIotTSimCardCancellation struct{}
|
||||
|
||||
func NewIotTSimCardCancellation() *sIotTSimCardCancellation {
|
||||
@@ -72,6 +80,7 @@ func (s *sIotTSimCardCancellation) List(ctx context.Context, in *model.TSimCardC
|
||||
return list, total, nil
|
||||
}
|
||||
|
||||
// Export 导出销户卡信息(异步:提交任务后到下载中心取文件)
|
||||
func (s *sIotTSimCardCancellation) Export(ctx context.Context, filter *model.TSimCardCancellationFilter) error {
|
||||
if filter == nil {
|
||||
filter = &model.TSimCardCancellationFilter{}
|
||||
@@ -80,77 +89,50 @@ func (s *sIotTSimCardCancellation) Export(ctx context.Context, filter *model.TSi
|
||||
return err
|
||||
}
|
||||
|
||||
total, err := s.filteredModel(ctx, filter).Count()
|
||||
if err != nil {
|
||||
return gerror.Wrap(err, "统计销户卡导出数量失败")
|
||||
}
|
||||
if total == 0 {
|
||||
return gerror.New("暂无可导出数据")
|
||||
}
|
||||
if total > cancellationExportMaxRows {
|
||||
return gerror.Newf("导出数据超过 Excel 上限 %d 条,请缩小筛选范围", cancellationExportMaxRows)
|
||||
}
|
||||
|
||||
file := excelize.NewFile()
|
||||
defer file.Close()
|
||||
const sheetName = "销户卡信息"
|
||||
file.SetSheetName(file.GetSheetName(0), sheetName)
|
||||
stream, err := file.NewStreamWriter(sheetName)
|
||||
if err != nil {
|
||||
return gerror.Wrap(err, "创建销户卡 Excel 流失败")
|
||||
}
|
||||
if err = stream.SetColWidth(1, len(cancellationExportHeaders), 22); err != nil {
|
||||
return gerror.Wrap(err, "设置销户卡 Excel 列宽失败")
|
||||
}
|
||||
if err = stream.SetColWidth(3, 6, 28); err != nil {
|
||||
return gerror.Wrap(err, "设置销户卡号 Excel 列宽失败")
|
||||
}
|
||||
headerStyle, err := file.NewStyle(&excelize.Style{Font: &excelize.Font{Bold: true}})
|
||||
if err != nil {
|
||||
return gerror.Wrap(err, "创建销户卡 Excel 表头样式失败")
|
||||
}
|
||||
if err = stream.SetRow("A1", cancellationExportHeaders, excelize.RowOpts{StyleID: headerStyle}); err != nil {
|
||||
return gerror.Wrap(err, "写入销户卡 Excel 表头失败")
|
||||
}
|
||||
|
||||
rowNumber := 2
|
||||
for offset := 0; offset < total; offset += cancellationExportBatchSize {
|
||||
var rows []*model.TSimCardCancellationListModel
|
||||
query := s.selectModel(ctx, filter).
|
||||
OrderDesc(dao.TSimCardCancellation.Table()+"."+dao.TSimCardCancellation.Columns().CancelledAt).
|
||||
OrderDesc(dao.TSimCardCancellation.Table()+"."+dao.TSimCardCancellation.Columns().Id).
|
||||
Limit(offset, cancellationExportBatchSize)
|
||||
if err = query.Scan(&rows); err != nil {
|
||||
return gerror.Wrap(err, "分批查询销户卡导出数据失败")
|
||||
}
|
||||
for _, row := range rows {
|
||||
cell, _ := excelize.CoordinatesToCellName(1, rowNumber)
|
||||
if err = stream.SetRow(cell, cancellationExportRow(row)); err != nil {
|
||||
return gerror.Wrap(err, "写入销户卡 Excel 数据失败")
|
||||
counted := false
|
||||
totalRows := 0
|
||||
_, err := exportcenter.Submit(ctx, &exportcenter.Request{
|
||||
BizKey: "tSimCardCancellation",
|
||||
BizName: "销户卡信息",
|
||||
PermCode: "cpm:iot:tSimCardCancellation:export",
|
||||
QueryParam: filter,
|
||||
Tags: func() ([]string, error) {
|
||||
headers := make([]string, 0, len(cancellationExportHeaders))
|
||||
for _, v := range cancellationExportHeaders {
|
||||
headers = append(headers, gconv.String(v))
|
||||
}
|
||||
rowNumber++
|
||||
}
|
||||
}
|
||||
if err = stream.Flush(); err != nil {
|
||||
return gerror.Wrap(err, "刷新销户卡 Excel 数据失败")
|
||||
}
|
||||
|
||||
request := ghttp.RequestFromCtx(ctx)
|
||||
if request == nil {
|
||||
return gerror.New("销户卡导出缺少 HTTP 请求上下文")
|
||||
}
|
||||
fileName := "销户卡信息-" + gtime.Now().Format("YmdHis") + ".xlsx"
|
||||
writer := request.Response.Writer
|
||||
writer.Header().Set("Content-Type", "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet")
|
||||
writer.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%s", url.QueryEscape(fileName)))
|
||||
writer.Header().Set("Content-Transfer-Encoding", "binary")
|
||||
writer.Header().Set("Access-Control-Expose-Headers", "Content-Disposition")
|
||||
if err = file.Write(writer); err != nil {
|
||||
return gerror.Wrap(err, "输出销户卡 Excel 失败")
|
||||
}
|
||||
request.Response.Status = http.StatusOK
|
||||
writer.Flush()
|
||||
return nil
|
||||
return headers, nil
|
||||
},
|
||||
Fetch: func(ctx context.Context, page, pageSize int) (rows []interface{}, total int, err error) {
|
||||
if !counted {
|
||||
totalRows, err = s.filteredModel(ctx, filter).Count()
|
||||
if err != nil {
|
||||
return nil, 0, gerror.Wrap(err, "统计销户卡导出数量失败")
|
||||
}
|
||||
counted = true
|
||||
}
|
||||
var list []*model.TSimCardCancellationListModel
|
||||
query := s.selectModel(ctx, filter).
|
||||
OrderDesc(dao.TSimCardCancellation.Table()+"."+dao.TSimCardCancellation.Columns().CancelledAt).
|
||||
OrderDesc(dao.TSimCardCancellation.Table()+"."+dao.TSimCardCancellation.Columns().Id).
|
||||
Limit((page-1)*pageSize, pageSize)
|
||||
if err = query.Scan(&list); err != nil {
|
||||
return nil, 0, gerror.Wrap(err, "分批查询销户卡导出数据失败")
|
||||
}
|
||||
rows = make([]interface{}, 0, len(list))
|
||||
for _, row := range list {
|
||||
rows = append(rows, row)
|
||||
}
|
||||
return rows, totalRows, nil
|
||||
},
|
||||
ToRow: func(v interface{}) []interface{} {
|
||||
row, _ := v.(*model.TSimCardCancellationListModel)
|
||||
return cancellationExportRow(row)
|
||||
},
|
||||
// ToRow 返回值与字段名的对应关系,供「按页面显示列导出」按字段取列。
|
||||
RowFields: cancellationExportFields,
|
||||
})
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *sIotTSimCardCancellation) filteredModel(ctx context.Context, filter *model.TSimCardCancellationFilter) *gdb.Model {
|
||||
@@ -195,13 +177,13 @@ func (s *sIotTSimCardCancellation) selectModel(ctx context.Context, filter *mode
|
||||
query := s.filteredModel(ctx, filter)
|
||||
query = query.FieldsPrefix(cardTable, model.TSimCardCancellationListModel{})
|
||||
query = query.Fields(
|
||||
channelTable+"."+dao.TSimChannels.Columns().ChanName+" AS chanName",
|
||||
channelDisplayNameSQL(ctx, channelTable)+" AS chanName",
|
||||
deptTable+"."+dao.SysDept.Columns().DeptName+" AS deptName",
|
||||
packageTable+"."+dao.TSimFlowPackageInfo.Columns().PackageName+" AS packageName",
|
||||
)
|
||||
query = query.LeftJoin(channelTable, channelTable+"."+dao.TSimChannels.Columns().ChanKey+"="+cardTable+"."+dao.TSimCardCancellation.Columns().ChanKey)
|
||||
query = query.LeftJoin(channelTable, channelTable+"."+dao.TSimChannels.Columns().ChanKey+"="+cardTable+"."+dao.TSimCardCancellation.Columns().ChanKey+" AND "+channelTable+"."+dao.TSimChannels.Columns().TenantId+"="+cardTable+"."+dao.TSimCardCancellation.Columns().TenantId)
|
||||
query = query.LeftJoin(deptTable, deptTable+"."+dao.SysDept.Columns().DeptId+"="+cardTable+"."+dao.TSimCardCancellation.Columns().DeptId)
|
||||
query = query.LeftJoin(packageTable, packageTable+"."+dao.TSimFlowPackageInfo.Columns().PackageCode+"="+cardTable+"."+dao.TSimCardCancellation.Columns().CurrentPackageCode)
|
||||
query = query.LeftJoin(packageTable, packageTable+"."+dao.TSimFlowPackageInfo.Columns().PackageCode+"="+cardTable+"."+dao.TSimCardCancellation.Columns().CurrentPackageCode+" AND "+packageTable+"."+dao.TSimFlowPackageInfo.Columns().TenantId+"="+cardTable+"."+dao.TSimCardCancellation.Columns().TenantId)
|
||||
return query.Hook(hook.MemberSummary)
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,213 @@
|
||||
package iot
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/consts"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/model"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/model/entity"
|
||||
)
|
||||
|
||||
func TestAddRenewDurationByUnitKeepsSameCalendarDate(t *testing.T) {
|
||||
begin := time.Date(2026, time.September, 1, 0, 0, 0, 0, time.Local)
|
||||
got := addRenewDurationByUnit(begin, "year", 1)
|
||||
want := time.Date(2027, time.September, 1, 23, 59, 59, 0, time.Local)
|
||||
if !got.Equal(want) {
|
||||
t.Fatalf(
|
||||
"addRenewDurationByUnit() = %s, want %s",
|
||||
got.Format(time.RFC3339),
|
||||
want.Format(time.RFC3339),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateRenewConfirmContract(t *testing.T) {
|
||||
snapshot := &model.TSimCardRenewPriceSnapshot{
|
||||
RenewableDurationOptions: []int{12, 24, 36},
|
||||
}
|
||||
if err := validateRenewConfirmContract(snapshot, 24); err != nil {
|
||||
t.Fatalf("valid renew contract rejected: %v", err)
|
||||
}
|
||||
if err := validateRenewConfirmContract(snapshot, 6); err == nil {
|
||||
t.Fatal("renew duration outside prepare contract was accepted")
|
||||
}
|
||||
if err := validateRenewConfirmContract(nil, 12); err == nil {
|
||||
t.Fatal("missing renew contract was accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateChangeConfirmContract(t *testing.T) {
|
||||
prepareSnapshot := &model.TSimCardChangePrepareSnapshot{
|
||||
OldPackageCode: "PKG-OLD",
|
||||
}
|
||||
priceSnapshot := &model.TSimCardChangePriceSnapshot{
|
||||
NewPackageCode: "PKG-NEW",
|
||||
RenewableDurationOptions: []int{12, 24},
|
||||
}
|
||||
valid := &model.TSimCardChangeBatchSubmitParam{
|
||||
NewPackageCode: "PKG-NEW",
|
||||
DurationMonths: 12,
|
||||
EffectiveMode: cardChangeEffectiveNextMonth,
|
||||
}
|
||||
if err := validateChangeConfirmContract(prepareSnapshot, priceSnapshot, cardChangeEffectiveNextMonth, valid); err != nil {
|
||||
t.Fatalf("valid change contract rejected: %v", err)
|
||||
}
|
||||
if err := validateChangeConfirmContract(prepareSnapshot, priceSnapshot, "", valid); err != nil {
|
||||
t.Fatalf("legacy change contract without effective mode rejected: %v", err)
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
in model.TSimCardChangeBatchSubmitParam
|
||||
}{
|
||||
{
|
||||
name: "current package selected",
|
||||
in: model.TSimCardChangeBatchSubmitParam{
|
||||
NewPackageCode: "PKG-OLD",
|
||||
DurationMonths: 12,
|
||||
EffectiveMode: cardChangeEffectiveNextMonth,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "target package changed",
|
||||
in: model.TSimCardChangeBatchSubmitParam{
|
||||
NewPackageCode: "PKG-OTHER",
|
||||
DurationMonths: 12,
|
||||
EffectiveMode: cardChangeEffectiveNextMonth,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "duration outside contract",
|
||||
in: model.TSimCardChangeBatchSubmitParam{
|
||||
NewPackageCode: "PKG-NEW",
|
||||
DurationMonths: 6,
|
||||
EffectiveMode: cardChangeEffectiveNextMonth,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "effective mode changed",
|
||||
in: model.TSimCardChangeBatchSubmitParam{
|
||||
NewPackageCode: "PKG-NEW",
|
||||
DurationMonths: 12,
|
||||
EffectiveMode: cardChangeEffectiveImmediate,
|
||||
},
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if err := validateChangeConfirmContract(prepareSnapshot, priceSnapshot, cardChangeEffectiveNextMonth, &tt.in); err == nil {
|
||||
t.Fatal("invalid change contract was accepted")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
if err := validateChangeConfirmContract(nil, priceSnapshot, cardChangeEffectiveNextMonth, valid); err == nil {
|
||||
t.Fatal("missing change contract was accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeAddPackagePaymentIntent(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
mode string
|
||||
confirmed bool
|
||||
isTopDept bool
|
||||
wantMode string
|
||||
wantConfirm bool
|
||||
wantErr string
|
||||
}{
|
||||
{name: "child default uses balance", wantMode: "balance"},
|
||||
{name: "top default uses balance", isTopDept: true, wantMode: "balance"},
|
||||
{name: "balance clears offline confirmation", mode: "balance", confirmed: true, isTopDept: true, wantMode: "balance"},
|
||||
{name: "child cannot use offline", mode: "offline", confirmed: true, wantErr: "必须使用余额支付"},
|
||||
{name: "top offline requires confirmation", mode: "offline", isTopDept: true, wantErr: "请确认线下合同"},
|
||||
{name: "top offline keeps confirmation", mode: "offline", confirmed: true, isTopDept: true, wantMode: "offline", wantConfirm: true},
|
||||
{name: "invalid payment mode", mode: "cash", isTopDept: true, wantErr: "付款方式不正确"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
gotMode, gotConfirm, err := normalizeAddPackagePaymentIntent(tt.mode, tt.confirmed, tt.isTopDept)
|
||||
if tt.wantErr != "" {
|
||||
if err == nil || !strings.Contains(err.Error(), tt.wantErr) {
|
||||
t.Fatalf("error = %v, want substring %q", err, tt.wantErr)
|
||||
}
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("normalizeAddPackagePaymentIntent() error = %v", err)
|
||||
}
|
||||
if gotMode != tt.wantMode {
|
||||
t.Fatalf("payment mode = %q, want %q", gotMode, tt.wantMode)
|
||||
}
|
||||
if gotConfirm != tt.wantConfirm {
|
||||
t.Fatalf("offline confirmation = %v, want %v", gotConfirm, tt.wantConfirm)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizedChangePrepareSourceBatchNo(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
sourceType int
|
||||
mainSource string
|
||||
snapshot string
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "input ignores self reference",
|
||||
sourceType: consts.BatchSourceTypeBatchInput,
|
||||
mainSource: "CHG20260803170000INPUT",
|
||||
snapshot: "CHG20260803170000INPUT",
|
||||
},
|
||||
{
|
||||
name: "file ignores stale source",
|
||||
sourceType: consts.BatchSourceTypeFile,
|
||||
mainSource: "CHG20260803170000FILE",
|
||||
snapshot: "OUT20260803STALE",
|
||||
},
|
||||
{
|
||||
name: "batch selection keeps snapshot source",
|
||||
sourceType: consts.BatchSourceTypeBatchSelect,
|
||||
mainSource: "OUT20260803MAIN",
|
||||
snapshot: "OUT20260803CONFIRMED",
|
||||
want: "OUT20260803CONFIRMED",
|
||||
},
|
||||
{
|
||||
name: "batch selection repairs worker self reference",
|
||||
sourceType: consts.BatchSourceTypeBatchSelect,
|
||||
mainSource: "OUT20260803MAIN",
|
||||
snapshot: "CHG20260803170000BATCH",
|
||||
want: "OUT20260803MAIN",
|
||||
},
|
||||
{
|
||||
name: "legacy self reference without real source becomes empty",
|
||||
sourceType: consts.BatchSourceTypeBatchSelect,
|
||||
mainSource: "CHG20260803170000BATCH",
|
||||
snapshot: "CHG20260803170000BATCH",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
batchNo := "CHG20260803170000BATCH"
|
||||
if tt.sourceType == consts.BatchSourceTypeBatchInput {
|
||||
batchNo = "CHG20260803170000INPUT"
|
||||
}
|
||||
if tt.sourceType == consts.BatchSourceTypeFile {
|
||||
batchNo = "CHG20260803170000FILE"
|
||||
}
|
||||
batch := &entity.BatchMain{
|
||||
BatchNo: batchNo,
|
||||
SourceType: tt.sourceType,
|
||||
SourceBatchNo: tt.mainSource,
|
||||
}
|
||||
if got := normalizedChangePrepareSourceBatchNo(batch, tt.snapshot); got != tt.want {
|
||||
t.Fatalf("sourceBatchNo = %q, want %q", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,17 +1,20 @@
|
||||
// Package gen
|
||||
// @Link https://github.com/xiujiecn/xiu-admin
|
||||
// @Copyright Copyright (c) 2026 LiXiujie
|
||||
// @Author Lxj <li@xiujie.cn>
|
||||
// @Author Lxj <xxx@example.com>
|
||||
// @License https://github.com/xiujiecn/xiu-admin/blob/master/LICENSE
|
||||
// @AutoGenerate Version
|
||||
package iot
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/consts"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/dao"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/batchinput/text"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/contexts"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/exportcenter"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/xgorm"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/xgorm/handler"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/xgorm/hook"
|
||||
@@ -21,15 +24,13 @@ import (
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/model/do"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/service"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/utility/convert"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/utility/excel"
|
||||
"strings"
|
||||
|
||||
"github.com/gogf/gf/v2/database/gdb"
|
||||
"github.com/gogf/gf/v2/encoding/gjson"
|
||||
"github.com/gogf/gf/v2/errors/gerror"
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
"github.com/gogf/gf/v2/os/gctx"
|
||||
"github.com/gogf/gf/v2/os/gtime"
|
||||
"github.com/gogf/gf/v2/util/gconv"
|
||||
)
|
||||
|
||||
type sIotTSimCardInfo struct{}
|
||||
@@ -66,6 +67,14 @@ func (s *sIotTSimCardInfo) Model(ctx context.Context, option ...*handler.Option)
|
||||
|
||||
// List 获取卡信息表列表
|
||||
func (s *sIotTSimCardInfo) List(ctx context.Context, in *model.TSimCardInfoListParam) (list []*model.TSimCardInfoListModel, totalCount int, err error) {
|
||||
var batchCondition string
|
||||
var batchArgs []interface{}
|
||||
if in.BatchInput != nil {
|
||||
batchCondition, batchArgs, err = simCardBatchCondition(ctx, *in.BatchInput)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
}
|
||||
mod := s.Model(ctx)
|
||||
cardInfoTable := dao.TSimCardInfo.Table()
|
||||
simChannels := dao.TSimChannels.Table()
|
||||
@@ -116,6 +125,9 @@ func (s *sIotTSimCardInfo) List(ctx context.Context, in *model.TSimCardInfoListP
|
||||
dao.TSimCardInfo.Columns().Imsi + " = ?)"
|
||||
mod = mod.Where(cardValueCondition, in.CardValue, in.CardValue, in.CardValue)
|
||||
}
|
||||
if batchCondition != "" {
|
||||
mod = mod.Where(batchCondition, batchArgs...)
|
||||
}
|
||||
if in.Remark != "" {
|
||||
mod = mod.Where(dao.TSimCardInfo.Columns().Remark+" LIKE ?", "%"+in.Remark+"%")
|
||||
}
|
||||
@@ -254,6 +266,7 @@ func (s *sIotTSimCardInfo) List(ctx context.Context, in *model.TSimCardInfoListP
|
||||
err = gerror.Wrap(err, "获取卡信息表列表失败,请稍后重试!")
|
||||
return
|
||||
}
|
||||
applyCardChannelDisplayNames(ctx, list)
|
||||
if resolver, resolverErr := newCardDeptDisplayResolver(ctx); resolverErr != nil {
|
||||
logCardDeptDisplayResolverError(ctx, resolverErr)
|
||||
} else {
|
||||
@@ -266,31 +279,127 @@ func (s *sIotTSimCardInfo) List(ctx context.Context, in *model.TSimCardInfoListP
|
||||
return
|
||||
}
|
||||
|
||||
// Export 导出卡信息表
|
||||
// exportColumns 取前端传入的导出列。
|
||||
// 优先解析 JSON 字符串形式(数组绑定可能丢失内容),失败再退回对象数组。
|
||||
func (s *sIotTSimCardInfo) exportColumns(in *model.TSimCardInfoListParam) []model.ExportColumn {
|
||||
if raw := strings.TrimSpace(in.ExportColumnsJson); raw != "" {
|
||||
cols := make([]model.ExportColumn, 0)
|
||||
if err := gjson.New(raw).Scan(&cols); err == nil && len(cols) > 0 {
|
||||
return cols
|
||||
}
|
||||
}
|
||||
return in.ExportColumns
|
||||
}
|
||||
|
||||
const simCardBatchMaxRows = 1000
|
||||
|
||||
// simCardBatchCondition 将批量查询输入编译为查询条件。
|
||||
// 每行支持 1 至 3 个号码值,同一行的多个值表示同一张卡的多个字段值。
|
||||
// 同一行的值互不相同,必然落在不同字段上,因此“该行每个值都命中这张卡的某个字段”
|
||||
// 已等价于“这些值都属于这张卡”,无需判断某个值具体是 ICCID、MSISDN 还是 IMSI。
|
||||
func simCardBatchCondition(ctx context.Context, input string) (string, []interface{}, error) {
|
||||
lines, err := text.ParseWhitespaceFields(ctx, input)
|
||||
if errors.Is(err, text.ErrEmptyInput) {
|
||||
return "", nil, gerror.New("批量查询卡号不能为空")
|
||||
}
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
if len(lines) > simCardBatchMaxRows {
|
||||
return "", nil, gerror.New("批量查询最多支持1000行")
|
||||
}
|
||||
|
||||
columns := dao.TSimCardInfo.Columns()
|
||||
// 单值行之间相互独立,合并成 IN 列表后语义不变,可显著降低条件求值成本。
|
||||
singleValues := make([]string, 0, len(lines))
|
||||
singleIndex := make(map[string]struct{}, len(lines))
|
||||
// 多值行需要保留同一行内的配对关系,不能跨行合并。
|
||||
multiConditions := make([]string, 0, len(lines))
|
||||
multiArgs := make([]interface{}, 0, len(lines)*9)
|
||||
multiIndex := make(map[string]struct{}, len(lines))
|
||||
|
||||
for _, line := range lines {
|
||||
switch len(line.Fields) {
|
||||
case 1:
|
||||
value := line.Fields[0]
|
||||
if _, exists := singleIndex[value]; exists {
|
||||
continue
|
||||
}
|
||||
singleIndex[value] = struct{}{}
|
||||
singleValues = append(singleValues, value)
|
||||
case 2, 3:
|
||||
key := strings.Join(line.Fields, "\x00")
|
||||
if _, exists := multiIndex[key]; exists {
|
||||
continue
|
||||
}
|
||||
multiIndex[key] = struct{}{}
|
||||
groups := make([]string, 0, len(line.Fields))
|
||||
for _, value := range line.Fields {
|
||||
groups = append(groups, "("+columns.Iccid+" = ? OR "+
|
||||
columns.Msisdn+" = ? OR "+
|
||||
columns.Imsi+" = ?)")
|
||||
multiArgs = append(multiArgs, value, value, value)
|
||||
}
|
||||
multiConditions = append(multiConditions, "("+strings.Join(groups, " AND ")+")")
|
||||
default:
|
||||
return "", nil, gerror.Newf("第%d行格式错误,每行支持 1 至 3 个号码值", line.RowNo)
|
||||
}
|
||||
}
|
||||
|
||||
parts := make([]string, 0, 2)
|
||||
args := make([]interface{}, 0, len(singleValues)*3+len(multiArgs))
|
||||
if len(singleValues) > 0 {
|
||||
placeholders := "(" + strings.TrimSuffix(strings.Repeat("?,", len(singleValues)), ",") + ")"
|
||||
parts = append(parts, "("+columns.Iccid+" IN "+placeholders+" OR "+
|
||||
columns.Msisdn+" IN "+placeholders+" OR "+
|
||||
columns.Imsi+" IN "+placeholders+")")
|
||||
for repeat := 0; repeat < 3; repeat++ {
|
||||
for _, value := range singleValues {
|
||||
args = append(args, value)
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(multiConditions) > 0 {
|
||||
parts = append(parts, "("+strings.Join(multiConditions, " OR ")+")")
|
||||
args = append(args, multiArgs...)
|
||||
}
|
||||
|
||||
return "(" + strings.Join(parts, " OR ") + ")", args, nil
|
||||
}
|
||||
|
||||
// Export 导出卡信息(异步:提交任务后到下载中心取文件)
|
||||
// 导出的列由前端按当前列表显示列传入,未传时按列表模型字段顺序全量导出。
|
||||
func (s *sIotTSimCardInfo) Export(ctx context.Context, in *model.TSimCardInfoListParam) (err error) {
|
||||
list, totalCount, err := s.List(ctx, in)
|
||||
if err != nil {
|
||||
return
|
||||
if in.BatchInput != nil {
|
||||
if _, _, err = simCardBatchCondition(ctx, *in.BatchInput); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// 字段的排序是依据tags的字段顺序,如果你不想使用默认的排序方式,可以直接定义 tags = []string{"字段名称", "字段名称2", ...}
|
||||
tags, err := convert.GetEntityDescTags(model.TSimCardInfoExportModel{})
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
var (
|
||||
fileName = "导出卡信息表-" + gctx.CtxId(ctx)
|
||||
sheetName = fmt.Sprintf("索引条件共%v行,共%v页,当前导出是第%v页,本页共%v行", totalCount, (totalCount+in.PageSize-1)/in.PageSize, in.Page, len(list))
|
||||
exports []*model.TSimCardInfoExportModel
|
||||
)
|
||||
|
||||
if err = gconv.Scan(list, &exports); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
err = excel.ExportByStructs(ctx, tags, exports, fileName, sheetName)
|
||||
return
|
||||
_, err = exportcenter.Submit(ctx, &exportcenter.Request{
|
||||
BizKey: "tSimCardInfo",
|
||||
BizName: "卡信息",
|
||||
PermCode: "cpm:iot:tSimCardInfo:export",
|
||||
QueryParam: in,
|
||||
Columns: s.exportColumns(in),
|
||||
Tags: func() ([]string, error) {
|
||||
return convert.GetEntityDescTags(model.TSimCardInfoListModel{})
|
||||
},
|
||||
Fetch: func(ctx context.Context, page, pageSize int) (rows []interface{}, total int, err error) {
|
||||
query := *in
|
||||
query.Page = page
|
||||
query.PageSize = pageSize
|
||||
list, count, err := s.List(ctx, &query)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
rows = make([]interface{}, 0, len(list))
|
||||
for _, item := range list {
|
||||
rows = append(rows, item)
|
||||
}
|
||||
return rows, count, nil
|
||||
},
|
||||
})
|
||||
return err
|
||||
}
|
||||
|
||||
// Edit 修改/新增卡信息表
|
||||
@@ -428,6 +537,7 @@ func (s *sIotTSimCardInfo) View(ctx context.Context, in *model.TSimCardInfoViewP
|
||||
return
|
||||
}
|
||||
if res != nil {
|
||||
res.ChanName = channelDisplayName(canViewChannelFullName(ctx), res.ChanName, res.ChanAlias)
|
||||
if resolver, resolverErr := newCardDeptDisplayResolver(ctx); resolverErr != nil {
|
||||
logCardDeptDisplayResolverError(ctx, resolverErr)
|
||||
} else {
|
||||
@@ -940,6 +1050,7 @@ func (s *sIotTSimCardInfo) GetCardsByPackageCodes(ctx context.Context, req *mode
|
||||
err = gerror.Wrap(err, "获取卡列表失败,请稍后重试!")
|
||||
return
|
||||
}
|
||||
applyCardChannelDisplayNames(ctx, list)
|
||||
if resolver, resolverErr := newCardDeptDisplayResolver(ctx); resolverErr != nil {
|
||||
logCardDeptDisplayResolverError(ctx, resolverErr)
|
||||
} else {
|
||||
|
||||
@@ -1,12 +1,127 @@
|
||||
package iot
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/model"
|
||||
)
|
||||
|
||||
func TestSimCardBatchCondition(t *testing.T) {
|
||||
condition, args, err := simCardBatchCondition(context.Background(), "\n iccid-1 \n\t\niccid-2 13800000000 460001234567890\n")
|
||||
if err != nil {
|
||||
t.Fatalf("parse batch input: %v", err)
|
||||
}
|
||||
if !strings.HasPrefix(condition, "((") || !strings.HasSuffix(condition, "))") {
|
||||
t.Fatalf("batch conditions must be grouped: %s", condition)
|
||||
}
|
||||
if !strings.Contains(condition, "iccid IN (?) OR msisdn IN (?) OR imsi IN (?)") {
|
||||
t.Fatalf("single values must be folded into IN lists: %s", condition)
|
||||
}
|
||||
if strings.Count(condition, "(iccid = ? OR msisdn = ? OR imsi = ?)") != 3 {
|
||||
t.Fatalf("three-value row must require every value on the same card: %s", condition)
|
||||
}
|
||||
if strings.Contains(condition, " = ? AND msisdn = ? AND imsi = ?") {
|
||||
t.Fatalf("field positions must not be assumed: %s", condition)
|
||||
}
|
||||
wantArgs := []interface{}{
|
||||
"iccid-1", "iccid-1", "iccid-1",
|
||||
"iccid-2", "iccid-2", "iccid-2",
|
||||
"13800000000", "13800000000", "13800000000",
|
||||
"460001234567890", "460001234567890", "460001234567890",
|
||||
}
|
||||
if len(args) != len(wantArgs) {
|
||||
t.Fatalf("got %d args, want %d: %#v", len(args), len(wantArgs), args)
|
||||
}
|
||||
for i := range wantArgs {
|
||||
if args[i] != wantArgs[i] {
|
||||
t.Fatalf("arg %d = %#v, want %#v", i, args[i], wantArgs[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSimCardBatchConditionAcceptsAnyTwoValuesPerRow(t *testing.T) {
|
||||
condition, args, err := simCardBatchCondition(context.Background(),
|
||||
"89860000000000000001 13800000000\n13800000001 460001234567891\n")
|
||||
if err != nil {
|
||||
t.Fatalf("two values per row must be accepted: %v", err)
|
||||
}
|
||||
if strings.Count(condition, "(iccid = ? OR msisdn = ? OR imsi = ?)") != 4 {
|
||||
t.Fatalf("each value must match any field, two rows expected four groups: %s", condition)
|
||||
}
|
||||
if len(args) != 12 {
|
||||
t.Fatalf("got %d args, want 12: %#v", len(args), args)
|
||||
}
|
||||
if !strings.Contains(condition, " AND ") {
|
||||
t.Fatalf("values of one row must be paired on the same card: %s", condition)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSimCardBatchConditionRejectsEmptyAndInvalidInput(t *testing.T) {
|
||||
for _, input := range []string{"", " \n\t ", "one two three four", "one two three four five"} {
|
||||
if _, _, err := simCardBatchCondition(context.Background(), input); err == nil {
|
||||
t.Errorf("expected input %q to be rejected", input)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSimCardBatchConditionLimitsNonEmptyRowsBeforeDeduplication(t *testing.T) {
|
||||
input := strings.Repeat("same-number\n", simCardBatchMaxRows)
|
||||
condition, args, err := simCardBatchCondition(context.Background(), input)
|
||||
if err != nil {
|
||||
t.Fatalf("accept exactly %d rows: %v", simCardBatchMaxRows, err)
|
||||
}
|
||||
if len(args) != 3 {
|
||||
t.Fatalf("duplicate single values must collapse to one IN list, got %d args: %s", len(args), condition)
|
||||
}
|
||||
|
||||
input += "one-more\n"
|
||||
if _, _, err := simCardBatchCondition(context.Background(), input); err == nil {
|
||||
t.Fatal("expected 1001 rows to be rejected even when values repeat")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSimCardBatchInputPresenceAndExportValidation(t *testing.T) {
|
||||
for _, payload := range []string{`{"batchInput":""}`, `{"batchInput":" \n\t"}`} {
|
||||
var in model.TSimCardInfoListParam
|
||||
if err := json.Unmarshal([]byte(payload), &in); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if in.BatchInput == nil {
|
||||
t.Fatal("显式空批量输入不能视为省略字段")
|
||||
}
|
||||
if _, _, err := NewIotTSimCardInfo().List(context.Background(), &in); err == nil {
|
||||
t.Fatal("列表应在访问数据库前拒绝空批量输入")
|
||||
}
|
||||
if err := NewIotTSimCardInfo().Export(context.Background(), &in); err == nil {
|
||||
t.Fatal("导出应在提交任务前拒绝空批量输入")
|
||||
}
|
||||
}
|
||||
var omitted model.TSimCardInfoListParam
|
||||
if err := json.Unmarshal([]byte(`{}`), &omitted); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if omitted.BatchInput != nil {
|
||||
t.Fatal("省略批量字段应保持原列表查询语义")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSimCardBatchExportKeepsListReuse(t *testing.T) {
|
||||
data, err := os.ReadFile("t_sim_card_info.go")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
source := string(data)
|
||||
for _, fragment := range []string{"QueryParam: in,", "query := *in", "s.List(ctx, &query)", "mod := s.Model(ctx)", "mod = mod.Where(batchCondition, batchArgs...)"} {
|
||||
if !strings.Contains(source, fragment) {
|
||||
t.Errorf("批量查询必须保留现有权限及导出复用: %s", fragment)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCardChannelAssocExistsSQLUsesChannelKeyOnly(t *testing.T) {
|
||||
query := cardChannelAssocExistsSQL("")
|
||||
|
||||
|
||||
@@ -1,30 +1,27 @@
|
||||
// Package gen
|
||||
// @Link https://github.com/xiujiecn/xiu-admin
|
||||
// @Copyright Copyright (c) 2026 LiXiujie
|
||||
// @Author Lxj <li@xiujie.cn>
|
||||
// @Author Lxj <xxx@example.com>
|
||||
// @License https://github.com/xiujiecn/xiu-admin/blob/master/LICENSE
|
||||
// @AutoGenerate Version
|
||||
package iot
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/dao"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/contexts"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/exportcenter"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/xgorm"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/xgorm/handler"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/xgorm/hook"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/model"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/service"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/utility/convert"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/utility/excel"
|
||||
|
||||
"github.com/gogf/gf/v2/database/gdb"
|
||||
"github.com/gogf/gf/v2/errors/gerror"
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
"github.com/gogf/gf/v2/os/gctx"
|
||||
"github.com/gogf/gf/v2/os/gtime"
|
||||
"github.com/gogf/gf/v2/util/gconv"
|
||||
)
|
||||
|
||||
type sIotTSimCardStock struct{}
|
||||
@@ -130,31 +127,32 @@ func (s *sIotTSimCardStock) List(ctx context.Context, in *model.TSimCardStockLis
|
||||
return
|
||||
}
|
||||
|
||||
// Export 导出库存卡信息表
|
||||
// Export 导出库存卡(异步:提交任务后到下载中心取文件)
|
||||
func (s *sIotTSimCardStock) Export(ctx context.Context, in *model.TSimCardStockListParam) (err error) {
|
||||
list, totalCount, err := s.List(ctx, in)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
// 字段的排序是依据tags的字段顺序,如果你不想使用默认的排序方式,可以直接定义 tags = []string{"字段名称", "字段名称2", ...}
|
||||
tags, err := convert.GetEntityDescTags(model.TSimCardStockExportModel{})
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
var (
|
||||
fileName = "导出库存卡信息表-" + gctx.CtxId(ctx)
|
||||
sheetName = fmt.Sprintf("索引条件共%v行,共%v页,当前导出是第%v页,本页共%v行", totalCount, (totalCount+in.PageSize-1)/in.PageSize, in.Page, len(list))
|
||||
exports []model.TSimCardStockExportModel
|
||||
)
|
||||
|
||||
if err = gconv.Scan(list, &exports); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
err = excel.ExportByStructs(ctx, tags, exports, fileName, sheetName)
|
||||
return
|
||||
_, err = exportcenter.Submit(ctx, &exportcenter.Request{
|
||||
BizKey: "tSimCardStock",
|
||||
BizName: "库存卡",
|
||||
PermCode: "cpm:iot:tSimCardStock:export",
|
||||
QueryParam: in,
|
||||
Tags: func() ([]string, error) {
|
||||
return convert.GetEntityDescTags(model.TSimCardStockListModel{})
|
||||
},
|
||||
Fetch: func(ctx context.Context, page, pageSize int) (rows []interface{}, total int, err error) {
|
||||
query := *in
|
||||
query.Page = page
|
||||
query.PageSize = pageSize
|
||||
list, count, err := s.List(ctx, &query)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
rows = make([]interface{}, 0, len(list))
|
||||
for _, item := range list {
|
||||
rows = append(rows, item)
|
||||
}
|
||||
return rows, count, nil
|
||||
},
|
||||
})
|
||||
return err
|
||||
}
|
||||
|
||||
// Edit 修改/新增库存卡信息表
|
||||
|
||||
@@ -2,26 +2,23 @@ package iot
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/consts"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/dao"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/contexts"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/exportcenter"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/logic/iot/simcardbatch/trafficcutoffconfig"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/model"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/model/do"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/service"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/utility/convert"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/utility/excel"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/utility/iot_excelize"
|
||||
|
||||
"github.com/gogf/gf/v2/database/gdb"
|
||||
"github.com/gogf/gf/v2/errors/gerror"
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
"github.com/gogf/gf/v2/net/ghttp"
|
||||
"github.com/gogf/gf/v2/os/gctx"
|
||||
"github.com/gogf/gf/v2/util/gconv"
|
||||
"github.com/xuri/excelize/v2"
|
||||
)
|
||||
|
||||
@@ -103,25 +100,32 @@ func (s *sIotTSimCardTrafficCutoff) List(ctx context.Context, in *model.TSimCard
|
||||
return service.IotTSimCardInfo().List(ctx, &cardParam)
|
||||
}
|
||||
|
||||
func (s *sIotTSimCardTrafficCutoff) Export(ctx context.Context, in *model.TSimCardTrafficCutoffListParam) error {
|
||||
if in == nil {
|
||||
in = &model.TSimCardTrafficCutoffListParam{}
|
||||
}
|
||||
list, totalCount, err := s.List(ctx, in)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
tags, err := convert.GetEntityDescTags(model.TSimCardTrafficCutoffExportModel{})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var exports []*model.TSimCardTrafficCutoffExportModel
|
||||
if err = gconv.Scan(list, &exports); err != nil {
|
||||
return err
|
||||
}
|
||||
fileName := "traffic-cutoff-card-info-" + gctx.CtxId(ctx)
|
||||
sheetName := fmt.Sprintf("traffic cutoff cards total %d, exported %d", totalCount, len(exports))
|
||||
return excel.ExportByStructs(ctx, tags, exports, fileName, sheetName)
|
||||
// Export 导出达量断网(异步:提交任务后到下载中心取文件)
|
||||
func (s *sIotTSimCardTrafficCutoff) Export(ctx context.Context, in *model.TSimCardTrafficCutoffListParam) (err error) {
|
||||
_, err = exportcenter.Submit(ctx, &exportcenter.Request{
|
||||
BizKey: "tSimCardTrafficCutoff",
|
||||
BizName: "达量断网",
|
||||
PermCode: "cpm:iot:tSimCardTrafficCutoff:export",
|
||||
QueryParam: in,
|
||||
Tags: func() ([]string, error) {
|
||||
return convert.GetEntityDescTags(model.TSimCardInfoListModel{})
|
||||
},
|
||||
Fetch: func(ctx context.Context, page, pageSize int) (rows []interface{}, total int, err error) {
|
||||
query := *in
|
||||
query.Page = page
|
||||
query.PageSize = pageSize
|
||||
list, count, err := s.List(ctx, &query)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
rows = make([]interface{}, 0, len(list))
|
||||
for _, item := range list {
|
||||
rows = append(rows, item)
|
||||
}
|
||||
return rows, count, nil
|
||||
},
|
||||
})
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *sIotTSimCardTrafficCutoff) Edit(ctx context.Context, in *model.TSimCardTrafficCutoffEditParam) error {
|
||||
|
||||
@@ -1,33 +1,30 @@
|
||||
// Package gen
|
||||
// @Link https://github.com/xiujiecn/xiu-admin
|
||||
// @Copyright Copyright (c) 2026 LiXiujie
|
||||
// @Author Lxj <li@xiujie.cn>
|
||||
// @Author Lxj <xxx@example.com>
|
||||
// @License https://github.com/xiujiecn/xiu-admin/blob/master/LICENSE
|
||||
// @AutoGenerate Version
|
||||
package iot
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/consts"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/dao"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/contexts"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/exportcenter"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/rabbitmq"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/xgorm/handler"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/xgorm/hook"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/model"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/service"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/utility/convert"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/utility/excel"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gogf/gf/v2/database/gdb"
|
||||
"github.com/gogf/gf/v2/errors/gerror"
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
"github.com/gogf/gf/v2/os/gctx"
|
||||
"github.com/gogf/gf/v2/os/gtime"
|
||||
"github.com/gogf/gf/v2/util/gconv"
|
||||
)
|
||||
|
||||
type sIotTSimChannelTemplate struct{}
|
||||
@@ -105,31 +102,32 @@ func (s *sIotTSimChannelTemplate) List(ctx context.Context, in *model.TSimChanne
|
||||
return
|
||||
}
|
||||
|
||||
// Export 导出通道模板
|
||||
// Export 导出通道模版(异步:提交任务后到下载中心取文件)
|
||||
func (s *sIotTSimChannelTemplate) Export(ctx context.Context, in *model.TSimChannelTemplateListParam) (err error) {
|
||||
list, totalCount, err := s.List(ctx, in)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
// 字段的排序是依据tags的字段顺序,如果你不想使用默认的排序方式,可以直接定义 tags = []string{"字段名称", "字段名称2", ...}
|
||||
tags, err := convert.GetEntityDescTags(model.TSimChannelTemplateExportModel{})
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
var (
|
||||
fileName = "导出通道模板-" + gctx.CtxId(ctx)
|
||||
sheetName = fmt.Sprintf("索引条件共%v行,共%v页,当前导出是第%v页,本页共%v行", totalCount, (totalCount+in.PageSize-1)/in.PageSize, in.Page, len(list))
|
||||
exports []model.TSimChannelTemplateExportModel
|
||||
)
|
||||
|
||||
if err = gconv.Scan(list, &exports); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
err = excel.ExportByStructs(ctx, tags, exports, fileName, sheetName)
|
||||
return
|
||||
_, err = exportcenter.Submit(ctx, &exportcenter.Request{
|
||||
BizKey: "tSimChannelTemplate",
|
||||
BizName: "通道模版",
|
||||
PermCode: "cpm:iot:tSimChannelTemplate:export",
|
||||
QueryParam: in,
|
||||
Tags: func() ([]string, error) {
|
||||
return convert.GetEntityDescTags(model.TSimChannelTemplateListModel{})
|
||||
},
|
||||
Fetch: func(ctx context.Context, page, pageSize int) (rows []interface{}, total int, err error) {
|
||||
query := *in
|
||||
query.Page = page
|
||||
query.PageSize = pageSize
|
||||
list, count, err := s.List(ctx, &query)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
rows = make([]interface{}, 0, len(list))
|
||||
for _, item := range list {
|
||||
rows = append(rows, item)
|
||||
}
|
||||
return rows, count, nil
|
||||
},
|
||||
})
|
||||
return err
|
||||
}
|
||||
|
||||
// Edit 修改/新增通道模板
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// Package gen
|
||||
// @Link https://github.com/xiujiecn/xiu-admin
|
||||
// @Copyright Copyright (c) 2026 LiXiujie
|
||||
// @Author Lxj <li@xiujie.cn>
|
||||
// @Author Lxj <xxx@example.com>
|
||||
// @License https://github.com/xiujiecn/xiu-admin/blob/master/LICENSE
|
||||
// @AutoGenerate Version
|
||||
package iot
|
||||
@@ -9,10 +9,11 @@ package iot
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"fmt"
|
||||
"io"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/consts"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/dao"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/contexts"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/exportcenter"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/rabbitmq"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/xgorm/handler"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/xgorm/hook"
|
||||
@@ -21,15 +22,12 @@ import (
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/model/do"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/service"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/utility/convert"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/utility/excel"
|
||||
"io"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gogf/gf/v2/database/gdb"
|
||||
"github.com/gogf/gf/v2/errors/gerror"
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
"github.com/gogf/gf/v2/os/gctx"
|
||||
"github.com/gogf/gf/v2/os/gtime"
|
||||
"github.com/gogf/gf/v2/util/gconv"
|
||||
)
|
||||
@@ -90,7 +88,7 @@ func (s *sIotTSimChannels) List(ctx context.Context, in *model.TSimChannelsListP
|
||||
}
|
||||
// 查询通道名称
|
||||
if in.ChanName != "" {
|
||||
mod = mod.Where(dao.TSimChannels.Columns().ChanName+" LIKE ?", "%"+in.ChanName+"%")
|
||||
mod = mod.Where(channelDisplayNameSQL(ctx, simChannels)+" LIKE ?", "%"+in.ChanName+"%")
|
||||
}
|
||||
// 查询通道别名
|
||||
if in.ChanAlias != "" {
|
||||
@@ -136,6 +134,12 @@ func (s *sIotTSimChannels) List(ctx context.Context, in *model.TSimChannelsListP
|
||||
err = gerror.Wrap(err, "获取通道列表列表失败,请稍后重试!")
|
||||
return
|
||||
}
|
||||
fullName := canViewChannelFullName(ctx)
|
||||
for _, item := range list {
|
||||
if item != nil {
|
||||
item.ChanName = channelDisplayName(fullName, item.ChanName, item.ChanAlias)
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
@@ -193,31 +197,32 @@ func (s *sIotTSimChannels) RefreshCardCounts(ctx context.Context) error {
|
||||
})
|
||||
}
|
||||
|
||||
// Export 导出通道列表
|
||||
// Export 导出通道管理(异步:提交任务后到下载中心取文件)
|
||||
func (s *sIotTSimChannels) Export(ctx context.Context, in *model.TSimChannelsListParam) (err error) {
|
||||
list, totalCount, err := s.List(ctx, in)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
// 字段的排序是依据tags的字段顺序,如果你不想使用默认的排序方式,可以直接定义 tags = []string{"字段名称", "字段名称2", ...}
|
||||
tags, err := convert.GetEntityDescTags(model.TSimChannelsExportModel{})
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
var (
|
||||
fileName = "导出通道列表-" + gctx.CtxId(ctx)
|
||||
sheetName = fmt.Sprintf("索引条件共%v行,共%v页,当前导出是第%v页,本页共%v行", totalCount, (totalCount+in.PageSize-1)/in.PageSize, in.Page, len(list))
|
||||
exports []model.TSimChannelsExportModel
|
||||
)
|
||||
|
||||
if err = gconv.Scan(list, &exports); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
err = excel.ExportByStructs(ctx, tags, exports, fileName, sheetName)
|
||||
return
|
||||
_, err = exportcenter.Submit(ctx, &exportcenter.Request{
|
||||
BizKey: "tSimChannels",
|
||||
BizName: "通道管理",
|
||||
PermCode: "cpm:iot:tSimChannels:export",
|
||||
QueryParam: in,
|
||||
Tags: func() ([]string, error) {
|
||||
return convert.GetEntityDescTags(model.TSimChannelsListModel{})
|
||||
},
|
||||
Fetch: func(ctx context.Context, page, pageSize int) (rows []interface{}, total int, err error) {
|
||||
query := *in
|
||||
query.Page = page
|
||||
query.PageSize = pageSize
|
||||
list, count, err := s.List(ctx, &query)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
rows = make([]interface{}, 0, len(list))
|
||||
for _, item := range list {
|
||||
rows = append(rows, item)
|
||||
}
|
||||
return rows, count, nil
|
||||
},
|
||||
})
|
||||
return err
|
||||
}
|
||||
|
||||
// shouldGeneratePlatformAppKey reports whether a channel uses the shared-token
|
||||
@@ -569,6 +574,9 @@ func (s *sIotTSimChannels) View(ctx context.Context, in *model.TSimChannelsViewP
|
||||
err = gerror.Wrap(err, "获取通道列表信息,请稍后重试!")
|
||||
return
|
||||
}
|
||||
if res != nil {
|
||||
res.ChanName = channelDisplayName(canViewChannelFullName(ctx), res.ChanName, res.ChanAlias)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
package iot
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestShouldGeneratePlatformAppKey(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
tempKey string
|
||||
tempModel string
|
||||
want bool
|
||||
}{
|
||||
{name: "third party shared token", tempKey: "vendor_alpha", tempModel: "shared_token", want: true},
|
||||
{name: "model comparison is case insensitive", tempKey: "vendor_alpha", tempModel: " SHARED_TOKEN ", want: true},
|
||||
{name: "official mobile keeps upstream key", tempKey: mobileOfficialTemplateKey, tempModel: sharedTokenTemplateModel, want: false},
|
||||
{name: "official mobile key comparison is case insensitive", tempKey: " SIM_YD_V5 ", tempModel: sharedTokenTemplateModel, want: false},
|
||||
{name: "other template model", tempKey: "vendor_alpha", tempModel: "oauth", want: false},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
if got := shouldGeneratePlatformAppKey(test.tempKey, test.tempModel); got != test.want {
|
||||
t.Fatalf("shouldGeneratePlatformAppKey(%q, %q) = %v, want %v", test.tempKey, test.tempModel, got, test.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsMissingAppKey(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
appKey string
|
||||
want bool
|
||||
}{
|
||||
{name: "empty", appKey: "", want: true},
|
||||
{name: "whitespace", appKey: " ", want: true},
|
||||
{name: "legacy slash placeholder", appKey: " / ", want: true},
|
||||
{name: "configured key", appKey: "YZT7F3K9M2Q8R6V4N5C2", want: false},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
if got := isMissingAppKey(test.appKey); got != test.want {
|
||||
t.Fatalf("isMissingAppKey(%q) = %v, want %v", test.appKey, got, test.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGeneratePlatformAppKey(t *testing.T) {
|
||||
seen := make(map[string]struct{}, 100)
|
||||
for index := 0; index < 100; index++ {
|
||||
appKey, err := generatePlatformAppKey()
|
||||
if err != nil {
|
||||
t.Fatalf("generatePlatformAppKey() error = %v", err)
|
||||
}
|
||||
if len(appKey) != len(platformAppKeyPrefix)+platformAppKeyRandomLength {
|
||||
t.Fatalf("generated app key length = %d, want %d", len(appKey), len(platformAppKeyPrefix)+platformAppKeyRandomLength)
|
||||
}
|
||||
if !strings.HasPrefix(appKey, platformAppKeyPrefix) {
|
||||
t.Fatalf("generated app key %q does not have prefix %q", appKey, platformAppKeyPrefix)
|
||||
}
|
||||
for _, character := range appKey[len(platformAppKeyPrefix):] {
|
||||
if !strings.ContainsRune(platformAppKeyAlphabet, character) {
|
||||
t.Fatalf("generated app key %q contains character %q outside alphabet", appKey, character)
|
||||
}
|
||||
}
|
||||
if _, duplicate := seen[appKey]; duplicate {
|
||||
t.Fatalf("generated duplicate app key %q", appKey)
|
||||
}
|
||||
seen[appKey] = struct{}{}
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveSharedTokenChannelKeyChange(t *testing.T) {
|
||||
requested := " new-key "
|
||||
oldKey, newKey, changed, err := resolveChannelKeyChange("old-key", 0, &requested)
|
||||
if err != nil {
|
||||
t.Fatalf("resolveChannelKeyChange() error = %v", err)
|
||||
}
|
||||
if oldKey != "old-key" || newKey != "new-key" || !changed {
|
||||
t.Fatalf("resolveChannelKeyChange() = (%q, %q, %v), want (%q, %q, true)", oldKey, newKey, changed, "old-key", "new-key")
|
||||
}
|
||||
|
||||
if _, _, _, err = resolveChannelKeyChange("old-key", 1, &requested); err == nil {
|
||||
t.Fatal("resolveChannelKeyChange() should reject renaming a shared-token channel")
|
||||
}
|
||||
empty := " "
|
||||
if _, _, _, err = resolveChannelKeyChange("old-key", 0, &empty); err == nil {
|
||||
t.Fatal("resolveChannelKeyChange() should reject an empty channel key")
|
||||
}
|
||||
|
||||
oldKey, newKey, changed, err = resolveChannelKeyChange("old-key", 0, nil)
|
||||
if err != nil || oldKey != "old-key" || newKey != "old-key" || changed {
|
||||
t.Fatalf("resolveChannelKeyChange() without request = (%q, %q, %v, %v), want unchanged", oldKey, newKey, changed, err)
|
||||
}
|
||||
}
|
||||
@@ -2,11 +2,11 @@ package iot
|
||||
|
||||
import (
|
||||
"context"
|
||||
"github.com/gogf/gf/v2/errors/gerror"
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/dao"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/contexts"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/xgorm/handler"
|
||||
"github.com/gogf/gf/v2/errors/gerror"
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
"strings"
|
||||
)
|
||||
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
package iot
|
||||
|
||||
import (
|
||||
"context"
|
||||
_ "github.com/gogf/gf/contrib/drivers/mysql/v2"
|
||||
_ "github.com/gogf/gf/contrib/drivers/sqlite/v2"
|
||||
"github.com/gogf/gf/v2/database/gdb"
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
"github.com/gogf/gf/v2/os/gcfg"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/consts"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/contexts"
|
||||
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/model"
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestLegacyDiagnoseFallback(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
previous := gdb.GetConfig("default")
|
||||
previousLegacy := gdb.GetConfig("legacyCard")
|
||||
adapter := g.Cfg().GetAdapter()
|
||||
cfg, err := gcfg.NewAdapterContent(`{"database":{"legacyCard":{"link":"sqlite::@file(:memory:)"}}}`)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
g.Cfg().SetAdapter(cfg)
|
||||
for _, group := range []string{"default", "legacyCard"} {
|
||||
if err := gdb.SetConfigGroup(group, gdb.ConfigGroup{{Type: "sqlite", Link: "sqlite::@file(:memory:)", MaxOpenConnCount: 1}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
_ = g.DB().Close(ctx)
|
||||
_ = g.DB("legacyCard").Close(ctx)
|
||||
_ = gdb.SetConfigGroup("default", previous)
|
||||
_ = gdb.SetConfigGroup("legacyCard", previousLegacy)
|
||||
g.Cfg().SetAdapter(adapter)
|
||||
})
|
||||
for _, sql := range []string{
|
||||
"CREATE TABLE yz_card_info(iccid TEXT, imsi TEXT, msisdn TEXT, channel_id TEXT)",
|
||||
"CREATE TABLE yz_card_route(cd_id TEXT)",
|
||||
"INSERT INTO yz_card_route VALUES ('77')",
|
||||
"INSERT INTO yz_card_info VALUES ('898607B91923C0327236','imsi','phone','77')",
|
||||
} {
|
||||
if _, err := g.DB("legacyCard").Exec(ctx, sql); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
for _, sql := range []string{
|
||||
"CREATE TABLE t_sim_card_info(iccid TEXT, imsi TEXT, msisdn TEXT, tenant_id TEXT)",
|
||||
"CREATE TABLE t_sim_channels(chan_key TEXT,temp_key TEXT,chan_status INTEGER,tenant_id TEXT,created_by INTEGER,created_dept INTEGER)",
|
||||
"INSERT INTO t_sim_channels VALUES ('77','sim_yunda_v1',0,'A',7,10)",
|
||||
} {
|
||||
if _, err := g.DB().Exec(ctx, sql); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
principal := &model.Identity{BaseClaims: model.BaseClaims{ID: 7, TenantId: "A", DeptId: 10, Roles: []model.Role{{RoleId: 9, DataScope: consts.SysRoleDataScopePersonal}}}}
|
||||
scopeCtx := contexts.Set(ctx, &model.Context{User: principal})
|
||||
for _, code := range []string{"898607B91923C0327236", "imsi", "phone"} {
|
||||
card, err := resolveLegacyDiagnoseCardInfo(scopeCtx, code)
|
||||
if err != nil || card == nil || card.ChanKey != "77" || card.HasLocalCard {
|
||||
t.Fatalf("card=%+v err=%v", card, err)
|
||||
}
|
||||
}
|
||||
if _, err := resolveLegacyDiagnoseCardInfo(ctx, "phone"); err == nil {
|
||||
t.Fatal("guest allowed")
|
||||
}
|
||||
principal.TenantId = "B"
|
||||
if _, err := resolveLegacyDiagnoseCardInfo(scopeCtx, "phone"); err == nil {
|
||||
t.Fatal("cross tenant channel allowed")
|
||||
}
|
||||
principal.TenantId = "A"
|
||||
principal.ID = 8
|
||||
if _, err := resolveLegacyDiagnoseCardInfo(scopeCtx, "phone"); err == nil {
|
||||
t.Fatal("foreign channel allowed")
|
||||
}
|
||||
principal.ID = 7
|
||||
if _, err := g.DB().Exec(ctx, "UPDATE t_sim_channels SET chan_status=1"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := resolveLegacyDiagnoseCardInfo(scopeCtx, "phone"); err == nil {
|
||||
t.Fatal("disabled channel allowed")
|
||||
}
|
||||
if _, err := g.DB().Exec(ctx, "INSERT INTO t_sim_card_info VALUES ('898607B91923C0327236','imsi','phone','A')"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := resolveLegacyDiagnoseCardInfo(scopeCtx, "phone"); err == nil {
|
||||
t.Fatal("local visibility bypassed")
|
||||
}
|
||||
if _, err := g.DB("legacyCard").Exec(ctx, "INSERT INTO yz_card_info VALUES ('duplicate','','phone','77')"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := queryLegacyDiagnoseCard(ctx, "phone"); err == nil {
|
||||
t.Fatal("ambiguous card accepted")
|
||||
}
|
||||
missing, err := queryLegacyDiagnoseCard(ctx, "missing")
|
||||
if err != nil || missing != nil {
|
||||
t.Fatalf("missing=%+v err=%v", missing, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLegacyDiagnoseReadOnlyIntegration(t *testing.T) {
|
||||
configPath := os.Getenv("IOTLCM_LEGACY_DIAGNOSIS_CONFIG")
|
||||
if configPath == "" {
|
||||
t.Skip("未启用真实旧库只读测试")
|
||||
}
|
||||
content, err := os.ReadFile(configPath)
|
||||
if err != nil {
|
||||
t.Fatal("无法读取测试环境配置")
|
||||
}
|
||||
adapter, err := gcfg.NewAdapterContent(string(content))
|
||||
if err != nil {
|
||||
t.Fatal("环境配置格式无效")
|
||||
}
|
||||
previous := g.Cfg().GetAdapter()
|
||||
g.Cfg().SetAdapter(adapter)
|
||||
t.Cleanup(func() { g.Cfg().SetAdapter(previous) })
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
|
||||
defer cancel()
|
||||
card, err := queryLegacyDiagnoseCard(ctx, "898607B91923C0327236")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if card == nil {
|
||||
t.Fatal("真实旧库未查到卡及路由映射")
|
||||
}
|
||||
t.Logf("旧库实际命中 ICCID=%s channelKey=%s IMSI存在=%t MSISDN存在=%t", card.ICCID, card.ChannelKey, card.IMSI != "", card.MSISDN != "")
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user