diff --git a/.gitignore b/.gitignore
index 353d5c5..0c046da 100644
--- a/.gitignore
+++ b/.gitignore
@@ -90,27 +90,43 @@ dump.rdb
*.exe
*.exe~
*.yaml
+!server/config/config.example.yaml
+!server/config/config_default.yaml
+!server/build/config.example.yaml
+!server/hack/config.yaml
test/*
*test.*
+!server/internal/model/t_sim_card_allocate_input_test.go
+!server/internal/logic/common/batch_card_status_test.go
!server/internal/logic/system/sys_menu_document_center_test.go
!server/internal/logic/trafficusage/history_sync_test.go
+!server/internal/library/tdengine/config_test.go
+!server/internal/logic/trafficusage/routing_test.go
!server/internal/logic/iot/t_sim_card_info_test.go
!server/internal/logic/iot/t_sim_card_renew_price_test.go
!server/internal/logic/trafficcutoff/types_test.go
!server/utility/ip_lookup_test.go
!server/utility/external_request_test.go
!server/internal/model/sync_scope_test.go
+!server/internal/logic/simwarning/monthly_expiry_test.go
+!server/internal/tasks/monthly_expiry_test.go
+!server/internal/router/message_log_unread_test.go
!server/internal/controller/iot/full_sync_monitor_channels_test.go
!server/internal/logic/system/sys_qrcode_timeout_test.go
+!server/internal/logic/system/sys_dept_test.go
+!server/internal/logic/system/sys_role_template_test.go
+!server/internal/logic/agent/agent_template_source_test.go
+!server/internal/library/rolepolicy/assignable_test.go
+!server/internal/logic/common/batch_main_channel_test.go
+!server/internal/logic/common/work_order_record_test.go
+server/.local-run/
!server/internal/logic/memorydb/memorydb_test.go
+!server/internal/library/exportcenter/columns_test.go
server/*.tar
*.tar
vue
+
+# 运行时导出产物(异步导出中心生成的 xlsx,属运行产物,勿入库)
+server/resource/upload/*/user/file/*/export/
!server/internal/logic/agent/agent_dept_passrole_test.go
!server/internal/logic/agent/agent_role_save_test.go
-
-# 开源版:配置文件以占位值入库(不含真实凭据),保证 clone 后可直接 go test
-!server/config/config.yaml
-!server/config/config_default.yaml
-!server/build/build_config.yaml
-!server/hack/config.yaml
diff --git a/README.md b/README.md
index a753068..e328864 100644
--- a/README.md
+++ b/README.md
@@ -37,7 +37,7 @@ http://127.0.0.1:8199/swagger # SwaggerUI
## 配置
-配置文件在 `server/config/config.yaml`(运行时)与 `server/config/config_default.yaml`(默认值)。**公开版里的凭据与地址全部是占位符**:
+将 `server/config/config.example.yaml` 复制为运行时的 `server/config/config.yaml`;`server/config/config_default.yaml` 保存默认值。**公开版里的凭据与地址使用环境变量占位符**:
| 配置项 | 占位 | 说明 |
| --- | --- | --- |
diff --git a/docs/agent-role-template.md b/docs/agent-role-template.md
index a08abed..936dfa7 100644
--- a/docs/agent-role-template.md
+++ b/docs/agent-role-template.md
@@ -1,15 +1,38 @@
-# 代理角色模板
+# 代理角色模板与机构权限上限
-使用现有 sys_config、sys_role、sys_role_menu、sys_dept、sys_user_role 表,不新增字段、索引或任务表。
+## 业务规则
-平台参数:tenant_id=000000,config_key=agent.role.template.role_id,config_value 为本平台企业的模板角色 ID。开发环境使用 82。模板在企业管理的角色管理维护,正常启用、is_pass=1、数据范围为本机构及下级(4),按钮不允许全部或自定义数据范围。每个企业只能有一个下发模板。
+使用现有 sys_config、sys_role、sys_role_menu、sys_dept、sys_user_role 表,不新增表、字段或前端“角色用途”。
-模板修改提交后直接同步直属企业,按 sys_dept.parent_id 定位直属客户、dept_role 定位该企业实际默认角色。通过 created_dept 和上下级关系识别下发角色,不依赖 source_role_id 或 source_dept_id。平台模板切换限定同一企业,旧任务读取当前配置。下级企业维护自己的客户模板,不直接继承平台对孙级的新增授权。
+平台在默认租户的系统参数 agent.role.template.role_id 中指定模板角色 ID。模板身份由参数决定,不再依赖隐藏的 is_pass=1。角色必须启用、属于有效机构、不是上级继承角色,角色数据范围为本机构及下级(4),按钮不能配置全部或自定义数据范围。
-同步保留角色 ID 和账号绑定,每家企业独立事务。单家失败不阻塞其他企业,接口报告同步失败,日志记录失败企业。保存成功和同步成功分开处理;失败时模板可能已保存,修复角色关联后重新保存可幂等重试。不会凭空修复账号绑定。
+## 创建代理与维护普通角色
-接口鉴权读取当前权限并与上级企业和平台模板取交集,不依赖长期用户权限缓存。没有有效角色或模板参数无效时拒绝授权。业务角色授权记录不会主动删除,模板恢复权限后仍保留授权的角色可能重新获得权限。前端按钮更新需刷新,后端执行当前鉴权。
+- 平台创建一级代理时,从参数模板复制权限,为新机构创建独立默认角色和管理员绑定,不共用平台角色 ID。
+- 各级代理创建下级时,从本机构有效默认角色复制权限,与上级链和平台模板取交集,不要求本机构再创建一个下发模板。
+- 各级机构可新增多个普通角色。保存不再强制 is_pass=1,也不强制覆盖普通角色数据范围。
+- 普通角色授权必须同时在本机构有效权限上限和当前操作账号有效角色的菜单范围内。停用角色不能提供授权依据。
+- 参数模板、机构默认继承角色、普通角色分别承担平台权限来源、机构权限上限、人员实际授权,不混用。
+- 未配置平台参数时,保留原有本级唯一 is_pass=1 角色的下发流程;配置平台参数后,历史本地下发标记不再触发权限覆盖。
-采用提交后直接同步,不保证模板保存和全体客户同步原子完成。进程在提交后、同步完成前退出时,需要重新保存触发补偿;没有数据库持久化任务,不承诺自动恢复丢失任务。原有角色同步队列保留兼容重试和旧消息处理,不新增队列配置或定时任务。
+## 多级异步同步
-正式上线前验证三级企业、参数切换、权限收回、旧登录账号、单家无效角色、重复同步和真实并发请求。同步随直属企业数量增加会延长请求耗时,需结合实际规模验证 API 超时与数据库负载。
+后端保存成功提交后,通过 RabbitMQ 发布任务,由独立 Worker 执行。平台模板变更按父先子后的顺序遍历各级企业,仅更新 sys_dept.dept_role 指定、归属本机构且来源为直属上级的有效继承角色。角色 ID、人员绑定和下级自建角色勾选保持不变。
+
+新增权限扩展各级继承默认角色的权限范围;普通角色需下级自己勾选。绑定默认继承角色的管理员随默认角色取得新增权限,需要与普通角色区分。
+
+撤销权限时,运行时逐级交集立即限制有效权限,不等待 Worker;Worker 随后更新继承角色记录。普通角色的历史勾选不主动删除,平台未来重新开放权限后,仍保留勾选的普通角色可能恢复该权限。这是现有授权记录保留语义,不表示“新权限自动勾选”。
+
+每机构独立事务。父机构失败时,该分支下级计入失败而不越过父级放权;其他分支继续。重试只处理失败机构及被阻断的下级,失去当前模板从属关系的重试目标明确失败。旧模板任务和历史本地下发任务不能覆盖当前参数模板的继承链。
+
+## 安全边界及历史数据
+
+平台有效超级管理员的登录权限加载不依赖代理模板是否有效,普通账号仍严格校验模板与机构权限。不能只凭旧令牌中的管理员标记绕过检查。
+
+默认角色缺失、停用、归属错误明确返回可处理错误,不再静默变成空菜单 404。角色列表查询不会自动建角色、绑定当前访问者或为历史账号扩权。历史 dept_role=0 数据需要平台管理员核实后修复,代码不会猜测哪个角色应是管理员。
+
+## 可靠性限制
+
+本分支不使用数据库任务表。保存与 MQ 发布不是原子操作;入队失败会明确提示“角色已保存”,需修复消息队列后重新保存补偿。进程若在数据库提交后、发布前退出,也需重新保存。消费者有限重试后进入死信队列,不承诺永不丢任务或 exactly-once。
+
+后台与 Worker 必须成套更新。仅重启后台不会加载新的 Worker 多级同步逻辑。
diff --git a/docs/普通角色与模板校验边界.md b/docs/普通角色与模板校验边界.md
new file mode 100644
index 0000000..c18bdca
--- /dev/null
+++ b/docs/普通角色与模板校验边界.md
@@ -0,0 +1,12 @@
+# 普通角色维护与代理模板校验边界
+
+- 平台模板由系统参数 agent.role.template.role_id 指定,与普通角色的隐藏 is_pass 标记分离。
+- 普通角色可以创建多个,保存不再强制变成下发角色。配置模板后,历史下发标记不限制普通角色数量、不触发继承链覆盖。
+- 普通角色的编辑、菜单、删除和数据权限维护只识别目标是否为配置模板;无关模板失效不应锁死平台超级管理员。
+- 模板停用、归属异常或记录缺失时仍保留身份及删除保护;仅平台超级管理员可修改模板,模板按钮不能授予全部或自定义数据范围。
+- 非超级管理员授予菜单时,同时核对机构及平台上限与操作员当前有效角色;禁止用已停用角色、旧管理员声明扩大授权。
+- 有效平台超级管理员的登录权限加载先核对数据库角色,跳过无关模板有效性校验;普通账号继续严格校验。
+- 缺失默认角色返回明确错误,不隐式创建角色或把访问角色列表的用户绑定成管理员。历史数据修复需核实后显式操作。
+- 多级同步仅更新默认继承角色,普通角色不自动勾选;完整流程与可靠性限制见 agent-role-template.md 和角色权限异步同步.md。
+
+验证使用 SQLite 隔离数据库,密码修改测试固定使用独立内存库和合成用户,不使用实际配置的数据库。未执行真实账号登录及真实消息队列端到端验证时,不以接口公开健康检查代替登录或同步成功。
diff --git a/docs/角色权限异步同步.md b/docs/角色权限异步同步.md
new file mode 100644
index 0000000..30bd499
--- /dev/null
+++ b/docs/角色权限异步同步.md
@@ -0,0 +1,30 @@
+# 角色权限异步同步
+
+当前 v1.0.4 实现采用现有 RabbitMQ 队列 iotlcm.agent.role.sync,不新增任务表或权限版本表。业务规则以《代理角色模板与机构权限上限》为准。
+
+## 保存与消费
+
+1. 后台在数据库事务中保存角色及菜单,事务内禁止发布消息。
+2. 提交后识别系统参数指定模板;模板不要求 is_pass=1。配置模板时,非模板角色不发布覆盖下级权限的任务。
+3. 消息含任务标识、来源角色、来源机构、租户、操作者及模板标记,不携带菜单旧快照。
+4. 后台使用发布确认;发布失败返回明确的“角色已保存、下发失败”错误,重新保存可补偿。
+ 提交后的入队使用保留身份上下文但不继承请求取消的独立超时,最长十五秒,发布确认仍最多等待十秒;错误附带任务 ID,便于排查。此处理不等同于事务发件箱,进程退出仍可能造成提交与发布之间的消息丢失。
+5. 独立 Worker 消费时读取当前模板、菜单和机构关系。配置已切换的模板任务,以及配置模板后的历史本地下发任务标记为 superseded,不按普通角色继续传播。
+6. 平台模板按父先子后同步整棵企业下级树;每家企业独立事务,锁定目标机构、默认角色、模板参数及模板角色并再次校验。
+7. 只更新有效的默认继承角色;不改用户角色绑定,不替下级普通角色增加勾选,不自动补齐缺失默认角色。
+
+## 失败与重试
+
+单家失败不阻塞其他分支;失败父机构的下级也计入失败,不能绕过父级。消息保存失败机构 ID 和统计,重试只处理失败目标。目标离开模板机构树后不能被静默算作成功。
+
+消费者失败后延迟重试,最多重试五次,之后发布死信。重试或死信发布失败不能确认原消息。无效 JSON 和无效来源消息隔离到死信队列,隔离发布失败则原消息不能确认。
+
+幂等同步保留角色 ID,重复消息读取当前配置,不恢复旧快照。但没有数据库任务表、事务发件箱或分布式租约,不能保证提交与发布原子性,也不保证任务持久化查询及人工重试页面。需要通过日志、消息队列与重新保存进行排查补偿。
+
+## 本地验证
+
+后台回归使用 SQLite 隔离数据库,覆盖模板异常与管理员登录、普通角色保存、操作员授权边界、默认角色异常、各级代理创建权限来源及提交后入队。
+
+Worker 回归覆盖多层新增、撤权、模板切换、普通角色和账号绑定保留、失败父子分支重试、跨租户隔离、机构循环及重试目标迁出。
+
+不应直接运行连接实际数据库的写入测试。密码修改回归已改为独立 SQLite 内存数据库及合成用户,不使用全局默认数据库、不读写真实账号,也不再提供环境变量开启真实库写入的入口。此前被修改账号的密码仍须管理员主动重置,测试隔离不能恢复原密码。
diff --git a/server/README.MD b/server/README.MD
new file mode 100644
index 0000000..9055a7c
--- /dev/null
+++ b/server/README.MD
@@ -0,0 +1,4 @@
+# GoFrame Template For SingleRepo
+
+Quick Start:
+- https://goframe.org/quick
diff --git a/server/api/agent/agent.go b/server/api/agent/agent.go
index 74c4a08..f7b0af9 100644
--- a/server/api/agent/agent.go
+++ b/server/api/agent/agent.go
@@ -11,6 +11,8 @@ import (
)
type IAgentV1 interface {
+ UserExport(ctx context.Context, req *v1.UserExportReq) (res *v1.UserExportRes, err error)
+ DeptExport(ctx context.Context, req *v1.DeptExportReq) (res *v1.DeptExportRes, err error)
DeptList(ctx context.Context, req *v1.DeptListReq) (res *v1.DeptListRes, err error)
DeptView(ctx context.Context, req *v1.DeptViewReq) (res *v1.DeptViewRes, err error)
DeptSave(ctx context.Context, req *v1.DeptSaveReq) (res *v1.DeptSaveRes, err error)
diff --git a/server/api/agent/v1/agent_dept.go b/server/api/agent/v1/agent_dept.go
index 019e9a0..9a30188 100644
--- a/server/api/agent/v1/agent_dept.go
+++ b/server/api/agent/v1/agent_dept.go
@@ -12,6 +12,13 @@ type DeptListReq struct {
model.AgentDeptListParam
}
+type DeptExportReq struct {
+ g.Meta `path:"/dept/export" method:"get" tags:"代理商组织管理" summary:"异步导出企业客户" x-check-permission:"cpm:agent:dept:export"`
+ model.AgentDeptListParam
+}
+
+type DeptExportRes struct{}
+
type DeptListRes struct {
response.PageResult
Data []*model.AgentEnterpriseCustomerModel `json:"items"`
diff --git a/server/api/agent/v1/agent_user.go b/server/api/agent/v1/agent_user.go
index b9e67e4..5f4747d 100644
--- a/server/api/agent/v1/agent_user.go
+++ b/server/api/agent/v1/agent_user.go
@@ -12,6 +12,13 @@ type UserListReq struct {
model.AgentUserListParam
}
+type UserExportReq struct {
+ g.Meta `path:"/user/export" method:"get" tags:"代理商用户管理" summary:"异步导出用户" x-check-permission:"cpm:agent:user:export"`
+ model.AgentUserListParam
+}
+
+type UserExportRes struct{}
+
type UserListRes struct {
response.PageResult
Data []*model.AgentUserListModel `json:"items"`
diff --git a/server/api/agent/v1/tagententerprise.go b/server/api/agent/v1/tagententerprise.go
index 074011e..ae9e875 100644
--- a/server/api/agent/v1/tagententerprise.go
+++ b/server/api/agent/v1/tagententerprise.go
@@ -1,7 +1,7 @@
// Package tagententerprise
// @Link https://github.com/xiujiecn/xiu-admin
// @Copyright Copyright (c) 2026 LiXiujie
-// @Author Lxj
+// @Author Lxj
// @License https://github.com/xiujiecn/xiu-admin/blob/master/LICENSE
// @AutoGenerate Version
package v1 //tagententerprise
diff --git a/server/api/agent/v1/tapi.go b/server/api/agent/v1/tapi.go
index 6a61b6c..5ef3856 100644
--- a/server/api/agent/v1/tapi.go
+++ b/server/api/agent/v1/tapi.go
@@ -1,7 +1,7 @@
// Package tapi
// @Link https://github.com/xiujiecn/xiu-admin
// @Copyright Copyright (c) 2026 LiXiujie
-// @Author Lxj
+// @Author Lxj
// @License https://github.com/xiujiecn/xiu-admin/blob/master/LICENSE
// @AutoGenerate Version
package v1 //tapi
diff --git a/server/api/agent/v1/tcustwithdrawaccount.go b/server/api/agent/v1/tcustwithdrawaccount.go
index fb0602a..5e93f82 100644
--- a/server/api/agent/v1/tcustwithdrawaccount.go
+++ b/server/api/agent/v1/tcustwithdrawaccount.go
@@ -1,7 +1,7 @@
// Package tcustwithdrawaccount
// @Link https://github.com/xiujiecn/xiu-admin
// @Copyright Copyright (c) 2026 LiXiujie
-// @Author Lxj
+// @Author Lxj
// @License https://github.com/xiujiecn/xiu-admin/blob/master/LICENSE
// @AutoGenerate Version
package v1 //tcustwithdrawaccount
diff --git a/server/api/common/common.go b/server/api/common/common.go
index 2b62547..dc90f65 100644
--- a/server/api/common/common.go
+++ b/server/api/common/common.go
@@ -16,6 +16,7 @@ type ICommonV1 interface {
BatchExecutionLogDetailList(ctx context.Context, req *v1.BatchExecutionLogDetailListReq) (res *v1.BatchExecutionLogDetailListRes, err error)
BatchMainList(ctx context.Context, req *v1.BatchMainListReq) (res *v1.BatchMainListRes, err error)
BatchMainSuccessList(ctx context.Context, req *v1.BatchMainSuccessListReq) (res *v1.BatchMainSuccessListRes, err error)
+ BatchMainExportWarehousing(ctx context.Context, req *v1.BatchMainExportWarehousingReq) (res *v1.BatchMainExportWarehousingRes, err error)
BatchMainCards(ctx context.Context, req *v1.BatchMainCardsReq) (res *v1.BatchMainCardsRes, err error)
BatchMainExport(ctx context.Context, req *v1.BatchMainExportReq) (res *v1.BatchMainExportRes, err error)
BatchMainView(ctx context.Context, req *v1.BatchMainViewReq) (res *v1.BatchMainViewRes, err error)
@@ -82,6 +83,9 @@ type ICommonV1 interface {
TMessageLogView(ctx context.Context, req *v1.TMessageLogViewReq) (res *v1.TMessageLogViewRes, err error)
TMessageLogEdit(ctx context.Context, req *v1.TMessageLogEditReq) (res *v1.TMessageLogEditRes, err error)
TMessageLogDelete(ctx context.Context, req *v1.TMessageLogDeleteReq) (res *v1.TMessageLogDeleteRes, err error)
+ TMessageLogUnreadList(ctx context.Context, req *v1.TMessageLogUnreadListReq) (res *v1.TMessageLogUnreadListRes, err error)
+ TMessageLogRead(ctx context.Context, req *v1.TMessageLogReadReq) (res *v1.TMessageLogReadRes, err error)
+ TMessageLogReadAll(ctx context.Context, req *v1.TMessageLogReadAllReq) (res *v1.TMessageLogReadAllRes, err error)
TNotifyLogList(ctx context.Context, req *v1.TNotifyLogListReq) (res *v1.TNotifyLogListRes, err error)
TNotifyLogExport(ctx context.Context, req *v1.TNotifyLogExportReq) (res *v1.TNotifyLogExportRes, err error)
TNotifyLogView(ctx context.Context, req *v1.TNotifyLogViewReq) (res *v1.TNotifyLogViewRes, err error)
diff --git a/server/api/common/v1/batchexecutionlog.go b/server/api/common/v1/batchexecutionlog.go
index f8b9588..888c50f 100644
--- a/server/api/common/v1/batchexecutionlog.go
+++ b/server/api/common/v1/batchexecutionlog.go
@@ -35,3 +35,12 @@ type BatchExecutionLogDetailListRes struct {
response.PageResult
Items []*model.BatchDetailListModel `json:"items" dc:"数据列表"`
}
+
+type BatchExecutionLogSyncReviewReq struct {
+ g.Meta `path:"/batchExecutionLog/syncReview" method:"post" tags:"执行日志" summary:"手动同步待核查批次的上游执行结果" x-check-permission:"cpm:common:batchExecutionLog:syncReview"`
+ model.BatchExecutionLogSyncReviewParam
+}
+
+type BatchExecutionLogSyncReviewRes struct {
+ *model.BatchExecutionLogSyncReviewModel
+}
diff --git a/server/api/common/v1/batchmain.go b/server/api/common/v1/batchmain.go
index 049651a..f73faac 100644
--- a/server/api/common/v1/batchmain.go
+++ b/server/api/common/v1/batchmain.go
@@ -1,7 +1,7 @@
// Package batchmain
// @Link https://github.com/xiujiecn/xiu-admin
// @Copyright Copyright (c) 2026 LiXiujie
-// @Author Lxj
+// @Author Lxj
// @License https://github.com/xiujiecn/xiu-admin/blob/master/LICENSE
// @AutoGenerate Version
package v1 //batchmain
@@ -34,6 +34,14 @@ type BatchMainSuccessListRes struct {
Items []*model.BatchMainSuccessListModel `json:"items" dc:"数据列表"`
}
+// ExportWarehousingReq 导出入库批次列表(数据来源同 successList 的入库类型)
+type BatchMainExportWarehousingReq struct {
+ g.Meta `path:"/batchMain/exportWarehousing" method:"post" tags:"批次主表" summary:"导出入库批次列表" x-check-permission:"cpm:iot:tSimCardStock:export"`
+ model.BatchMainSuccessListParam
+}
+
+type BatchMainExportWarehousingRes struct{}
+
type BatchMainCardsReq struct {
g.Meta `path:"/batchMain/cards" method:"get" tags:"批次主表" summary:"批次卡清单" x-check-permission:"cpm:common:batchMain:cards"`
model.BatchCardListParam
diff --git a/server/api/common/v1/downloadcenter.go b/server/api/common/v1/downloadcenter.go
new file mode 100644
index 0000000..8d4b91e
--- /dev/null
+++ b/server/api/common/v1/downloadcenter.go
@@ -0,0 +1,45 @@
+package v1
+
+import (
+ "git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/model"
+ "git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/model/response"
+
+ "github.com/gogf/gf/v2/frame/g"
+)
+
+// DownloadCenterListReq 下载中心列表
+type DownloadCenterListReq struct {
+ g.Meta `path:"/downloadCenter/list" method:"get" tags:"下载中心" summary:"获取下载任务列表" x-check-permission:"cpm:common:downloadCenter:list"`
+ model.DownloadCenterListParam
+}
+
+type DownloadCenterListRes struct {
+ response.PageResult
+ Items []*model.DownloadCenterListModel `json:"items" dc:"数据列表"`
+}
+
+// DownloadCenterRecentReq 右上角全局入口:最近任务与进行中数量
+type DownloadCenterRecentReq struct {
+ g.Meta `path:"/downloadCenter/recent" method:"get" tags:"下载中心" summary:"获取最近导出任务" x-check-permission:"cpm:common:downloadCenter:list"`
+ model.DownloadCenterRecentParam
+}
+
+type DownloadCenterRecentRes struct {
+ *model.DownloadCenterRecentModel
+}
+
+// DownloadCenterDownloadReq 下载导出文件(直接返回文件流)
+type DownloadCenterDownloadReq struct {
+ g.Meta `path:"/downloadCenter/download" method:"get" tags:"下载中心" summary:"下载导出文件" x-check-permission:"cpm:common:downloadCenter:download"`
+ model.DownloadCenterTaskParam
+}
+
+type DownloadCenterDownloadRes struct{}
+
+// DownloadCenterRemoveReq 删除文件并保留导出任务历史
+type DownloadCenterRemoveReq struct {
+ g.Meta `path:"/downloadCenter/remove" method:"post" tags:"下载中心" summary:"删除导出任务" x-check-permission:"cpm:common:downloadCenter:remove"`
+ model.DownloadCenterTaskParam
+}
+
+type DownloadCenterRemoveRes struct{}
diff --git a/server/api/common/v1/tagentbalance.go b/server/api/common/v1/tagentbalance.go
index 8ca468e..b26cb4c 100644
--- a/server/api/common/v1/tagentbalance.go
+++ b/server/api/common/v1/tagentbalance.go
@@ -1,7 +1,7 @@
// Package tagentbalance
// @Link https://github.com/xiujiecn/xiu-admin
// @Copyright Copyright (c) 2026 LiXiujie
-// @Author Lxj
+// @Author Lxj
// @License https://github.com/xiujiecn/xiu-admin/blob/master/LICENSE
// @AutoGenerate Version
package v1 //tagentbalance
diff --git a/server/api/common/v1/tbalancedetail.go b/server/api/common/v1/tbalancedetail.go
index 35feba1..805dda2 100644
--- a/server/api/common/v1/tbalancedetail.go
+++ b/server/api/common/v1/tbalancedetail.go
@@ -1,7 +1,7 @@
// Package tbalancedetail
// @Link https://github.com/xiujiecn/xiu-admin
// @Copyright Copyright (c) 2026 LiXiujie
-// @Author Lxj
+// @Author Lxj
// @License https://github.com/xiujiecn/xiu-admin/blob/master/LICENSE
// @AutoGenerate Version
package v1 //tbalancedetail
diff --git a/server/api/common/v1/tconsumeaccountmonthbill.go b/server/api/common/v1/tconsumeaccountmonthbill.go
index 041d810..dae3d65 100644
--- a/server/api/common/v1/tconsumeaccountmonthbill.go
+++ b/server/api/common/v1/tconsumeaccountmonthbill.go
@@ -1,7 +1,7 @@
// Package tconsumeaccountmonthbill
// @Link https://github.com/xiujiecn/xiu-admin
// @Copyright Copyright (c) 2026 LiXiujie
-// @Author Lxj
+// @Author Lxj
// @License https://github.com/xiujiecn/xiu-admin/blob/master/LICENSE
// @AutoGenerate Version
package v1 //tconsumeaccountmonthbill
diff --git a/server/api/common/v1/tcreditdetail.go b/server/api/common/v1/tcreditdetail.go
index a0897c7..5845a45 100644
--- a/server/api/common/v1/tcreditdetail.go
+++ b/server/api/common/v1/tcreditdetail.go
@@ -1,7 +1,7 @@
// Package tcreditdetail
// @Link https://github.com/xiujiecn/xiu-admin
// @Copyright Copyright (c) 2026 LiXiujie
-// @Author Lxj
+// @Author Lxj
// @License https://github.com/xiujiecn/xiu-admin/blob/master/LICENSE
// @AutoGenerate Version
package v1 //tcreditdetail
diff --git a/server/api/common/v1/tdivisioncode.go b/server/api/common/v1/tdivisioncode.go
index a83682d..ddf3396 100644
--- a/server/api/common/v1/tdivisioncode.go
+++ b/server/api/common/v1/tdivisioncode.go
@@ -1,7 +1,7 @@
// Package tdivisioncode
// @Link https://github.com/xiujiecn/xiu-admin
// @Copyright Copyright (c) 2026 LiXiujie
-// @Author Lxj
+// @Author Lxj
// @License https://github.com/xiujiecn/xiu-admin/blob/master/LICENSE
// @Autoioterate Version
package v1 //tdivisioncode
diff --git a/server/api/common/v1/tfinanceapply.go b/server/api/common/v1/tfinanceapply.go
index 667f142..a28f242 100644
--- a/server/api/common/v1/tfinanceapply.go
+++ b/server/api/common/v1/tfinanceapply.go
@@ -1,7 +1,7 @@
// Package tfinanceapply
// @Link https://github.com/xiujiecn/xiu-admin
// @Copyright Copyright (c) 2026 LiXiujie
-// @Author Lxj
+// @Author Lxj
// @License https://github.com/xiujiecn/xiu-admin/blob/master/LICENSE
// @AutoGenerate Version
package v1 //tfinanceapply
diff --git a/server/api/common/v1/tfinanceauditrecord.go b/server/api/common/v1/tfinanceauditrecord.go
index 6a3a721..71f1674 100644
--- a/server/api/common/v1/tfinanceauditrecord.go
+++ b/server/api/common/v1/tfinanceauditrecord.go
@@ -1,7 +1,7 @@
// Package tfinanceauditrecord
// @Link https://github.com/xiujiecn/xiu-admin
// @Copyright Copyright (c) 2026 LiXiujie
-// @Author Lxj
+// @Author Lxj
// @License https://github.com/xiujiecn/xiu-admin/blob/master/LICENSE
// @AutoGenerate Version
package v1 //tfinanceauditrecord
diff --git a/server/api/common/v1/tfincommissiondetail.go b/server/api/common/v1/tfincommissiondetail.go
index 9ebb1f8..4d0d41d 100644
--- a/server/api/common/v1/tfincommissiondetail.go
+++ b/server/api/common/v1/tfincommissiondetail.go
@@ -1,7 +1,7 @@
// Package tfincommissiondetail
// @Link https://github.com/xiujiecn/xiu-admin
// @Copyright Copyright (c) 2026 LiXiujie
-// @Author Lxj
+// @Author Lxj
// @License https://github.com/xiujiecn/xiu-admin/blob/master/LICENSE
// @AutoGenerate Version
package v1 //tfincommissiondetail
diff --git a/server/api/common/v1/tfinwithdrawablechange.go b/server/api/common/v1/tfinwithdrawablechange.go
index 7b78bc5..08af6b0 100644
--- a/server/api/common/v1/tfinwithdrawablechange.go
+++ b/server/api/common/v1/tfinwithdrawablechange.go
@@ -1,7 +1,7 @@
// Package tfinwithdrawablechange
// @Link https://github.com/xiujiecn/xiu-admin
// @Copyright Copyright (c) 2026 LiXiujie
-// @Author Lxj
+// @Author Lxj
// @License https://github.com/xiujiecn/xiu-admin/blob/master/LICENSE
// @AutoGenerate Version
package v1 //tfinwithdrawablechange
diff --git a/server/api/common/v1/tmessagelog.go b/server/api/common/v1/tmessagelog.go
index 1f1f956..1e6d001 100644
--- a/server/api/common/v1/tmessagelog.go
+++ b/server/api/common/v1/tmessagelog.go
@@ -1,7 +1,7 @@
// Package tmessagelog
// @Link https://github.com/xiujiecn/xiu-admin
// @Copyright Copyright (c) 2026 LiXiujie
-// @Author Lxj
+// @Author Lxj
// @License https://github.com/xiujiecn/xiu-admin/blob/master/LICENSE
// @AutoGenerate Version
package v1 //tmessagelog
@@ -57,3 +57,31 @@ type TMessageLogDeleteReq struct {
}
type TMessageLogDeleteRes struct{}
+
+// UnreadListReq 获取当前登录用户未读消息通知
+// 右上角消息提醒:任意登录用户都只能查看自己的未读消息,故仅校验登录态。
+type TMessageLogUnreadListReq struct {
+ g.Meta `path:"/tMessageLog/unreadList" method:"get" tags:"消息日志" summary:"获取当前用户未读消息通知" x-check-permission:"cpc:current:user"`
+ model.TMessageLogUnreadListParam
+}
+
+type TMessageLogUnreadListRes struct {
+ Items []*model.TMessageLogUnreadListModel `json:"items" dc:"未读消息列表"`
+ Total int `json:"total" dc:"未读消息总数"`
+}
+
+// ReadReq 标记当前登录用户指定消息已读
+type TMessageLogReadReq struct {
+ g.Meta `path:"/tMessageLog/read" method:"post" tags:"消息日志" summary:"标记当前用户指定消息已读" x-check-permission:"cpc:current:user"`
+ model.TMessageLogReadParam
+}
+
+type TMessageLogReadRes struct{}
+
+// ReadAllReq 标记当前登录用户全部消息已读
+type TMessageLogReadAllReq struct {
+ g.Meta `path:"/tMessageLog/readAll" method:"post" tags:"消息日志" summary:"标记当前用户全部消息已读" x-check-permission:"cpc:current:user"`
+ model.TMessageLogReadAllParam
+}
+
+type TMessageLogReadAllRes struct{}
diff --git a/server/api/common/v1/tnotifylog.go b/server/api/common/v1/tnotifylog.go
index 0b1992a..214cbe1 100644
--- a/server/api/common/v1/tnotifylog.go
+++ b/server/api/common/v1/tnotifylog.go
@@ -1,7 +1,7 @@
// Package tnotifylog
// @Link https://github.com/xiujiecn/xiu-admin
// @Copyright Copyright (c) 2026 LiXiujie
-// @Author Lxj
+// @Author Lxj
// @License https://github.com/xiujiecn/xiu-admin/blob/master/LICENSE
// @AutoGenerate Version
package v1 //tnotifylog
diff --git a/server/api/common/v1/tprofitaccountmonthbill.go b/server/api/common/v1/tprofitaccountmonthbill.go
index d8ca64d..5110e53 100644
--- a/server/api/common/v1/tprofitaccountmonthbill.go
+++ b/server/api/common/v1/tprofitaccountmonthbill.go
@@ -1,7 +1,7 @@
// Package tprofitaccountmonthbill
// @Link https://github.com/xiujiecn/xiu-admin
// @Copyright Copyright (c) 2026 LiXiujie
-// @Author Lxj
+// @Author Lxj
// @License https://github.com/xiujiecn/xiu-admin/blob/master/LICENSE
// @AutoGenerate Version
package v1 //tprofitaccountmonthbill
diff --git a/server/api/common/v1/twocommunication.go b/server/api/common/v1/twocommunication.go
index 327c52d..13277cb 100644
--- a/server/api/common/v1/twocommunication.go
+++ b/server/api/common/v1/twocommunication.go
@@ -1,7 +1,7 @@
// Package twocommunication
// @Link https://github.com/xiujiecn/xiu-admin
// @Copyright Copyright (c) 2026 LiXiujie
-// @Author Lxj
+// @Author Lxj
// @License https://github.com/xiujiecn/xiu-admin/blob/master/LICENSE
// @AutoGenerate Version
package v1 //twocommunication
diff --git a/server/api/common/v1/twomain.go b/server/api/common/v1/twomain.go
index eb28ff1..5c3199e 100644
--- a/server/api/common/v1/twomain.go
+++ b/server/api/common/v1/twomain.go
@@ -1,7 +1,7 @@
// Package twomain
// @Link https://github.com/xiujiecn/xiu-admin
// @Copyright Copyright (c) 2026 LiXiujie
-// @Author Lxj
+// @Author Lxj
// @License https://github.com/xiujiecn/xiu-admin/blob/master/LICENSE
// @AutoGenerate Version
package v1 //twomain
diff --git a/server/api/common/v1/tworecord.go b/server/api/common/v1/tworecord.go
index 93ca452..b01af7e 100644
--- a/server/api/common/v1/tworecord.go
+++ b/server/api/common/v1/tworecord.go
@@ -1,7 +1,7 @@
// Package tworecord
// @Link https://github.com/xiujiecn/xiu-admin
// @Copyright Copyright (c) 2026 LiXiujie
-// @Author Lxj
+// @Author Lxj
// @License https://github.com/xiujiecn/xiu-admin/blob/master/LICENSE
// @AutoGenerate Version
package v1 //tworecord
diff --git a/server/api/common/v1/upload.go b/server/api/common/v1/upload.go
index 0af5169..db74363 100644
--- a/server/api/common/v1/upload.go
+++ b/server/api/common/v1/upload.go
@@ -1,7 +1,7 @@
// Package v1
// @Link https://github.com/xiujiecn/xiu-admin
// @Copyright Copyright (c) 2025 LiXiujie
-// @Author Lxj
+// @Author Lxj
// @License https://github.com/xiujiecn/xiu-admin/blob/master/LICENSE
package v1
diff --git a/server/api/gen/testdemo/v1/testdemo.go b/server/api/gen/testdemo/v1/testdemo.go
index d514cee..69fe744 100644
--- a/server/api/gen/testdemo/v1/testdemo.go
+++ b/server/api/gen/testdemo/v1/testdemo.go
@@ -1,7 +1,7 @@
// Package testdemo
// @Link https://github.com/xiujiecn/xiu-admin
// @Copyright Copyright (c) 2025 LiXiujie
-// @Author Lxj
+// @Author Lxj
// @License https://github.com/xiujiecn/xiu-admin/blob/master/LICENSE
// @AutoGenerate Version
package v1 //testdemo
diff --git a/server/api/gen_codes/v1/gen_table.go b/server/api/gen_codes/v1/gen_table.go
index 97dbabe..7a23e7b 100644
--- a/server/api/gen_codes/v1/gen_table.go
+++ b/server/api/gen_codes/v1/gen_table.go
@@ -1,7 +1,7 @@
// Package v1
// @Link https://github.com/xiujiecn/xiu-admin
// @Copyright Copyright (c) 2025 LiXiujie
-// @Author Lxj
+// @Author Lxj
// @License https://github.com/xiujiecn/xiu-admin/blob/master/LICENSE
package v1
diff --git a/server/api/home/v1/home.go b/server/api/home/v1/home.go
index 9af93f1..b4c4376 100644
--- a/server/api/home/v1/home.go
+++ b/server/api/home/v1/home.go
@@ -1,7 +1,7 @@
// Package v1
// @Link https://github.com/xiujiecn/xiu-admin
// @Copyright Copyright (c) 2025 LiXiujie
-// @Author Lxj
+// @Author Lxj
// @License https://github.com/xiujiecn/xiu-admin/blob/master/LICENSE
package v1
diff --git a/server/api/iot/iot.go b/server/api/iot/iot.go
index ef3854e..1d81949 100644
--- a/server/api/iot/iot.go
+++ b/server/api/iot/iot.go
@@ -83,6 +83,13 @@ type IIotV1 interface {
TSimCardInfoSyncSimCardStatusQueue(ctx context.Context, req *v1.TSimCardInfoSyncSimCardStatusQueueReq) (res *v1.TSimCardInfoSyncSimCardStatusQueueRes, err error)
TSimCardInfoQuerySimSession(ctx context.Context, req *v1.TSimCardInfoQuerySimSessionReq) (res *v1.TSimCardInfoQuerySimSessionRes, err error)
TSimCardInfoQuerySimStatus(ctx context.Context, req *v1.TSimCardInfoQuerySimStatusReq) (res *v1.TSimCardInfoQuerySimStatusRes, err error)
+ SimCardBaseQueryTemplate(ctx context.Context, req *v1.SimCardBaseQueryTemplateReq) (res *v1.SimCardBaseQueryTemplateRes, err error)
+ SimCardBaseQueryColumns(ctx context.Context, req *v1.SimCardBaseQueryColumnsReq) (res *v1.SimCardBaseQueryColumnsRes, err error)
+ SimCardBaseQueryExport(ctx context.Context, req *v1.SimCardBaseQueryExportReq) (res *v1.SimCardBaseQueryExportRes, err error)
+ SimCardUsageHistoryTemplate(ctx context.Context, req *v1.SimCardUsageHistoryTemplateReq) (res *v1.SimCardUsageHistoryTemplateRes, err error)
+ SimCardUsageHistoryColumns(ctx context.Context, req *v1.SimCardUsageHistoryColumnsReq) (res *v1.SimCardUsageHistoryColumnsRes, err error)
+ SimCardUsageHistoryDeptOptions(ctx context.Context, req *v1.SimCardUsageHistoryDeptOptionsReq) (res *v1.SimCardUsageHistoryDeptOptionsRes, err error)
+ SimCardUsageHistoryExport(ctx context.Context, req *v1.SimCardUsageHistoryExportReq) (res *v1.SimCardUsageHistoryExportRes, err error)
TSimCardOutboundExcelTemplate(ctx context.Context, req *v1.TSimCardOutboundExcelTemplateReq) (res *v1.TSimCardOutboundExcelTemplateRes, err error)
TSimCardOutboundExcelSubmit(ctx context.Context, req *v1.TSimCardOutboundExcelSubmitReq) (res *v1.TSimCardOutboundExcelSubmitRes, err error)
TSimCardOutboundRangeSubmit(ctx context.Context, req *v1.TSimCardOutboundRangeSubmitReq) (res *v1.TSimCardOutboundRangeSubmitRes, err error)
diff --git a/server/api/iot/v1/simcardquery.go b/server/api/iot/v1/simcardquery.go
new file mode 100644
index 0000000..00bfd8d
--- /dev/null
+++ b/server/api/iot/v1/simcardquery.go
@@ -0,0 +1,77 @@
+package v1
+
+import (
+ "git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/model"
+
+ "github.com/gogf/gf/v2/frame/g"
+)
+
+// ---------------------------------------------------------------------------
+// 卡基础信息查询
+// ---------------------------------------------------------------------------
+
+type SimCardBaseQueryTemplateReq struct {
+ g.Meta `path:"/tSimCardBaseQuery/template" method:"get" tags:"卡基础信息查询" summary:"下载卡基础信息查询Excel模板" x-check-permission:"cpm:iot:tSimCardBaseQuery:view"`
+}
+
+type SimCardBaseQueryTemplateRes struct{}
+
+type SimCardBaseQueryColumnsReq struct {
+ g.Meta `path:"/tSimCardBaseQuery/columns" method:"get" tags:"卡基础信息查询" summary:"卡基础信息查询可选导出列" x-check-permission:"cpm:iot:tSimCardBaseQuery:view"`
+}
+
+type SimCardBaseQueryColumnsRes struct {
+ Items []model.ExportColumn `json:"items" dc:"可选导出列"`
+}
+
+// SimCardBaseQueryExportReq 提交卡基础信息查询导出。
+// Excel 模式上传 file;批量输入填 batchInput;连号填 dimension/startNum/endNum。
+type SimCardBaseQueryExportReq struct {
+ g.Meta `path:"/tSimCardBaseQuery/export" method:"post" tags:"卡基础信息查询" summary:"提交卡基础信息查询导出" mime:"multipart/form-data" x-check-permission:"cpm:iot:tSimCardBaseQuery:view"`
+ model.SimCardQueryExportParam
+}
+
+type SimCardBaseQueryExportRes struct {
+ *model.SimCardQuerySubmitRes
+}
+
+// ---------------------------------------------------------------------------
+// 月历史用量查询
+// ---------------------------------------------------------------------------
+
+type SimCardUsageHistoryTemplateReq struct {
+ g.Meta `path:"/tSimCardUsageHistory/template" method:"get" tags:"月历史用量查询" summary:"下载月历史用量查询Excel模板" x-check-permission:"cpm:iot:tSimCardUsageHistory:view"`
+}
+
+type SimCardUsageHistoryTemplateRes struct{}
+
+type SimCardUsageHistoryColumnsReq struct {
+ g.Meta `path:"/tSimCardUsageHistory/columns" method:"get" tags:"月历史用量查询" summary:"月历史用量查询可选列与月份清单" x-check-permission:"cpm:iot:tSimCardUsageHistory:view"`
+}
+
+type SimCardUsageHistoryColumnsRes struct {
+ Items []model.ExportColumn `json:"items" dc:"可选固定列"`
+ Months []string `json:"months" dc:"近12个月月份清单,顺序即导出列顺序"`
+}
+
+// SimCardUsageHistoryDeptOptionsReq 所属公司下拉,展示范围由数据权限决定。
+type SimCardUsageHistoryDeptOptionsReq struct {
+ g.Meta `path:"/tSimCardUsageHistory/deptOptions" method:"get" tags:"月历史用量查询" summary:"月历史用量查询可选公司" x-check-permission:"cpm:iot:tSimCardUsageHistory:view"`
+}
+
+type SimCardUsageHistoryDeptOptionsRes struct {
+ Items []*model.SimCardQueryDeptOption `json:"items" dc:"可选公司"`
+ // CurrentDeptId 当前登录用户所属部门,前端用于把公司下拉默认指向本企业。
+ CurrentDeptId int64 `json:"currentDeptId" dc:"当前用户所属部门ID"`
+}
+
+// SimCardUsageHistoryExportReq 提交月历史用量查询导出。
+// 比卡基础信息查询多一个 dept 模式:按所属公司整批导出。
+type SimCardUsageHistoryExportReq struct {
+ g.Meta `path:"/tSimCardUsageHistory/export" method:"post" tags:"月历史用量查询" summary:"提交月历史用量查询导出" mime:"multipart/form-data" x-check-permission:"cpm:iot:tSimCardUsageHistory:view"`
+ model.SimCardUsageHistoryExportParam
+}
+
+type SimCardUsageHistoryExportRes struct {
+ *model.SimCardQuerySubmitRes
+}
diff --git a/server/api/iot/v1/simwarningconfig.go b/server/api/iot/v1/simwarningconfig.go
index 31ae3a0..22e6053 100644
--- a/server/api/iot/v1/simwarningconfig.go
+++ b/server/api/iot/v1/simwarningconfig.go
@@ -1,7 +1,7 @@
// Package torder
// @Link https://github.com/xiujiecn/xiu-admin
// @Copyright Copyright (c) 2026 LiXiujie
-// @Author Lxj
+// @Author Lxj
// @License https://github.com/xiujiecn/xiu-admin/blob/master/LICENSE
// @AutoGenerate Version
package v1
diff --git a/server/api/iot/v1/tflowpackageconfig.go b/server/api/iot/v1/tflowpackageconfig.go
index 1b2a477..f9eb142 100644
--- a/server/api/iot/v1/tflowpackageconfig.go
+++ b/server/api/iot/v1/tflowpackageconfig.go
@@ -1,7 +1,7 @@
// Package tflowpackageconfig
// @Link https://github.com/xiujiecn/xiu-admin
// @Copyright Copyright (c) 2026 LiXiujie
-// @Author Lxj
+// @Author Lxj
// @License https://github.com/xiujiecn/xiu-admin/blob/master/LICENSE
// @AutoGenerate Version
package v1 //tflowpackageconfig
diff --git a/server/api/iot/v1/torder.go b/server/api/iot/v1/torder.go
index 9d032da..9e31e20 100644
--- a/server/api/iot/v1/torder.go
+++ b/server/api/iot/v1/torder.go
@@ -1,7 +1,7 @@
// Package torder
// @Link https://github.com/xiujiecn/xiu-admin
// @Copyright Copyright (c) 2026 LiXiujie
-// @Author Lxj
+// @Author Lxj
// @License https://github.com/xiujiecn/xiu-admin/blob/master/LICENSE
// @AutoGenerate Version
package v1 //torder
diff --git a/server/api/iot/v1/tsimcardallocate.go b/server/api/iot/v1/tsimcardallocate.go
index bbc44d5..541ca46 100644
--- a/server/api/iot/v1/tsimcardallocate.go
+++ b/server/api/iot/v1/tsimcardallocate.go
@@ -18,7 +18,8 @@ type TSimCardAllocateExcelTemplateReq struct {
type TSimCardAllocateExcelTemplateRes struct{}
type TSimCardAllocateTargetListRes struct {
- Items []*model.TSimCardAllocateTargetModel `json:"items" dc:"目标客户列表"`
+ MaxInputRows int `json:"maxInputRows" dc:"单次批量输入条数上限"`
+ Items []*model.TSimCardAllocateTargetModel `json:"items" dc:"目标客户列表"`
}
type TSimCardAllocateInputPrepareReq struct {
diff --git a/server/api/iot/v1/tsimcardinfo.go b/server/api/iot/v1/tsimcardinfo.go
index 048d84b..0f23466 100644
--- a/server/api/iot/v1/tsimcardinfo.go
+++ b/server/api/iot/v1/tsimcardinfo.go
@@ -1,7 +1,7 @@
// Package tsimcardinfo
// @Link https://github.com/xiujiecn/xiu-admin
// @Copyright Copyright (c) 2026 LiXiujie
-// @Author Lxj
+// @Author Lxj
// @License https://github.com/xiujiecn/xiu-admin/blob/master/LICENSE
// @AutoGenerate Version
package v1 //tsimcardinfo
@@ -15,7 +15,7 @@ import (
// ListReq 查询卡信息表列表
type TSimCardInfoListReq struct {
- g.Meta `path:"/tSimCardInfo/list" method:"get" tags:"卡信息表" summary:"获取卡信息表列表" x-check-permission:"cpm:iot:tSimCardInfo:list"`
+ g.Meta `path:"/tSimCardInfo/list" method:"get,post" tags:"卡信息表" summary:"获取卡信息表列表" x-check-permission:"cpm:iot:tSimCardInfo:list"`
model.TSimCardInfoListParam
}
diff --git a/server/api/iot/v1/tsimcardstock.go b/server/api/iot/v1/tsimcardstock.go
index 80ab4a2..bb5d0a3 100644
--- a/server/api/iot/v1/tsimcardstock.go
+++ b/server/api/iot/v1/tsimcardstock.go
@@ -1,7 +1,7 @@
// Package tsimcardstock
// @Link https://github.com/xiujiecn/xiu-admin
// @Copyright Copyright (c) 2026 LiXiujie
-// @Author Lxj
+// @Author Lxj
// @License https://github.com/xiujiecn/xiu-admin/blob/master/LICENSE
// @AutoGenerate Version
package v1 //tsimcardstock
diff --git a/server/api/iot/v1/tsimchannels.go b/server/api/iot/v1/tsimchannels.go
index 418d896..b3a46b7 100644
--- a/server/api/iot/v1/tsimchannels.go
+++ b/server/api/iot/v1/tsimchannels.go
@@ -1,7 +1,7 @@
// Package tsimchannels
// @Link https://github.com/xiujiecn/xiu-admin
// @Copyright Copyright (c) 2026 LiXiujie
-// @Author Lxj
+// @Author Lxj
// @License https://github.com/xiujiecn/xiu-admin/blob/master/LICENSE
// @AutoGenerate Version
package v1 //tsimchannels
diff --git a/server/api/iot/v1/tsimchanneltemplate.go b/server/api/iot/v1/tsimchanneltemplate.go
index 3e27c7e..991a8ee 100644
--- a/server/api/iot/v1/tsimchanneltemplate.go
+++ b/server/api/iot/v1/tsimchanneltemplate.go
@@ -1,7 +1,7 @@
// Package tsimchanneltemplate
// @Link https://github.com/xiujiecn/xiu-admin
// @Copyright Copyright (c) 2026 LiXiujie
-// @Author Lxj
+// @Author Lxj
// @License https://github.com/xiujiecn/xiu-admin/blob/master/LICENSE
// @AutoGenerate Version
package v1 //tsimchanneltemplate
diff --git a/server/api/iot/v1/tsimdiagnose.go b/server/api/iot/v1/tsimdiagnose.go
index 4be887d..3e602f0 100644
--- a/server/api/iot/v1/tsimdiagnose.go
+++ b/server/api/iot/v1/tsimdiagnose.go
@@ -1,7 +1,7 @@
// Package tsimdiagnose
// @Link https://github.com/xiujiecn/xiu-admin
// @Copyright Copyright (c) 2026 LiXiujie
-// @Author Lxj
+// @Author Lxj
// @License https://github.com/xiujiecn/xiu-admin/blob/master/LICENSE
// @AutoGenerate Version
package v1 //tsimdiagnose
diff --git a/server/api/iot/v1/tsimflowpackageinfo.go b/server/api/iot/v1/tsimflowpackageinfo.go
index 21a81ee..efb179b 100644
--- a/server/api/iot/v1/tsimflowpackageinfo.go
+++ b/server/api/iot/v1/tsimflowpackageinfo.go
@@ -1,7 +1,7 @@
// Package tsimflowpackageinfo
// @Link https://github.com/xiujiecn/xiu-admin
// @Copyright Copyright (c) 2026 LiXiujie
-// @Author Lxj
+// @Author Lxj
// @License https://github.com/xiujiecn/xiu-admin/blob/master/LICENSE
// @AutoGenerate Version
package v1 //tsimflowpackageinfo
diff --git a/server/api/iot/v1/tsimflowpackageprice.go b/server/api/iot/v1/tsimflowpackageprice.go
index f2a72ae..768515a 100644
--- a/server/api/iot/v1/tsimflowpackageprice.go
+++ b/server/api/iot/v1/tsimflowpackageprice.go
@@ -1,7 +1,7 @@
// Package tsimflowpackageprice
// @Link https://github.com/xiujiecn/xiu-admin
// @Copyright Copyright (c) 2026 LiXiujie
-// @Author Lxj
+// @Author Lxj
// @License https://github.com/xiujiecn/xiu-admin/blob/master/LICENSE
// @AutoGenerate Version
package v1 //tsimflowpackageprice
diff --git a/server/api/iot/v1/tsimflowpool.go b/server/api/iot/v1/tsimflowpool.go
index fbc4344..6dea15c 100644
--- a/server/api/iot/v1/tsimflowpool.go
+++ b/server/api/iot/v1/tsimflowpool.go
@@ -1,7 +1,7 @@
// Package tsimflowpool
// @Link https://github.com/xiujiecn/xiu-admin
// @Copyright Copyright (c) 2026 LiXiujie
-// @Author Lxj
+// @Author Lxj
// @License https://github.com/xiujiecn/xiu-admin/blob/master/LICENSE
// @AutoGenerate Version
package v1 //tsimflowpool
diff --git a/server/api/iot/v1/tsimpackagecategory.go b/server/api/iot/v1/tsimpackagecategory.go
index 163a6f8..2adddc6 100644
--- a/server/api/iot/v1/tsimpackagecategory.go
+++ b/server/api/iot/v1/tsimpackagecategory.go
@@ -1,7 +1,7 @@
// Package tsimpackagecategory
// @Link https://github.com/xiujiecn/xiu-admin
// @Copyright Copyright (c) 2026 LiXiujie
-// @Author Lxj
+// @Author Lxj
// @License https://github.com/xiujiecn/xiu-admin/blob/master/LICENSE
// @AutoGenerate Version
package v1 //tsimpackagecategory
diff --git a/server/api/iot/v1/tsimpoolwarnconfig.go b/server/api/iot/v1/tsimpoolwarnconfig.go
index 5783045..db33884 100644
--- a/server/api/iot/v1/tsimpoolwarnconfig.go
+++ b/server/api/iot/v1/tsimpoolwarnconfig.go
@@ -1,7 +1,7 @@
// Package tsimpoolwarnconfig
// @Link https://github.com/xiujiecn/xiu-admin
// @Copyright Copyright (c) 2026 LiXiujie
-// @Author Lxj
+// @Author Lxj
// @License https://github.com/xiujiecn/xiu-admin/blob/master/LICENSE
// @AutoGenerate Version
package v1 //tsimpoolwarnconfig
diff --git a/server/api/iot/v1/tsimupstreampackage.go b/server/api/iot/v1/tsimupstreampackage.go
index 39346d3..627b9bc 100644
--- a/server/api/iot/v1/tsimupstreampackage.go
+++ b/server/api/iot/v1/tsimupstreampackage.go
@@ -1,7 +1,7 @@
// Package tsimupstreampackage
// @Link https://github.com/xiujiecn/xiu-admin
// @Copyright Copyright (c) 2026 LiXiujie
-// @Author Lxj
+// @Author Lxj
// @License https://github.com/xiujiecn/xiu-admin/blob/master/LICENSE
// @AutoGenerate Version
package v1 //tsimupstreampackage
diff --git a/server/api/iot/v1/tsimwarningconfig.go b/server/api/iot/v1/tsimwarningconfig.go
index ad7d0ce..0fa45b4 100644
--- a/server/api/iot/v1/tsimwarningconfig.go
+++ b/server/api/iot/v1/tsimwarningconfig.go
@@ -7,21 +7,21 @@ import (
)
type TSimWarningConfigExcelTemplateReq struct {
- g.Meta `path:"/tSimWarningConfig/excel/template" method:"get" tags:"SIM卡预警配置" summary:"下载SIM卡预警配置Excel模板" x-check-permission:"cpm:iot:tSimWarningConfig:excel:template"`
+ g.Meta `path:"/tSimWarningConfig/excel/template" method:"get" tags:"SIM卡预警配置" summary:"下载SIM卡预警配置Excel模板" x-check-permission:"cpm:iot:tSimWarningConfig:excel:template,cpm:iot:tSimPoolWarnConfig:excel:template"`
ItemType int `json:"itemType" form:"itemType" dc:"对象类型:1单卡,2流量池;默认1"`
}
type TSimWarningConfigExcelTemplateRes struct{}
type TSimWarningConfigEmailCodeReq struct {
- g.Meta `path:"/tSimWarningConfig/email/code" method:"post" tags:"SIM卡预警配置" summary:"发送预警邮箱验证码" x-check-permission:"cpm:iot:tSimWarningConfig:input:submit"`
+ g.Meta `path:"/tSimWarningConfig/email/code" method:"post" tags:"SIM卡预警配置" summary:"发送预警邮箱验证码" x-check-permission:"cpm:iot:tSimWarningConfig:input:submit,cpm:iot:tSimPoolWarnConfig:input:submit"`
model.TSimWarningConfigEmailCodeParam
}
type TSimWarningConfigEmailCodeRes struct{}
type TSimWarningConfigExcelSubmitReq struct {
- g.Meta `path:"/tSimWarningConfig/excel/submit" method:"post" tags:"SIM卡预警配置" summary:"提交Excel批量SIM卡预警配置" mime:"multipart/form-data" x-check-permission:"cpm:iot:tSimWarningConfig:excel:submit"`
+ g.Meta `path:"/tSimWarningConfig/excel/submit" method:"post" tags:"SIM卡预警配置" summary:"提交Excel批量SIM卡预警配置" mime:"multipart/form-data" x-check-permission:"cpm:iot:tSimWarningConfig:excel:submit,cpm:iot:tSimPoolWarnConfig:excel:submit"`
model.TSimWarningConfigExcelSubmitParam
}
@@ -30,7 +30,7 @@ type TSimWarningConfigExcelSubmitRes struct {
}
type TSimWarningConfigInputSubmitReq struct {
- g.Meta `path:"/tSimWarningConfig/input/submit" method:"post" tags:"SIM卡预警配置" summary:"提交批量输入SIM卡预警配置" x-check-permission:"cpm:iot:tSimWarningConfig:input:submit"`
+ g.Meta `path:"/tSimWarningConfig/input/submit" method:"post" tags:"SIM卡预警配置" summary:"提交批量输入SIM卡预警配置" x-check-permission:"cpm:iot:tSimWarningConfig:input:submit,cpm:iot:tSimPoolWarnConfig:input:submit"`
model.TSimWarningConfigInputSubmitParam
}
@@ -39,7 +39,7 @@ type TSimWarningConfigInputSubmitRes struct {
}
type TSimWarningConfigRangeSubmitReq struct {
- g.Meta `path:"/tSimWarningConfig/range/submit" method:"post" tags:"SIM卡预警配置" summary:"提交连号SIM卡预警配置" x-check-permission:"cpm:iot:tSimWarningConfig:range:submit"`
+ g.Meta `path:"/tSimWarningConfig/range/submit" method:"post" tags:"SIM卡预警配置" summary:"提交连号SIM卡预警配置" x-check-permission:"cpm:iot:tSimWarningConfig:range:submit,cpm:iot:tSimPoolWarnConfig:range:submit"`
model.TSimWarningConfigRangeSubmitParam
}
diff --git a/server/api/monitor/v1/cache.go b/server/api/monitor/v1/cache.go
index a5480bd..afa6d06 100644
--- a/server/api/monitor/v1/cache.go
+++ b/server/api/monitor/v1/cache.go
@@ -1,7 +1,7 @@
// Package v1
// @Link https://github.com/xiujiecn/xiu-admin
// @Copyright Copyright (c) 2025 LiXiujie
-// @Author Lxj
+// @Author Lxj
// @License https://github.com/xiujiecn/xiu-admin/blob/master/LICENSE
package v1
diff --git a/server/api/system/v1/auth.go b/server/api/system/v1/auth.go
index ffde433..7b3b208 100644
--- a/server/api/system/v1/auth.go
+++ b/server/api/system/v1/auth.go
@@ -1,7 +1,7 @@
// Package v1
// @Link https://github.com/xiujiecn/xiu-admin
// @Copyright Copyright (c) 2025 LiXiujie
-// @Author Lxj
+// @Author Lxj
// @License https://github.com/xiujiecn/xiu-admin/blob/master/LICENSE
package v1
diff --git a/server/api/system/v1/captcha.go b/server/api/system/v1/captcha.go
index 979a188..82e3917 100644
--- a/server/api/system/v1/captcha.go
+++ b/server/api/system/v1/captcha.go
@@ -1,7 +1,7 @@
// Package v1
// @Link https://github.com/xiujiecn/xiu-admin
// @Copyright Copyright (c) 2025 LiXiujie
-// @Author Lxj
+// @Author Lxj
// @License https://github.com/xiujiecn/xiu-admin/blob/master/LICENSE
package v1
diff --git a/server/api/system/v1/client.go b/server/api/system/v1/client.go
index 90fafb1..13e0b26 100644
--- a/server/api/system/v1/client.go
+++ b/server/api/system/v1/client.go
@@ -1,7 +1,7 @@
// Package v1
// @Link https://github.com/xiujiecn/xiu-admin
// @Copyright Copyright (c) 2025 LiXiujie
-// @Author Lxj
+// @Author Lxj
// @License https://github.com/xiujiecn/xiu-admin/blob/master/LICENSE
package v1
diff --git a/server/api/system/v1/config.go b/server/api/system/v1/config.go
index b1c2798..218f55e 100644
--- a/server/api/system/v1/config.go
+++ b/server/api/system/v1/config.go
@@ -1,7 +1,7 @@
// Package v1
// @Link https://github.com/xiujiecn/xiu-admin
// @Copyright Copyright (c) 2025 LiXiujie
-// @Author Lxj
+// @Author Lxj
// @License https://github.com/xiujiecn/xiu-admin/blob/master/LICENSE
package v1
diff --git a/server/api/system/v1/dept.go b/server/api/system/v1/dept.go
index 2725fd1..b88a195 100644
--- a/server/api/system/v1/dept.go
+++ b/server/api/system/v1/dept.go
@@ -1,7 +1,7 @@
// Package v1
// @Link https://github.com/xiujiecn/xiu-admin
// @Copyright Copyright (c) 2025 LiXiujie
-// @Author Lxj
+// @Author Lxj
// @License https://github.com/xiujiecn/xiu-admin/blob/master/LICENSE
package v1
diff --git a/server/api/system/v1/dict.go b/server/api/system/v1/dict.go
index 9afbee4..94ed764 100644
--- a/server/api/system/v1/dict.go
+++ b/server/api/system/v1/dict.go
@@ -1,7 +1,7 @@
// Package v1
// @Link https://github.com/xiujiecn/xiu-admin
// @Copyright Copyright (c) 2025 LiXiujie
-// @Author Lxj
+// @Author Lxj
// @License https://github.com/xiujiecn/xiu-admin/blob/master/LICENSE
package v1
diff --git a/server/api/system/v1/job.go b/server/api/system/v1/job.go
index 5122489..9808962 100644
--- a/server/api/system/v1/job.go
+++ b/server/api/system/v1/job.go
@@ -1,7 +1,7 @@
// Package v1
// @Link https://github.com/xiujiecn/xiu-admin
// @Copyright Copyright (c) 2025 LiXiujie
-// @Author Lxj
+// @Author Lxj
// @License https://github.com/xiujiecn/xiu-admin/blob/master/LICENSE
package v1
diff --git a/server/api/system/v1/logininfor.go b/server/api/system/v1/logininfor.go
index 16e9c06..64f45e2 100644
--- a/server/api/system/v1/logininfor.go
+++ b/server/api/system/v1/logininfor.go
@@ -1,7 +1,7 @@
// Package v1
// @Link https://github.com/xiujiecn/xiu-admin
// @Copyright Copyright (c) 2025 LiXiujie
-// @Author Lxj
+// @Author Lxj
// @License https://github.com/xiujiecn/xiu-admin/blob/master/LICENSE
package v1
diff --git a/server/api/system/v1/menu.go b/server/api/system/v1/menu.go
index e66319a..3df21ae 100644
--- a/server/api/system/v1/menu.go
+++ b/server/api/system/v1/menu.go
@@ -1,7 +1,7 @@
// Package v1
// @Link https://github.com/xiujiecn/xiu-admin
// @Copyright Copyright (c) 2025 LiXiujie
-// @Author Lxj
+// @Author Lxj
// @License https://github.com/xiujiecn/xiu-admin/blob/master/LICENSE
package v1
diff --git a/server/api/system/v1/notice.go b/server/api/system/v1/notice.go
index 42f47fa..55304f4 100644
--- a/server/api/system/v1/notice.go
+++ b/server/api/system/v1/notice.go
@@ -1,7 +1,7 @@
// Package v1
// @Link https://github.com/xiujiecn/xiu-admin
// @Copyright Copyright (c) 2025 LiXiujie
-// @Author Lxj
+// @Author Lxj
// @License https://github.com/xiujiecn/xiu-admin/blob/master/LICENSE
package v1
diff --git a/server/api/system/v1/online.go b/server/api/system/v1/online.go
index e541386..6c1ab44 100644
--- a/server/api/system/v1/online.go
+++ b/server/api/system/v1/online.go
@@ -1,7 +1,7 @@
// Package v1
// @Link https://github.com/xiujiecn/xiu-admin
// @Copyright Copyright (c) 2025 LiXiujie
-// @Author Lxj
+// @Author Lxj
// @License https://github.com/xiujiecn/xiu-admin/blob/master/LICENSE
package v1
diff --git a/server/api/system/v1/oper_log.go b/server/api/system/v1/oper_log.go
index b65b998..0dcd94f 100644
--- a/server/api/system/v1/oper_log.go
+++ b/server/api/system/v1/oper_log.go
@@ -1,7 +1,7 @@
// Package v1
// @Link https://github.com/xiujiecn/xiu-admin
// @Copyright Copyright (c) 2025 LiXiujie
-// @Author Lxj
+// @Author Lxj
// @License https://github.com/xiujiecn/xiu-admin/blob/master/LICENSE
package v1
diff --git a/server/api/system/v1/oss.go b/server/api/system/v1/oss.go
index b64d4f4..e58b5c3 100644
--- a/server/api/system/v1/oss.go
+++ b/server/api/system/v1/oss.go
@@ -1,7 +1,7 @@
// Package v1
// @Link https://github.com/xiujiecn/xiu-admin
// @Copyright Copyright (c) 2025 LiXiujie
-// @Author Lxj
+// @Author Lxj
// @License https://github.com/xiujiecn/xiu-admin/blob/master/LICENSE
package v1
diff --git a/server/api/system/v1/oss_config.go b/server/api/system/v1/oss_config.go
index dda85e3..83b44b8 100644
--- a/server/api/system/v1/oss_config.go
+++ b/server/api/system/v1/oss_config.go
@@ -1,7 +1,7 @@
// Package v1
// @Link https://github.com/xiujiecn/xiu-admin
// @Copyright Copyright (c) 2025 LiXiujie
-// @Author Lxj
+// @Author Lxj
// @License https://github.com/xiujiecn/xiu-admin/blob/master/LICENSE
package v1
diff --git a/server/api/system/v1/post.go b/server/api/system/v1/post.go
index b45118c..9d8082d 100644
--- a/server/api/system/v1/post.go
+++ b/server/api/system/v1/post.go
@@ -1,7 +1,7 @@
// Package v1
// @Link https://github.com/xiujiecn/xiu-admin
// @Copyright Copyright (c) 2025 LiXiujie
-// @Author Lxj
+// @Author Lxj
// @License https://github.com/xiujiecn/xiu-admin/blob/master/LICENSE
package v1
diff --git a/server/api/system/v1/role.go b/server/api/system/v1/role.go
index 9425ab9..9f277b9 100644
--- a/server/api/system/v1/role.go
+++ b/server/api/system/v1/role.go
@@ -1,7 +1,7 @@
// Package v1
// @Link https://github.com/xiujiecn/xiu-admin
// @Copyright Copyright (c) 2025 LiXiujie
-// @Author Lxj
+// @Author Lxj
// @License https://github.com/xiujiecn/xiu-admin/blob/master/LICENSE
package v1
diff --git a/server/api/system/v1/social.go b/server/api/system/v1/social.go
index 596e129..768e81d 100644
--- a/server/api/system/v1/social.go
+++ b/server/api/system/v1/social.go
@@ -1,7 +1,7 @@
// Package v1
// @Link https://github.com/xiujiecn/xiu-admin
// @Copyright Copyright (c) 2025 LiXiujie
-// @Author Lxj
+// @Author Lxj
// @License https://github.com/xiujiecn/xiu-admin/blob/master/LICENSE
package v1
diff --git a/server/api/system/v1/sysnoticeuser.go b/server/api/system/v1/sysnoticeuser.go
index e219b7e..cfece29 100644
--- a/server/api/system/v1/sysnoticeuser.go
+++ b/server/api/system/v1/sysnoticeuser.go
@@ -1,7 +1,7 @@
// Package sysnoticeuser
// @Link https://github.com/xiujiecn/xiu-admin
// @Copyright Copyright (c) 2025 LiXiujie
-// @Author Lxj
+// @Author Lxj
// @License https://github.com/xiujiecn/xiu-admin/blob/master/LICENSE
// @AutoGenerate Version
package v1 //sysnoticeuser
diff --git a/server/api/system/v1/tenant.go b/server/api/system/v1/tenant.go
index 89ccbdf..98e96b5 100644
--- a/server/api/system/v1/tenant.go
+++ b/server/api/system/v1/tenant.go
@@ -1,7 +1,7 @@
// Package v1
// @Link https://github.com/xiujiecn/xiu-admin
// @Copyright Copyright (c) 2025 LiXiujie
-// @Author Lxj
+// @Author Lxj
// @License https://github.com/xiujiecn/xiu-admin/blob/master/LICENSE
package v1
diff --git a/server/api/system/v1/ttplatformsetting.go b/server/api/system/v1/ttplatformsetting.go
index 917ba5d..64155d8 100644
--- a/server/api/system/v1/ttplatformsetting.go
+++ b/server/api/system/v1/ttplatformsetting.go
@@ -1,7 +1,7 @@
// Package ttplatformsetting
// @Link https://github.com/xiujiecn/xiu-admin
// @Copyright Copyright (c) 2026 LiXiujie
-// @Author Lxj
+// @Author Lxj
// @License https://github.com/xiujiecn/xiu-admin/blob/master/LICENSE
// @AutoGenerate Version
package v1 //ttplatformsetting
diff --git a/server/api/system/v1/user.go b/server/api/system/v1/user.go
index 25a1f97..c49f587 100644
--- a/server/api/system/v1/user.go
+++ b/server/api/system/v1/user.go
@@ -1,7 +1,7 @@
// Package v1
// @Link https://github.com/xiujiecn/xiu-admin
// @Copyright Copyright (c) 2025 LiXiujie
-// @Author Lxj
+// @Author Lxj
// @License https://github.com/xiujiecn/xiu-admin/blob/master/LICENSE
package v1
diff --git a/server/build/Dockerfile b/server/build/Dockerfile
index 6afe9ce..62b4da3 100644
--- a/server/build/Dockerfile
+++ b/server/build/Dockerfile
@@ -1,4 +1,5 @@
-FROM golang:1.25-alpine AS builder
+ARG BASE_REGISTRY=docker.io/library
+FROM ${BASE_REGISTRY}/golang:1.25-alpine AS builder
LABEL stage=gobuilder
@@ -8,7 +9,10 @@ ENV CGO_ENABLED=1
ENV GOOS=linux
ENV GOARCH=amd64
-RUN apk add --no-cache gcc musl-dev git tzdata
+# 基础镜像源:阿里云 Alpine 镜像站曾出现索引与实际文件不同步(APKINDEX 标 r24、
+# 目录只有 r23,导致 musl/musl-dev 拉取 404)。改用清华源,可用性更稳。
+RUN sed -i 's#https://dl-cdn.alpinelinux.org/alpine#https://mirrors.tuna.tsinghua.edu.cn/alpine#g' /etc/apk/repositories && \
+ apk add --no-cache gcc musl-dev git tzdata expect
WORKDIR /build
@@ -29,7 +33,7 @@ RUN set -euxo pipefail && \
-ldflags "-X main.BuildVersion=${BuildVersion} -X main.CommitID=${CommitID} -X main.BuildTime=${BuildTime}" \
.
-FROM alpine:3.23
+FROM ${BASE_REGISTRY}/alpine:3.23 AS alpine
RUN apk add --no-cache ca-certificates tzdata
@@ -39,6 +43,6 @@ EXPOSE 13100
WORKDIR /app
COPY --from=builder /build/iotlcmadmin_svc /app/iotlcmadmin_svc
-COPY --from=builder /build/build/build_config.yaml /app/config/config.yaml
+COPY --from=builder /build/build/config.example.yaml /app/config/config.yaml
CMD ["./iotlcmadmin_svc"]
diff --git a/server/build/build_config.yaml b/server/build/config.example.yaml
similarity index 94%
rename from server/build/build_config.yaml
rename to server/build/config.example.yaml
index c7b73ca..2c1cd01 100644
--- a/server/build/build_config.yaml
+++ b/server/build/config.example.yaml
@@ -1,7 +1,7 @@
# config.yaml 配置文件
# @Link https://github.com/xiujiecn/xiu-admin
# @Copyright Copyright (c) 2025 LiXiujie
-# @Author Lxj
+# @Author Lxj
# @License https://github.com/xiujiecn/xiu-admin/blob/master/LICENSE
# 系统配置
system:
@@ -79,7 +79,7 @@ database:
level: "all"
stdout: true
default: # 分组名称,可自定义,默认为default
- link: "mysql:${DB_USER}:${DB_PASSWORD}@tcp(${DB_HOST}:3306)/db_iotlcm?charset=utf8mb4&parseTime=True&loc=Asia%2FShanghai"
+ link: "${DB_DSN}"
debug: true #开启调试模式
charset: "utf8mb4" # (可选)数据库编码(如: utf8mb4/utf8/gbk/gb2312),一般设置为utf8mb4。默认为utf8。
dryRun: false #ORM空跑(只读不写)
@@ -87,9 +87,9 @@ database:
maxOpen: 100 #连接池最大打开的连接数
maxLifetime: 120 #(单位秒)连接对象可重复使用的时间长度
memorydb:
- # link: "sqlite::@file(jieiotmem.db)?journal_mode=WAL"
- link: "sqlite::@file(:memory:)"
- # link: ""
+ # link: "${DB_DSN}"
+ link: "${DB_DSN}"
+ # link: "${DB_DSN}"
debug: true
maxIdle: 1
maxOpen: 1
@@ -106,9 +106,9 @@ redis:
default:
mode: "single" #redis模式,支持:single|cluster|sentinel
sentinelMasterName: "iotlcm-master" #哨兵模式主节点名称
- address: 172.17.0.1:6379
+ address: 127.0.0.1:6379
db: 0
- pass: "${REDIS_PASSWORD}"
+ pass: "${SERVICE_PASSWORD}"
minIdle: 5 #最小空闲连接数
maxIdle: 30 #允许闲置的最大连接数
maxActive: 5000 #最大活跃连接数
diff --git a/server/config/config.yaml b/server/config/config.example.yaml
similarity index 81%
rename from server/config/config.yaml
rename to server/config/config.example.yaml
index 93c7a17..63ac220 100644
--- a/server/config/config.yaml
+++ b/server/config/config.example.yaml
@@ -1,7 +1,7 @@
# config.yaml 配置文件
# @Link https://github.com/xiujiecn/xiu-admin
# @Copyright Copyright (c) 2025 LiXiujie
-# @Author Lxj
+# @Author Lxj
# @License https://github.com/xiujiecn/xiu-admin/blob/master/LICENSE
# 系统配置
system:
@@ -86,9 +86,9 @@ register:
email:
host: "smtp.exmail.qq.com"
port: 465
- username: "iotlcm@example.com"
- password: "JH8DaDFxJuzFRLyB"
- from: "iotlcm@example.com"
+ username: "${SERVICE_USERNAME}"
+ password: "${SERVICE_PASSWORD}"
+ from: "xxx@example.com"
fromName: "物联卡管理平台"
subject: "注册验证码"
bodyTemplate: "您的注册验证码是:{code},6小时内有效。"
@@ -112,20 +112,20 @@ warning:
# 阿里云 serviceType=1,腾讯云 serviceType=2。
serviceType: ""
regionId: ""
- accessKeyId: ""
- accessKeySecret: ""
- sdkAppId: ""
- signName: ""
- templateId: ""
+ accessKeyId: "${ACCESS_KEY_ID}"
+ accessKeySecret: "${ACCESS_KEY_SECRET}"
+ sdkAppId: "${SDK_APP_ID}"
+ signName: "${SMS_SIGN_NAME}"
+ templateId: "${TEMPLATE_ID}"
# 阿里云模板参数通常需要 JSON;如果预警模板变量名是 content,可配置为 '{"content":{contentJson}}'。
# 腾讯云多参数模板可按实际顺序配置,例如 '{firstItem}|{count}'。
msgContentTemplate: ""
# 长期建议:短信平台签名、模板和密钥由短信微服务自行处理,lcm 只传手机号和聚合内容。
wechat:
enabled: false
- appId: "wx9e26905702c1a372"
- appSecret: ""
- templateId: "DMDP2PKktdtG1ES_3Ic1JcR0LGELQmWE6LunYyerHCY"
+ appId: "${APP_ID}"
+ appSecret: "${APP_SECRET}"
+ templateId: "${TEMPLATE_ID}"
page: "pages/message/index"
timeout: "5s"
@@ -136,7 +136,7 @@ database:
level: "all"
stdout: true
default: # 分组名称,可自定义,默认为default
- link: "mysql:${DB_USER}:${DB_PASSWORD}@tcp(${DB_HOST}:3306)/db_iotlcm?charset=utf8mb4&parseTime=True&loc=Asia%2FShanghai"
+ link: "${DB_DSN}"
debug: true #开启调试模式
charset: "utf8mb4" # (可选)数据库编码(如: utf8mb4/utf8/gbk/gb2312),一般设置为utf8mb4。默认为utf8。
dryRun: false #ORM空跑(只读不写)
@@ -144,9 +144,9 @@ database:
maxOpen: 100 #连接池最大打开的连接数
maxLifetime: 120 #(单位秒)连接对象可重复使用的时间长度
memorydb:
- # link: "sqlite::@file(jieiotmem.db)?journal_mode=WAL"
- link: "sqlite::@file(:memory:)"
- # link: ""
+ # link: "${DB_DSN}"
+ link: "${DB_DSN}"
+ # link: "${DB_DSN}"
debug: true
maxIdle: 1
maxOpen: 1
@@ -163,9 +163,9 @@ redis:
default:
mode: "single" #redis模式,支持:single|cluster|sentinel
sentinelMasterName: "iotlcm-master" #哨兵模式主节点名称
- address: 192.0.2.29:6379
+ address: 127.0.0.1:6379
db: 0
- pass: "${REDIS_PASSWORD}"
+ pass: "${SERVICE_PASSWORD}"
minIdle: 5 #最小空闲连接数
maxIdle: 30 #允许闲置的最大连接数
maxActive: 5000 #最大活跃连接数
@@ -174,18 +174,18 @@ redis:
rabbitmq:
enabled: true
- # 示例:amqp://${MQ_USER}:${MQ_PASSWORD}@${MQ_HOST}/
- # dsn: "amqp://${MQ_USER}:${MQ_PASSWORD}@${MQ_HOST}/"
- dsn: "amqp://${MQ_USER}:${MQ_PASSWORD}@${MQ_HOST}/"
+ # 示例:amqp://guest:guest@127.0.0.1:5672/
+ # dsn: "${SERVICE_DSN}"
+ dsn: "${SERVICE_DSN}"
tsdb:
database: "TdEngine" #可选择 TdEngine、Influxdb
tdengine:
# type: "taosRestful" #http连接方式,端口是6041
- # dsn: "root:taosdata@http(127.0.0.1:6041)/"
+ # dsn: "${SERVICE_DSN}"
type: "taosWS" #websocket连接方式,端口是6041
- # dsn: "root:taosdata@ws(127.0.0.1:6041)/"
- dsn: "root:sdyz2026__@ws(192.0.2.29:6041)/"
+ # dsn: "${SERVICE_DSN}"
+ dsn: "${SERVICE_DSN}"
dbName: "iotlcm"
maxOpenConns: 100
maxIdleConns: 10
@@ -205,8 +205,8 @@ router:
# 生成代码
alipay:
- appId: "9021000165668942"
- privateKey: "MIIEowIBAAKCAQEAg+4BpLO2Fw5GMbrihnugyQqiLBp2RhSbSsvjNyzaG10u900N1DcVlh8HNdcus5AGjfbiUwMQwv7Os+NnFrEZA3CtWLXH+uczHIpcMqJJhoVgA//4gf2TX9K4OSQ1pgJeAhTS9WWMEB7DQLb7A6xGjvBdGNbPAzucGhUmtG1tgZSVOcpWqN2h4dQxkZhfR+6m3AqWAPahpQXsNINHewetQ4mIG+b3+z/OknEll0RDRmw7OihOJ24B51Phy7lewl160LgLpLffyaQUUXcFdcNo+HvWqtIT0mH8WWHAgqfN/WVkMz/WVjE0rzbIVgmpQc7+hMbrcafTmeq0wdnVXrKS6QIDAQABAoIBAEagy1ZuMyRiqueCojTWyHv4altsCzd3Xq+LOWfXZihSGYuhP68FklPc5pJHUaQljazAp+dC891AZk5dhlnak4aI8vRT1OyYi39S/wrIYM5YZs+dYF/O7e/VsnQ800+9YrCAj3N6WqJiparmirkrqFZrjZt3nDmh1u8Ltb3P+1jr90MBEdS+BXaEQP/KGxFiKZyoWFn9B4l0aPFkPExF+hgtRpP3gxFcyljQKGVmSUIkT6V08F1oRm72NQOhcizCh9lX+5HYLTkUVQVg6FHwdZs/KeJcZu6Y829pNSNeJmPbR3VPUpTbvwb0qeEoc42T3H/C0xXUeTYizUpHmhJJqnUCgYEA3Y7Xz+MUk98zRJnlr2BDUc+Qnhv3wmSA5LCcRbxTNNBp4TXjnZknY6Ms9D+nJupL4nw6QSYkO6O+tAEmU6gaSDEHKI1uKqU0zOdbGIHNkMqka3rinQcMJNNRZ8T1FLCPFna7S1FNlxulZgWxjqxP/KvWOEpCWLCLzwZNk8fEd+MCgYEAmHBNbaMmT4WvLevY/OMv/5e5csnDFIcsNJKvFLZZMBLuKT/26YN2ryVUivY+TTyHL5zk4VybeauTdQHIvX6sNJoh2Ii6vthKfSufL4rkMob8PHiG33iHBx3VxAwiVXfu3JHVnbCPcXTOzsdRzT17X3rODK9t8319qfVjd0Gxi8MCgYEAqgNc0+ea+MXD35aAzA0AW5z9inZHruHUjqh/onubFeH//ZOOsHa/5RNoAZ1+tLwBszBEu417LFDAm/xt3rAWSuNkbNfqe8r+M2YtXybsCXmWpetMTdcEmJWX2BspPdEmW8SMgIVJFvVd/GXOFH9hebCa1+MT+l6vShO6H/ahaqUCgYAzon6nr4bPkvHWtTfH2VCt2//mIO+iaXMQz8rcUjP9s7vWXyNMz8oI47UyaNyLnwAPoa5po8vKN9L35EXR4jGEk9z5Er9m267HnVoLaZmIf+66sPzGf3YFdBavlIiguMJBFmjsRB0iijVvF1bfFX9PxTI8W8P7oTtKsaMihy8TrwKBgCgjA8DL8HJbRwZ+V/KWOh8rzO5cBUUH3ThIj6u7Wn4tMlscfi+KFKTXghjSORfIVx8v9v2aNZKwGZ547AOPRXhDF09cLAcCfl7uOrwsftziIeBQnrqtpIqcVTgRwGMIMBJEtQEks5N/E/d+dSL1YQKC76mVC8/RMf52WYKH1QCa"
+ appId: "${APP_ID}"
+ privateKey: "${PRIVATE_KEY}"
alipayPublicKey: "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA8Df1EPFkA2xy8NgF0UdRYxsshslUi2XSGTG01oQKsrYNB+rhAw9MJdMlunVQxSrBZsw5N7htXGdiTceaLo/n8Wuibp0cRHdErJSp9GhP4pW2IR3FoCCPYlJlyiCUR/NInI3XMai0UdjgiPX03bvYLRAKE8lLiOSgxNagQmOFLmQYRYfFhGbxDe98XMg83YdSWvZKrlM+vzgk8/aPgq7BFLpbRKqLvd8OXXBHDCaWMSSGwCN0whhdEiwbIhSVgwTgkhWXUuNDmT741/+7D46djp8wX/84SDts0fWb63/in9ATTGtNivJX7HPDyqrmcN2iUi5xFHS9XwYBdT4yu/xFQwIDAQAB"
notifyUrl: "https://evolution-starless-municipal.ngrok-free.dev/api/v1/pay/alipay/notify"
returnUrl: "http://localhost:5666/enterpriseManagement/tBalanceDetail/tAgentBalance"
diff --git a/server/config/config_default.yaml b/server/config/config_default.yaml
index f010756..78c541b 100644
--- a/server/config/config_default.yaml
+++ b/server/config/config_default.yaml
@@ -1,7 +1,7 @@
# config_default.yaml 配置文件
# @Link https://github.com/xiujiecn/xiu-admin
# @Copyright Copyright (c) 2025 LiXiujie
-# @Author Lxj
+# @Author Lxj
# @License https://github.com/xiujiecn/xiu-admin/blob/master/LICENSE
# 系统配置
system:
@@ -78,6 +78,12 @@ warning:
firstWarnDays: 30
secondWarnDays: 15
thirdWarnDays: 7
+ monthly:
+ # 每月15日09:00企业汇总;以下参数名已按阿里云审核通过的模板核实。
+ # JSON字符串值支持 {count}、{expireDate};部署时同步到实际使用的配置文件。
+ # 空值仍不发送短信,不影响站内系统通知;不复用验证码/通用流量预警模板。
+ currentMonthParamsTemplate: '{"card_num":"{count}","expire_date":"{expireDate}"}' # SMS_512596191
+ nextMonthParamsTemplate: '{"card_num":"{count}","expire_date":"{expireDate}"}' # SMS_512646156
sms:
enabled: false
# HTTP 接口地址;如果配置 grpcAddr,则优先走 gRPC。
@@ -89,11 +95,11 @@ warning:
# 阿里云 serviceType=1,腾讯云 serviceType=2。
serviceType: ""
regionId: ""
- accessKeyId: ""
- accessKeySecret: ""
- sdkAppId: ""
- signName: ""
- templateId: ""
+ accessKeyId: "${ACCESS_KEY_ID}"
+ accessKeySecret: "${ACCESS_KEY_SECRET}"
+ sdkAppId: "${SDK_APP_ID}"
+ signName: "${SMS_SIGN_NAME}"
+ templateId: "${TEMPLATE_ID}"
# 阿里云模板参数通常需要 JSON;如果预警模板变量名是 content,可配置为 '{"content":{contentJson}}'。
# 腾讯云多参数模板可按实际顺序配置,例如 '{firstItem}|{count}'。
msgContentTemplate: ""
@@ -101,22 +107,22 @@ warning:
wechat:
enabled: false
- appId: "wx9e26905702c1a372"
- appSecret: ""
- templateId: "DMDP2PKktdtG1ES_3Ic1JcR0LGELQmWE6LunYyerHCY"
+ appId: "${APP_ID}"
+ appSecret: "${APP_SECRET}"
+ templateId: "${TEMPLATE_ID}"
page: "pages/message/index"
timeout: "5s"
database:
legacyCard:
- link: ""
+ link: "${DB_DSN}"
debug: false
logger:
path: "/app/logs/sql"
level: "all"
stdout: true
default: # 分组名称,可自定义,默认为default
- link: "mysql:${DB_USER}:${DB_PASSWORD}@tcp(${DB_HOST}:3306)/iotlcm?charset=utf8mb4&parseTime=True&loc=Asia%2FShanghai"
+ link: "${DB_DSN}"
debug: true #开启调试模式
charset: "utf8mb4" # (可选)数据库编码(如: utf8mb4/utf8/gbk/gb2312),一般设置为utf8mb4。默认为utf8。
dryRun: false #ORM空跑(只读不写)
@@ -134,10 +140,10 @@ redis:
# 单实例配置
default:
mode: "single" #redis模式,支持:single|cluster|sentinel
- sentinelMasterName: "${REDIS_SENTINEL_MASTER}" #哨兵模式主节点名称
- address: 192.0.2.200:6379
+ sentinelMasterName: "xiujie-master" #哨兵模式主节点名称
+ address: 127.0.0.1:6379
db: 0
- pass: "${REDIS_PASSWORD}"
+ pass: "${SERVICE_PASSWORD}"
minIdle: 5 #最小空闲连接数
maxIdle: 30 #允许闲置的最大连接数
maxActive: 5000 #最大活跃连接数
@@ -149,9 +155,9 @@ queue:
type: "asynq" # 队列类型
asynq:
redis:
- address: 192.0.2.200:6379
+ address: 127.0.0.1:6379
db: 0
- pass: "${REDIS_PASSWORD}"
+ pass: "${SERVICE_PASSWORD}"
user:
concurrency: 10 # 最大同时执行的任务数量
logLevel: 4 # 日志级别 DebugLevel 1, InfoLevel 2, WarnLevel 3, ErrorLevel 4, FatalLevel 5
diff --git a/server/go.mod b/server/go.mod
index e00ebcf..68f9d71 100644
--- a/server/go.mod
+++ b/server/go.mod
@@ -22,12 +22,17 @@ require (
github.com/robfig/cron/v3 v3.0.1
github.com/shirou/gopsutil/v3 v3.24.5
github.com/smartwalle/alipay/v3 v3.2.29
+ github.com/taosdata/driver-go/v3 v3.8.0
+ github.com/tealeg/xlsx v1.0.5
github.com/xiujiecn/asynq v0.25.10
github.com/xuri/excelize/v2 v2.9.0
go.opentelemetry.io/otel v1.38.0
golang.org/x/exp v0.0.0-20250305212735-054e65f0b394
golang.org/x/net v0.40.0
+ golang.org/x/sync v0.14.0
golang.org/x/tools v0.31.0
+ google.golang.org/grpc v1.64.1
+ google.golang.org/protobuf v1.36.6
gorm.io/gorm v1.25.12
)
@@ -70,8 +75,6 @@ require (
github.com/smartwalle/ngx v1.1.0 // indirect
github.com/smartwalle/nsign v1.0.9 // indirect
github.com/spf13/cast v1.7.0 // indirect
- github.com/taosdata/driver-go/v3 v3.8.0 // indirect
- github.com/tealeg/xlsx v1.0.5 // indirect
github.com/tklauser/go-sysconf v0.3.12 // indirect
github.com/tklauser/numcpus v0.6.1 // indirect
github.com/xuri/efp v0.0.0-20240408161823-9ad904a10d6d // indirect
@@ -84,13 +87,10 @@ require (
golang.org/x/crypto v0.38.0 // indirect
golang.org/x/image v0.23.0 // indirect
golang.org/x/mod v0.24.0 // indirect
- golang.org/x/sync v0.14.0 // indirect
golang.org/x/sys v0.35.0 // indirect
golang.org/x/text v0.25.0 // indirect
golang.org/x/time v0.8.0 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20240318140521-94a12d6c2237 // indirect
- google.golang.org/grpc v1.64.1 // indirect
- google.golang.org/protobuf v1.36.6 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
modernc.org/libc v1.22.5 // indirect
modernc.org/mathutil v1.5.0 // indirect
diff --git a/server/hack/config.yaml b/server/hack/config.yaml
index 82dec45..0b7b35b 100644
--- a/server/hack/config.yaml
+++ b/server/hack/config.yaml
@@ -4,7 +4,7 @@
gfcli:
gen:
dao:
- - link: "mysql:${DB_USER}:${DB_PASSWORD}@tcp(${DB_HOST}:3306)/db_iotlcm?charset=utf8mb4&parseTime=True&loc=Asia%2FShanghai"
+ - link: "mysql:root:sdyz2026@tcp(127.0.0.1:3306)/db_iotlcm?charset=utf8mb4&parseTime=True&loc=Asia%2FShanghai"
tables: "sys_*"
descriptionTag: true # 数据表字段注释
noModelComment: true
@@ -16,4 +16,4 @@ gfcli:
docker:
build: "-a amd64 -s linux -p temp -ew"
tagPrefixes:
- - my.image.pub/my-app
\ No newline at end of file
+ - my.image.pub/my-app
diff --git a/server/hack/hack.mk b/server/hack/hack.mk
index cbdb4cc..ba49a6f 100644
--- a/server/hack/hack.mk
+++ b/server/hack/hack.mk
@@ -89,7 +89,7 @@ export NOWTIME=$(shell date "+%Y%m%d%H%M%S")
.PHONY: clean_docker_iotlcmadmin create_docker_iotlcmadmin
clean_docker_iotlcmadmin:
- docker tag docker_iotlcmadmin:v0 ${REGISTRY}/docker_iotlcmadmin:v0
+ docker tag docker_iotlcmadmin:v0 docker_iotlcmadmin:v0.${NOWTIME}.bak
docker rmi docker_iotlcmadmin:v0
create_docker_iotlcmadmin:
@@ -102,6 +102,3 @@ mod:
dockerbuild: create_docker_iotlcmadmin
echo "docker build success"
-dockerpush:
- docker tag docker_iotlcmadmin:v0 ${REGISTRY}/docker_iotlcmadmin:v0
- docker push ${REGISTRY}/$1:$2
\ No newline at end of file
diff --git a/server/internal/cmd/cmd.go b/server/internal/cmd/cmd.go
index 7acaf5a..4d2b5a0 100644
--- a/server/internal/cmd/cmd.go
+++ b/server/internal/cmd/cmd.go
@@ -1,7 +1,7 @@
// Package cmd
// @Link https://github.com/xiujiecn/xiu-admin
// @Copyright Copyright (c) 2025 LiXiujie
-// @Author Lxj
+// @Author Lxj
// @License https://github.com/xiujiecn/xiu-admin/blob/master/LICENSE
package cmd
diff --git a/server/internal/cmd/global.go b/server/internal/cmd/global.go
index 8f40b6e..cda7e79 100644
--- a/server/internal/cmd/global.go
+++ b/server/internal/cmd/global.go
@@ -1,7 +1,7 @@
// Package cmd
// @Link https://github.com/xiujiecn/xiu-admin
// @Copyright Copyright (c) 2025 LiXiujie
-// @Author Lxj
+// @Author Lxj
// @License https://github.com/xiujiecn/xiu-admin/blob/master/LICENSE
package cmd
diff --git a/server/internal/cmd/http.go b/server/internal/cmd/http.go
index a3aa28f..d8ed9b1 100644
--- a/server/internal/cmd/http.go
+++ b/server/internal/cmd/http.go
@@ -3,6 +3,7 @@ package cmd
import (
"context"
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/cmd/inithttp"
+ "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/websocket"
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/router"
@@ -17,11 +18,19 @@ import (
func init() {
inithttp.RegisterHttpInitFunc("initJobTask", initJobTask)
+ inithttp.RegisterHttpInitFunc("exportCenter", initExportCenter)
inithttp.RegisterHttpCleanupFunc("rabbitmq", func(ctx context.Context) error {
return rabbitmq.Close()
})
}
+// initExportCenter 导出中心:补偿上次进程遗留的任务,并启动过期文件清理。
+func initExportCenter(ctx context.Context) error {
+ exportcenter.RecoverOnStart(ctx)
+ exportcenter.StartCleanup(ctx)
+ return nil
+}
+
var Http = &gcmd.Command{
Name: "http",
Usage: "http",
diff --git a/server/internal/cmd/initfuunc.go b/server/internal/cmd/initfuunc.go
index 4bc4d3b..26e793d 100644
--- a/server/internal/cmd/initfuunc.go
+++ b/server/internal/cmd/initfuunc.go
@@ -1,7 +1,7 @@
// Package cmd
// @Link https://github.com/xiujiecn/xiu-admin
// @Copyright Copyright (c) 2025 LiXiujie
-// @Author Lxj
+// @Author Lxj
// @License https://github.com/xiujiecn/xiu-admin/blob/master/LICENSE
package cmd
diff --git a/server/internal/cmd/monitor.go b/server/internal/cmd/monitor.go
index d114108..bf5357f 100644
--- a/server/internal/cmd/monitor.go
+++ b/server/internal/cmd/monitor.go
@@ -1,7 +1,7 @@
// Package cmd
// @Link https://github.com/xiujiecn/xiu-admin
// @Copyright Copyright (c) 2025 LiXiujie
-// @Author Lxj
+// @Author Lxj
// @License https://github.com/xiujiecn/xiu-admin/blob/master/LICENSE
package cmd
diff --git a/server/internal/consts/batch.go b/server/internal/consts/batch.go
index 72fc6c3..db08115 100644
--- a/server/internal/consts/batch.go
+++ b/server/internal/consts/batch.go
@@ -7,6 +7,7 @@ const (
BatchAsyncStatusSuccess = 3
BatchAsyncStatusFailed = 4
BatchAsyncStatusPartialSuccess = 5
+ BatchAsyncStatusReview = 6
)
const (
@@ -15,6 +16,7 @@ const (
BatchRunResultSuccess = 2
BatchRunResultFailed = 3
BatchRunResultPartialSuccess = 4
+ BatchRunResultReview = 5
)
const (
@@ -23,10 +25,12 @@ const (
)
const (
- BatchDetailStatusPending = 0
- BatchDetailStatusRunning = 1
- BatchDetailStatusSuccess = 2
- BatchDetailStatusFailed = 3
+ BatchDetailStatusPending = 0
+ BatchDetailStatusRunning = 1
+ BatchDetailStatusSuccess = 2
+ BatchDetailStatusFailed = 3
+ BatchDetailStatusUnknown = 4
+ BatchDetailStatusNotExecuted = 5
)
const (
@@ -44,6 +48,7 @@ const (
BatchTypePrepare = 4
BatchTypeCardAddPackage = 11
BatchTypePoolAddPackage = 12
+ BatchTypeExport = 14 // 数据导出(仅写执行日志,不出现在批次管理页)
BatchTypeSimWarningConfig = 40
BatchTypeTrafficCutoffConfig = 41
@@ -65,6 +70,7 @@ const (
BatchNoPrefixAllocate = "ALC"
BatchNoPrefixCardAddPackage = "ADP"
BatchNoPrefixPoolAddPackage = "PAP"
+ BatchNoPrefixExport = "EXP"
BatchNoPrefixSimWarningConfig = "SWC"
BatchNoPrefixTrafficCutoffConfig = "TCC"
diff --git a/server/internal/consts/code.go b/server/internal/consts/code.go
index 49fb677..b3cda02 100644
--- a/server/internal/consts/code.go
+++ b/server/internal/consts/code.go
@@ -1,7 +1,7 @@
// Package consts
// @Link https://github.com/xiujiecn/xiu-admin
// @Copyright Copyright (c) 2025 LiXiujie
-// @Author Lxj
+// @Author Lxj
// @License https://github.com/xiujiecn/xiu-admin/blob/master/LICENSE
package consts
diff --git a/server/internal/consts/config.go b/server/internal/consts/config.go
index b6bf46d..6ac44c7 100644
--- a/server/internal/consts/config.go
+++ b/server/internal/consts/config.go
@@ -1,7 +1,7 @@
// Package consts
// @Link https://github.com/xiujiecn/xiu-admin
// @Copyright Copyright (c) 2025 LiXiujie
-// @Author Lxj
+// @Author Lxj
// @License https://github.com/xiujiecn/xiu-admin/blob/master/LICENSE
package consts
diff --git a/server/internal/consts/consts.go b/server/internal/consts/consts.go
index da0878d..8b6530f 100644
--- a/server/internal/consts/consts.go
+++ b/server/internal/consts/consts.go
@@ -1,7 +1,7 @@
// Package consts
// @Link https://github.com/xiujiecn/xiu-admin
// @Copyright Copyright (c) 2025 LiXiujie
-// @Author Lxj
+// @Author Lxj
// @License https://github.com/xiujiecn/xiu-admin/blob/master/LICENSE
package consts
diff --git a/server/internal/consts/event.go b/server/internal/consts/event.go
index d568d9b..d38efcc 100644
--- a/server/internal/consts/event.go
+++ b/server/internal/consts/event.go
@@ -1,7 +1,7 @@
// Package consts
// @Link https://github.com/xiujiecn/xiu-admin
// @Copyright Copyright (c) 2025 LiXiujie
-// @Author Lxj
+// @Author Lxj
// @License https://github.com/xiujiecn/xiu-admin/blob/master/LICENSE
package consts
diff --git a/server/internal/consts/http.go b/server/internal/consts/http.go
index 6db02b7..166107f 100644
--- a/server/internal/consts/http.go
+++ b/server/internal/consts/http.go
@@ -1,7 +1,7 @@
// Package consts
// @Link https://github.com/xiujiecn/xiu-admin
// @Copyright Copyright (c) 2025 LiXiujie
-// @Author Lxj
+// @Author Lxj
// @License https://github.com/xiujiecn/xiu-admin/blob/master/LICENSE
package consts
diff --git a/server/internal/consts/mcache.go b/server/internal/consts/mcache.go
index 65f7e54..cf8d597 100644
--- a/server/internal/consts/mcache.go
+++ b/server/internal/consts/mcache.go
@@ -1,7 +1,7 @@
// Package consts
// @Link https://github.com/xiujiecn/xiu-admin
// @Copyright Copyright (c) 2025 LiXiujie
-// @Author Lxj
+// @Author Lxj
// @License https://github.com/xiujiecn/xiu-admin/blob/master/LICENSE
package consts
diff --git a/server/internal/consts/queue.go b/server/internal/consts/queue.go
index 92284d1..da4cbad 100644
--- a/server/internal/consts/queue.go
+++ b/server/internal/consts/queue.go
@@ -1,7 +1,7 @@
// Package consts
// @Link https://github.com/xiujiecn/xiu-admin
// @Copyright Copyright (c) 2025 LiXiujie
-// @Author Lxj
+// @Author Lxj
// @License https://github.com/xiujiecn/xiu-admin/blob/master/LICENSE
package consts
@@ -30,9 +30,12 @@ const (
QueueBalanceRechargeDelay = "iotlcm.balance.recharge.delay"
QueuePackageCustomerSync = "iotlcm.package_customer.sync"
QueueChannelRefresh = "iotlcm.channel.refresh"
- QueueAgentRoleSync = "iotlcm.agent.role.sync"
- QueueAgentRoleSyncRetry = "iotlcm.agent.role.sync.retry"
- QueueAgentRoleSyncDeadLetter = "iotlcm.agent.role.sync.dlq"
+ // QueueDataReviewQuery carries the manual "sync upstream result" command.
+ // Worker only re-reads existing operationIds/jobIds, never resubmits.
+ QueueDataReviewQuery = "iotlcm.data.review.query"
+ QueueAgentRoleSync = "iotlcm.agent.role.sync"
+ QueueAgentRoleSyncRetry = "iotlcm.agent.role.sync.retry"
+ QueueAgentRoleSyncDeadLetter = "iotlcm.agent.role.sync.dlq"
)
// 数据库变动类型
diff --git a/server/internal/consts/system.go b/server/internal/consts/system.go
index c12cf33..2e2172c 100644
--- a/server/internal/consts/system.go
+++ b/server/internal/consts/system.go
@@ -1,7 +1,7 @@
// Package consts
// @Link https://github.com/xiujiecn/xiu-admin
// @Copyright Copyright (c) 2025 LiXiujie
-// @Author Lxj
+// @Author Lxj
// @License https://github.com/xiujiecn/xiu-admin/blob/master/LICENSE
package consts
diff --git a/server/internal/consts/websocket.go b/server/internal/consts/websocket.go
index 9c49716..e26f8ff 100644
--- a/server/internal/consts/websocket.go
+++ b/server/internal/consts/websocket.go
@@ -1,7 +1,7 @@
// Package consts
// @Link https://github.com/xiujiecn/xiu-admin
// @Copyright Copyright (c) 2025 LiXiujie
-// @Author Lxj
+// @Author Lxj
// @License https://github.com/xiujiecn/xiu-admin/blob/master/LICENSE
package consts
diff --git a/server/internal/controller/agent/agent_dept.go b/server/internal/controller/agent/agent_dept.go
index 538ec13..9aebce2 100644
--- a/server/internal/controller/agent/agent_dept.go
+++ b/server/internal/controller/agent/agent_dept.go
@@ -8,12 +8,16 @@ import (
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/service"
)
+func (c *ControllerV1) DeptExport(ctx context.Context, req *v1.DeptExportReq) (*v1.DeptExportRes, error) {
+ return &v1.DeptExportRes{}, service.AgentDept().DeptExport(ctx, &req.AgentDeptListParam)
+}
+
func (c *ControllerV1) DeptList(ctx context.Context, req *v1.DeptListReq) (res *v1.DeptListRes, err error) {
data, total, err := service.AgentDept().DeptList(ctx, &req.AgentDeptListParam)
if err != nil {
return nil, err
}
- return &v1.DeptListRes{PageResult: response.PageResult{Page: 1, PageSize: 5000, Total: total}, Data: data}, nil
+ return &v1.DeptListRes{PageResult: response.PageResult{Page: req.Page, PageSize: req.PageSize, Total: total}, Data: data}, nil
}
func (c *ControllerV1) DeptView(ctx context.Context, req *v1.DeptViewReq) (res *v1.DeptViewRes, err error) {
diff --git a/server/internal/controller/agent/agent_user.go b/server/internal/controller/agent/agent_user.go
index 6c11228..386c6f3 100644
--- a/server/internal/controller/agent/agent_user.go
+++ b/server/internal/controller/agent/agent_user.go
@@ -8,6 +8,10 @@ import (
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/service"
)
+func (c *ControllerV1) UserExport(ctx context.Context, req *v1.UserExportReq) (*v1.UserExportRes, error) {
+ return &v1.UserExportRes{}, service.AgentUser().UserExport(ctx, &req.AgentUserListParam)
+}
+
func (c *ControllerV1) UserList(ctx context.Context, req *v1.UserListReq) (res *v1.UserListRes, err error) {
data, total, err := service.AgentUser().UserList(ctx, &req.AgentUserListParam)
if err != nil {
diff --git a/server/internal/controller/common/common_v1_batchExecutionLog.go b/server/internal/controller/common/common_v1_batchExecutionLog.go
index 649fb9c..2f38b9c 100644
--- a/server/internal/controller/common/common_v1_batchExecutionLog.go
+++ b/server/internal/controller/common/common_v1_batchExecutionLog.go
@@ -51,3 +51,13 @@ func (c *ControllerV1) BatchExecutionLogDetailList(ctx context.Context, req *v1.
res.PageResult.Total = totalCount
return
}
+
+func (c *ControllerV1) BatchExecutionLogSyncReview(ctx context.Context, req *v1.BatchExecutionLogSyncReviewReq) (res *v1.BatchExecutionLogSyncReviewRes, err error) {
+ data, err := service.CommonBatchExecutionLog().SyncReview(ctx, &req.BatchExecutionLogSyncReviewParam)
+ if err != nil {
+ return
+ }
+ res = new(v1.BatchExecutionLogSyncReviewRes)
+ res.BatchExecutionLogSyncReviewModel = data
+ return
+}
diff --git a/server/internal/controller/common/common_v1_batchMain.go b/server/internal/controller/common/common_v1_batchMain.go
index 4b5a1fb..6f612aa 100644
--- a/server/internal/controller/common/common_v1_batchMain.go
+++ b/server/internal/controller/common/common_v1_batchMain.go
@@ -1,7 +1,7 @@
// Package gen
// @Link https://github.com/xiujiecn/xiu-admin
// @Copyright Copyright (c) 2026 LiXiujie
-// @Author Lxj
+// @Author Lxj
// @License https://github.com/xiujiecn/xiu-admin/blob/master/LICENSE
// @AutoGenerate Version
package common
@@ -69,6 +69,12 @@ func (c *ControllerV1) BatchMainCards(ctx context.Context, req *v1.BatchMainCard
return
}
+// ExportWarehousing 导出入库批次列表(数据与入库管理页一致)
+func (c *ControllerV1) BatchMainExportWarehousing(ctx context.Context, req *v1.BatchMainExportWarehousingReq) (res *v1.BatchMainExportWarehousingRes, err error) {
+ err = service.CommonBatchMain().ExportWarehousing(ctx, &req.BatchMainSuccessListParam)
+ return
+}
+
// Export 导出批次主表列表
func (c *ControllerV1) BatchMainExport(ctx context.Context, req *v1.BatchMainExportReq) (res *v1.BatchMainExportRes, err error) {
err = service.CommonBatchMain().Export(ctx, &req.BatchMainListParam)
diff --git a/server/internal/controller/common/common_v1_downloadCenter.go b/server/internal/controller/common/common_v1_downloadCenter.go
new file mode 100644
index 0000000..34bbe60
--- /dev/null
+++ b/server/internal/controller/common/common_v1_downloadCenter.go
@@ -0,0 +1,49 @@
+package common
+
+import (
+ "context"
+
+ v1 "git.5iot.com/iotlcm-public/iotlcm-admin-svr/api/common/v1"
+ "git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/model"
+ "git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/service"
+)
+
+func (c *ControllerV1) DownloadCenterList(ctx context.Context, req *v1.DownloadCenterListReq) (res *v1.DownloadCenterListRes, err error) {
+ list, totalCount, err := service.CommonDownloadCenter().List(ctx, &req.DownloadCenterListParam)
+ if err != nil {
+ return
+ }
+ if list == nil {
+ list = []*model.DownloadCenterListModel{}
+ }
+ res = new(v1.DownloadCenterListRes)
+ res.Items = list
+ res.PageResult.Page = req.Page
+ res.PageResult.PageSize = req.PageSize
+ res.PageResult.Total = totalCount
+ return
+}
+
+func (c *ControllerV1) DownloadCenterRecent(ctx context.Context, req *v1.DownloadCenterRecentReq) (res *v1.DownloadCenterRecentRes, err error) {
+ data, err := service.CommonDownloadCenter().Recent(ctx, &req.DownloadCenterRecentParam)
+ if err != nil {
+ return
+ }
+ res = new(v1.DownloadCenterRecentRes)
+ res.DownloadCenterRecentModel = data
+ return
+}
+
+func (c *ControllerV1) DownloadCenterDownload(ctx context.Context, req *v1.DownloadCenterDownloadReq) (res *v1.DownloadCenterDownloadRes, err error) {
+ if err = service.CommonDownloadCenter().Download(ctx, &req.DownloadCenterTaskParam); err != nil {
+ return nil, err
+ }
+ return nil, nil
+}
+
+func (c *ControllerV1) DownloadCenterRemove(ctx context.Context, req *v1.DownloadCenterRemoveReq) (res *v1.DownloadCenterRemoveRes, err error) {
+ if err = service.CommonDownloadCenter().Remove(ctx, &req.DownloadCenterTaskParam); err != nil {
+ return nil, err
+ }
+ return new(v1.DownloadCenterRemoveRes), nil
+}
diff --git a/server/internal/controller/common/common_v1_tagentbalance.go b/server/internal/controller/common/common_v1_tagentbalance.go
index 0ca1750..e3e73a6 100644
--- a/server/internal/controller/common/common_v1_tagentbalance.go
+++ b/server/internal/controller/common/common_v1_tagentbalance.go
@@ -1,7 +1,7 @@
// Package gen
// @Link https://github.com/xiujiecn/xiu-admin
// @Copyright Copyright (c) 2026 LiXiujie
-// @Author Lxj
+// @Author Lxj
// @License https://github.com/xiujiecn/xiu-admin/blob/master/LICENSE
// @AutoGenerate Version
package common
diff --git a/server/internal/controller/common/common_v1_tbalancedetail.go b/server/internal/controller/common/common_v1_tbalancedetail.go
index 4390ae9..c3d2162 100644
--- a/server/internal/controller/common/common_v1_tbalancedetail.go
+++ b/server/internal/controller/common/common_v1_tbalancedetail.go
@@ -1,7 +1,7 @@
// Package gen
// @Link https://github.com/xiujiecn/xiu-admin
// @Copyright Copyright (c) 2026 LiXiujie
-// @Author Lxj
+// @Author Lxj
// @License https://github.com/xiujiecn/xiu-admin/blob/master/LICENSE
// @AutoGenerate Version
package common
diff --git a/server/internal/controller/common/common_v1_tconsumeaccountmonth_bill.go b/server/internal/controller/common/common_v1_tconsumeaccountmonth_bill.go
index 508f9d4..d977547 100644
--- a/server/internal/controller/common/common_v1_tconsumeaccountmonth_bill.go
+++ b/server/internal/controller/common/common_v1_tconsumeaccountmonth_bill.go
@@ -1,7 +1,7 @@
// Package gen
// @Link https://github.com/xiujiecn/xiu-admin
// @Copyright Copyright (c) 2026 LiXiujie
-// @Author Lxj
+// @Author Lxj
// @License https://github.com/xiujiecn/xiu-admin/blob/master/LICENSE
// @AutoGenerate Version
package common
diff --git a/server/internal/controller/common/common_v1_tcreditdetail.go b/server/internal/controller/common/common_v1_tcreditdetail.go
index 0be8961..c292ea4 100644
--- a/server/internal/controller/common/common_v1_tcreditdetail.go
+++ b/server/internal/controller/common/common_v1_tcreditdetail.go
@@ -1,7 +1,7 @@
// Package gen
// @Link https://github.com/xiujiecn/xiu-admin
// @Copyright Copyright (c) 2026 LiXiujie
-// @Author Lxj
+// @Author Lxj
// @License https://github.com/xiujiecn/xiu-admin/blob/master/LICENSE
// @AutoGenerate Version
package common
diff --git a/server/internal/controller/common/common_v1_tfincommissiondetail.go b/server/internal/controller/common/common_v1_tfincommissiondetail.go
index 2e4bf18..ed96a3d 100644
--- a/server/internal/controller/common/common_v1_tfincommissiondetail.go
+++ b/server/internal/controller/common/common_v1_tfincommissiondetail.go
@@ -1,7 +1,7 @@
// Package gen
// @Link https://github.com/xiujiecn/xiu-admin
// @Copyright Copyright (c) 2026 LiXiujie
-// @Author Lxj
+// @Author Lxj
// @License https://github.com/xiujiecn/xiu-admin/blob/master/LICENSE
// @AutoGenerate Version
package common
diff --git a/server/internal/controller/common/common_v1_tfinwithdrawablechange.go b/server/internal/controller/common/common_v1_tfinwithdrawablechange.go
index 6a4ff19..2914ec0 100644
--- a/server/internal/controller/common/common_v1_tfinwithdrawablechange.go
+++ b/server/internal/controller/common/common_v1_tfinwithdrawablechange.go
@@ -1,7 +1,7 @@
// Package gen
// @Link https://github.com/xiujiecn/xiu-admin
// @Copyright Copyright (c) 2026 LiXiujie
-// @Author Lxj
+// @Author Lxj
// @License https://github.com/xiujiecn/xiu-admin/blob/master/LICENSE
// @AutoGenerate Version
package common
diff --git a/server/internal/controller/common/common_v1_tmessagelog.go b/server/internal/controller/common/common_v1_tmessagelog.go
index 9e34434..254cd40 100644
--- a/server/internal/controller/common/common_v1_tmessagelog.go
+++ b/server/internal/controller/common/common_v1_tmessagelog.go
@@ -55,3 +55,32 @@ func (c *ControllerV1) TMessageLogDelete(ctx context.Context, req *v1.TMessageLo
err = service.CommonTMessageLog().Delete(ctx, &req.TMessageLogDeleteParam)
return
}
+
+// UnreadList 获取当前用户未读消息通知
+func (c *ControllerV1) TMessageLogUnreadList(ctx context.Context, req *v1.TMessageLogUnreadListReq) (res *v1.TMessageLogUnreadListRes, err error) {
+ list, totalCount, err := service.CommonTMessageLog().UnreadList(ctx, &req.TMessageLogUnreadListParam)
+ if err != nil {
+ return
+ }
+
+ if list == nil {
+ list = []*model.TMessageLogUnreadListModel{}
+ }
+
+ res = new(v1.TMessageLogUnreadListRes)
+ res.Items = list
+ res.Total = totalCount
+ return
+}
+
+// Read 标记当前用户指定消息已读
+func (c *ControllerV1) TMessageLogRead(ctx context.Context, req *v1.TMessageLogReadReq) (res *v1.TMessageLogReadRes, err error) {
+ err = service.CommonTMessageLog().Read(ctx, &req.TMessageLogReadParam)
+ return
+}
+
+// ReadAll 标记当前用户全部消息已读
+func (c *ControllerV1) TMessageLogReadAll(ctx context.Context, req *v1.TMessageLogReadAllReq) (res *v1.TMessageLogReadAllRes, err error) {
+ err = service.CommonTMessageLog().ReadAll(ctx, &req.TMessageLogReadAllParam)
+ return
+}
diff --git a/server/internal/controller/common/common_v1_tprofitaccountmonth_bill.go b/server/internal/controller/common/common_v1_tprofitaccountmonth_bill.go
index 6978ae1..280a4af 100644
--- a/server/internal/controller/common/common_v1_tprofitaccountmonth_bill.go
+++ b/server/internal/controller/common/common_v1_tprofitaccountmonth_bill.go
@@ -1,7 +1,7 @@
// Package gen
// @Link https://github.com/xiujiecn/xiu-admin
// @Copyright Copyright (c) 2026 LiXiujie
-// @Author Lxj
+// @Author Lxj
// @License https://github.com/xiujiecn/xiu-admin/blob/master/LICENSE
// @AutoGenerate Version
package common
diff --git a/server/internal/controller/common/t_division_code.go b/server/internal/controller/common/t_division_code.go
index 2d2402f..1ce386b 100644
--- a/server/internal/controller/common/t_division_code.go
+++ b/server/internal/controller/common/t_division_code.go
@@ -1,7 +1,7 @@
// Package iot
// @Link https://github.com/xiujiecn/xiu-admin
// @Copyright Copyright (c) 2026 LiXiujie
-// @Author Lxj
+// @Author Lxj
// @License https://github.com/xiujiecn/xiu-admin/blob/master/LICENSE
// @AutoGenerate Version
package common
diff --git a/server/internal/controller/gen/test_demo.go b/server/internal/controller/gen/test_demo.go
index 8e9cffd..1dee489 100644
--- a/server/internal/controller/gen/test_demo.go
+++ b/server/internal/controller/gen/test_demo.go
@@ -1,7 +1,7 @@
// Package gen
// @Link https://github.com/xiujiecn/xiu-admin
// @Copyright Copyright (c) 2025 LiXiujie
-// @Author Lxj
+// @Author Lxj
// @License https://github.com/xiujiecn/xiu-admin/blob/master/LICENSE
// @AutoGenerate Version
package gen
diff --git a/server/internal/controller/iot/full_sync_monitor_start_test.go b/server/internal/controller/iot/full_sync_monitor_start_test.go
new file mode 100644
index 0000000..9ae7f0a
--- /dev/null
+++ b/server/internal/controller/iot/full_sync_monitor_start_test.go
@@ -0,0 +1,292 @@
+package iot
+
+import (
+ "context"
+ "encoding/json"
+ "net/http"
+ "net/http/httptest"
+ "reflect"
+ "testing"
+
+ "github.com/gogf/gf/v2/errors/gcode"
+ "github.com/gogf/gf/v2/errors/gerror"
+ v1 "git.5iot.com/iotlcm-public/iotlcm-admin-svr/api/iot/v1"
+ "git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/contexts"
+ _ "git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/logic/imeiqueue"
+ _ "git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/logic/simstatusqueue"
+ _ "git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/logic/trafficusage"
+ "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/service/fullsyncretry"
+)
+
+type scopedStarter func(context.Context, *model.SyncScope) (string, error)
+
+// periodStarter is the shape of the *At starters: a scope plus a yyyyMMdd or
+// yyyyMM period.
+type periodStarter func(context.Context, *model.SyncScope, string) (string, error)
+
+type monthlyStarterStub struct {
+ service.ITrafficUsageMonthlySync
+ current scopedStarter
+ history scopedStarter
+ at periodStarter
+}
+
+func (s monthlyStarterStub) StartMonthlyRunScoped(ctx context.Context, scope *model.SyncScope) (string, error) {
+ return s.current(ctx, scope)
+}
+
+func (s monthlyStarterStub) StartMonthlyHistoryRunScoped(ctx context.Context, scope *model.SyncScope) (string, error) {
+ return s.history(ctx, scope)
+}
+
+func (s monthlyStarterStub) StartMonthlyHistoryRunScopedAt(ctx context.Context, scope *model.SyncScope, queryMonth string) (string, error) {
+ return s.at(ctx, scope, queryMonth)
+}
+
+type dailyStarterStub struct {
+ service.ITrafficUsageDailySync
+ start scopedStarter
+ at periodStarter
+}
+
+func (s dailyStarterStub) StartDailyRunScoped(ctx context.Context, scope *model.SyncScope) (string, error) {
+ return s.start(ctx, scope)
+}
+
+func (s dailyStarterStub) StartDailyRunScopedAt(ctx context.Context, scope *model.SyncScope, queryDate string) (string, error) {
+ return s.at(ctx, scope, queryDate)
+}
+
+type statusStarterStub struct {
+ service.ISimStatusQueue
+ start scopedStarter
+}
+
+func (s statusStarterStub) StartFullSyncRunScoped(ctx context.Context, scope *model.SyncScope) (string, error) {
+ return s.start(ctx, scope)
+}
+
+type imeiStarterStub struct {
+ service.IImeiQueue
+ start scopedStarter
+}
+
+func (s imeiStarterStub) StartImeiRunScoped(ctx context.Context, scope *model.SyncScope) (string, error) {
+ return s.start(ctx, scope)
+}
+
+func installFullSyncStarters(t *testing.T, start func(kind string, scope *model.SyncScope, periods ...string) (string, error)) {
+ t.Helper()
+ var (
+ monthly = service.TrafficUsageMonthlySync()
+ daily = service.TrafficUsageDailySync()
+ status = service.SimStatusQueue()
+ imei = service.ImeiQueue()
+ )
+ t.Cleanup(func() {
+ service.RegisterTrafficUsageMonthlySync(monthly)
+ service.RegisterTrafficUsageDailySync(daily)
+ service.RegisterSimStatusQueue(status)
+ service.RegisterImeiQueue(imei)
+ })
+ forKind := func(kind string) scopedStarter {
+ return func(_ context.Context, scope *model.SyncScope) (string, error) { return start(kind, scope) }
+ }
+ forKindAt := func(kind string) periodStarter {
+ return func(_ context.Context, scope *model.SyncScope, period string) (string, error) {
+ return start(kind, scope, period)
+ }
+ }
+ service.RegisterTrafficUsageMonthlySync(monthlyStarterStub{current: forKind("traffic_usage"), history: forKind("traffic_usage_monthly"), at: forKindAt("traffic_usage_monthly")})
+ service.RegisterTrafficUsageDailySync(dailyStarterStub{start: forKind("traffic_usage_daily"), at: forKindAt("traffic_usage_daily")})
+ service.RegisterSimStatusQueue(statusStarterStub{start: forKind("sim_status")})
+ service.RegisterImeiQueue(imeiStarterStub{start: forKind("imei")})
+}
+
+func fullSyncActorContext() context.Context {
+ return contexts.Set(context.Background(), &model.Context{User: &model.Identity{BaseClaims: model.BaseClaims{ID: 42}}})
+}
+
+func TestFullSyncStartAndMonitorAllFiveKinds(t *testing.T) {
+ var started []string
+ var periods []string
+ installFullSyncStarters(t, func(kind string, scope *model.SyncScope, period ...string) (string, error) {
+ started = append(started, kind)
+ if scope.TriggerSource != model.TriggerSourceManual || !reflect.DeepEqual(scope.Channels, []string{"a", "b"}) {
+ t.Errorf("%s scope = %+v", kind, scope)
+ }
+ // Without a period field the *At starters must receive an empty period,
+ // i.e. the run keeps its historical default window.
+ periods = append(periods, period...)
+ return "run-" + kind, nil
+ })
+ var filters []string
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodGet || r.URL.Path != "/internal/v1/full-sync-runs" || r.URL.Query().Get("limit") != "100" {
+ t.Errorf("unexpected active-run request: %s %s", r.Method, r.URL)
+ }
+ filters = append(filters, r.URL.Query().Get("syncKind"))
+ _, _ = w.Write([]byte(`{"items":[],"total":0}`))
+ }))
+ defer server.Close()
+ controller := &FullSyncMonitorController{client: fullsyncretry.Client{BaseURL: server.URL}}
+ tests := []struct{ kind, operation string }{
+ {"traffic_usage", "current_month_usage_full_sync"},
+ {"sim_status", "sim_status_queue_full_sync"},
+ {"imei", "imei_full_sync"},
+ {"traffic_usage_daily", "previous_day_usage_full_sync"},
+ {"traffic_usage_monthly", "previous_month_usage_full_sync"},
+ }
+ for _, tt := range tests {
+ t.Run(tt.kind, func(t *testing.T) {
+ var req v1.FullSyncRunStartReq
+ body, _ := json.Marshal(map[string]any{"syncKind": tt.kind, "channels": []string{"b", "a", "b"}})
+ if err := json.Unmarshal(body, &req); err != nil {
+ t.Fatal(err)
+ }
+ res, err := controller.Start(fullSyncActorContext(), &req)
+ if err != nil || res.RunID != "run-"+tt.kind || res.SyncKind != tt.kind || res.Status != "published" || res.ScopeChannels != 2 {
+ t.Fatalf("start = %+v, err = %v", res, err)
+ }
+ if res.QueryPeriod != "" {
+ t.Fatalf("%s queryPeriod = %q, want empty for the default period", tt.kind, res.QueryPeriod)
+ }
+ if got, name := fullSyncKindName(tt.operation); got != tt.kind || name == "" {
+ t.Fatalf("operation %q maps to %q, %q", tt.operation, got, name)
+ }
+ if _, err := controller.List(context.Background(), &v1.FullSyncRunsListReq{SyncKind: tt.kind, Limit: 100}); err != nil {
+ t.Fatal(err)
+ }
+ })
+ }
+ if len(started) != 5 || len(filters) != 10 {
+ t.Fatalf("starts = %v, filters = %v", started, filters)
+ }
+ // Only the daily and monthly-history kinds go through a period-aware starter.
+ if len(periods) != 2 || periods[0] != "" || periods[1] != "" {
+ t.Fatalf("periods = %v, want two empty periods", periods)
+ }
+ for i, tt := range tests {
+ if started[i] != tt.kind || filters[2*i] != tt.kind || filters[2*i+1] != tt.kind {
+ t.Fatalf("incorrect kind routing: starts = %v, filters = %v", started, filters)
+ }
+ }
+}
+
+func TestFullSyncStartRejectsInvalidAndActiveRequests(t *testing.T) {
+ starts, queries := 0, 0
+ installFullSyncStarters(t, func(string, *model.SyncScope, ...string) (string, error) {
+ starts++
+ return "unexpected", nil
+ })
+ active := "running"
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ queries++
+ _ = json.NewEncoder(w).Encode(fullsyncretry.RunList{Items: []fullsyncretry.RunView{{Run: fullsyncretry.RunMeta{Status: active}}}})
+ }))
+ defer server.Close()
+ controller := &FullSyncMonitorController{client: fullsyncretry.Client{BaseURL: server.URL}}
+ if _, err := controller.Start(context.Background(), &v1.FullSyncRunStartReq{SyncKind: "imei"}); gerror.Code(err) != gcode.CodeNotAuthorized {
+ t.Fatalf("anonymous start = %v", err)
+ }
+ for _, req := range []v1.FullSyncRunStartReq{
+ {SyncKind: ""}, {SyncKind: "unknown"}, {SyncKind: "imei", Channels: []string{"bad/channel"}},
+ } {
+ if _, err := controller.Start(fullSyncActorContext(), &req); gerror.Code(err) != gcode.CodeInvalidParameter {
+ t.Fatalf("invalid request %+v = %v", req, err)
+ }
+ }
+ if queries != 0 {
+ t.Fatal("invalid request queried Worker")
+ }
+ for _, kind := range []string{"traffic_usage", "sim_status", "imei", "traffic_usage_daily", "traffic_usage_monthly"} {
+ for _, active = range []string{"running", "producing"} {
+ if _, err := controller.Start(fullSyncActorContext(), &v1.FullSyncRunStartReq{SyncKind: kind}); gerror.Code(err) != gcode.CodeInvalidParameter {
+ t.Fatalf("%s/%s active run = %v", kind, active, err)
+ }
+ }
+ }
+ if starts != 0 || queries != 10 {
+ t.Fatalf("starts = %d, queries = %d", starts, queries)
+ }
+}
+
+// TestFullSyncStartForwardsQueryPeriod pins the backfill wiring: the requested
+// period must reach the period-aware starter, and the response must echo it so
+// the operator can see which window the run covers.
+func TestFullSyncStartForwardsQueryDate(t *testing.T) {
+ assertFullSyncPeriodForwarded(t, "traffic_usage_daily", "20261014")
+}
+
+func TestFullSyncStartForwardsQueryMonth(t *testing.T) {
+ assertFullSyncPeriodForwarded(t, "traffic_usage_monthly", "202604")
+}
+
+func assertFullSyncPeriodForwarded(t *testing.T, syncKind, period string) {
+ t.Helper()
+ type call struct{ kind, period string }
+ var calls []call
+ installFullSyncStarters(t, func(kind string, _ *model.SyncScope, periods ...string) (string, error) {
+ calls = append(calls, call{kind: kind, period: periods[0]})
+ return "run-" + kind, nil
+ })
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ _, _ = w.Write([]byte(`{"items":[],"total":0}`))
+ }))
+ defer server.Close()
+ controller := &FullSyncMonitorController{client: fullsyncretry.Client{BaseURL: server.URL}}
+
+ req := &v1.FullSyncRunStartReq{SyncKind: syncKind}
+ if syncKind == "traffic_usage_daily" {
+ req.QueryDate = period
+ } else {
+ req.QueryMonth = period
+ }
+ res, err := controller.Start(fullSyncActorContext(), req)
+ if err != nil {
+ t.Fatalf("start = %v", err)
+ }
+ if res.QueryPeriod != period {
+ t.Fatalf("queryPeriod = %q, want %q", res.QueryPeriod, period)
+ }
+ if len(calls) != 1 || calls[0].kind != syncKind || calls[0].period != period {
+ t.Fatalf("starter calls = %+v, want one %s call with period %s", calls, syncKind, period)
+ }
+}
+
+// TestFullSyncStartRejectsMismatchedPeriodField keeps a stray period field from
+// silently doing nothing: queryMonth on a daily run is a caller mistake.
+func TestFullSyncStartRejectsMismatchedPeriodField(t *testing.T) {
+ starts := 0
+ installFullSyncStarters(t, func(string, *model.SyncScope, ...string) (string, error) {
+ starts++
+ return "unexpected", nil
+ })
+ queries := 0
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ queries++
+ _, _ = w.Write([]byte(`{"items":[],"total":0}`))
+ }))
+ defer server.Close()
+ controller := &FullSyncMonitorController{client: fullsyncretry.Client{BaseURL: server.URL}}
+
+ for _, req := range []v1.FullSyncRunStartReq{
+ {SyncKind: "traffic_usage_daily", QueryMonth: "202604"},
+ {SyncKind: "traffic_usage_monthly", QueryDate: "20261014"},
+ {SyncKind: "imei", QueryDate: "20261014"},
+ {SyncKind: "sim_status", QueryMonth: "202604"},
+ {SyncKind: "traffic_usage", QueryMonth: "202604"},
+ } {
+ if _, err := controller.Start(fullSyncActorContext(), &req); gerror.Code(err) != gcode.CodeInvalidParameter {
+ t.Fatalf("mismatched request %+v = %v, want invalid parameter", req, err)
+ }
+ }
+ if starts != 0 {
+ t.Fatalf("starters called %d times for rejected requests", starts)
+ }
+ if queries != 0 {
+ t.Fatal("rejected request queried Worker")
+ }
+}
diff --git a/server/internal/controller/iot/full_sync_replay_test.go b/server/internal/controller/iot/full_sync_replay_test.go
new file mode 100644
index 0000000..01375a8
--- /dev/null
+++ b/server/internal/controller/iot/full_sync_replay_test.go
@@ -0,0 +1,83 @@
+package iot
+
+import (
+ "context"
+ "encoding/json"
+ "net/http"
+ "net/http/httptest"
+ "testing"
+
+ "github.com/gogf/gf/v2/errors/gcode"
+ "github.com/gogf/gf/v2/errors/gerror"
+ "github.com/gogf/gf/v2/frame/g"
+ v1 "git.5iot.com/iotlcm-public/iotlcm-admin-svr/api/iot/v1"
+ "git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/service/fullsyncretry"
+)
+
+func TestFullSyncReplayRequestMaxRoundsThroughController(t *testing.T) {
+ var bodies []map[string]any
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodPost || r.URL.Path != "/internal/v1/full-sync-runs/source/replay" || r.Header.Get("Content-Type") != "application/json" {
+ t.Errorf("unexpected Worker request: %s %s", r.Method, r.URL)
+ }
+ var body map[string]any
+ if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
+ t.Error(err)
+ }
+ bodies = append(bodies, body)
+ w.WriteHeader(http.StatusAccepted)
+ _, _ = w.Write([]byte(`{"replayRunId":"replay-1","status":"accepted"}`))
+ }))
+ defer server.Close()
+ controller := &FullSyncReplayController{client: fullsyncretry.Client{BaseURL: server.URL}}
+ tests := []struct {
+ body string
+ rounds int
+ }{
+ {`{"sourceRunId":"source"}`, 0},
+ {`{"sourceRunId":"source","maxRounds":0}`, 0},
+ {`{"sourceRunId":"source","maxRounds":1}`, 1},
+ {`{"sourceRunId":"source","maxRounds":3}`, 3},
+ {`{"sourceRunId":"source","maxRounds":7}`, 7},
+ {`{"sourceRunId":"source","maxRounds":100}`, 100},
+ }
+ for i, tt := range tests {
+ var req v1.FullSyncReplayReq
+ if err := json.Unmarshal([]byte(tt.body), &req); err != nil {
+ t.Fatal(err)
+ }
+ if err := g.Validator().Data(req).Run(context.Background()); err != nil {
+ t.Fatalf("valid request %s: %v", tt.body, err)
+ }
+ res, err := controller.Replay(fullSyncActorContext(), &req)
+ if err != nil || res.ReplayRunID != "replay-1" || res.Status != "accepted" {
+ t.Fatalf("replay = %+v, err = %v", res, err)
+ }
+ body := bodies[i]
+ if body["requestedBy"] != "42" {
+ t.Fatalf("actor not forwarded: %+v", body)
+ }
+ if tt.rounds == 0 {
+ if len(body) != 1 {
+ t.Fatalf("legacy Worker must receive no extra field: %+v", body)
+ }
+ } else if len(body) != 2 || body["maxRounds"] != float64(tt.rounds) {
+ t.Fatalf("rounds not forwarded: %+v", body)
+ }
+ }
+ for _, rounds := range []int{-1, 101} {
+ req := v1.FullSyncReplayReq{SourceRunID: "source", MaxRounds: rounds}
+ if err := g.Validator().Data(req).Run(context.Background()); err == nil {
+ t.Fatalf("API accepted maxRounds=%d", rounds)
+ }
+ if _, err := controller.Replay(fullSyncActorContext(), &req); gerror.Code(err) != gcode.CodeInvalidParameter {
+ t.Fatalf("controller accepted maxRounds=%d: %v", rounds, err)
+ }
+ }
+ if _, err := controller.Replay(context.Background(), &v1.FullSyncReplayReq{SourceRunID: "source", MaxRounds: 3}); gerror.Code(err) != gcode.CodeNotAuthorized {
+ t.Fatalf("anonymous replay = %v", err)
+ }
+ if len(bodies) != len(tests) {
+ t.Fatalf("invalid/anonymous request reached Worker: %d calls", len(bodies))
+ }
+}
diff --git a/server/internal/controller/iot/iot_v1_tsimcardallocate.go b/server/internal/controller/iot/iot_v1_tsimcardallocate.go
index cca93a0..deb0f78 100644
--- a/server/internal/controller/iot/iot_v1_tsimcardallocate.go
+++ b/server/internal/controller/iot/iot_v1_tsimcardallocate.go
@@ -4,6 +4,7 @@ import (
"context"
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/api/iot/v1"
+ "git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/model"
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/service"
)
@@ -17,10 +18,13 @@ func (c *ControllerV1) TSimCardAllocateTargetList(ctx context.Context, req *v1.T
if err != nil {
return nil, err
}
- return &v1.TSimCardAllocateTargetListRes{Items: items}, nil
+ return &v1.TSimCardAllocateTargetListRes{Items: items, MaxInputRows: model.SimCardAllocateMaxInputRows}, nil
}
func (c *ControllerV1) TSimCardAllocateInputPrepare(ctx context.Context, req *v1.TSimCardAllocateInputPrepareReq) (res *v1.TSimCardAllocateInputPrepareRes, err error) {
+ if err = req.ValidateInput(); err != nil {
+ return nil, err
+ }
data, err := service.IotTSimCardAllocate().PrepareInput(ctx, &req.TSimCardAllocatePrepareParam)
if err != nil {
return nil, err
diff --git a/server/internal/controller/iot/simcardquery.go b/server/internal/controller/iot/simcardquery.go
new file mode 100644
index 0000000..0193536
--- /dev/null
+++ b/server/internal/controller/iot/simcardquery.go
@@ -0,0 +1,152 @@
+package iot
+
+import (
+ "bytes"
+ "context"
+ "io"
+ "time"
+
+ v1 "git.5iot.com/iotlcm-public/iotlcm-admin-svr/api/iot/v1"
+ "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/simcardquery"
+ "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/errors/gerror"
+ "github.com/gogf/gf/v2/frame/g"
+ "github.com/gogf/gf/v2/net/ghttp"
+)
+
+// simCardQueryService 两个查询功能共用同一份实现,避免重复注册。
+var simCardQueryService = simcardquery.New()
+
+// SimCardBaseQueryTemplate 下载卡基础信息查询模板
+func (c *ControllerV1) SimCardBaseQueryTemplate(ctx context.Context, req *v1.SimCardBaseQueryTemplateReq) (res *v1.SimCardBaseQueryTemplateRes, err error) {
+ file, name, err := simCardQueryService.BaseTemplate(ctx)
+ if err != nil {
+ return nil, err
+ }
+ defer file.Close()
+ return &v1.SimCardBaseQueryTemplateRes{}, iot_excelize.WriteHTTP(ctx, file, name)
+}
+
+// SimCardBaseQueryColumns 卡基础信息查询可选导出列
+func (c *ControllerV1) SimCardBaseQueryColumns(ctx context.Context, req *v1.SimCardBaseQueryColumnsReq) (res *v1.SimCardBaseQueryColumnsRes, err error) {
+ return &v1.SimCardBaseQueryColumnsRes{Items: simCardQueryService.BaseColumnOptions()}, nil
+}
+
+// SimCardBaseQueryExport 提交卡基础信息查询导出
+func (c *ControllerV1) SimCardBaseQueryExport(ctx context.Context, req *v1.SimCardBaseQueryExportReq) (res *v1.SimCardBaseQueryExportRes, err error) {
+ input, err := excelInput(ctx, req.Mode)
+ if err != nil {
+ return nil, err
+ }
+ submit, err := simCardQueryService.ExportBase(ctx, &req.SimCardQueryExportParam, input)
+ if err != nil {
+ return nil, err
+ }
+ return &v1.SimCardBaseQueryExportRes{SimCardQuerySubmitRes: toSubmitRes(submit)}, nil
+}
+
+// SimCardUsageHistoryTemplate 下载月历史用量查询模板
+func (c *ControllerV1) SimCardUsageHistoryTemplate(ctx context.Context, req *v1.SimCardUsageHistoryTemplateReq) (res *v1.SimCardUsageHistoryTemplateRes, err error) {
+ file, name, err := simCardQueryService.UsageHistoryTemplate(ctx)
+ if err != nil {
+ return nil, err
+ }
+ defer file.Close()
+ return &v1.SimCardUsageHistoryTemplateRes{}, iot_excelize.WriteHTTP(ctx, file, name)
+}
+
+// SimCardUsageHistoryColumns 月历史用量查询固定列与近12个月月份清单
+func (c *ControllerV1) SimCardUsageHistoryColumns(ctx context.Context, req *v1.SimCardUsageHistoryColumnsReq) (res *v1.SimCardUsageHistoryColumnsRes, err error) {
+ return &v1.SimCardUsageHistoryColumnsRes{
+ Items: simCardQueryService.UsageHistoryColumnOptions(),
+ Months: simCardQueryService.UsageHistoryMonths(time.Now()),
+ }, nil
+}
+
+// SimCardUsageHistoryDeptOptions 月历史用量查询可选公司
+func (c *ControllerV1) SimCardUsageHistoryDeptOptions(ctx context.Context, req *v1.SimCardUsageHistoryDeptOptionsReq) (res *v1.SimCardUsageHistoryDeptOptionsRes, err error) {
+ items, err := simCardQueryService.DeptOptions(ctx)
+ if err != nil {
+ return nil, err
+ }
+ return &v1.SimCardUsageHistoryDeptOptionsRes{
+ Items: items,
+ CurrentDeptId: contexts.GetDeptId(ctx),
+ }, nil
+}
+
+// SimCardUsageHistoryExport 提交月历史用量查询导出
+func (c *ControllerV1) SimCardUsageHistoryExport(ctx context.Context, req *v1.SimCardUsageHistoryExportReq) (res *v1.SimCardUsageHistoryExportRes, err error) {
+ input, err := excelInput(ctx, req.Mode)
+ if err != nil {
+ return nil, err
+ }
+ submit, err := simCardQueryService.ExportUsageHistory(ctx, &req.SimCardUsageHistoryExportParam, input)
+ if err != nil {
+ return nil, err
+ }
+ return &v1.SimCardUsageHistoryExportRes{SimCardQuerySubmitRes: toSubmitRes(submit)}, nil
+}
+
+// excelInput 只在 Excel 模式下读取上传文件。
+// 一次性读进内存再交给后台解析:提交接口必须立即返回,
+// 而 HTTP 请求结束后临时上传文件可能被清理,不能在后台协程里再读。
+func excelInput(ctx context.Context, mode string) (simcardquery.ExcelInput, error) {
+ if mode != model.SimCardQueryModeExcel {
+ return nil, nil
+ }
+ request := g.RequestFromCtx(ctx)
+ if request == nil {
+ return nil, gerror.New("当前上下文不是HTTP请求")
+ }
+ file := request.GetUploadFile("file")
+ if file == nil {
+ return nil, gerror.New("请先上传 Excel 文件")
+ }
+ content, err := readUploadContent(file)
+ if err != nil {
+ return nil, err
+ }
+ if len(content) == 0 {
+ return nil, gerror.New("上传的 Excel 文件为空")
+ }
+ return func() (io.Reader, error) {
+ return bytes.NewReader(content), nil
+ }, nil
+}
+
+func toSubmitRes(submit *exportcenter.SubmitResult) *model.SimCardQuerySubmitRes {
+ if submit == nil {
+ return nil
+ }
+ return &model.SimCardQuerySubmitRes{
+ TaskId: submit.TaskId,
+ TaskNo: submit.TaskNo,
+ Total: submit.Total,
+ }
+}
+
+// maxUploadBytes Excel 上传大小上限(MB 换算后的字节数)。
+// 20MB 足够容纳 20000 行三列号码,再大基本是用户传错文件。
+const maxUploadBytes = 20 << 20
+
+// readUploadContent 一次性读出上传文件内容。
+func readUploadContent(file *ghttp.UploadFile) ([]byte, error) {
+ reader, err := file.Open()
+ if err != nil {
+ return nil, gerror.Wrap(err, "读取上传文件失败")
+ }
+ defer reader.Close()
+ if file.Size > maxUploadBytes {
+ return nil, gerror.Newf("上传文件超过 %dMB 上限", maxUploadBytes>>20)
+ }
+ content, err := io.ReadAll(reader)
+ if err != nil {
+ return nil, gerror.Wrap(err, "读取上传文件失败")
+ }
+ return content, nil
+}
diff --git a/server/internal/controller/iot/t_order.go b/server/internal/controller/iot/t_order.go
index 1875ac3..0929455 100644
--- a/server/internal/controller/iot/t_order.go
+++ b/server/internal/controller/iot/t_order.go
@@ -1,7 +1,7 @@
// Package gen
// @Link https://github.com/xiujiecn/xiu-admin
// @Copyright Copyright (c) 2026 LiXiujie
-// @Author Lxj
+// @Author Lxj
// @License https://github.com/xiujiecn/xiu-admin/blob/master/LICENSE
// @AutoGenerate Version
package iot
diff --git a/server/internal/controller/iot/t_sim_card_info.go b/server/internal/controller/iot/t_sim_card_info.go
index 6ac9c1a..1862602 100644
--- a/server/internal/controller/iot/t_sim_card_info.go
+++ b/server/internal/controller/iot/t_sim_card_info.go
@@ -1,7 +1,7 @@
// Package gen
// @Link https://github.com/xiujiecn/xiu-admin
// @Copyright Copyright (c) 2026 LiXiujie
-// @Author Lxj
+// @Author Lxj
// @License https://github.com/xiujiecn/xiu-admin/blob/master/LICENSE
// @AutoGenerate Version
package iot
diff --git a/server/internal/controller/iot/t_sim_card_stock.go b/server/internal/controller/iot/t_sim_card_stock.go
index 25323b1..9c94d37 100644
--- a/server/internal/controller/iot/t_sim_card_stock.go
+++ b/server/internal/controller/iot/t_sim_card_stock.go
@@ -1,7 +1,7 @@
// Package gen
// @Link https://github.com/xiujiecn/xiu-admin
// @Copyright Copyright (c) 2026 LiXiujie
-// @Author Lxj
+// @Author Lxj
// @License https://github.com/xiujiecn/xiu-admin/blob/master/LICENSE
// @AutoGenerate Version
package iot
@@ -13,6 +13,7 @@ import (
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/service"
)
+
// List 查看库存卡信息表列表
func (c *ControllerV1) TSimCardStockList(ctx context.Context, req *v1.TSimCardStockListReq) (res *v1.TSimCardStockListRes, err error) {
list, totalCount, err := service.IotTSimCardStock().List(ctx, &req.TSimCardStockListParam)
diff --git a/server/internal/controller/iot/t_sim_channel_template.go b/server/internal/controller/iot/t_sim_channel_template.go
index 25077bf..81e319f 100644
--- a/server/internal/controller/iot/t_sim_channel_template.go
+++ b/server/internal/controller/iot/t_sim_channel_template.go
@@ -1,7 +1,7 @@
// Package gen
// @Link https://github.com/xiujiecn/xiu-admin
// @Copyright Copyright (c) 2026 LiXiujie
-// @Author Lxj
+// @Author Lxj
// @License https://github.com/xiujiecn/xiu-admin/blob/master/LICENSE
// @AutoGenerate Version
package iot
diff --git a/server/internal/controller/iot/t_sim_channels.go b/server/internal/controller/iot/t_sim_channels.go
index 97f9aaf..177c49c 100644
--- a/server/internal/controller/iot/t_sim_channels.go
+++ b/server/internal/controller/iot/t_sim_channels.go
@@ -1,7 +1,7 @@
// Package gen
// @Link https://github.com/xiujiecn/xiu-admin
// @Copyright Copyright (c) 2026 LiXiujie
-// @Author Lxj
+// @Author Lxj
// @License https://github.com/xiujiecn/xiu-admin/blob/master/LICENSE
// @AutoGenerate Version
package iot
diff --git a/server/internal/controller/iot/t_sim_flow_package_info.go b/server/internal/controller/iot/t_sim_flow_package_info.go
index a9b6fb3..1057433 100644
--- a/server/internal/controller/iot/t_sim_flow_package_info.go
+++ b/server/internal/controller/iot/t_sim_flow_package_info.go
@@ -1,7 +1,7 @@
// Package gen
// @Link https://github.com/xiujiecn/xiu-admin
// @Copyright Copyright (c) 2026 LiXiujie
-// @Author Lxj
+// @Author Lxj
// @License https://github.com/xiujiecn/xiu-admin/blob/master/LICENSE
// @AutoGenerate Version
package iot
diff --git a/server/internal/controller/iot/t_sim_flow_package_price.go b/server/internal/controller/iot/t_sim_flow_package_price.go
index 6e7d438..4d34e29 100644
--- a/server/internal/controller/iot/t_sim_flow_package_price.go
+++ b/server/internal/controller/iot/t_sim_flow_package_price.go
@@ -1,7 +1,7 @@
// Package gen
// @Link https://github.com/xiujiecn/xiu-admin
// @Copyright Copyright (c) 2026 LiXiujie
-// @Author Lxj
+// @Author Lxj
// @License https://github.com/xiujiecn/xiu-admin/blob/master/LICENSE
// @AutoGenerate Version
package iot
diff --git a/server/internal/controller/iot/t_sim_flow_pool.go b/server/internal/controller/iot/t_sim_flow_pool.go
index 880fcff..d3254ec 100644
--- a/server/internal/controller/iot/t_sim_flow_pool.go
+++ b/server/internal/controller/iot/t_sim_flow_pool.go
@@ -1,7 +1,7 @@
// Package gen
// @Link https://github.com/xiujiecn/xiu-admin
// @Copyright Copyright (c) 2026 LiXiujie
-// @Author Lxj
+// @Author Lxj
// @License https://github.com/xiujiecn/xiu-admin/blob/master/LICENSE
// @AutoGenerate Version
package iot
diff --git a/server/internal/controller/iot/t_sim_package_category.go b/server/internal/controller/iot/t_sim_package_category.go
index d9bdc50..5a9f359 100644
--- a/server/internal/controller/iot/t_sim_package_category.go
+++ b/server/internal/controller/iot/t_sim_package_category.go
@@ -1,7 +1,7 @@
// Package gen
// @Link https://github.com/xiujiecn/xiu-admin
// @Copyright Copyright (c) 2026 LiXiujie
-// @Author Lxj
+// @Author Lxj
// @License https://github.com/xiujiecn/xiu-admin/blob/master/LICENSE
// @AutoGenerate Version
package iot
diff --git a/server/internal/controller/iot/t_sim_pool_warn_config.go b/server/internal/controller/iot/t_sim_pool_warn_config.go
index 0644938..3fbc094 100644
--- a/server/internal/controller/iot/t_sim_pool_warn_config.go
+++ b/server/internal/controller/iot/t_sim_pool_warn_config.go
@@ -1,7 +1,7 @@
// Package gen
// @Link https://github.com/xiujiecn/xiu-admin
// @Copyright Copyright (c) 2026 LiXiujie
-// @Author Lxj
+// @Author Lxj
// @License https://github.com/xiujiecn/xiu-admin/blob/master/LICENSE
// @AutoGenerate Version
package iot
diff --git a/server/internal/controller/iot/t_sim_upstream_package.go b/server/internal/controller/iot/t_sim_upstream_package.go
index 0965b70..f4a57df 100644
--- a/server/internal/controller/iot/t_sim_upstream_package.go
+++ b/server/internal/controller/iot/t_sim_upstream_package.go
@@ -1,7 +1,7 @@
// Package gen
// @Link https://github.com/xiujiecn/xiu-admin
// @Copyright Copyright (c) 2026 LiXiujie
-// @Author Lxj
+// @Author Lxj
// @License https://github.com/xiujiecn/xiu-admin/blob/master/LICENSE
// @AutoGenerate Version
package iot
diff --git a/server/internal/controller/system/sys_notice_user.go b/server/internal/controller/system/sys_notice_user.go
index 281034b..bee867e 100644
--- a/server/internal/controller/system/sys_notice_user.go
+++ b/server/internal/controller/system/sys_notice_user.go
@@ -1,7 +1,7 @@
// Package gen
// @Link https://github.com/xiujiecn/xiu-admin
// @Copyright Copyright (c) 2025 LiXiujie
-// @Author Lxj
+// @Author Lxj
// @License https://github.com/xiujiecn/xiu-admin/blob/master/LICENSE
// @AutoGenerate Version
package system
diff --git a/server/internal/dao/internal/sys_permission_revision.go b/server/internal/dao/internal/sys_permission_revision.go
new file mode 100644
index 0000000..66c22f3
--- /dev/null
+++ b/server/internal/dao/internal/sys_permission_revision.go
@@ -0,0 +1,83 @@
+// ==========================================================================
+// Code generated and maintained by GoFrame CLI tool. DO NOT EDIT.
+// ==========================================================================
+
+package internal
+
+import (
+ "context"
+
+ "github.com/gogf/gf/v2/database/gdb"
+ "github.com/gogf/gf/v2/frame/g"
+)
+
+// SysPermissionRevisionDao is the data access object for the table sys_permission_revision.
+type SysPermissionRevisionDao struct {
+ table string // table is the underlying table name of the DAO.
+ group string // group is the database configuration group name of the current DAO.
+ columns SysPermissionRevisionColumns // columns contains all the column names of Table for convenient usage.
+ handlers []gdb.ModelHandler // handlers for customized model modification.
+}
+
+// SysPermissionRevisionColumns defines and stores column names for the table sys_permission_revision.
+type SysPermissionRevisionColumns struct {
+ Id string //
+ Revision string //
+ UpdatedAt string //
+}
+
+// sysPermissionRevisionColumns holds the columns for the table sys_permission_revision.
+var sysPermissionRevisionColumns = SysPermissionRevisionColumns{
+ Id: "id",
+ Revision: "revision",
+ UpdatedAt: "updated_at",
+}
+
+// NewSysPermissionRevisionDao creates and returns a new DAO object for table data access.
+func NewSysPermissionRevisionDao(handlers ...gdb.ModelHandler) *SysPermissionRevisionDao {
+ return &SysPermissionRevisionDao{
+ group: "default",
+ table: "sys_permission_revision",
+ columns: sysPermissionRevisionColumns,
+ handlers: handlers,
+ }
+}
+
+// DB retrieves and returns the underlying raw database management object of the current DAO.
+func (dao *SysPermissionRevisionDao) DB() gdb.DB {
+ return g.DB(dao.group)
+}
+
+// Table returns the table name of the current DAO.
+func (dao *SysPermissionRevisionDao) Table() string {
+ return dao.table
+}
+
+// Columns returns all column names of the current DAO.
+func (dao *SysPermissionRevisionDao) Columns() SysPermissionRevisionColumns {
+ return dao.columns
+}
+
+// Group returns the database configuration group name of the current DAO.
+func (dao *SysPermissionRevisionDao) Group() string {
+ return dao.group
+}
+
+// Ctx creates and returns a Model for the current DAO. It automatically sets the context for the current operation.
+func (dao *SysPermissionRevisionDao) Ctx(ctx context.Context) *gdb.Model {
+ model := dao.DB().Model(dao.table)
+ for _, handler := range dao.handlers {
+ model = handler(model)
+ }
+ return model.Safe().Ctx(ctx)
+}
+
+// Transaction wraps the transaction logic using function f.
+// It rolls back the transaction and returns the error if function f returns a non-nil error.
+// It commits the transaction and returns nil if function f returns nil.
+//
+// Note: Do not commit or roll back the transaction in function f,
+// as it is automatically handled by this function.
+func (dao *SysPermissionRevisionDao) Transaction(ctx context.Context, f func(ctx context.Context, tx gdb.TX) error) (err error) {
+ return dao.Ctx(ctx).Transaction(ctx, f)
+}
diff --git a/server/internal/dao/internal/sys_role_sync_task.go b/server/internal/dao/internal/sys_role_sync_task.go
new file mode 100644
index 0000000..ebb51a4
--- /dev/null
+++ b/server/internal/dao/internal/sys_role_sync_task.go
@@ -0,0 +1,111 @@
+// ==========================================================================
+// Code generated and maintained by GoFrame CLI tool. DO NOT EDIT.
+// ==========================================================================
+
+package internal
+
+import (
+ "context"
+
+ "github.com/gogf/gf/v2/database/gdb"
+ "github.com/gogf/gf/v2/frame/g"
+)
+
+// SysRoleSyncTaskDao is the data access object for the table sys_role_sync_task.
+type SysRoleSyncTaskDao struct {
+ table string // table is the underlying table name of the DAO.
+ group string // group is the database configuration group name of the current DAO.
+ columns SysRoleSyncTaskColumns // columns contains all the column names of Table for convenient usage.
+ handlers []gdb.ModelHandler // handlers for customized model modification.
+}
+
+// SysRoleSyncTaskColumns defines and stores column names for the table sys_role_sync_task.
+type SysRoleSyncTaskColumns struct {
+ TaskId string //
+ TenantId string //
+ SourceRoleId string //
+ SourceDeptId string //
+ ActorId string //
+ Status string //
+ Attempts string //
+ NextAttemptAt string //
+ LeaseUntil string //
+ LeaseToken string //
+ Payload string //
+ LastError string //
+ TotalCount string //
+ SuccessCount string //
+ FailedCount string //
+ CreatedAt string //
+ UpdatedAt string //
+}
+
+// sysRoleSyncTaskColumns holds the columns for the table sys_role_sync_task.
+var sysRoleSyncTaskColumns = SysRoleSyncTaskColumns{
+ TaskId: "task_id",
+ TenantId: "tenant_id",
+ SourceRoleId: "source_role_id",
+ SourceDeptId: "source_dept_id",
+ ActorId: "actor_id",
+ Status: "status",
+ Attempts: "attempts",
+ NextAttemptAt: "next_attempt_at",
+ LeaseUntil: "lease_until",
+ LeaseToken: "lease_token",
+ Payload: "payload",
+ LastError: "last_error",
+ TotalCount: "total_count",
+ SuccessCount: "success_count",
+ FailedCount: "failed_count",
+ CreatedAt: "created_at",
+ UpdatedAt: "updated_at",
+}
+
+// NewSysRoleSyncTaskDao creates and returns a new DAO object for table data access.
+func NewSysRoleSyncTaskDao(handlers ...gdb.ModelHandler) *SysRoleSyncTaskDao {
+ return &SysRoleSyncTaskDao{
+ group: "default",
+ table: "sys_role_sync_task",
+ columns: sysRoleSyncTaskColumns,
+ handlers: handlers,
+ }
+}
+
+// DB retrieves and returns the underlying raw database management object of the current DAO.
+func (dao *SysRoleSyncTaskDao) DB() gdb.DB {
+ return g.DB(dao.group)
+}
+
+// Table returns the table name of the current DAO.
+func (dao *SysRoleSyncTaskDao) Table() string {
+ return dao.table
+}
+
+// Columns returns all column names of the current DAO.
+func (dao *SysRoleSyncTaskDao) Columns() SysRoleSyncTaskColumns {
+ return dao.columns
+}
+
+// Group returns the database configuration group name of the current DAO.
+func (dao *SysRoleSyncTaskDao) Group() string {
+ return dao.group
+}
+
+// Ctx creates and returns a Model for the current DAO. It automatically sets the context for the current operation.
+func (dao *SysRoleSyncTaskDao) Ctx(ctx context.Context) *gdb.Model {
+ model := dao.DB().Model(dao.table)
+ for _, handler := range dao.handlers {
+ model = handler(model)
+ }
+ return model.Safe().Ctx(ctx)
+}
+
+// Transaction wraps the transaction logic using function f.
+// It rolls back the transaction and returns the error if function f returns a non-nil error.
+// It commits the transaction and returns nil if function f returns nil.
+//
+// Note: Do not commit or roll back the transaction in function f,
+// as it is automatically handled by this function.
+func (dao *SysRoleSyncTaskDao) Transaction(ctx context.Context, f func(ctx context.Context, tx gdb.TX) error) (err error) {
+ return dao.Ctx(ctx).Transaction(ctx, f)
+}
diff --git a/server/internal/dao/internal/sys_user.go b/server/internal/dao/internal/sys_user.go
index f14fa96..a07be89 100644
--- a/server/internal/dao/internal/sys_user.go
+++ b/server/internal/dao/internal/sys_user.go
@@ -21,60 +21,60 @@ type SysUserDao struct {
// SysUserColumns defines and stores column names for the table sys_user.
type SysUserColumns struct {
- UserId string // 用户ID
- TenantId string // 租户编号
- DeptId string // 部门ID
- UserName string // 用户账号
- NickName string // 用户昵称
- UserType string // 用户类型(sys_user系统用户)
- Email string // 用户邮箱
- Phonenumber string // 手机号码
- Sex string // 用户性别(0男 1女 2未知)
- Avatar string // 头像地址
- Salt string // 加密盐
- Password string // 密码
+ UserId string // 用户ID
+ TenantId string // 租户编号
+ DeptId string // 部门ID
+ UserName string // 用户账号
+ NickName string // 用户昵称
+ UserType string // 用户类型(sys_user系统用户)
+ Email string // 用户邮箱
+ Phonenumber string // 手机号码
+ Sex string // 用户性别(0男 1女 2未知)
+ Avatar string // 头像地址
+ Salt string // 加密盐
+ Password string // 密码
IsInitPassword string // 当前密码是否未由用户本人设置(1=初始或管理员临时密码 0=用户已自设)
- Status string // 帐号状态(0正常 1停用)
- LoginIp string // 最后登录IP
- LoginDate string // 最后登录时间
- MfaEnabled string // 是否开启邮箱MFA 0否 1是
- CreatedDept string // 创建部门
- CreatedBy string // 创建者
- CreatedAt string // 创建时间
- UpdatedBy string // 更新者
- UpdatedAt string // 更新时间
- DeletedBy string // 删除人
- DeletedAt string // 删除时间
- Remark string // 备注
+ Status string // 帐号状态(0正常 1停用)
+ LoginIp string // 最后登录IP
+ LoginDate string // 最后登录时间
+ MfaEnabled string // 是否开启邮箱MFA 0否 1是
+ CreatedDept string // 创建部门
+ CreatedBy string // 创建者
+ CreatedAt string // 创建时间
+ UpdatedBy string // 更新者
+ UpdatedAt string // 更新时间
+ DeletedBy string // 删除人
+ DeletedAt string // 删除时间
+ Remark string // 备注
}
// sysUserColumns holds the columns for the table sys_user.
var sysUserColumns = SysUserColumns{
- UserId: "user_id",
- TenantId: "tenant_id",
- DeptId: "dept_id",
- UserName: "user_name",
- NickName: "nick_name",
- UserType: "user_type",
- Email: "email",
- Phonenumber: "phonenumber",
- Sex: "sex",
- Avatar: "avatar",
- Salt: "salt",
- Password: "password",
+ UserId: "user_id",
+ TenantId: "tenant_id",
+ DeptId: "dept_id",
+ UserName: "user_name",
+ NickName: "nick_name",
+ UserType: "user_type",
+ Email: "email",
+ Phonenumber: "phonenumber",
+ Sex: "sex",
+ Avatar: "avatar",
+ Salt: "salt",
+ Password: "password",
IsInitPassword: "is_init_password",
- Status: "status",
- LoginIp: "login_ip",
- LoginDate: "login_date",
- MfaEnabled: "mfa_enabled",
- CreatedDept: "created_dept",
- CreatedBy: "created_by",
- CreatedAt: "created_at",
- UpdatedBy: "updated_by",
- UpdatedAt: "updated_at",
- DeletedBy: "deleted_by",
- DeletedAt: "deleted_at",
- Remark: "remark",
+ Status: "status",
+ LoginIp: "login_ip",
+ LoginDate: "login_date",
+ MfaEnabled: "mfa_enabled",
+ CreatedDept: "created_dept",
+ CreatedBy: "created_by",
+ CreatedAt: "created_at",
+ UpdatedBy: "updated_by",
+ UpdatedAt: "updated_at",
+ DeletedBy: "deleted_by",
+ DeletedAt: "deleted_at",
+ Remark: "remark",
}
// NewSysUserDao creates and returns a new DAO object for table data access.
diff --git a/server/internal/dao/internal/t_export_task.go b/server/internal/dao/internal/t_export_task.go
new file mode 100644
index 0000000..6a39b6f
--- /dev/null
+++ b/server/internal/dao/internal/t_export_task.go
@@ -0,0 +1,128 @@
+// ==========================================================================
+// Code generated and maintained by GoFrame CLI tool. DO NOT EDIT.
+// ==========================================================================
+
+package internal
+
+import (
+ "context"
+
+ "github.com/gogf/gf/v2/database/gdb"
+ "github.com/gogf/gf/v2/frame/g"
+)
+
+// TExportTaskDao is the data access object for the table t_export_task.
+type TExportTaskDao struct {
+ table string // table is the underlying table name of the DAO.
+ group string // group is the database configuration group name of the current DAO.
+ columns TExportTaskColumns // columns contains all the column names of Table for convenient usage.
+ handlers []gdb.ModelHandler // handlers for customized model modification.
+}
+
+// TExportTaskColumns defines and stores column names for the table t_export_task.
+type TExportTaskColumns struct {
+ Id string // 主键ID
+ TaskNo string // 任务编号
+ BizKey string // 业务标识
+ BizName string // 业务名称
+ PermCode string // 导出权限码快照
+ QueryParams string // 筛选条件快照
+ IdentitySnapshot string // 提交时用户身份与角色范围快照
+ Status string // 状态
+ TotalCount string // 导出总行数
+ OssId string // OSS文件ID
+ FileName string // 文件名
+ FileSize string // 文件大小
+ FileMd5 string // 文件MD5
+ DownloadCount string // 下载次数
+ LastDownloadTime string // 最后下载时间
+ ErrorMessage string // 失败原因
+ OwnerUserId string // 发起用户ID
+ DeptId string // 机构ID
+ TenantId string // 租户ID
+ CreatedDept string // 创建部门ID
+ CreatedBy string // 创建者ID
+ CreatedAt string // 创建时间
+ StartTime string // 开始生成时间
+ FinishTime string // 完成/失败时间
+ ExpireTime string // 文件过期时间
+ DeletedTime string // 文件清理时间
+ DeleteReason string // 清理原因
+ UpdatedAt string // 更新时间
+}
+
+// tExportTaskColumns holds the columns for the table t_export_task.
+var tExportTaskColumns = TExportTaskColumns{
+ Id: "id",
+ TaskNo: "task_no",
+ BizKey: "biz_key",
+ BizName: "biz_name",
+ PermCode: "perm_code",
+ QueryParams: "query_params",
+ IdentitySnapshot: "identity_snapshot",
+ Status: "status",
+ TotalCount: "total_count",
+ OssId: "oss_id",
+ FileName: "file_name",
+ FileSize: "file_size",
+ FileMd5: "file_md5",
+ DownloadCount: "download_count",
+ LastDownloadTime: "last_download_time",
+ ErrorMessage: "error_message",
+ OwnerUserId: "owner_user_id",
+ DeptId: "dept_id",
+ TenantId: "tenant_id",
+ CreatedDept: "created_dept",
+ CreatedBy: "created_by",
+ CreatedAt: "created_at",
+ StartTime: "start_time",
+ FinishTime: "finish_time",
+ ExpireTime: "expire_time",
+ DeletedTime: "deleted_time",
+ DeleteReason: "delete_reason",
+ UpdatedAt: "updated_at",
+}
+
+// NewTExportTaskDao creates and returns a new DAO object for table data access.
+func NewTExportTaskDao(handlers ...gdb.ModelHandler) *TExportTaskDao {
+ return &TExportTaskDao{
+ group: "default",
+ table: "t_export_task",
+ columns: tExportTaskColumns,
+ handlers: handlers,
+ }
+}
+
+// DB retrieves and returns the underlying raw database management object of the current DAO.
+func (dao *TExportTaskDao) DB() gdb.DB {
+ return g.DB(dao.group)
+}
+
+// Table returns the table name of the current DAO.
+func (dao *TExportTaskDao) Table() string {
+ return dao.table
+}
+
+// Columns returns all column names of the current DAO.
+func (dao *TExportTaskDao) Columns() TExportTaskColumns {
+ return dao.columns
+}
+
+// Group returns the database configuration group name of the current DAO.
+func (dao *TExportTaskDao) Group() string {
+ return dao.group
+}
+
+// Ctx creates and returns a Model for the current DAO. It automatically sets the context for the current operation.
+func (dao *TExportTaskDao) Ctx(ctx context.Context) *gdb.Model {
+ model := dao.DB().Model(dao.table)
+ for _, handler := range dao.handlers {
+ model = handler(model)
+ }
+ return model.Safe().Ctx(ctx)
+}
+
+// Transaction wraps the transaction logic using function f.
+func (dao *TExportTaskDao) Transaction(ctx context.Context, f func(ctx context.Context, tx gdb.TX) error) (err error) {
+ return dao.Ctx(ctx).Transaction(ctx, f)
+}
diff --git a/server/internal/dao/sys_permission_revision.go b/server/internal/dao/sys_permission_revision.go
new file mode 100644
index 0000000..258ccd7
--- /dev/null
+++ b/server/internal/dao/sys_permission_revision.go
@@ -0,0 +1,22 @@
+// =================================================================================
+// This file is auto-generated by the GoFrame CLI tool. You may modify it as needed.
+// =================================================================================
+
+package dao
+
+import (
+ "git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/dao/internal"
+)
+
+// sysPermissionRevisionDao is the data access object for the table sys_permission_revision.
+// You can define custom methods on it to extend its functionality as needed.
+type sysPermissionRevisionDao struct {
+ *internal.SysPermissionRevisionDao
+}
+
+var (
+ // SysPermissionRevision is a globally accessible object for table sys_permission_revision operations.
+ SysPermissionRevision = sysPermissionRevisionDao{internal.NewSysPermissionRevisionDao()}
+)
+
+// Add your custom methods and functionality below.
diff --git a/server/internal/dao/sys_role_sync_task.go b/server/internal/dao/sys_role_sync_task.go
new file mode 100644
index 0000000..f646f25
--- /dev/null
+++ b/server/internal/dao/sys_role_sync_task.go
@@ -0,0 +1,22 @@
+// =================================================================================
+// This file is auto-generated by the GoFrame CLI tool. You may modify it as needed.
+// =================================================================================
+
+package dao
+
+import (
+ "git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/dao/internal"
+)
+
+// sysRoleSyncTaskDao is the data access object for the table sys_role_sync_task.
+// You can define custom methods on it to extend its functionality as needed.
+type sysRoleSyncTaskDao struct {
+ *internal.SysRoleSyncTaskDao
+}
+
+var (
+ // SysRoleSyncTask is a globally accessible object for table sys_role_sync_task operations.
+ SysRoleSyncTask = sysRoleSyncTaskDao{internal.NewSysRoleSyncTaskDao()}
+)
+
+// Add your custom methods and functionality below.
diff --git a/server/internal/dao/t_export_task.go b/server/internal/dao/t_export_task.go
new file mode 100644
index 0000000..97500e5
--- /dev/null
+++ b/server/internal/dao/t_export_task.go
@@ -0,0 +1,21 @@
+// =================================================================================
+// This file is auto-generated by the GoFrame CLI tool. You may modify it as needed.
+// =================================================================================
+
+package dao
+
+import (
+ "git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/dao/internal"
+)
+
+// tExportTaskDao is the data access object for the table t_export_task.
+type tExportTaskDao struct {
+ *internal.TExportTaskDao
+}
+
+var (
+ // TExportTask is a globally accessible object for table t_export_task operations.
+ TExportTask = tExportTaskDao{internal.NewTExportTaskDao()}
+)
+
+// Add your custom methods and functionality below.
diff --git a/server/internal/library/addons/addons.go b/server/internal/library/addons/addons.go
index 7a12736..47eae25 100644
--- a/server/internal/library/addons/addons.go
+++ b/server/internal/library/addons/addons.go
@@ -1,7 +1,7 @@
// Package addons
// @Link https://github.com/xiujiecn/xiu-admin
// @Copyright Copyright (c) 2025 LiXiujie
-// @Author Lxj
+// @Author Lxj
// @License https://github.com/xiujiecn/xiu-admin/blob/master/LICENSE
package addons
diff --git a/server/internal/library/allocationunits/units.go b/server/internal/library/allocationunits/units.go
index 56f8a5e..d508d91 100644
--- a/server/internal/library/allocationunits/units.go
+++ b/server/internal/library/allocationunits/units.go
@@ -2,10 +2,10 @@ package allocationunits
import (
"context"
- "git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/dao"
"github.com/gogf/gf/v2/database/gdb"
"github.com/gogf/gf/v2/encoding/gjson"
"github.com/gogf/gf/v2/errors/gerror"
+ "git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/dao"
"strings"
)
diff --git a/server/internal/library/allocationunits/units_test.go b/server/internal/library/allocationunits/units_test.go
new file mode 100644
index 0000000..9b043a8
--- /dev/null
+++ b/server/internal/library/allocationunits/units_test.go
@@ -0,0 +1,55 @@
+package allocationunits
+
+import (
+ "context"
+ "encoding/json"
+ _ "github.com/gogf/gf/contrib/drivers/sqlite/v2"
+ "github.com/gogf/gf/v2/database/gdb"
+ "github.com/gogf/gf/v2/encoding/gjson"
+ "github.com/gogf/gf/v2/frame/g"
+ "strings"
+ "testing"
+)
+
+func TestSnapshotAndLegacyFallback(t *testing.T) {
+ ctx := context.Background()
+ if err := gdb.SetConfig(gdb.Config{"default": gdb.ConfigGroup{{Type: "sqlite", Link: "sqlite::@file(:memory:)", MaxOpenConnCount: 1}}}); err != nil {
+ t.Fatal(err)
+ }
+ t.Cleanup(func() { _ = g.DB().Close(ctx) })
+ for _, sql := range []string{"CREATE TABLE sys_dept(dept_id INTEGER,tenant_id TEXT,dept_key TEXT,dept_name TEXT,deleted_at TEXT)", "INSERT INTO sys_dept VALUES(1,'A','a','划拨单位',NULL),(2,'A','b','接收单位',NULL),(3,'B','other','其他租户',NULL),(4,'A','gone','已删除单位','2026-01-01')"} {
+ if _, err := g.DB().Exec(ctx, sql); err != nil {
+ t.Fatal(err)
+ }
+ }
+ snapshot, err := Snapshot(ctx, "A", 1, 2)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if _, err := g.DB().Exec(ctx, "UPDATE sys_dept SET dept_name='新名称' WHERE dept_id=2"); err != nil {
+ t.Fatal(err)
+ }
+ frozen := Resolve(snapshot, 0, 0)
+ legacy := Resolve(nil, 1, 2)
+ foreign := Resolve(nil, 3, 4)
+ if err := Fill(ctx, "A", []*Fields{&frozen, &legacy, &foreign}); err != nil {
+ t.Fatal(err)
+ }
+ if frozen.TargetDeptName != "接收单位" || legacy.TargetDeptName != "新名称" || foreign.SourceDeptName != "" || foreign.TargetDeptName != "" {
+ t.Fatalf("快照/回退/隔离错误: %+v %+v %+v", frozen, legacy, foreign)
+ }
+ if f := Resolve(gjson.New(map[string]any{"sourceDeptName": "历史名称"}), 1, 0); f.TargetDeptMode != "per_row" {
+ t.Fatal(f)
+ }
+ encoded, err := json.Marshal(frozen)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if !strings.Contains(string(encoded), "sourceDeptName") {
+ t.Fatal("JSON契约缺少单位字段")
+ }
+ merged := Merge(gjson.New(map[string]any{"jobRole": "allocate_parent"}), snapshot)
+ if merged.Get("jobRole").String() != "allocate_parent" || merged.Get("sourceDeptName").String() != "划拨单位" {
+ t.Fatal(merged)
+ }
+}
diff --git a/server/internal/library/batchimport/runner.go b/server/internal/library/batchimport/runner.go
index f7bcc64..210508c 100644
--- a/server/internal/library/batchimport/runner.go
+++ b/server/internal/library/batchimport/runner.go
@@ -3,6 +3,7 @@ package batchimport
import (
"context"
"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/batchmetadata"
@@ -12,7 +13,6 @@ import (
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/service"
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/utility/hash"
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/utility/iot_excelize"
- "io"
"os"
"strings"
@@ -255,6 +255,9 @@ func saveBatchAndExecute(ctx context.Context, req *SubmitRequest, p Processor, r
if failReason == "" && result.ErrorRows > 0 {
failReason = "存在校验失败数据,本次未写入"
}
+ if result.ImportStatus == consts.ImportStatusFailed && result.ErrorRows > 0 {
+ markValidRowsNotExecuted(rows)
+ }
batchType := batchTypeByImportStatus(req, p, result.ImportStatus)
result.BatchType = batchType
@@ -575,7 +578,29 @@ func batchTypeByImportStatus(req *SubmitRequest, p Processor, importStatus strin
return p.Config().BatchType
}
+const NotExecutedReasonPrefix = "\u672c\u6279\u5b58\u5728\u6821\u9a8c\u5931\u8d25\uff0c\u6574\u6279\u672a\u6267\u884c\uff1a"
+
+func markValidRowsNotExecuted(rows []Row) {
+ for _, row := range rows {
+ if row != nil && len(row.GetErrorReasons()) == 0 {
+ row.AddErrorReason(NotExecutedReasonPrefix + "\u672c\u5361\u6821\u9a8c\u901a\u8fc7")
+ }
+ }
+}
+
+func IsNotExecuted(row Row) bool {
+ for _, reason := range row.GetErrorReasons() {
+ if strings.HasPrefix(reason, NotExecutedReasonPrefix) {
+ return true
+ }
+ }
+ return false
+}
+
func detailStatus(row Row) int {
+ if IsNotExecuted(row) {
+ return consts.BatchDetailStatusNotExecuted
+ }
if len(row.GetErrorReasons()) > 0 {
return consts.BatchDetailStatusFailed
}
@@ -583,6 +608,9 @@ func detailStatus(row Row) int {
}
func displayRowStatus(row Row) string {
+ if IsNotExecuted(row) {
+ return "未执行"
+ }
if len(row.GetErrorReasons()) > 0 {
return "失败"
}
diff --git a/server/internal/library/batchmetadata/metadata_test.go b/server/internal/library/batchmetadata/metadata_test.go
new file mode 100644
index 0000000..6ee76c5
--- /dev/null
+++ b/server/internal/library/batchmetadata/metadata_test.go
@@ -0,0 +1,52 @@
+package batchmetadata
+
+import "testing"
+
+func TestValidateCreation(t *testing.T) {
+ for _, channel := range []string{Console, MiniProgram, API, System} {
+ for _, mode := range []int{Single, Bulk} {
+ if err := ValidateCreation(channel, mode); err != nil {
+ t.Fatal(channel, mode, err)
+ }
+ }
+ }
+ for _, channel := range []string{"", Legacy} {
+ if err := ValidateCreation(channel, Single); err == nil {
+ t.Fatal("missing new batch attribution accepted", channel)
+ }
+ }
+ if ValidateCreation("worker", Single) == nil {
+ t.Fatal("invalid source accepted")
+ }
+ for _, mode := range []int{-1, Unknown, 3} {
+ if ValidateCreation(Console, mode) == nil {
+ t.Fatal("invalid new execution size accepted", mode)
+ }
+ }
+}
+
+func TestMetadata(t *testing.T) {
+ if NormalizeMode(0) != Unknown {
+ t.Fatal("unspecified entry mode must remain unknown")
+ }
+ if NormalizeMode(Single) != Single {
+ t.Fatal("single entry mode lost")
+ }
+ if NormalizeMode(Bulk) != Bulk {
+ t.Fatal("bulk entry mode lost")
+ }
+ if NormalizeMode(99) != Unknown {
+ t.Fatal("invalid entry mode accepted")
+ }
+ for _, c := range []string{Console, MiniProgram, API, System, Legacy} {
+ if Channel(c) != c {
+ t.Fatal(c)
+ }
+ }
+ if Channel("worker") != Legacy || Channel("") != Legacy {
+ t.Fatal("unknown source was attributed")
+ }
+ if ValidMode(-1) || ValidMode(3) {
+ t.Fatal("invalid mode accepted")
+ }
+}
diff --git a/server/internal/library/bcache/bcache.go b/server/internal/library/bcache/bcache.go
index 083c8a3..ea55bf0 100644
--- a/server/internal/library/bcache/bcache.go
+++ b/server/internal/library/bcache/bcache.go
@@ -1,6 +1,6 @@
// Package bcache
// @Link https://github.com/xiujiecn/xiu-admin
// @Copyright Copyright (c) 2025 LiXiujie
-// @Author Lxj
+// @Author Lxj
// @License https://github.com/xiujiecn/xiu-admin/blob/master/LICENSE
package bcache
diff --git a/server/internal/library/bcache/bcache_system_auth.go b/server/internal/library/bcache/bcache_system_auth.go
index 92fa2d3..b2d96dd 100644
--- a/server/internal/library/bcache/bcache_system_auth.go
+++ b/server/internal/library/bcache/bcache_system_auth.go
@@ -1,7 +1,7 @@
// Package bcache
// @Link https://github.com/xiujiecn/xiu-admin
// @Copyright Copyright (c) 2025 LiXiujie
-// @Author Lxj
+// @Author Lxj
// @License https://github.com/xiujiecn/xiu-admin/blob/master/LICENSE
package bcache
diff --git a/server/internal/library/cache/cache.go b/server/internal/library/cache/cache.go
index b640904..b8b0d99 100644
--- a/server/internal/library/cache/cache.go
+++ b/server/internal/library/cache/cache.go
@@ -1,7 +1,7 @@
// Package cache
// @Link https://github.com/xiujiecn/xiu-admin
// @Copyright Copyright (c) 2025 LiXiujie
-// @Author Lxj
+// @Author Lxj
// @License https://github.com/xiujiecn/xiu-admin/blob/master/LICENSE
package cache
diff --git a/server/internal/library/cache/file/file.go b/server/internal/library/cache/file/file.go
index 3fdb7ba..9bbdfef 100644
--- a/server/internal/library/cache/file/file.go
+++ b/server/internal/library/cache/file/file.go
@@ -1,7 +1,7 @@
// Package file
// @Link https://github.com/xiujiecn/xiu-admin
// @Copyright Copyright (c) 2025 LiXiujie
-// @Author Lxj
+// @Author Lxj
// @License https://github.com/xiujiecn/xiu-admin/blob/master/LICENSE
package file
diff --git a/server/internal/library/contexts/contexts.go b/server/internal/library/contexts/contexts.go
index 10e096c..8df0286 100644
--- a/server/internal/library/contexts/contexts.go
+++ b/server/internal/library/contexts/contexts.go
@@ -1,7 +1,7 @@
// Package contexts
// @Link https://github.com/xiujiecn/xiu-admin
// @Copyright Copyright (c) 2025 LiXiujie
-// @Author Lxj
+// @Author Lxj
// @License https://github.com/xiujiecn/xiu-admin/blob/master/LICENSE
package contexts
diff --git a/server/internal/library/event/event.go b/server/internal/library/event/event.go
index 68fa5a8..867199e 100644
--- a/server/internal/library/event/event.go
+++ b/server/internal/library/event/event.go
@@ -1,7 +1,7 @@
// Package event
// @Link https://github.com/xiujiecn/xiu-admin
// @Copyright Copyright (c) 2025 LiXiujie
-// @Author Lxj
+// @Author Lxj
// @License https://github.com/xiujiecn/xiu-admin/blob/master/LICENSE
package event
diff --git a/server/internal/library/exportcenter/cleanup.go b/server/internal/library/exportcenter/cleanup.go
new file mode 100644
index 0000000..d5f054e
--- /dev/null
+++ b/server/internal/library/exportcenter/cleanup.go
@@ -0,0 +1,130 @@
+package exportcenter
+
+import (
+ "context"
+ "os"
+
+ "github.com/gogf/gf/v2/errors/gerror"
+ "time"
+
+ "github.com/gogf/gf/v2/database/gdb"
+ "github.com/gogf/gf/v2/frame/g"
+ "github.com/gogf/gf/v2/os/gctx"
+ "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/model/entity"
+)
+
+const (
+ cleanupBatchSize = 200
+ cleanupInterval = 5 * time.Minute
+)
+
+// RemoveTaskFile 清理文件与OSS记录,保留任务历史。
+// 文件删除不可事务回滚;数据库提交失败时保留索引,下次按文件不存在幂等重试。
+func RemoveTaskFile(ctx context.Context, taskID int64, reason string, validate func(*entity.TExportTask) error) (affected int64, err error) {
+ err = g.DB().Transaction(ctx, func(ctx context.Context, tx gdb.TX) error {
+ columns := dao.TExportTask.Columns()
+ task := &entity.TExportTask{}
+ if err := dao.TExportTask.Ctx(ctx).TX(tx).Where(columns.Id, taskID).LockUpdate().Scan(task); err != nil {
+ return err
+ }
+ if task.Id == 0 {
+ return gerror.New("导出任务不存在")
+ }
+ if err := validate(task); err != nil {
+ return err
+ }
+ if task.DeletedTime != nil {
+ return nil
+ }
+ if task.OssId > 0 {
+ oss := &entity.SysOss{}
+ if err := dao.SysOss.Ctx(ctx).TX(tx).Where(dao.SysOss.Columns().OssId, task.OssId).LockUpdate().Scan(oss); err != nil {
+ return err
+ }
+ if oss.OssId > 0 {
+ if info, statErr := os.Stat(oss.Path); statErr == nil {
+ if !info.Mode().IsRegular() {
+ return gerror.New("导出文件路径不是普通文件")
+ }
+ if err := os.Remove(oss.Path); err != nil && !os.IsNotExist(err) {
+ return err
+ }
+ } else if !os.IsNotExist(statErr) {
+ return statErr
+ }
+ if _, err := dao.SysOss.Ctx(ctx).TX(tx).Where(dao.SysOss.Columns().OssId, oss.OssId).Delete(); err != nil {
+ return err
+ }
+ }
+ }
+ var updateErr error
+ affected, updateErr = dao.TExportTask.Ctx(ctx).TX(tx).Where(columns.Id, task.Id).WhereNull(columns.DeletedTime).
+ Data(g.Map{columns.DeletedTime: gtime.Now(), columns.DeleteReason: reason, columns.OssId: 0}).UpdateAndGetAffected()
+ if updateErr != nil {
+ return updateErr
+ }
+ if affected != 1 {
+ return gerror.New("任务状态已变更,请刷新后重试")
+ }
+ return nil
+ })
+ return affected, err
+}
+
+// StartCleanup 每五分钟清理过期任务和失败任务残留文件。
+func StartCleanup(ctx context.Context) {
+ go func() {
+ ticker := time.NewTicker(cleanupInterval)
+ defer ticker.Stop()
+ for {
+ select {
+ case <-ctx.Done():
+ return
+ case <-ticker.C:
+ cleanupOnce(gctx.New())
+ }
+ }
+ }()
+}
+
+func cleanupOnce(ctx context.Context) {
+ var lastID int64
+ for {
+ tasks := make([]*entity.TExportTask, 0, cleanupBatchSize)
+ if err := dao.TExportTask.Ctx(ctx).
+ WhereNull(dao.TExportTask.Columns().DeletedTime).
+ WhereIn(dao.TExportTask.Columns().Status, []int{consts.DownloadStatusGenerated, consts.DownloadStatusFailed}).
+ WhereLT(dao.TExportTask.Columns().ExpireTime, gtime.Now()).
+ WhereGT(dao.TExportTask.Columns().Id, lastID).
+ OrderAsc(dao.TExportTask.Columns().Id).
+ Limit(cleanupBatchSize).Scan(&tasks); err != nil {
+ g.Log().Errorf(ctx, "[exportcenter] 查询过期导出任务失败: %v", err)
+ return
+ }
+ progress := false
+ for _, task := range tasks {
+ affected, err := RemoveTaskFile(ctx, task.Id, "expired", func(locked *entity.TExportTask) error {
+ if (locked.Status != consts.DownloadStatusGenerated && locked.Status != consts.DownloadStatusFailed) || locked.ExpireTime == nil || !locked.ExpireTime.Before(gtime.Now()) {
+ return gerror.New("任务尚未过期或状态已变更")
+ }
+ return nil
+ })
+ if err != nil {
+ g.Log().Warningf(ctx, "[exportcenter] 清理过期任务失败 taskNo=%s err=%v", task.TaskNo, err)
+ } else if affected > 0 {
+ progress = true
+ }
+ }
+ if len(tasks) < cleanupBatchSize {
+ return
+ }
+ nextID := tasks[len(tasks)-1].Id
+ if !progress && nextID <= lastID {
+ return
+ }
+ lastID = nextID
+ }
+}
diff --git a/server/internal/library/exportcenter/columns.go b/server/internal/library/exportcenter/columns.go
new file mode 100644
index 0000000..42b44e7
--- /dev/null
+++ b/server/internal/library/exportcenter/columns.go
@@ -0,0 +1,335 @@
+package exportcenter
+
+import (
+ "context"
+ "reflect"
+ "strings"
+ "sync"
+
+ "github.com/gogf/gf/v2/encoding/gjson"
+ "github.com/gogf/gf/v2/frame/g"
+ "github.com/gogf/gf/v2/net/ghttp"
+ "github.com/gogf/gf/v2/util/gconv"
+
+ "git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/model"
+)
+
+// fieldList 导出列字段名清单(json tag)。
+type fieldList []string
+
+// columnsFromRequest 从当前请求里读取导出列清单。
+// 前端统一传 exportColumnsJson(JSON 字符串):导出接口多为 x-www-form-urlencoded,
+// 数组套对象会在表单编码时被拆散,字符串形式不会。
+// 这样各导出无需改参数结构即可支持「按页面显示列导出」。
+func columnsFromRequest(ctx context.Context) []model.ExportColumn {
+ r := ghttp.RequestFromCtx(ctx)
+ if r == nil {
+ return nil
+ }
+ if raw := strings.TrimSpace(r.Get("exportColumnsJson").String()); raw != "" {
+ cols := make([]model.ExportColumn, 0)
+ if err := gjson.New(raw).Scan(&cols); err == nil && len(cols) > 0 {
+ return cols
+ }
+ }
+ // 兼容直接传数组的调用(如 JSON 请求体)
+ if v := r.Get("exportColumns"); v != nil && !v.IsNil() {
+ cols := make([]model.ExportColumn, 0)
+ if err := gconv.Structs(v.Interface(), &cols); err == nil && len(cols) > 0 {
+ return cols
+ }
+ }
+ return nil
+}
+
+// resolveColumns 生成表头。
+// 有列清单时按清单顺序取 label 作表头,并按 field 逐行取值;
+// 没有列清单时沿用调用方给的 Tags(兼容未改造的导出)。
+func resolveColumns(req *Request) (header []interface{}, fields fieldList, err error) {
+ for _, c := range req.Columns {
+ field := strings.TrimSpace(c.Field)
+ if field == "" {
+ continue
+ }
+ label := strings.TrimSpace(c.Label)
+ if label == "" {
+ label = field
+ }
+ fields = append(fields, field)
+ header = append(header, label)
+ }
+ if len(fields) > 0 {
+ // 自定义取值的导出(ToRow)要按 RowFields 取列,字段名对不上会静默出空列,这里留一条日志。
+ if req.ToRow != nil && len(req.RowFields) > 0 {
+ if missing := missingRowFields(fields, req.RowFields); len(missing) > 0 {
+ g.Log().Warningf(context.Background(),
+ "[exportcenter] 导出列在 RowFields 中不存在,将输出空列 biz=%s fields=%v", req.BizKey, missing)
+ }
+ }
+ return header, fields, nil
+ }
+
+ tags, err := req.Tags()
+ if err != nil {
+ return nil, nil, err
+ }
+ header = make([]interface{}, len(tags))
+ for i, v := range tags {
+ header[i] = v
+ }
+ return header, nil, nil
+}
+
+// rowValues 按列清单取值;无列清单时保持原有转换方式。
+func rowValues(v interface{}, fields fieldList, req *Request) []interface{} {
+ if len(fields) > 0 {
+ // 自定义取值的导出(ToRow)按 RowFields 声明的位置取列,避免绕过原有格式化。
+ if req.ToRow != nil && len(req.RowFields) > 0 {
+ values := pickRowFields(req.ToRow(v), req.RowFields, fields)
+ i := 0
+ for _, column := range req.Columns {
+ if strings.TrimSpace(column.Field) == "" {
+ continue
+ }
+ // 字典列/固定值映射列始终取列表模型的原值,避免对 ToRow 已翻译的中文再次查 key。
+ if (column.DictType != "" || len(column.ValueMap) > 0) && v != nil {
+ values[i] = valuesByFields(v, fieldList{column.Field})[0]
+ }
+ i++
+ }
+ return values
+ }
+ values := valuesByFields(v, fields)
+ i := 0
+ for _, column := range req.Columns {
+ if strings.TrimSpace(column.Field) == "" {
+ continue
+ }
+ if column.FallbackField != "" && (values[i] == nil || gconv.String(values[i]) == "") {
+ values[i] = valuesByFields(v, fieldList{column.FallbackField})[0]
+ }
+ i++
+ }
+ return values
+ }
+ if req.ToRow != nil {
+ return req.ToRow(v)
+ }
+ return structValues(v)
+}
+
+// loadColumnDicts 每个任务一次性加载列表列使用的字典。
+func loadColumnDicts(ctx context.Context, columns []model.ExportColumn, load func(context.Context, []string) ([]model.SysDictDataListModel, error)) (map[string]map[string]string, error) {
+ types := make([]string, 0)
+ seen := make(map[string]bool)
+ for _, column := range columns {
+ typ := strings.TrimSpace(column.DictType)
+ if strings.TrimSpace(column.Field) != "" && typ != "" && !seen[typ] {
+ seen[typ] = true
+ types = append(types, typ)
+ }
+ }
+ result := make(map[string]map[string]string)
+ if len(types) == 0 {
+ return result, nil
+ }
+ items, err := load(ctx, types)
+ if err != nil {
+ return nil, err
+ }
+ for _, item := range items {
+ if result[item.DictType] == nil {
+ result[item.DictType] = make(map[string]string)
+ }
+ result[item.DictType][item.DictValue] = item.DictLabel
+ }
+ return result, nil
+}
+
+func translateRow(values []interface{}, columns []model.ExportColumn, dicts map[string]map[string]string) {
+ i := 0
+ for _, column := range columns {
+ if strings.TrimSpace(column.Field) == "" {
+ continue
+ }
+ if i >= len(values) {
+ return
+ }
+ value := values[i]
+ str := ""
+ if value != nil {
+ str = gconv.String(value)
+ }
+ // 固定值映射优先:页面用三元表达式渲染的列(非字典)直接出显示文案。
+ if len(column.ValueMap) > 0 {
+ if mapped, ok := column.ValueMap[str]; ok {
+ values[i] = mapped
+ i++
+ continue
+ }
+ if column.ValueDefault != "" {
+ values[i] = column.ValueDefault
+ i++
+ continue
+ }
+ }
+ if str != "" {
+ key := str
+ if normalized, ok := column.DictValueMap[key]; ok {
+ key = normalized
+ }
+ if label, ok := dicts[strings.TrimSpace(column.DictType)][key]; ok {
+ values[i] = label
+ }
+ }
+ i++
+ }
+}
+
+// missingRowFields 找出列清单里 RowFields 无法提供的字段。
+func missingRowFields(fields fieldList, rowFields []string) []string {
+ known := make(map[string]bool, len(rowFields))
+ for _, name := range rowFields {
+ known[name] = true
+ }
+ missing := make([]string, 0)
+ for _, name := range fields {
+ if !known[name] {
+ missing = append(missing, name)
+ }
+ }
+ return missing
+}
+
+// pickRowFields 从自定义行数据里按字段名取列:row 与 rowFields 顺序一一对应。
+func pickRowFields(row []interface{}, rowFields []string, fields fieldList) []interface{} {
+ index := make(map[string]int, len(rowFields))
+ for i, name := range rowFields {
+ index[name] = i
+ }
+ out := make([]interface{}, len(fields))
+ for i, name := range fields {
+ pos, ok := index[name]
+ if !ok || pos < 0 || pos >= len(row) {
+ out[i] = ""
+ continue
+ }
+ out[i] = row[pos]
+ }
+ return out
+}
+
+// jsonFieldIndexCache 缓存「结构体类型 -> json tag 到字段下标的映射」,
+// 导出大结果集时避免每行重复解析 tag。
+var jsonFieldIndexCache sync.Map
+
+func jsonFieldIndexes(t reflect.Type) map[string]int {
+ if cached, ok := jsonFieldIndexCache.Load(t); ok {
+ return cached.(map[string]int)
+ }
+ indexes := make(map[string]int, t.NumField())
+ for i := 0; i < t.NumField(); i++ {
+ field := t.Field(i)
+ if field.PkgPath != "" { // 未导出字段
+ continue
+ }
+ name := strings.Split(field.Tag.Get("json"), ",")[0]
+ if name == "" || name == "-" {
+ continue
+ }
+ if _, exists := indexes[name]; !exists {
+ indexes[name] = i
+ }
+ }
+ jsonFieldIndexCache.Store(t, indexes)
+ return indexes
+}
+
+// valuesByFields 按字段名(json tag)从结构体取值,缺失字段留空。
+// 支持 "createdBySumma.name" 这类嵌套路径(列表页常按嵌套字段显示)。
+func valuesByFields(v interface{}, fields fieldList) []interface{} {
+ out := make([]interface{}, len(fields))
+ rv := reflect.ValueOf(v)
+ for rv.IsValid() && rv.Kind() == reflect.Ptr {
+ if rv.IsNil() {
+ return out
+ }
+ rv = rv.Elem()
+ }
+ if !rv.IsValid() || rv.Kind() != reflect.Struct {
+ return out
+ }
+ for i, name := range fields {
+ if value, ok := structFieldValue(rv, name); ok {
+ out[i] = exportCellValue(value)
+ } else {
+ out[i] = ""
+ }
+ }
+ return out
+}
+
+// structFieldValue 按 "a.b.c" 的 json tag 路径逐层取值。
+func structFieldValue(rv reflect.Value, path string) (reflect.Value, bool) {
+ cur := rv
+ for _, name := range strings.Split(path, ".") {
+ for cur.IsValid() {
+ if (cur.Kind() == reflect.Ptr || cur.Kind() == reflect.Interface) && cur.IsNil() {
+ return reflect.Value{}, false
+ }
+ if cur.CanInterface() {
+ if object, ok := cur.Interface().(interface{ Map() map[string]interface{} }); ok {
+ cur = reflect.ValueOf(object.Map())
+ break
+ }
+ }
+ if cur.Kind() != reflect.Ptr && cur.Kind() != reflect.Interface {
+ break
+ }
+ cur = cur.Elem()
+ }
+ if !cur.IsValid() {
+ return reflect.Value{}, false
+ }
+ if cur.Kind() == reflect.Map && cur.Type().Key().Kind() == reflect.String {
+ cur = cur.MapIndex(reflect.ValueOf(name))
+ if !cur.IsValid() {
+ return reflect.Value{}, false
+ }
+ continue
+ }
+ if cur.Kind() != reflect.Struct {
+ return reflect.Value{}, false
+ }
+ pos, ok := jsonFieldIndexes(cur.Type())[name]
+ if !ok {
+ return reflect.Value{}, false
+ }
+ cur = cur.Field(pos)
+ }
+ return cur, true
+}
+
+// exportCellValue 把字段值转成可写入单元格的值。
+// 指针为空、时间、嵌套结构都要处理,避免把内存地址写进 Excel。
+func exportCellValue(v reflect.Value) interface{} {
+ for v.IsValid() && v.Kind() == reflect.Ptr {
+ if v.IsNil() {
+ return ""
+ }
+ v = v.Elem()
+ }
+ if !v.IsValid() || !v.CanInterface() {
+ return ""
+ }
+ switch v.Kind() {
+ case reflect.Struct, reflect.Map, reflect.Slice, reflect.Array:
+ // 时间等有 String() 的类型交给 gconv,其余复合类型同样转字符串。
+ if s, ok := v.Interface().(interface{ String() string }); ok {
+ return s.String()
+ }
+ return gconv.String(v.Interface())
+ default:
+ return v.Interface()
+ }
+}
diff --git a/server/internal/library/exportcenter/columns_test.go b/server/internal/library/exportcenter/columns_test.go
new file mode 100644
index 0000000..ff2c55d
--- /dev/null
+++ b/server/internal/library/exportcenter/columns_test.go
@@ -0,0 +1,268 @@
+package exportcenter
+
+import (
+ "context"
+ "errors"
+ "github.com/gogf/gf/v2/encoding/gjson"
+ "github.com/xuri/excelize/v2"
+ "reflect"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/gogf/gf/v2/os/gtime"
+
+ "git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/model"
+)
+
+func TestSubmitQueueBoundedBeforePersistence(t *testing.T) {
+ for len(slots) < cap(slots) {
+ slots <- struct{}{}
+ }
+ defer func() {
+ for len(slots) > 0 {
+ <-slots
+ }
+ }()
+ _, err := Submit(context.Background(), &Request{
+ BizKey: "test", BizName: "test", Tags: func() ([]string, error) { return nil, nil },
+ Fetch: func(context.Context, int, int) ([]interface{}, int, error) {
+ return []interface{}{1}, 1, nil
+ },
+ })
+ if err == nil || !strings.Contains(err.Error(), "排队已满") {
+ t.Fatalf("队列满时应拒绝提交: %v", err)
+ }
+}
+
+func TestColumnDictBatchAndExcel(t *testing.T) {
+ columns := []model.ExportColumn{{Field: "status", DictType: "enabled"}, {Field: "", DictType: "ignored"}, {Field: "other", DictType: "enabled"}, {Field: "inverse", DictType: "yes", DictValueMap: map[string]string{"0": "1", "1": "0"}}}
+ calls := 0
+ dicts, err := loadColumnDicts(context.Background(), columns, func(_ context.Context, types []string) ([]model.SysDictDataListModel, error) {
+ calls++
+ if !reflect.DeepEqual(types, []string{"enabled", "yes"}) {
+ t.Fatalf("字典未去重: %v", types)
+ }
+ return []model.SysDictDataListModel{{DictType: "enabled", DictValue: "0", DictLabel: "启用"}, {DictType: "enabled", DictValue: "1", DictLabel: "停用"}, {DictType: "yes", DictValue: "1", DictLabel: "否"}}, nil
+ })
+ if err != nil || calls != 1 {
+ t.Fatalf("批量加载失败: %v %d", err, calls)
+ }
+ for _, tc := range []struct{ input, want interface{} }{{0, "启用"}, {"0", "启用"}, {1, "停用"}, {"", ""}, {nil, nil}, {"未知", "未知"}, {"启用", "启用"}} {
+ values := []interface{}{tc.input, "保留", 0}
+ translateRow(values, columns, dicts)
+ if !reflect.DeepEqual(values, []interface{}{tc.want, "保留", "否"}) {
+ t.Fatalf("映射 %v 得到 %v", tc.input, values)
+ }
+ }
+ f := excelize.NewFile()
+ defer f.Close()
+ stream, err := f.NewStreamWriter("Sheet1")
+ if err != nil {
+ t.Fatal(err)
+ }
+ values := []interface{}{0, "未知", 0}
+ translateRow(values, columns, dicts)
+ if err = stream.SetRow("A1", values); err != nil {
+ t.Fatal(err)
+ }
+ if err = stream.Flush(); err != nil {
+ t.Fatal(err)
+ }
+ buffer, err := f.WriteToBuffer()
+ if err != nil {
+ t.Fatal(err)
+ }
+ read, err := excelize.OpenReader(buffer)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer read.Close()
+ rows, err := read.GetRows("Sheet1")
+ if err != nil || !reflect.DeepEqual(rows, [][]string{{"启用", "未知", "否"}}) {
+ t.Fatalf("xlsx 内容: %v %v", rows, err)
+ }
+}
+
+func TestColumnDictNoMetadataAndLoadFailure(t *testing.T) {
+ called := false
+ _, err := loadColumnDicts(context.Background(), nil, func(context.Context, []string) ([]model.SysDictDataListModel, error) { called = true; return nil, nil })
+ if called || err != nil {
+ t.Fatal("旧页面不应加载字典")
+ }
+ failure := errors.New("字典加载失败")
+ _, err = loadColumnDicts(context.Background(), []model.ExportColumn{{Field: "status", DictType: "x"}}, func(context.Context, []string) ([]model.SysDictDataListModel, error) { return nil, failure })
+ if !errors.Is(err, failure) {
+ t.Fatal("应传播字典加载错误")
+ }
+}
+
+func TestNestedDictFieldsAndFormattedRow(t *testing.T) {
+ row := struct {
+ BatchInfo *gjson.Json `json:"batchInfo"`
+ }{gjson.New(map[string]interface{}{"inboundOptions": map[string]interface{}{"status": 0}, "legacy": 1})}
+ columns := []model.ExportColumn{{Field: "batchInfo.inboundOptions.status"}, {Field: "batchInfo.inboundOptions.missing", FallbackField: "batchInfo.legacy"}}
+ req := &Request{Columns: columns}
+ _, fields, _ := resolveColumns(req)
+ values := rowValues(row, fields, req)
+ if !reflect.DeepEqual(values, []interface{}{0, 1}) {
+ t.Fatalf("JSON 嵌套与备用字段: %v", values)
+ }
+ req = &Request{Columns: []model.ExportColumn{{Field: "status", DictType: "x"}}, RowFields: []string{"status"}, ToRow: func(interface{}) []interface{} { return []interface{}{"已销户"} }}
+ values = rowValues(struct {
+ Status int `json:"status"`
+ }{9}, fieldList{"status"}, req)
+ translateRow(values, req.Columns, map[string]map[string]string{"x": {"9": "已销户"}})
+ if values[0] != "已销户" {
+ t.Fatalf("已中文行被改变: %v", values)
+ }
+}
+
+type columnSampleRow struct {
+ Iccid string `json:"iccid"`
+ DeptName string `json:"deptName"`
+ UsedFlowMb float64 `json:"usedFlowMb"`
+ ActiveAt *gtime.Time `json:"activeAt"`
+ Extra *string `json:"extra"`
+}
+
+func TestResolveColumnsUsesRequestedOrder(t *testing.T) {
+ req := &Request{
+ Columns: []model.ExportColumn{
+ {Field: "deptName", Label: "所属企业"},
+ {Field: "iccid"},
+ {Field: " ", Label: "空字段忽略"},
+ {Field: "usedFlowMb", Label: "已用流量(MB)"},
+ },
+ Tags: func() ([]string, error) {
+ t.Fatal("有列清单时不应回退到 Tags")
+ return nil, nil
+ },
+ }
+
+ header, fields, err := resolveColumns(req)
+ if err != nil {
+ t.Fatalf("resolveColumns error: %v", err)
+ }
+ wantHeader := []interface{}{"所属企业", "iccid", "已用流量(MB)"}
+ if len(header) != len(wantHeader) {
+ t.Fatalf("header len = %d, want %d", len(header), len(wantHeader))
+ }
+ for i := range wantHeader {
+ if header[i] != wantHeader[i] {
+ t.Fatalf("header[%d] = %v, want %v", i, header[i], wantHeader[i])
+ }
+ }
+ wantFields := fieldList{"deptName", "iccid", "usedFlowMb"}
+ if len(fields) != len(wantFields) {
+ t.Fatalf("fields len = %d, want %d", len(fields), len(wantFields))
+ }
+ for i := range wantFields {
+ if fields[i] != wantFields[i] {
+ t.Fatalf("fields[%d] = %q, want %q", i, fields[i], wantFields[i])
+ }
+ }
+}
+
+func TestResolveColumnsFallsBackToTags(t *testing.T) {
+ req := &Request{
+ Tags: func() ([]string, error) { return []string{"ICCID", "所属企业"}, nil },
+ }
+ header, fields, err := resolveColumns(req)
+ if err != nil {
+ t.Fatalf("resolveColumns error: %v", err)
+ }
+ if fields != nil {
+ t.Fatalf("fields should be nil when no columns, got %v", fields)
+ }
+ if len(header) != 2 || header[0] != "ICCID" || header[1] != "所属企业" {
+ t.Fatalf("unexpected header: %v", header)
+ }
+}
+
+func TestValuesByFields(t *testing.T) {
+ activeAt := gtime.NewFromTime(time.Date(2026, 10, 19, 15, 4, 5, 0, time.Local))
+ row := &columnSampleRow{
+ Iccid: "8986001",
+ DeptName: "测试企业",
+ UsedFlowMb: 12.5,
+ ActiveAt: activeAt,
+ }
+
+ values := valuesByFields(row, fieldList{"deptName", "missingField", "usedFlowMb", "activeAt", "extra"})
+ if len(values) != 5 {
+ t.Fatalf("values len = %d, want 5", len(values))
+ }
+ if values[0] != "测试企业" {
+ t.Fatalf("deptName = %v", values[0])
+ }
+ if values[1] != "" {
+ t.Fatalf("missing field should be empty, got %v", values[1])
+ }
+ if values[2] != 12.5 {
+ t.Fatalf("usedFlowMb = %v", values[2])
+ }
+ if values[3] != "2026-10-19 15:04:05" {
+ t.Fatalf("activeAt = %v", values[3])
+ }
+ if values[4] != "" {
+ t.Fatalf("nil pointer should be empty, got %v", values[4])
+ }
+}
+
+func TestValuesByFieldsKeepsRowOrder(t *testing.T) {
+ row := &columnSampleRow{Iccid: "A", DeptName: "B"}
+ values := valuesByFields(row, fieldList{"iccid", "deptName"})
+ if values[0] != "A" || values[1] != "B" {
+ t.Fatalf("unexpected values: %v", values)
+ }
+}
+
+// TestValueMapColumn 覆盖非字典的固定值映射(页面用三元表达式渲染的列)。
+func TestValueMapColumn(t *testing.T) {
+ columns := []model.ExportColumn{
+ {
+ Field: "isFlowPool",
+ ValueMap: map[string]string{"1": "池卡", "0": "单卡"},
+ ValueDefault: "单卡",
+ },
+ {Field: "status", DictType: "common_status"},
+ {Field: "keep", ValueMap: map[string]string{"1": "是"}},
+ }
+ dicts := map[string]map[string]string{"common_status": {"1": "正常", "0": "停用"}}
+
+ // 1/0 命中映射;9 未命中走兜底;状态列仍走字典
+ values := []interface{}{1, 1, 1}
+ translateRow(values, columns, dicts)
+ if !reflect.DeepEqual(values, []interface{}{"池卡", "正常", "是"}) {
+ t.Fatalf("命中映射失败: %v", values)
+ }
+
+ // 0 必须映射,不能被当成空值跳过
+ values = []interface{}{0, "0", 1}
+ translateRow(values, columns, dicts)
+ if values[0] != "单卡" || values[1] != "停用" {
+ t.Fatalf("0 值映射失败: %v", values)
+ }
+
+ // 未命中且无兜底时保留原值
+ values = []interface{}{9, "", 7}
+ translateRow(values, columns, dicts)
+ if values[0] != "单卡" {
+ t.Fatalf("兜底失败: %v", values)
+ }
+ if values[2] != 7 {
+ t.Fatalf("无兜底应保留原值: %v", values)
+ }
+}
+
+// TestMissingRowFields 自定义 ToRow 导出:列 field 必须能在 RowFields 里找到。
+func TestMissingRowFields(t *testing.T) {
+ rowFields := []string{"iccid", "createdBy"}
+ if missing := missingRowFields(fieldList{"iccid"}, rowFields); len(missing) != 0 {
+ t.Fatalf("不应有缺失: %v", missing)
+ }
+ if missing := missingRowFields(fieldList{"iccid", "createdBySumma"}, rowFields); !reflect.DeepEqual(missing, []string{"createdBySumma"}) {
+ t.Fatalf("缺失识别错误: %v", missing)
+ }
+}
diff --git a/server/internal/library/exportcenter/runner.go b/server/internal/library/exportcenter/runner.go
new file mode 100644
index 0000000..c495112
--- /dev/null
+++ b/server/internal/library/exportcenter/runner.go
@@ -0,0 +1,498 @@
+package exportcenter
+
+import (
+ "context"
+ "os"
+ "path/filepath"
+ "reflect"
+ "runtime/debug"
+ "strings"
+ "time"
+
+ "github.com/gogf/gf/v2/crypto/gmd5"
+ "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/xuri/excelize/v2"
+
+ "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/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/hash"
+)
+
+// slots 在提交时预留容量,避免无限积压等待执行的 goroutine。
+var slots = make(chan struct{}, MaxConcurrent+MaxQueued)
+
+// sem 限制同时执行的导出任务数,避免把 DB 连接与磁盘打满。
+var sem = make(chan struct{}, MaxConcurrent)
+
+func run(taskNo string, req *Request) {
+ defer func() { <-slots }()
+ baseCtx := gctx.New()
+ var task *entity.TExportTask
+ var generatedPath string
+ defer func() {
+ if p := recover(); p != nil {
+ g.Log().Errorf(baseCtx, "[exportcenter] 导出协程异常 taskNo=%s panic=%v stack=%s", taskNo, p, debug.Stack())
+ if task == nil || task.Id == 0 {
+ task = &entity.TExportTask{}
+ if err := dao.TExportTask.Ctx(baseCtx).Where(dao.TExportTask.Columns().TaskNo, taskNo).Scan(task); err != nil {
+ g.Log().Errorf(baseCtx, "[exportcenter] panic 后加载任务失败 taskNo=%s err=%v", taskNo, err)
+ }
+ }
+ if task != nil && task.Id > 0 {
+ markFailed(baseCtx, task, gerror.Newf("导出任务执行异常: %v", p))
+ }
+ }
+ }()
+ sem <- struct{}{}
+ defer func() { <-sem }()
+
+ task = &entity.TExportTask{}
+ if err := dao.TExportTask.Ctx(baseCtx).
+ Where(dao.TExportTask.Columns().TaskNo, taskNo).
+ Scan(task); err != nil || task.Id <= 0 {
+ g.Log().Errorf(baseCtx, "[exportcenter] 加载任务失败 taskNo=%s err=%v", taskNo, err)
+ return
+ }
+
+ // CAS 抢占:只有把 0 改成 2 的协程才能执行
+ now := gtime.Now()
+ affected, err := dao.TExportTask.Ctx(baseCtx).
+ Data(g.Map{
+ dao.TExportTask.Columns().Status: consts.DownloadStatusRunning,
+ dao.TExportTask.Columns().StartTime: now,
+ }).
+ Where(dao.TExportTask.Columns().Id, task.Id).
+ WhereNull(dao.TExportTask.Columns().DeletedTime).
+ Where(dao.TExportTask.Columns().Status, consts.DownloadStatusPending).
+ UpdateAndGetAffected()
+ if err != nil {
+ g.Log().Errorf(baseCtx, "[exportcenter] 抢占任务失败 taskNo=%s err=%v", taskNo, err)
+ return
+ }
+ if affected == 0 {
+ return
+ }
+
+ execCtx, cancel := context.WithTimeout(restoreContext(baseCtx, task), TaskTimeout)
+ defer cancel()
+
+ markBatchRunning(execCtx, taskNo)
+
+ if err = generate(execCtx, task, req, &generatedPath); err != nil {
+ g.Log().Errorf(execCtx, "[exportcenter] 导出失败 taskNo=%s err=%v", taskNo, err)
+ markFailed(baseCtx, task, err)
+ }
+}
+
+// restoreContext 用提交时的身份快照还原 ctx,保证 FilterAuth 仍按提交人范围过滤。
+func restoreContext(base context.Context, task *entity.TExportTask) context.Context {
+ snap := &model.ExportIdentitySnapshot{}
+ if task.IdentitySnapshot != nil {
+ if err := task.IdentitySnapshot.Scan(snap); err != nil {
+ g.Log().Warningf(base, "[exportcenter] 身份快照解析失败 taskNo=%s err=%v", task.TaskNo, err)
+ }
+ }
+ if snap.UserId <= 0 {
+ snap.UserId = task.OwnerUserId
+ snap.DeptId = task.DeptId
+ snap.TenantId = task.TenantId
+ }
+
+ identity := &model.Identity{}
+ identity.ID = snap.UserId
+ identity.DeptId = snap.DeptId
+ identity.TenantId = snap.TenantId
+ identity.Roles = snap.Roles
+ identity.AccessCodeList = snap.AccessCodeList
+
+ data := g.Map{}
+ if snap.CurrentRoleId != "" {
+ data["currentRoleId"] = snap.CurrentRoleId
+ }
+ if snap.CurrentRoleDataScope != "" {
+ data["currentRoleDataScope"] = snap.CurrentRoleDataScope
+ }
+
+ return contexts.Set(base, &model.Context{User: identity, Data: data})
+}
+
+func generate(ctx context.Context, task *entity.TExportTask, req *Request, generatedPath *string) (err error) {
+ header, fields, err := resolveColumns(req)
+ if err != nil {
+ return gerror.Wrap(err, "生成表头失败")
+ }
+
+ dicts, err := loadColumnDicts(ctx, req.Columns, func(ctx context.Context, types []string) ([]model.SysDictDataListModel, error) {
+ return service.SysDictData().GetDictListByTypes(ctx, types)
+ })
+ if err != nil {
+ return gerror.Wrap(err, "加载导出字典失败")
+ }
+
+ sheet := req.SheetName
+ if sheet == "" {
+ sheet = req.BizName
+ }
+ sheet = truncateSheetName(sheet)
+
+ f := excelize.NewFile()
+ defer f.Close()
+ if err = f.SetSheetName(f.GetSheetName(0), sheet); err != nil {
+ return gerror.Wrap(err, "设置工作表名称失败")
+ }
+ stream, err := f.NewStreamWriter(sheet)
+ if err != nil {
+ return gerror.Wrap(err, "创建工作表流失败")
+ }
+
+ if err = stream.SetRow("A1", header); err != nil {
+ return gerror.Wrap(err, "写入表头失败")
+ }
+
+ rowNum := 2
+ written := 0
+ for page := 1; ; page++ {
+ rows, total, fetchErr := req.Fetch(ctx, page, DefaultPageSize)
+ if fetchErr != nil {
+ // 页码只留给日志排查;对用户展示业务侧给出的可读原因,不暴露分页与驱动细节。
+ g.Log().Errorf(ctx, "[exportcenter] 第 %d 页取数失败 taskNo=%s err=%v", page, task.TaskNo, fetchErr)
+ return fetchErr
+ }
+ if len(rows) == 0 {
+ break
+ }
+ for _, v := range rows {
+ cell, _ := excelize.CoordinatesToCellName(1, rowNum)
+ values := rowValues(v, fields, req)
+ translateRow(values, req.Columns, dicts)
+ if err = stream.SetRow(cell, values); err != nil {
+ return gerror.Wrapf(err, "写入第 %d 行失败", rowNum)
+ }
+ rowNum++
+ written++
+ if written > MaxRows {
+ return gerror.Newf("导出行数超过上限 %d 行", MaxRows)
+ }
+ }
+ // 已取满或不足一页,结束
+ if len(rows) < DefaultPageSize || (total > 0 && written >= total) {
+ break
+ }
+ }
+ if err = stream.Flush(); err != nil {
+ return gerror.Wrap(err, "刷新工作表失败")
+ }
+
+ content, err := toBytes(f)
+ if err != nil {
+ return err
+ }
+ if int64(len(content)) > MaxFileSize {
+ return gerror.Newf("导出文件超过 %d MB 上限,请缩小筛选范围", MaxFileSize>>20)
+ }
+
+ oss, err := savePrivateExport(ctx, task, content)
+ if err != nil {
+ return gerror.Wrap(err, "保存导出文件失败")
+ }
+ *generatedPath = oss.Path
+ task.OssId = oss.OssId
+ if err = markSuccess(ctx, task, oss, written, req); err != nil {
+ return err
+ }
+ *generatedPath = ""
+ return nil
+}
+
+func savePrivateExport(ctx context.Context, task *entity.TExportTask, content []byte) (*model.SysOssSaveContentModel, error) {
+ uploadRoot := strings.TrimSpace(g.Cfg().MustGet(ctx, "system.upload.local.path").String())
+ if uploadRoot == "" {
+ return nil, gerror.New("上传路径未配置")
+ }
+ root, err := filepath.Abs(uploadRoot)
+ if err != nil {
+ return nil, err
+ }
+ // 私有目录必须与上传静态根目录隔离,不能生成可访问的 OSS URL。
+ path := filepath.Join(filepath.Dir(root), "private-export", task.TenantId, task.TaskNo+".xlsx")
+ if err = os.MkdirAll(filepath.Dir(path), 0700); err != nil {
+ return nil, err
+ }
+ registered := false
+ defer func() {
+ if !registered {
+ if removeErr := os.Remove(path); removeErr != nil && !os.IsNotExist(removeErr) {
+ g.Log().Errorf(gctx.New(), "[exportcenter] 清理未登记文件失败 path=%s err=%v", path, removeErr)
+ }
+ }
+ }()
+ if err = os.WriteFile(path, content, 0600); err != nil {
+ return nil, err
+ }
+ md5, err := gmd5.EncryptFile(path)
+ if err != nil {
+ return nil, err
+ }
+ var ossId int64
+ // 这里仅登记元数据;通用 OSS API 拒绝导出文件,实际下载走下载中心。
+ err = g.DB().Transaction(gctx.New(), func(ctx context.Context, tx gdb.TX) error {
+ var insertErr error
+ ossId, insertErr = dao.SysOss.Ctx(ctx).TX(tx).Data(g.Map{
+ dao.SysOss.Columns().TenantId: task.TenantId,
+ dao.SysOss.Columns().FileName: task.FileName,
+ dao.SysOss.Columns().OriginalName: task.FileName,
+ dao.SysOss.Columns().FileSuffix: "xlsx",
+ dao.SysOss.Columns().Path: path,
+ dao.SysOss.Columns().Url: "",
+ dao.SysOss.Columns().CreatedDept: task.DeptId,
+ dao.SysOss.Columns().CreatedBy: task.OwnerUserId,
+ dao.SysOss.Columns().CreatedAt: gtime.Now(),
+ dao.SysOss.Columns().Service: "local",
+ dao.SysOss.Columns().Md5: md5,
+ dao.SysOss.Columns().FileSize: len(content),
+ dao.SysOss.Columns().FileCrc16: hash.Crc16ChecksumFile(path),
+ dao.SysOss.Columns().FileSum: hash.Sum16ChecksumFile(path),
+ }).InsertAndGetId()
+ if insertErr != nil {
+ return insertErr
+ }
+ affected, updateErr := dao.TExportTask.Ctx(ctx).TX(tx).
+ Data(g.Map{dao.TExportTask.Columns().OssId: ossId}).
+ Where(dao.TExportTask.Columns().Id, task.Id).
+ Where(dao.TExportTask.Columns().Status, consts.DownloadStatusRunning).UpdateAndGetAffected()
+ if updateErr != nil {
+ return updateErr
+ }
+ if affected != 1 {
+ return gerror.New("登记导出文件失败:任务状态已变更")
+ }
+ return nil
+ })
+ if err != nil {
+ return nil, err
+ }
+ registered = true
+ task.OssId = ossId
+ return &model.SysOssSaveContentModel{SysOss: &entity.SysOss{OssId: ossId, Path: path, FileSize: len(content), Md5: md5}}, nil
+}
+
+func structValues(v interface{}) []interface{} {
+ rv := reflect.ValueOf(v)
+ for rv.Kind() == reflect.Ptr {
+ if rv.IsNil() {
+ return nil
+ }
+ rv = rv.Elem()
+ }
+ if rv.Kind() != reflect.Struct {
+ return []interface{}{v}
+ }
+ fields := rv.NumField()
+ out := make([]interface{}, fields)
+ for i := 0; i < fields; i++ {
+ out[i] = rv.Field(i).Interface()
+ }
+ return out
+}
+
+func toBytes(f *excelize.File) ([]byte, error) {
+ tmp, err := os.CreateTemp("", "iotlcm-export-*.xlsx")
+ if err != nil {
+ return nil, gerror.Wrap(err, "创建临时文件失败")
+ }
+ name := tmp.Name()
+ defer os.Remove(name)
+ defer tmp.Close()
+
+ if err = f.Write(tmp); err != nil {
+ return nil, gerror.Wrap(err, "写入临时文件失败")
+ }
+ if err = tmp.Sync(); err != nil {
+ return nil, gerror.Wrap(err, "刷新临时文件失败")
+ }
+ content, err := os.ReadFile(name)
+ if err != nil {
+ return nil, gerror.Wrap(err, "读取临时文件失败")
+ }
+ return content, nil
+}
+
+func truncateSheetName(name string) string {
+ if name == "" {
+ return "Sheet1"
+ }
+ rs := []rune(name)
+ if len(rs) > 31 {
+ rs = rs[:31]
+ }
+ return string(rs)
+}
+
+func markBatchRunning(ctx context.Context, taskNo string) {
+ _, err := dao.BatchMain.Ctx(ctx).
+ Data(g.Map{
+ dao.BatchMain.Columns().BatchAsyncStatus: consts.BatchAsyncStatusProcessing,
+ dao.BatchMain.Columns().RunResult: consts.BatchRunResultRunning,
+ dao.BatchMain.Columns().StartTime: gtime.Now(),
+ }).
+ Where(dao.BatchMain.Columns().BatchNo, taskNo).
+ Update()
+ if err != nil {
+ g.Log().Warningf(ctx, "[exportcenter] 更新执行日志开始状态失败 taskNo=%s err=%v", taskNo, err)
+ }
+}
+
+func markSuccess(ctx context.Context, task *entity.TExportTask, oss *model.SysOssSaveContentModel, written int, req *Request) error {
+ now := gtime.Now()
+ // 默认按全部成功回填;查询类导出可通过 Request.Result 改判为部分成功/失败并写汇总说明。
+ // 注意:即使判定为失败,文件本身仍已生成并可下载(文件里写明每条失败原因),
+ // 因此这里只改执行日志的 async_status/run_result,不改 t_export_task 的下载状态。
+ runResult := consts.BatchRunResultSuccess
+ asyncStatus := consts.BatchAsyncStatusSuccess
+ summary := ""
+ if req != nil && req.Result != nil {
+ if result, text := req.Result(written); result > 0 {
+ runResult = result
+ summary = text
+ switch result {
+ case consts.BatchRunResultPartialSuccess:
+ asyncStatus = consts.BatchAsyncStatusPartialSuccess
+ summary = text
+ case consts.BatchRunResultFailed:
+ asyncStatus = consts.BatchAsyncStatusFailed
+ summary = text
+ default:
+ asyncStatus = consts.BatchAsyncStatusSuccess
+ }
+ }
+ }
+ // 文件已写磁盘,两张表必须同一事务提交;失败保留 OSS 索引供过期清理重试。
+ return g.DB().Transaction(gctx.New(), func(ctx context.Context, tx gdb.TX) error {
+ affected, err := dao.TExportTask.Ctx(ctx).TX(tx).
+ Data(g.Map{
+ dao.TExportTask.Columns().Status: consts.DownloadStatusGenerated,
+ dao.TExportTask.Columns().OssId: oss.OssId,
+ dao.TExportTask.Columns().FileSize: oss.FileSize,
+ dao.TExportTask.Columns().FileMd5: oss.Md5,
+ dao.TExportTask.Columns().TotalCount: written,
+ dao.TExportTask.Columns().FinishTime: now,
+ dao.TExportTask.Columns().ExpireTime: now.AddDate(0, 0, ExpireDays),
+ }).
+ Where(dao.TExportTask.Columns().Id, task.Id).
+ Where(dao.TExportTask.Columns().Status, consts.DownloadStatusRunning).
+ UpdateAndGetAffected()
+ if err != nil {
+ return gerror.Wrap(err, "回填导出任务失败")
+ }
+ if affected != 1 {
+ return gerror.New("回填导出任务失败:任务状态已变更")
+ }
+ affected, err = dao.BatchMain.Ctx(ctx).TX(tx).
+ Data(g.Map{
+ dao.BatchMain.Columns().BatchAsyncStatus: asyncStatus,
+ dao.BatchMain.Columns().RunResult: runResult,
+ dao.BatchMain.Columns().FailReason: summary,
+ dao.BatchMain.Columns().ResultOssId: oss.OssId,
+ dao.BatchMain.Columns().TotalCount: written,
+ dao.BatchMain.Columns().EndTime: now,
+ }).
+ Where(dao.BatchMain.Columns().BatchNo, task.TaskNo).
+ UpdateAndGetAffected()
+ if err != nil {
+ return gerror.Wrap(err, "回填执行日志失败")
+ }
+ if affected != 1 {
+ return gerror.New("回填执行日志失败:记录不存在")
+ }
+ return nil
+ })
+}
+
+func markFailed(ctx context.Context, task *entity.TExportTask, cause error) {
+ // 失败也要落库,用独立 ctx 避免任务 ctx 已超时
+ ctx = gctx.New()
+ msg := cause.Error()
+ if len([]rune(msg)) > 1900 {
+ msg = string([]rune(msg)[:1900])
+ }
+ now := gtime.Now()
+
+ affected, err := dao.TExportTask.Ctx(ctx).
+ Data(g.Map{
+ dao.TExportTask.Columns().Status: consts.DownloadStatusFailed,
+ dao.TExportTask.Columns().ErrorMessage: msg,
+ dao.TExportTask.Columns().FinishTime: now,
+ }).
+ Where(dao.TExportTask.Columns().Id, task.Id).
+ WhereNull(dao.TExportTask.Columns().DeletedTime).
+ WhereIn(dao.TExportTask.Columns().Status, []int{consts.DownloadStatusPending, consts.DownloadStatusRunning}).
+ UpdateAndGetAffected()
+ if err != nil {
+ g.Log().Errorf(ctx, "[exportcenter] 回填失败状态失败 taskNo=%s err=%v", task.TaskNo, err)
+ return
+ }
+ if affected == 0 {
+ current := &entity.TExportTask{}
+ if err := dao.TExportTask.Ctx(ctx).Where(dao.TExportTask.Columns().Id, task.Id).Scan(current); err != nil {
+ g.Log().Errorf(ctx, "[exportcenter] 核对失败状态失败 taskNo=%s err=%v", task.TaskNo, err)
+ return
+ }
+ if current.Id == 0 || current.Status != consts.DownloadStatusFailed {
+ return
+ }
+ }
+
+ _, err = dao.BatchMain.Ctx(ctx).
+ Data(g.Map{
+ dao.BatchMain.Columns().BatchAsyncStatus: consts.BatchAsyncStatusFailed,
+ dao.BatchMain.Columns().RunResult: consts.BatchRunResultFailed,
+ dao.BatchMain.Columns().FailReason: msg,
+ dao.BatchMain.Columns().EndTime: now,
+ }).
+ Where(dao.BatchMain.Columns().BatchNo, task.TaskNo).
+ Update()
+ if err != nil {
+ g.Log().Errorf(ctx, "[exportcenter] 回填执行日志失败状态失败 taskNo=%s err=%v", task.TaskNo, err)
+ }
+}
+
+// RecoverOnStart 进程启动时把残留的「排队中/生成中」任务标记为失败。
+// 进程内任务不跨重启恢复,失败后用户可在下载中心点重试重新提交。
+func RecoverOnStart(ctx context.Context) {
+ now := time.Now()
+ _, err := dao.TExportTask.Ctx(ctx).
+ Data(g.Map{
+ dao.TExportTask.Columns().Status: consts.DownloadStatusFailed,
+ dao.TExportTask.Columns().ErrorMessage: "服务重启,任务已中断,请重新导出",
+ dao.TExportTask.Columns().FinishTime: now,
+ }).
+ WhereNull(dao.TExportTask.Columns().DeletedTime).
+ WhereIn(dao.TExportTask.Columns().Status, []int{consts.DownloadStatusPending, consts.DownloadStatusRunning}).
+ Update()
+ if err != nil {
+ g.Log().Errorf(ctx, "[exportcenter] 启动补偿失败: %v", err)
+ return
+ }
+
+ _, err = dao.BatchMain.Ctx(ctx).
+ Data(g.Map{
+ dao.BatchMain.Columns().BatchAsyncStatus: consts.BatchAsyncStatusFailed,
+ dao.BatchMain.Columns().RunResult: consts.BatchRunResultFailed,
+ dao.BatchMain.Columns().FailReason: "服务重启,任务已中断",
+ dao.BatchMain.Columns().EndTime: now,
+ }).
+ Where(dao.BatchMain.Columns().BatchType, consts.BatchTypeExport).
+ WhereIn(dao.BatchMain.Columns().BatchAsyncStatus, []int{consts.BatchAsyncStatusCreated, consts.BatchAsyncStatusProcessing}).
+ Update()
+ if err != nil {
+ g.Log().Errorf(ctx, "[exportcenter] 启动补偿执行日志失败: %v", err)
+ }
+}
diff --git a/server/internal/library/exportcenter/submit.go b/server/internal/library/exportcenter/submit.go
new file mode 100644
index 0000000..184242f
--- /dev/null
+++ b/server/internal/library/exportcenter/submit.go
@@ -0,0 +1,173 @@
+package exportcenter
+
+import (
+ "context"
+ "time"
+
+ "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/util/gconv"
+
+ "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/batchimport"
+ "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"
+)
+
+func snapshot(ctx context.Context) (*model.ExportIdentitySnapshot, error) {
+ user := contexts.GetUser(ctx)
+ if user == nil || user.ID <= 0 {
+ return nil, gerror.New("登录状态已失效,请重新登录")
+ }
+ snap := &model.ExportIdentitySnapshot{
+ UserId: user.ID,
+ DeptId: user.DeptId,
+ TenantId: user.TenantId,
+ Roles: user.Roles,
+ AccessCodeList: user.AccessCodeList,
+ }
+ if v := contexts.GetDataValue(ctx, "currentRoleId"); v != nil {
+ snap.CurrentRoleId = gconv.String(v)
+ }
+ if v := contexts.GetDataValue(ctx, "currentRoleDataScope"); v != nil {
+ snap.CurrentRoleDataScope = gconv.String(v)
+ }
+ return snap, nil
+}
+
+// Submit 提交一次导出任务:预校验行数、落任务与执行日志、投递后台执行。
+func Submit(ctx context.Context, req *Request) (res *SubmitResult, err error) {
+ if req == nil || req.Tags == nil || req.Fetch == nil {
+ return nil, gerror.New("导出配置不完整")
+ }
+ if req.BizKey == "" || req.BizName == "" {
+ return nil, gerror.New("导出业务标识不能为空")
+ }
+
+ // 提交前占用执行/排队容量,避免高峰期无限创建等待执行的 goroutine。
+ select {
+ case slots <- struct{}{}:
+ default:
+ return nil, gerror.New("导出任务排队已满,请稍后重试")
+ }
+ ownershipTransferred := false
+ defer func() {
+ if !ownershipTransferred {
+ <-slots
+ }
+ }()
+
+ snap, err := snapshot(ctx)
+ if err != nil {
+ return nil, failSubmit(ctx, "snapshot", req.BizKey, err)
+ }
+
+ // 预校验:只取 1 行,拿到筛选条件下的总行数。
+ // 业务侧可能返回 -1 表示「提交阶段还无法确定条数」(如按号段/按公司圈卡,
+ // 需要后台实际查库才知道命中多少),此时放行,最终条数以实际写出为准。
+ // total=0 也放行:空结果同样要产出文件(至少带表头与失败原因列),
+ // 让用户能拿到「查了但没命中」的凭据,而不是只看到一句报错。
+ _, total, err := req.Fetch(ctx, 1, 1)
+ if err != nil {
+ return nil, failSubmit(ctx, "count", req.BizKey, gerror.Wrap(err, "统计导出数据失败"))
+ }
+ if total > MaxRows {
+ return nil, gerror.Newf("本次导出 %d 行,超过单次上限 %d 行,请缩小筛选范围", total, MaxRows)
+ }
+ // 未知条数不写入任务记录,避免 -1 出现在列表与执行日志里。
+ recordedTotal := total
+ if recordedTotal < 0 {
+ recordedTotal = 0
+ }
+
+ taskNo, err := batchimport.NextBatchNo(consts.BatchNoPrefixExport)
+ if err != nil {
+ return nil, failSubmit(ctx, "batch_no", req.BizKey, gerror.Wrap(err, "生成任务编号失败"))
+ }
+
+ // 列清单直接影响导出内容,留痕便于排查「导出列与页面不一致」。
+ if len(req.Columns) == 0 {
+ req.Columns = columnsFromRequest(ctx)
+ }
+ g.Log().Infof(ctx, "[exportcenter] 提交导出 biz=%s user=%d columns=%d", req.BizKey, snap.UserId, len(req.Columns))
+
+ now := time.Now()
+ expire := now.AddDate(0, 0, ExpireDays)
+ fileName := taskNo + ".xlsx"
+
+ batchInfo := gjson.New(g.Map{
+ "exportBizKey": req.BizKey,
+ "exportBizName": req.BizName,
+ "permCode": req.PermCode,
+ })
+
+ var taskId int64
+ err = g.DB().Transaction(ctx, func(ctx context.Context, tx gdb.TX) error {
+ id, insertErr := dao.TExportTask.Ctx(ctx).TX(tx).Data(g.Map{
+ dao.TExportTask.Columns().TaskNo: taskNo,
+ dao.TExportTask.Columns().BizKey: req.BizKey,
+ dao.TExportTask.Columns().BizName: req.BizName,
+ dao.TExportTask.Columns().PermCode: req.PermCode,
+ dao.TExportTask.Columns().QueryParams: gjson.New(req.QueryParam),
+ dao.TExportTask.Columns().IdentitySnapshot: gjson.New(snap),
+ dao.TExportTask.Columns().Status: consts.DownloadStatusPending,
+ dao.TExportTask.Columns().TotalCount: recordedTotal,
+ dao.TExportTask.Columns().FileName: fileName,
+ dao.TExportTask.Columns().OwnerUserId: snap.UserId,
+ dao.TExportTask.Columns().DeptId: snap.DeptId,
+ dao.TExportTask.Columns().TenantId: snap.TenantId,
+ dao.TExportTask.Columns().CreatedDept: snap.DeptId,
+ dao.TExportTask.Columns().CreatedBy: snap.UserId,
+ dao.TExportTask.Columns().CreatedAt: now,
+ dao.TExportTask.Columns().ExpireTime: expire,
+ }).InsertAndGetId()
+ if insertErr != nil {
+ return gerror.Wrap(insertErr, "创建导出任务失败")
+ }
+ taskId = id
+
+ _, insertErr = dao.BatchMain.Ctx(ctx).TX(tx).Data(g.Map{
+ dao.BatchMain.Columns().BatchNo: taskNo,
+ dao.BatchMain.Columns().BatchType: consts.BatchTypeExport,
+ dao.BatchMain.Columns().BatchStatus: consts.BatchStatusNormal,
+ dao.BatchMain.Columns().BatchAsyncStatus: consts.BatchAsyncStatusCreated,
+ dao.BatchMain.Columns().SourceChannel: batchmetadata.Console,
+ dao.BatchMain.Columns().ExecutionMode: batchmetadata.Bulk,
+ dao.BatchMain.Columns().RunResult: consts.BatchRunResultPending,
+ dao.BatchMain.Columns().TotalCount: recordedTotal,
+ dao.BatchMain.Columns().BatchInfo: batchInfo,
+ dao.BatchMain.Columns().TenantId: snap.TenantId,
+ dao.BatchMain.Columns().CreatedDept: snap.DeptId,
+ // dept_id 必须与 created_dept 一致。
+ // FilterAuth 对 batch_main 优先用 dept_id 过滤,留空(NUL)会让提交人自己也看不到
+ // 这条执行日志(只有全部数据权限的管理员能看到),表现为「导出了但日志里没有」。
+ dao.BatchMain.Columns().DeptId: snap.DeptId,
+ dao.BatchMain.Columns().CreatedBy: snap.UserId,
+ dao.BatchMain.Columns().CreatedAt: now,
+ }).Insert()
+ if insertErr != nil {
+ return gerror.Wrap(insertErr, "写导出执行日志失败")
+ }
+ return nil
+ })
+ if err != nil {
+ return nil, failSubmit(ctx, "persist", req.BizKey, err)
+ }
+
+ go run(taskNo, req)
+ ownershipTransferred = true
+
+ return &SubmitResult{TaskId: taskId, TaskNo: taskNo, Total: total}, nil
+}
+
+// failSubmit 统一记录提交失败的环节与原因。
+// 框架会把未带错误码的 error 统一转成「内部服务器错误」,真实原因只能从日志看,
+// 所以这里必须把 step 打出来,否则线上无法定位。
+func failSubmit(ctx context.Context, step, bizKey string, err error) error {
+ g.Log().Errorf(ctx, "[exportcenter] 提交导出失败 step=%s biz=%s err=%+v", step, bizKey, err)
+ return err
+}
diff --git a/server/internal/library/exportcenter/types.go b/server/internal/library/exportcenter/types.go
new file mode 100644
index 0000000..3f8e4ee
--- /dev/null
+++ b/server/internal/library/exportcenter/types.go
@@ -0,0 +1,76 @@
+// Package exportcenter 通用异步导出中心。
+//
+// 调用方在导出接口里只负责描述「怎么取一页数据」,提交、排队、生成文件、
+// 落 OSS、写执行日志、失败回填都由本包完成。
+//
+// 注意事项:
+// - 异步执行不持有 HTTP 上下文,必须靠提交时的身份快照还原 ctx,
+// 否则 FilterAuth 取不到用户会漏掉部门数据权限。
+// - 导出批次只写 batch_main,不写 batch_detail;执行日志中的运行结果
+// 跟随导出任务状态更新,因此不会出现在批次管理页,只出现在执行日志页。
+package exportcenter
+
+import (
+ "context"
+ "time"
+
+ "git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/model"
+)
+
+const (
+ // DefaultPageSize 每次从业务侧取多少行
+ DefaultPageSize = 2000
+ // MaxRows 单任务行数上限(Excel 硬上限 104 万,留出余量)
+ MaxRows = 500000
+ // MaxFileSize 单文件大小上限
+ MaxFileSize int64 = 100 << 20
+ // MaxConcurrent 同时执行的导出任务数
+ MaxConcurrent = 2
+ // MaxQueued 允许等待执行的导出任务数(单实例)
+ MaxQueued = 20
+ // ExpireDays 文件保留天数
+ ExpireDays = 3
+ // TaskTimeout 单任务超时
+ TaskTimeout = 10 * time.Minute
+)
+
+// Request 一次导出请求的完整描述
+type Request struct {
+ BizKey string // 业务标识,如 tSimCardInfo
+ BizName string // 业务名称,如 卡信息
+ PermCode string // 接口权限码快照,K1 用
+ SheetName string // Excel sheet 名,留空则用 BizName
+ QueryParam interface{} // 筛选条件快照,落库备查
+ // Columns 可选:前端传入的「当前列表显示列」,顺序即导出列顺序。
+ // 非空时按 Field(列表模型 json tag)逐行取值、Label 作表头;
+ // 为空时沿用 Tags + 结构体字段顺序(兼容尚未改造的导出)。
+ Columns []model.ExportColumn
+ // RowFields 可选:自定义 ToRow 取值时,声明 ToRow 返回值与字段名的对应顺序,
+ // 这样按列表列导出时仍能复用 ToRow 的格式化结果。为空则直接从结构体取值。
+ RowFields []string
+ // Tags 表头,通常 convert.GetEntityDescTags(model.XxxExportModel{})
+ Tags func() ([]string, error)
+ // Fetch 取一页数据。page 从 1 开始;返回的 total 必须为筛选条件下的总行数。
+ // rows 里每个元素应是结构体或结构体指针,字段顺序即列顺序。
+ Fetch func(ctx context.Context, page, pageSize int) (rows []interface{}, total int, err error)
+ // ToRow 可选:自定义「结构体 -> 行」的转换(如需要状态名、时间格式化)。
+ // 为空时按结构体字段顺序取值。
+ ToRow func(v interface{}) []interface{}
+ // Result 可选:文件生成完成后回填执行结果汇总。
+ // 用于「按输入逐条反馈」的查询类导出:全部命中记为成功,部分未命中记为部分成功。
+ // 返回的 runResult 取 consts.BatchRunResult*;summary 写入执行日志的失败原因/汇总说明。
+ // 为空时保持历史行为(一律成功)。
+ Result func(written int) (runResult int, summary string)
+}
+
+// SubmitResult 提交结果
+type SubmitResult struct {
+ TaskId int64 `json:"taskId" dc:"任务ID"`
+ TaskNo string `json:"taskNo" dc:"任务编号"`
+ Total int `json:"total" dc:"预计导出行数"`
+}
+
+// Snapshot 由 ctx 生成身份快照
+func Snapshot(ctx context.Context) (*model.ExportIdentitySnapshot, error) {
+ return snapshot(ctx)
+}
diff --git a/server/internal/library/mcache/mcache.go b/server/internal/library/mcache/mcache.go
index 8644828..7370803 100644
--- a/server/internal/library/mcache/mcache.go
+++ b/server/internal/library/mcache/mcache.go
@@ -1,7 +1,7 @@
// Package mcache
// @Link https://github.com/xiujiecn/xiu-admin
// @Copyright Copyright (c) 2025 LiXiujie
-// @Author Lxj
+// @Author Lxj
// @License https://github.com/xiujiecn/xiu-admin/blob/master/LICENSE
// 内存缓存管理
package mcache
@@ -16,19 +16,15 @@ import (
// cache 缓存驱动
var (
- mcache *gcache.Cache
- mu sync.Mutex
+ mcache *gcache.Cache
+ cacheOnce sync.Once
)
// Instance 缓存实例
func Instance() *gcache.Cache {
- if mcache == nil {
- mu.Lock()
- if mcache == nil {
- mcache = gcache.NewWithAdapter(gcache.NewAdapterMemory())
- }
- mu.Unlock()
- }
+ cacheOnce.Do(func() {
+ mcache = gcache.NewWithAdapter(gcache.NewAdapterMemory())
+ })
return mcache
}
diff --git a/server/internal/library/mcache/mcache_access_list.go b/server/internal/library/mcache/mcache_access_list.go
index bdf1812..6411082 100644
--- a/server/internal/library/mcache/mcache_access_list.go
+++ b/server/internal/library/mcache/mcache_access_list.go
@@ -1,7 +1,7 @@
// Package mcache
// @Link https://github.com/xiujiecn/xiu-admin
// @Copyright Copyright (c) 2025 LiXiujie
-// @Author Lxj
+// @Author Lxj
// @License https://github.com/xiujiecn/xiu-admin/blob/master/LICENSE
package mcache
@@ -28,22 +28,19 @@ func init() {
}
+var loadUserPermissions = func(ctx context.Context, userID int64) ([]string, []string, error) {
+ return service.SysAuth().GetUserAccessCodeList(ctx, userID)
+}
+
func GetUserAccessCodeList(ctx context.Context, userId int64) ([]string, []string, error) {
- accessCodeList, err := Instance().Get(ctx, fmt.Sprintf(consts.MemCacheUserAccessCodeList, userId))
- menuRoleDataAccessCodeList, err := Instance().Get(ctx, fmt.Sprintf(consts.MemCacheUserRoleDataAccessCodeList, userId))
- if err != nil || accessCodeList == nil {
- accessCodeCurrList, menuRoleDataAccessCodeList, err := service.SysAuth().GetUserAccessCodeList(ctx, userId)
- if err != nil {
- return nil, nil, err
- }
- if len(accessCodeCurrList) == 0 {
- accessCodeCurrList = []string{"null"}
- }
- Instance().Set(ctx, fmt.Sprintf(consts.MemCacheUserAccessCodeList, userId), accessCodeCurrList, time.Hour*24)
- Instance().Set(ctx, fmt.Sprintf(consts.MemCacheUserRoleDataAccessCodeList, userId), menuRoleDataAccessCodeList, time.Hour*24)
- return accessCodeCurrList, menuRoleDataAccessCodeList, nil
+ codes, dataCodes, err := loadUserPermissions(ctx, userId)
+ if err != nil {
+ return nil, nil, err
}
- return accessCodeList.Strings(), menuRoleDataAccessCodeList.Strings(), nil
+ if len(codes) == 0 {
+ codes = []string{"null"}
+ }
+ return codes, dataCodes, nil
}
func SetUserAccessCodeList(ctx context.Context, userId int64, accessCodeList []string) error {
@@ -55,11 +52,11 @@ func SetUserRoleDataAccessCodeList(ctx context.Context, userId int64, roleDataAc
}
func RemoveUserRoleDataAccessCodeList(ctx context.Context, userId int64) error {
- _, err := Instance().Remove(ctx, fmt.Sprintf(consts.MemCacheUserRoleDataAccessCodeList, userId))
+ err := Instance().Removes(ctx, []interface{}{fmt.Sprintf(consts.MemCacheUserRoleDataAccessCodeList, userId)})
return err
}
func RemoveUserAccessCodeList(ctx context.Context, userId int64) error {
- _, err := Instance().Remove(ctx, fmt.Sprintf(consts.MemCacheUserAccessCodeList, userId))
+ err := Instance().Removes(ctx, []interface{}{fmt.Sprintf(consts.MemCacheUserAccessCodeList, userId)})
return err
}
diff --git a/server/internal/library/mcache/mcache_access_list_test.go b/server/internal/library/mcache/mcache_access_list_test.go
new file mode 100644
index 0000000..e3df83f
--- /dev/null
+++ b/server/internal/library/mcache/mcache_access_list_test.go
@@ -0,0 +1,28 @@
+package mcache
+
+import (
+ "context"
+ "reflect"
+ "testing"
+)
+
+func TestPermissionsReloadWithoutRevisionTable(t *testing.T) {
+ original := loadUserPermissions
+ t.Cleanup(func() { loadUserPermissions = original })
+ loads := 0
+ loadUserPermissions = func(context.Context, int64) ([]string, []string, error) {
+ loads++
+ if loads == 1 {
+ return []string{"old"}, nil, nil
+ }
+ return []string{"new"}, nil, nil
+ }
+ ctx := context.Background()
+ if _, _, err := GetUserAccessCodeList(ctx, 99); err != nil {
+ t.Fatal(err)
+ }
+ codes, _, err := GetUserAccessCodeList(ctx, 99)
+ if err != nil || !reflect.DeepEqual(codes, []string{"new"}) || loads != 2 {
+ t.Fatalf("stale authorization: codes=%v loads=%d err=%v", codes, loads, err)
+ }
+}
diff --git a/server/internal/library/mcache/mcache_system_config.go b/server/internal/library/mcache/mcache_system_config.go
index 849ee57..18f2c40 100644
--- a/server/internal/library/mcache/mcache_system_config.go
+++ b/server/internal/library/mcache/mcache_system_config.go
@@ -1,7 +1,7 @@
// Package mcache
// @Link https://github.com/xiujiecn/xiu-admin
// @Copyright Copyright (c) 2025 LiXiujie
-// @Author Lxj
+// @Author Lxj
// @License https://github.com/xiujiecn/xiu-admin/blob/master/LICENSE
package mcache
diff --git a/server/internal/library/mcache/mcache_system_dept.go b/server/internal/library/mcache/mcache_system_dept.go
index 3a77498..7fc33c5 100644
--- a/server/internal/library/mcache/mcache_system_dept.go
+++ b/server/internal/library/mcache/mcache_system_dept.go
@@ -1,7 +1,7 @@
// Package mcache
// @Link https://github.com/xiujiecn/xiu-admin
// @Copyright Copyright (c) 2025 LiXiujie
-// @Author Lxj
+// @Author Lxj
// @License https://github.com/xiujiecn/xiu-admin/blob/master/LICENSE
package mcache
diff --git a/server/internal/library/mcache/mcache_system_user.go b/server/internal/library/mcache/mcache_system_user.go
index 762103b..249fee6 100644
--- a/server/internal/library/mcache/mcache_system_user.go
+++ b/server/internal/library/mcache/mcache_system_user.go
@@ -1,7 +1,7 @@
// Package mcache
// @Link https://github.com/xiujiecn/xiu-admin
// @Copyright Copyright (c) 2025 LiXiujie
-// @Author Lxj
+// @Author Lxj
// @License https://github.com/xiujiecn/xiu-admin/blob/master/LICENSE
package mcache
diff --git a/server/internal/library/rabbitmq/confirmed.go b/server/internal/library/rabbitmq/confirmed.go
index 3c6b45e..11afd0d 100644
--- a/server/internal/library/rabbitmq/confirmed.go
+++ b/server/internal/library/rabbitmq/confirmed.go
@@ -3,6 +3,8 @@ package rabbitmq
import (
"context"
"encoding/json"
+ "net"
+ "strconv"
"time"
"github.com/gogf/gf/v2/errors/gerror"
@@ -14,12 +16,41 @@ func PublishConfirmed(ctx context.Context, queue string, data any) error {
if err != nil {
return err
}
- channel, err := Channel(ctx)
+ return publishConfirmedBody(ctx, queue, body, "", "", 0)
+}
+
+func PublishDelayedConfirmed(ctx context.Context, delayQueue, targetQueue string, delay time.Duration, data any) error {
+ body, err := json.Marshal(data)
if err != nil {
return err
}
- defer channel.Close()
- if err = declareQueue(channel, queue); err != nil {
+ return publishConfirmedBody(ctx, delayQueue, body, delayQueue, targetQueue, delay)
+}
+
+func publishConfirmedBody(ctx context.Context, queue string, body []byte, delayQueue, targetQueue string, delay time.Duration) error {
+ ctx, cancel := context.WithTimeout(ctx, 10*time.Second)
+ defer cancel()
+ cfg := GetConfig(ctx)
+ if !cfg.Enabled || cfg.Dsn == "" {
+ return gerror.New("rabbitmq is disabled or dsn is empty")
+ }
+ connection, cleanup, err := openConfirmedConnection(ctx, cfg.Dsn)
+ if err != nil {
+ return err
+ }
+ defer cleanup()
+ channel, err := connection.Channel()
+ if err != nil {
+ return err
+ }
+ if delayQueue != "" {
+ if err = declareQueue(channel, targetQueue); err != nil {
+ return err
+ }
+ if err = declareDelayQueue(channel, delayQueue, targetQueue); err != nil {
+ return err
+ }
+ } else if err = declareQueue(channel, queue); err != nil {
return err
}
if err = channel.Confirm(false); err != nil {
@@ -27,10 +58,8 @@ func PublishConfirmed(ctx context.Context, queue string, data any) error {
}
confirmations := channel.NotifyPublish(make(chan amqp.Confirmation, 1))
returns := channel.NotifyReturn(make(chan amqp.Return, 1))
- ctx, cancel := context.WithTimeout(ctx, 10*time.Second)
- defer cancel()
if err = channel.PublishWithContext(ctx, "", queue, true, false, amqp.Publishing{
- ContentType: "application/json", DeliveryMode: amqp.Persistent, Timestamp: time.Now(), Body: body,
+ ContentType: "application/json", DeliveryMode: amqp.Persistent, Timestamp: time.Now(), Body: body, Expiration: confirmedExpiration(delay),
}); err != nil {
return err
}
@@ -38,16 +67,61 @@ func PublishConfirmed(ctx context.Context, queue string, data any) error {
case <-ctx.Done():
return ctx.Err()
case returned := <-returns:
- return gerror.Newf("traffic usage message unroutable: %s", returned.ReplyText)
+ return gerror.Newf("rabbitmq message unroutable: %s", returned.ReplyText)
case confirmation, ok := <-confirmations:
if !ok || !confirmation.Ack {
- return gerror.New("traffic usage message was not confirmed")
+ return gerror.New("rabbitmq message was not confirmed")
}
select {
case returned := <-returns:
- return gerror.Newf("traffic usage message unroutable: %s", returned.ReplyText)
+ return gerror.Newf("rabbitmq message unroutable: %s", returned.ReplyText)
default:
return nil
}
}
}
+
+func openConfirmedConnection(ctx context.Context, dsn string) (*amqp.Connection, func(), error) {
+ if err := ctx.Err(); err != nil {
+ return nil, nil, err
+ }
+ var (
+ socket net.Conn
+ stop func() bool
+ )
+ cleanup := func() {
+ if stop != nil {
+ stop()
+ }
+ if socket != nil {
+ _ = socket.Close()
+ }
+ }
+ connection, err := amqp.DialConfig(dsn, amqp.Config{
+ Dial: func(network, address string) (net.Conn, error) {
+ var dialErr error
+ socket, dialErr = (&net.Dialer{}).DialContext(ctx, network, address)
+ if dialErr != nil {
+ return nil, dialErr
+ }
+ transport := socket
+ stop = context.AfterFunc(ctx, func() { _ = transport.Close() })
+ return socket, nil
+ },
+ })
+ if err != nil {
+ cleanup()
+ if ctx.Err() != nil {
+ return nil, nil, ctx.Err()
+ }
+ return nil, nil, gerror.Wrap(err, "rabbitmq confirmed publisher connect failed")
+ }
+ return connection, cleanup, nil
+}
+
+func confirmedExpiration(delay time.Duration) string {
+ if delay <= 0 {
+ return ""
+ }
+ return strconv.FormatInt(delay.Milliseconds(), 10)
+}
diff --git a/server/internal/library/rabbitmq/confirmed_test.go b/server/internal/library/rabbitmq/confirmed_test.go
new file mode 100644
index 0000000..62b97e3
--- /dev/null
+++ b/server/internal/library/rabbitmq/confirmed_test.go
@@ -0,0 +1,83 @@
+package rabbitmq
+
+import (
+ "context"
+ "errors"
+ "io"
+ "net"
+ "testing"
+ "time"
+)
+
+func TestConfirmedExpiration(t *testing.T) {
+ for _, item := range []struct {
+ delay time.Duration
+ want string
+ }{{0, ""}, {-time.Second, ""}, {time.Second, "1000"}, {1500 * time.Millisecond, "1500"}} {
+ if got := confirmedExpiration(item.delay); got != item.want {
+ t.Fatalf("expiration(%v)=%q, want %q", item.delay, got, item.want)
+ }
+ }
+}
+
+func TestConfirmedConnectionHandshakeTimeout(t *testing.T) {
+ listener, err := net.Listen("tcp", "127.0.0.1:0")
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer listener.Close()
+ finished := make(chan struct{})
+ go func() {
+ defer close(finished)
+ connection, acceptErr := listener.Accept()
+ if acceptErr != nil {
+ return
+ }
+ defer connection.Close()
+ _ = connection.SetDeadline(time.Now().Add(3 * time.Second))
+ _, _ = io.Copy(io.Discard, connection)
+ }()
+ ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
+ defer cancel()
+ started := time.Now()
+ connection, cleanup, err := openConfirmedConnection(ctx, "amqp://guest:guest@"+listener.Addr().String()+"/")
+ if cleanup != nil {
+ cleanup()
+ }
+ if connection != nil || !errors.Is(err, context.DeadlineExceeded) {
+ t.Fatalf("expected handshake timeout, connection=%v err=%v", connection, err)
+ }
+ if time.Since(started) > 2*time.Second {
+ t.Fatal("handshake exceeded the context deadline")
+ }
+ select {
+ case <-finished:
+ case <-time.After(2 * time.Second):
+ t.Fatal("timed out socket was not closed")
+ }
+}
+
+func TestConfirmedConnectionCanceledContext(t *testing.T) {
+ ctx, cancel := context.WithCancel(context.Background())
+ cancel()
+ connection, cleanup, err := openConfirmedConnection(ctx, "invalid-dsn")
+ if connection != nil || cleanup != nil || !errors.Is(err, context.Canceled) {
+ t.Fatalf("expected cancellation before dialing, connection=%v err=%v", connection, err)
+ }
+}
+
+func TestConsumerCloserUnregister(t *testing.T) {
+ closerMu.Lock()
+ initial := len(closers)
+ closerMu.Unlock()
+ for attempt := 0; attempt < 1000; attempt++ {
+ unregister := registerCloser(func() error { return nil })
+ unregister()
+ unregister()
+ }
+ closerMu.Lock()
+ defer closerMu.Unlock()
+ if len(closers) != initial {
+ t.Fatalf("stale consumer closers retained: initial=%d actual=%d", initial, len(closers))
+ }
+}
diff --git a/server/internal/library/rabbitmq/rabbitmq.go b/server/internal/library/rabbitmq/rabbitmq.go
index d86f529..08e025c 100644
--- a/server/internal/library/rabbitmq/rabbitmq.go
+++ b/server/internal/library/rabbitmq/rabbitmq.go
@@ -27,7 +27,8 @@ type JSONHandler[T any] func(ctx context.Context, message T) error
var (
conn *amqp.Connection
connMu sync.Mutex
- closers []func() error
+ closers = make(map[uint64]func() error)
+ closerID uint64
closerMu sync.Mutex
)
@@ -217,7 +218,7 @@ func Close() error {
for _, closer := range closers {
_ = closer()
}
- closers = nil
+ closers = make(map[uint64]func() error)
closerMu.Unlock()
connMu.Lock()
@@ -252,8 +253,107 @@ func declareDelayQueue(ch *amqp.Channel, delayQueue, targetQueue string) error {
}
// registerCloser 记录消费者 channel,服务退出时统一关闭。
-func registerCloser(closer func() error) {
+func registerCloser(closer func() error) func() {
closerMu.Lock()
defer closerMu.Unlock()
- closers = append(closers, closer)
+ closerID++
+ id := closerID
+ closers[id] = closer
+ return func() {
+ closerMu.Lock()
+ defer closerMu.Unlock()
+ delete(closers, id)
+ }
+}
+
+func ConsumeJSONRequeue[T any](ctx context.Context, queue, consumer string, handler JSONHandler[T]) error {
+ ctx, cancel := context.WithCancel(ctx)
+ open := func() (*amqp.Channel, <-chan amqp.Delivery, error) {
+ channel, err := Channel(ctx)
+ if err != nil {
+ return nil, nil, err
+ }
+ if err = declareQueue(channel, queue); err == nil {
+ err = channel.Qos(1, 0, false)
+ }
+ if err != nil {
+ _ = channel.Close()
+ return nil, nil, err
+ }
+ deliveries, err := channel.Consume(queue, consumer, false, false, false, false, nil)
+ if err != nil {
+ _ = channel.Close()
+ return nil, nil, err
+ }
+ return channel, deliveries, nil
+ }
+ channel, deliveries, err := open()
+ if err != nil {
+ cancel()
+ return err
+ }
+ var channelMu sync.Mutex
+ unregister := registerCloser(func() error {
+ cancel()
+ channelMu.Lock()
+ defer channelMu.Unlock()
+ return channel.Close()
+ })
+ go func() {
+ defer func() {
+ cancel()
+ unregister()
+ _ = channel.Close()
+ }()
+ for {
+ select {
+ case <-ctx.Done():
+ return
+ case delivery, ok := <-deliveries:
+ if !ok {
+ _ = channel.Close()
+ for {
+ select {
+ case <-ctx.Done():
+ return
+ case <-time.After(3 * time.Second):
+ }
+ next, stream, reconnectErr := open()
+ if reconnectErr != nil {
+ g.Log().Errorf(ctx, "role sync consumer reconnect failed, queue:%s, err:%v", queue, reconnectErr)
+ continue
+ }
+ channelMu.Lock()
+ if ctx.Err() != nil {
+ channelMu.Unlock()
+ _ = next.Close()
+ return
+ }
+ channel, deliveries = next, stream
+ channelMu.Unlock()
+ break
+ }
+ continue
+ }
+ var message T
+ if decodeErr := json.Unmarshal(delivery.Body, &message); decodeErr != nil {
+ g.Log().Errorf(ctx, "rabbitmq invalid role sync message, queue:%s, err:%v", queue, decodeErr)
+ _ = delivery.Ack(false)
+ continue
+ }
+ if handleErr := handler(ctx, message); handleErr != nil {
+ g.Log().Errorf(ctx, "role sync message retained, queue:%s, err:%v", queue, handleErr)
+ select {
+ case <-ctx.Done():
+ return
+ case <-time.After(time.Second):
+ }
+ _ = delivery.Nack(false, true)
+ continue
+ }
+ _ = delivery.Ack(false)
+ }
+ }
+ }()
+ return nil
}
diff --git a/server/internal/library/rolepolicy/assignable.go b/server/internal/library/rolepolicy/assignable.go
new file mode 100644
index 0000000..8a071fc
--- /dev/null
+++ b/server/internal/library/rolepolicy/assignable.go
@@ -0,0 +1,69 @@
+package rolepolicy
+
+import (
+ "context"
+ "slices"
+
+ "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/model/entity"
+
+ "github.com/gogf/gf/v2/errors/gerror"
+)
+
+func WithMenuAncestors(ctx context.Context, ids []int64) ([]int64, error) {
+ var (
+ seen = make(map[int64]bool, len(ids))
+ queue = slices.Clone(ids)
+ )
+ for _, id := range ids {
+ seen[id] = true
+ }
+ for len(queue) > 0 {
+ var menus []*entity.SysMenu
+ if err := dao.SysMenu.Ctx(ctx).Master().WhereIn("menu_id", queue).Where("status", consts.SysMenuStatusNormal).Scan(&menus); err != nil {
+ return nil, err
+ }
+ queue = nil
+ for _, menu := range menus {
+ if menu.ParentId > 0 && !seen[menu.ParentId] {
+ seen[menu.ParentId] = true
+ queue = append(queue, menu.ParentId)
+ }
+ }
+ }
+ result := make([]int64, 0, len(seen))
+ for id := range seen {
+ result = append(result, id)
+ }
+ slices.Sort(result)
+ return result, nil
+}
+
+func NormalizeAssignableMenus(ctx context.Context, tenantID string, deptID, userID int64, ids []int64) ([]int64, error) {
+ allowed, err := RestrictAssignableMenus(ctx, tenantID, deptID, userID, ids)
+ if err != nil {
+ return nil, err
+ }
+ if len(allowed) > 0 {
+ var menus []*entity.SysMenu
+ if err := dao.SysMenu.Ctx(ctx).Master().WhereIn("menu_id", allowed).Where("status", consts.SysMenuStatusNormal).Scan(&menus); err != nil {
+ return nil, err
+ }
+ allowed = make([]int64, 0, len(menus))
+ for _, menu := range menus {
+ allowed = append(allowed, menu.MenuId)
+ }
+ }
+ visible, err := WithMenuAncestors(ctx, allowed)
+ if err != nil {
+ return nil, err
+ }
+ for _, id := range ids {
+ if !slices.Contains(visible, id) {
+ return nil, gerror.Newf("角色授权超出机构或模板范围,或菜单已停用(菜单ID:%d),请刷新授权列表后重试", id)
+ }
+ }
+ slices.Sort(allowed)
+ return slices.Compact(allowed), nil
+}
diff --git a/server/internal/library/rolepolicy/assignable_test.go b/server/internal/library/rolepolicy/assignable_test.go
new file mode 100644
index 0000000..8997e95
--- /dev/null
+++ b/server/internal/library/rolepolicy/assignable_test.go
@@ -0,0 +1,113 @@
+package rolepolicy
+
+import (
+ "context"
+ "reflect"
+ "strings"
+ "testing"
+
+ _ "github.com/gogf/gf/contrib/drivers/sqlite/v2"
+ "github.com/gogf/gf/v2/database/gdb"
+ "github.com/gogf/gf/v2/frame/g"
+)
+
+func TestNormalizeAssignableMenus(t *testing.T) {
+ cases := []struct {
+ name string
+ ids []int64
+ mutation string
+ want []int64
+ wantError bool
+ }{
+ {name: "补齐多级父节点但不把父权限写入角色", ids: []int64{1, 10, 100}, want: []int64{100}},
+ {name: "重复选择去重", ids: []int64{100, 100}, want: []int64{100}},
+ {name: "空选择不自动授权", ids: []int64{}, want: []int64{}},
+ {name: "无关菜单仍拒绝", ids: []int64{100, 200}, wantError: true},
+ {name: "父节点单独不能获得权限", ids: []int64{10}, wantError: true},
+ {name: "不存在菜单仍拒绝", ids: []int64{999}, wantError: true},
+ {name: "停用菜单不能继续下发", ids: []int64{100}, mutation: "UPDATE sys_menu SET status='1' WHERE menu_id=100", wantError: true},
+ {name: "模板保存前后不裁剪已下发权限", ids: []int64{100}, mutation: "DELETE FROM sys_role_menu WHERE role_id=82 AND menu_id=100", want: []int64{100}},
+ {name: "上级角色调整不裁剪本机构权限", ids: []int64{100}, mutation: "DELETE FROM sys_role_menu WHERE role_id=120 AND menu_id=100", want: []int64{100}},
+ {name: "本机构默认角色撤权后不能继续下放", ids: []int64{100}, mutation: "DELETE FROM sys_role_menu WHERE role_id=121", wantError: true},
+ {name: "操作员无权限不能借机构权限授权", ids: []int64{100}, mutation: "DELETE FROM sys_role_menu WHERE role_id=122", wantError: true},
+ {name: "操作员角色停用不能授权", ids: []int64{100}, mutation: "UPDATE sys_role SET status='1' WHERE role_id=122", wantError: true},
+ {name: "菜单循环不会无限遍历", ids: []int64{1, 10, 100}, mutation: "UPDATE sys_menu SET parent_id=100 WHERE menu_id=1", want: []int64{100}},
+ }
+ for _, test := range cases {
+ t.Run(test.name, func(t *testing.T) {
+ setupAssignableTestDB(t)
+ ctx := context.Background()
+ if test.mutation != "" {
+ if _, err := g.DB().Exec(ctx, test.mutation); err != nil {
+ t.Fatal(err)
+ }
+ }
+ got, err := NormalizeAssignableMenus(ctx, "000000", 199, 162, test.ids)
+ if test.wantError {
+ if err == nil || !strings.Contains(err.Error(), "菜单ID") {
+ t.Fatalf("expected actionable denial, got %v, %v", got, err)
+ }
+ return
+ }
+ if err != nil || !reflect.DeepEqual(got, test.want) {
+ t.Fatalf("got %v, %v; want %v", got, err, test.want)
+ }
+ })
+ }
+}
+
+func setupAssignableTestDB(t *testing.T) {
+ t.Helper()
+ if err := gdb.SetConfig(gdb.Config{"default": {{Type: "sqlite", Link: "sqlite::@file(:memory:)"}}}); err != nil {
+ t.Fatal(err)
+ }
+ ctx := context.Background()
+ t.Cleanup(func() { _ = g.DB().Close(ctx) })
+ for _, statement := range []string{
+ "CREATE TABLE sys_config (tenant_id TEXT, config_key TEXT, config_value TEXT)",
+ "CREATE TABLE sys_role (role_id INTEGER PRIMARY KEY, tenant_id TEXT, dept_id INTEGER, created_dept INTEGER, status TEXT, data_scope TEXT, deleted_at DATETIME)",
+ "CREATE TABLE sys_dept (dept_id INTEGER PRIMARY KEY, tenant_id TEXT, parent_id INTEGER, dept_role INTEGER, status TEXT, deleted_at DATETIME)",
+ "CREATE TABLE sys_role_menu (tenant_id TEXT, role_id INTEGER, menu_id INTEGER, data_scope TEXT)",
+ "CREATE TABLE sys_user_role (tenant_id TEXT, user_id INTEGER, role_id INTEGER)",
+ "CREATE TABLE sys_menu (menu_id INTEGER PRIMARY KEY, parent_id INTEGER, status TEXT, perms TEXT)",
+ "INSERT INTO sys_config VALUES ('000000','agent.role.template.role_id','82')",
+ "INSERT INTO sys_dept VALUES (100,'000000',0,1,'0',NULL),(198,'000000',100,120,'0',NULL),(199,'000000',198,121,'0',NULL)",
+ "INSERT INTO sys_role VALUES (82,'000000',100,100,'0','4',NULL),(120,'000000',198,198,'0','4',NULL),(121,'000000',199,199,'0','4',NULL),(122,'000000',199,199,'0','4',NULL)",
+ "INSERT INTO sys_menu VALUES (1,0,'0',''),(10,1,'0','system:parent:list'),(100,10,'0','system:child:view'),(200,1,'0','system:other:view')",
+ "INSERT INTO sys_role_menu VALUES ('000000',82,100,'0'),('000000',120,100,'0'),('000000',121,100,'0'),('000000',122,100,'0')",
+ "INSERT INTO sys_user_role VALUES ('000000',162,122)",
+ } {
+ if _, err := g.DB().Exec(ctx, statement); err != nil {
+ t.Fatal(err)
+ }
+ }
+}
+
+// Runtime permissions come from the user's roles; delegation additionally uses
+// the organization's current default role, never the template's menu list.
+func TestIndividualRolePermissionsAndDelegation(t *testing.T) {
+ setupAssignableTestDB(t)
+ ctx := context.Background()
+ for _, statement := range []string{
+ "INSERT INTO sys_role_menu VALUES ('000000',122,200,'0')",
+ "DELETE FROM sys_role_menu WHERE role_id IN (82,120) AND menu_id=100",
+ } {
+ if _, err := g.DB().Exec(ctx, statement); err != nil {
+ t.Fatal(err)
+ }
+ }
+ got, err := RestrictMenus(ctx, "000000", 199, []int64{100, 200})
+ if err != nil || !reflect.DeepEqual(got, []int64{100, 200}) {
+ t.Fatalf("individual role expansion must survive runtime checks: %v %v", got, err)
+ }
+ if _, err := NormalizeAssignableMenus(ctx, "000000", 199, 162, []int64{200}); err == nil {
+ t.Fatal("operator cannot delegate outside local default role")
+ }
+ if _, err := g.DB().Exec(ctx, "INSERT INTO sys_role_menu VALUES ('000000',121,200,'0')"); err != nil {
+ t.Fatal(err)
+ }
+ got, err = NormalizeAssignableMenus(ctx, "000000", 199, 162, []int64{200})
+ if err != nil || !reflect.DeepEqual(got, []int64{200}) {
+ t.Fatalf("platform expansion of local default role must become delegable: %v %v", got, err)
+ }
+}
diff --git a/server/internal/library/rolepolicy/assignment.go b/server/internal/library/rolepolicy/assignment.go
new file mode 100644
index 0000000..c8e510e
--- /dev/null
+++ b/server/internal/library/rolepolicy/assignment.go
@@ -0,0 +1,54 @@
+package rolepolicy
+
+import (
+ "context"
+ "slices"
+
+ "github.com/gogf/gf/v2/errors/gerror"
+ "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/model/entity"
+)
+
+// ValidateUserRoleAssignment prevents local operators from obtaining platform-only
+// permissions by binding a more privileged role instead of editing its menus.
+// Existing bindings may be retained when editing unrelated user information.
+func ValidateUserRoleAssignment(ctx context.Context, tenantID string, deptID int64, requested, existing []int64) error {
+ if contexts.IsSuperAdmin(ctx) {
+ return nil
+ }
+ template, err := Template(ctx)
+ if err != nil {
+ return err
+ }
+ if template == nil || tenantID != template.TenantId {
+ return nil
+ }
+ if contexts.GetTenantId(ctx) != tenantID {
+ return gerror.New("不能跨租户分配角色")
+ }
+ for _, roleID := range requested {
+ if roleID == consts.SuperAdminRoleId {
+ return gerror.New("不能分配平台超级管理员角色")
+ }
+ if slices.Contains(existing, roleID) {
+ continue
+ }
+ var role *entity.SysRole
+ if err := dao.SysRole.Ctx(ctx).Master().Where("tenant_id", tenantID).Where("dept_id", deptID).Where("role_id", roleID).Where("status", "0").Scan(&role); err != nil {
+ return err
+ }
+ if role == nil {
+ return gerror.Newf("角色%d不存在、已停用或不属于用户所在机构", roleID)
+ }
+ menus, err := MenuIDs(ctx, roleID)
+ if err != nil {
+ return err
+ }
+ if _, err := NormalizeAssignableMenus(ctx, tenantID, contexts.GetDeptId(ctx), contexts.GetUserId(ctx), menus); err != nil {
+ return gerror.Wrapf(err, "角色%d包含超出当前账号可分配范围的权限,请联系平台管理员绑定", roleID)
+ }
+ }
+ return nil
+}
diff --git a/server/internal/library/rolepolicy/assignment_test.go b/server/internal/library/rolepolicy/assignment_test.go
new file mode 100644
index 0000000..60f77d5
--- /dev/null
+++ b/server/internal/library/rolepolicy/assignment_test.go
@@ -0,0 +1,48 @@
+package rolepolicy
+
+import (
+ "context"
+ "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/library/contexts"
+ "git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/model"
+)
+
+func TestUserRoleAssignmentCannotBypassDelegation(t *testing.T) {
+ for _, test := range []struct {
+ name string
+ requested, existing []int64
+ super, denied bool
+ }{
+ {name: "可分配的普通角色", requested: []int64{124}},
+ {name: "不能通过绑定特殊角色扩权", requested: []int64{123}, denied: true},
+ {name: "修改资料允许保留平台已有授权", requested: []int64{123}, existing: []int64{123}},
+ {name: "平台可以单独绑定特殊角色", requested: []int64{123}, super: true},
+ {name: "本地账号不能授予超级管理员角色", requested: []int64{1}, denied: true},
+ {name: "不接受其他机构角色", requested: []int64{120}, denied: true},
+ {name: "可以移除已有角色", requested: []int64{}, existing: []int64{123}},
+ } {
+ t.Run(test.name, func(t *testing.T) {
+ setupAssignableTestDB(t)
+ actor := &model.Identity{BaseClaims: model.BaseClaims{ID: 162, TenantId: "000000", DeptId: 199}}
+ if test.super {
+ actor.Roles = []model.Role{{RoleId: consts.SuperAdminRoleId}}
+ }
+ ctx := contexts.Set(context.Background(), &model.Context{User: actor})
+ for _, statement := range []string{
+ "INSERT INTO sys_role VALUES(123,'000000',199,199,'0','4',NULL),(124,'000000',199,199,'0','4',NULL)",
+ "INSERT INTO sys_role_menu VALUES('000000',123,200,'0'),('000000',124,100,'0')",
+ } {
+ if _, err := g.DB().Exec(ctx, statement); err != nil {
+ t.Fatal(err)
+ }
+ }
+ err := ValidateUserRoleAssignment(ctx, "000000", 199, test.requested, test.existing)
+ if (err != nil) != test.denied {
+ t.Fatalf("denied=%v, error=%v", test.denied, err)
+ }
+ })
+ }
+}
diff --git a/server/internal/library/rolepolicy/template.go b/server/internal/library/rolepolicy/template.go
index 084dbf5..bb789ce 100644
--- a/server/internal/library/rolepolicy/template.go
+++ b/server/internal/library/rolepolicy/template.go
@@ -2,19 +2,16 @@ package rolepolicy
import (
"context"
+ "github.com/gogf/gf/v2/errors/gerror"
"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/model/entity"
- "github.com/gogf/gf/v2/errors/gerror"
"strconv"
"strings"
)
const TemplateConfigKey = "agent.role.template.role_id"
-// TemplateForRoles reports whether any of roleIDs is the configured platform template.
-// Callers use it to tell "this save touches the template" apart from "this save is an
-// ordinary role" — the latter must not be forced to is_pass=1.
func TemplateForRoles(ctx context.Context, roleIDs ...int64) (*entity.SysRole, error) {
var configs []*entity.SysConfig
if err := dao.SysConfig.Ctx(ctx).Master().Where("tenant_id", consts.DefaultSystemTenantCode).Where("config_key", TemplateConfigKey).Scan(&configs); err != nil {
@@ -47,7 +44,7 @@ func TemplateForRoles(ctx context.Context, roleIDs ...int64) (*entity.SysRole, e
func Template(ctx context.Context) (*entity.SysRole, error) {
var configs []*entity.SysConfig
- if err := dao.SysConfig.Ctx(ctx).Where("tenant_id", consts.DefaultSystemTenantCode).Where("config_key", TemplateConfigKey).Scan(&configs); err != nil {
+ if err := dao.SysConfig.Ctx(ctx).Master().Where("tenant_id", consts.DefaultSystemTenantCode).Where("config_key", TemplateConfigKey).Scan(&configs); err != nil {
return nil, err
}
if len(configs) == 0 {
@@ -65,23 +62,23 @@ func ValidateTemplate(ctx context.Context, value string) (*entity.SysRole, error
return nil, gerror.New("代理模板参数必须填写非超级管理员的有效角色ID")
}
var role *entity.SysRole
- if err = dao.SysRole.Ctx(ctx).Where("role_id", roleID).Where("tenant_id", consts.DefaultSystemTenantCode).Scan(&role); err != nil {
+ if err = dao.SysRole.Ctx(ctx).Master().Where("role_id", roleID).Where("tenant_id", consts.DefaultSystemTenantCode).Scan(&role); err != nil {
return nil, err
}
- if role == nil || role.Status != "0" || (role.CreatedDept > 0 && role.CreatedDept != role.DeptId) || role.DeptId <= 0 || role.IsPass != 1 {
- return nil, gerror.New("请在企业管理的角色管理创建下发模板,模板须正常启用、绑定本级机构且非上级继承角色")
+ if role == nil || role.Status != "0" || (role.CreatedDept > 0 && role.CreatedDept != role.DeptId) || role.DeptId <= 0 {
+ return nil, gerror.New("代理模板参数对应角色须正常启用、绑定本级机构且非上级继承角色")
}
if role.DataScope != consts.SysRoleDataScopeDeptAndBelow {
return nil, gerror.New("代理模板数据范围必须为本机构及下级")
}
var dept *entity.SysDept
- if err := dao.SysDept.Ctx(ctx).Where("tenant_id", role.TenantId).Where("dept_id", role.DeptId).Where("status", "0").Scan(&dept); err != nil {
+ if err := dao.SysDept.Ctx(ctx).Master().Where("tenant_id", role.TenantId).Where("dept_id", role.DeptId).Where("status", "0").Scan(&dept); err != nil {
return nil, err
}
if dept == nil {
return nil, gerror.New("代理模板所属机构不存在或已停用")
}
- invalidScopes, err := dao.SysRoleMenu.Ctx(ctx).Where("tenant_id", role.TenantId).Where("role_id", role.RoleId).WhereIn("data_scope", []string{consts.SysRoleDataScopeAll, consts.SysRoleDataScopeCustom}).Count()
+ invalidScopes, err := dao.SysRoleMenu.Ctx(ctx).Master().Where("tenant_id", role.TenantId).Where("role_id", role.RoleId).WhereIn("data_scope", []string{consts.SysRoleDataScopeAll, consts.SysRoleDataScopeCustom}).Count()
if err != nil {
return nil, err
}
@@ -93,7 +90,7 @@ func ValidateTemplate(ctx context.Context, value string) (*entity.SysRole, error
func MenuIDs(ctx context.Context, roleID int64) ([]int64, error) {
var menus []*entity.SysRoleMenu
- if err := dao.SysRoleMenu.Ctx(ctx).Where("role_id", roleID).Scan(&menus); err != nil {
+ if err := dao.SysRoleMenu.Ctx(ctx).Master().Where("role_id", roleID).Scan(&menus); err != nil {
return nil, err
}
ids := make([]int64, 0, len(menus))
@@ -122,19 +119,22 @@ func CurrentSuperAdmin(ctx context.Context, userID int64) (bool, error) {
return false, nil
}
var relations []*entity.SysUserRole
- if err := dao.SysUserRole.Ctx(ctx).Where("tenant_id", consts.DefaultSystemTenantCode).Where("user_id", userID).Where("role_id", consts.SuperAdminRoleId).Scan(&relations); err != nil {
+ if err := dao.SysUserRole.Ctx(ctx).Master().Where("tenant_id", consts.DefaultSystemTenantCode).Where("user_id", userID).Where("role_id", consts.SuperAdminRoleId).Scan(&relations); err != nil {
return false, err
}
if len(relations) == 0 {
return false, nil
}
var role *entity.SysRole
- if err := dao.SysRole.Ctx(ctx).Where("tenant_id", consts.DefaultSystemTenantCode).Where("role_id", consts.SuperAdminRoleId).Where("status", "0").Scan(&role); err != nil {
+ if err := dao.SysRole.Ctx(ctx).Master().Where("tenant_id", consts.DefaultSystemTenantCode).Where("role_id", consts.SuperAdminRoleId).Where("status", "0").Scan(&role); err != nil {
return false, err
}
return role != nil, nil
}
+// RestrictMenus validates organization membership and role bindings. Templates are
+// overwrite sources, not runtime permission ceilings: platform role edits must
+// remain effective until the next explicit template distribution.
func RestrictMenus(ctx context.Context, tenantID string, deptID int64, ids []int64) ([]int64, error) {
template, err := Template(ctx)
if err != nil {
@@ -150,35 +150,27 @@ func RestrictMenus(ctx context.Context, tenantID string, deptID int64, ids []int
}
seen[deptID] = true
var dept *entity.SysDept
- if err := dao.SysDept.Ctx(ctx).Where("tenant_id", tenantID).Where("dept_id", deptID).Scan(&dept); err != nil {
+ if err := dao.SysDept.Ctx(ctx).Master().Where("tenant_id", tenantID).Where("dept_id", deptID).Scan(&dept); err != nil {
return nil, err
}
if dept == nil || dept.Status != "0" {
return []int64{}, nil
}
- if template != nil && tenantID == template.TenantId && deptID == template.DeptId {
- if len(seen) > 1 {
- menus, err := MenuIDs(ctx, template.RoleId)
- if err != nil {
- return nil, err
- }
- ids = Intersect(ids, menus)
- }
+ if tenantID == template.TenantId && deptID == template.DeptId {
break
}
- if dept.DeptRole != consts.SuperAdminRoleId {
- var role *entity.SysRole
- if err := dao.SysRole.Ctx(ctx).Where("tenant_id", tenantID).Where("dept_id", deptID).Where("role_id", dept.DeptRole).Where("status", "0").Scan(&role); err != nil {
- return nil, err
- }
- if role == nil {
- return []int64{}, nil
- }
- menus, err := MenuIDs(ctx, role.RoleId)
- if err != nil {
- return nil, err
- }
- ids = Intersect(ids, menus)
+ if dept.DeptRole == consts.SuperAdminRoleId {
+ return nil, gerror.Newf("机构%d不能使用超级管理员角色作为代理权限上限,请核对机构角色绑定", dept.DeptId)
+ }
+ if dept.DeptRole <= 0 {
+ return nil, gerror.Newf("机构%d未关联默认角色,请联系平台管理员核对机构角色绑定", dept.DeptId)
+ }
+ var role *entity.SysRole
+ if err := dao.SysRole.Ctx(ctx).Master().Where("tenant_id", tenantID).Where("dept_id", deptID).Where("role_id", dept.DeptRole).Where("status", "0").Scan(&role); err != nil {
+ return nil, err
+ }
+ if role == nil {
+ return nil, gerror.Newf("机构%d默认角色不存在、已停用或归属不匹配,请联系平台管理员核对", dept.DeptId)
}
deptID = dept.ParentId
}
@@ -187,3 +179,64 @@ func RestrictMenus(ctx context.Context, tenantID string, deptID int64, ids []int
}
return ids, nil
}
+
+func RestrictAssignableMenus(ctx context.Context, tenantID string, deptID, userID int64, ids []int64) ([]int64, error) {
+ allowed, err := RestrictMenus(ctx, tenantID, deptID, ids)
+ if err != nil {
+ return nil, err
+ }
+ // Local administrators can delegate only their organization's current default
+ // role permissions and their own active permissions. Ancestor/template menus
+ // do not cap a platform-authorized individual expansion.
+ template, err := Template(ctx)
+ if err != nil {
+ return nil, err
+ }
+ if template != nil && (tenantID != template.TenantId || deptID != template.DeptId) {
+ var dept *entity.SysDept
+ if err := dao.SysDept.Ctx(ctx).Master().Where("tenant_id", tenantID).Where("dept_id", deptID).Scan(&dept); err != nil {
+ return nil, err
+ }
+ if dept == nil {
+ return []int64{}, nil
+ }
+ menus, err := MenuIDs(ctx, dept.DeptRole)
+ if err != nil {
+ return nil, err
+ }
+ allowed = Intersect(allowed, menus)
+ }
+ var relations []*entity.SysUserRole
+ if err := dao.SysUserRole.Ctx(ctx).Master().Where("tenant_id", tenantID).Where("user_id", userID).Scan(&relations); err != nil {
+ return nil, err
+ }
+ roleIDs := make([]int64, 0, len(relations))
+ for _, relation := range relations {
+ if relation.RoleId != consts.SuperAdminRoleId {
+ roleIDs = append(roleIDs, relation.RoleId)
+ }
+ }
+ if len(roleIDs) == 0 {
+ return []int64{}, nil
+ }
+ var roles []*entity.SysRole
+ if err := dao.SysRole.Ctx(ctx).Master().Where("tenant_id", tenantID).WhereIn("role_id", roleIDs).Where("status", "0").Scan(&roles); err != nil {
+ return nil, err
+ }
+ roleIDs = roleIDs[:0]
+ for _, role := range roles {
+ roleIDs = append(roleIDs, role.RoleId)
+ }
+ if len(roleIDs) == 0 {
+ return []int64{}, nil
+ }
+ var menus []*entity.SysRoleMenu
+ if err := dao.SysRoleMenu.Ctx(ctx).Master().Where("tenant_id", tenantID).WhereIn("role_id", roleIDs).Scan(&menus); err != nil {
+ return nil, err
+ }
+ owned := make([]int64, 0, len(menus))
+ for _, menu := range menus {
+ owned = append(owned, menu.MenuId)
+ }
+ return Intersect(allowed, owned), nil
+}
diff --git a/server/internal/library/rolesync/sync.go b/server/internal/library/rolesync/sync.go
index 8e4fab8..87fe3a0 100644
--- a/server/internal/library/rolesync/sync.go
+++ b/server/internal/library/rolesync/sync.go
@@ -2,29 +2,73 @@ package rolesync
import (
"context"
- "git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/dao"
- "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"
+ "time"
+
"github.com/gogf/gf/v2/database/gdb"
"github.com/gogf/gf/v2/errors/gerror"
"github.com/gogf/gf/v2/frame/g"
+ "github.com/google/uuid"
+ "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/rabbitmq"
+ "git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/rolepolicy"
+ "git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/model"
+ "git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/model/entity"
)
+type deferRecordingContextKey struct{}
+
+func DeferRecording(ctx context.Context) context.Context {
+ return context.WithValue(ctx, deferRecordingContextKey{}, true)
+}
+
func EnqueueRole(ctx context.Context, roleID, actorID int64) error {
- if gdb.TXFromCtx(ctx, g.DB().GetGroup()) != nil {
+ if deferred, _ := ctx.Value(deferRecordingContextKey{}).(bool); deferred {
return nil
}
+ if gdb.TXFromCtx(ctx, g.DB().GetGroup()) != nil {
+ return gerror.New("权限下发必须在数据库事务提交后入队")
+ }
+ ctx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 15*time.Second)
+ defer cancel()
var role *entity.SysRole
- if err := dao.SysRole.Ctx(ctx).Where("role_id", roleID).Scan(&role); err != nil {
+ if err := dao.SysRole.Ctx(ctx).Master().Where("role_id", roleID).Scan(&role); err != nil {
return err
}
- if role == nil || role.IsPass != 1 {
+ if role == nil {
return nil
}
- err := service.AgentRole().SyncInheritedRole(ctx, &model.AgentRoleSyncMessage{TenantId: role.TenantId, SourceRoleId: role.RoleId, SourceDeptId: role.DeptId, ActorId: actorID})
+ target, err := rolepolicy.TemplateForRoles(ctx, roleID)
if err != nil {
- return gerror.Wrap(err, "模板已保存,但部分企业同步失败,请修复关联后重新保存")
+ return gerror.Wrap(err, "角色已保存,但无法识别代理模板,请检查系统参数后重新保存")
+ }
+ if target == nil {
+ if role.IsPass != 1 {
+ return nil
+ }
+ configured, err := dao.SysConfig.Ctx(ctx).Master().Where("tenant_id", consts.DefaultSystemTenantCode).Where("config_key", rolepolicy.TemplateConfigKey).Count()
+ if err != nil {
+ return gerror.Wrap(err, "角色已保存,但无法识别代理模板,请稍后重新保存")
+ }
+ if configured > 0 {
+ return nil
+ }
+ }
+ template, err := rolepolicy.Template(ctx)
+ if err != nil {
+ return gerror.Wrap(err, "角色已保存,但代理模板配置异常,无法下发;请修复模板后重新保存")
+ }
+ message := &model.AgentRoleSyncMessage{
+ TaskId: uuid.NewString(), TenantId: role.TenantId, SourceRoleId: role.RoleId,
+ SourceDeptId: role.DeptId, ActorId: actorID,
+ TemplateRole: template != nil && template.RoleId == role.RoleId,
+ }
+ if err := publishRoleSync(ctx, message); err != nil {
+ return gerror.Wrapf(err, "角色已保存,但下级权限下发任务入队失败,请稍后重新保存(任务ID:%s)", message.TaskId)
}
return nil
}
+
+var publishRoleSync = func(ctx context.Context, message *model.AgentRoleSyncMessage) error {
+ return rabbitmq.PublishConfirmed(ctx, consts.QueueAgentRoleSync, message)
+}
diff --git a/server/internal/library/rolesync/sync_test.go b/server/internal/library/rolesync/sync_test.go
new file mode 100644
index 0000000..7e5393d
--- /dev/null
+++ b/server/internal/library/rolesync/sync_test.go
@@ -0,0 +1,113 @@
+package rolesync
+
+import (
+ "context"
+ "errors"
+ "strings"
+ "testing"
+ "time"
+
+ _ "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/model"
+)
+
+func TestEnqueueRoleOnlyAfterCommit(t *testing.T) {
+ if err := gdb.SetConfig(gdb.Config{"default": {{Type: "sqlite", Link: "sqlite::@file(:memory:)"}}}); err != nil {
+ t.Fatal(err)
+ }
+ ctx := context.Background()
+ t.Cleanup(func() { _ = g.DB().Close(ctx) })
+ for _, statement := range []string{
+ "CREATE TABLE sys_role (role_id INTEGER PRIMARY KEY, tenant_id TEXT, dept_id INTEGER, is_pass INTEGER, status TEXT DEFAULT '0', data_scope TEXT DEFAULT '4', created_dept INTEGER DEFAULT 100, deleted_at DATETIME)",
+ "CREATE TABLE sys_config (tenant_id TEXT, config_key TEXT, config_value TEXT)",
+ "CREATE TABLE sys_dept (dept_id INTEGER PRIMARY KEY, tenant_id TEXT, status TEXT)",
+ "CREATE TABLE sys_role_menu (tenant_id TEXT, role_id INTEGER, menu_id INTEGER, data_scope TEXT)",
+ "INSERT INTO sys_dept VALUES(100,'000000','0')",
+ "INSERT INTO sys_role(role_id,tenant_id,dept_id,is_pass) VALUES (18,'000000',100,1),(19,'000000',100,0)",
+ } {
+ if _, err := g.DB().Exec(ctx, statement); err != nil {
+ t.Fatal(err)
+ }
+ }
+ original := publishRoleSync
+ t.Cleanup(func() { publishRoleSync = original })
+ var messages []*model.AgentRoleSyncMessage
+ publishRoleSync = func(_ context.Context, message *model.AgentRoleSyncMessage) error {
+ messages = append(messages, message)
+ return nil
+ }
+ if err := g.DB().Transaction(ctx, func(txCtx context.Context, _ gdb.TX) error {
+ return EnqueueRole(txCtx, 18, 7)
+ }); err == nil {
+ t.Fatal("must reject publishing before commit")
+ }
+ if len(messages) != 0 {
+ t.Fatal("published inside transaction")
+ }
+ if err := EnqueueRole(ctx, 18, 7); err != nil {
+ t.Fatal(err)
+ }
+ if len(messages) != 1 || messages[0].TaskId == "" || messages[0].SourceRoleId != 18 {
+ t.Fatalf("unexpected message: %+v", messages)
+ }
+ if err := EnqueueRole(DeferRecording(ctx), 18, 7); err != nil {
+ t.Fatal(err)
+ }
+ if err := EnqueueRole(ctx, 19, 7); err != nil {
+ t.Fatal(err)
+ }
+ if len(messages) != 1 {
+ t.Fatal("non-inheritable or deferred role published")
+ }
+ publishRoleSync = func(context.Context, *model.AgentRoleSyncMessage) error { return errors.New("broker offline") }
+ if err := EnqueueRole(ctx, 18, 7); err == nil || !strings.Contains(err.Error(), "角色已保存") {
+ t.Fatalf("must report committed save and failed publication: %v", err)
+ }
+ publishRoleSync = func(_ context.Context, message *model.AgentRoleSyncMessage) error {
+ messages = append(messages, message)
+ return nil
+ }
+ if _, err := g.DB().Exec(ctx, "INSERT INTO sys_config VALUES('000000','agent.role.template.role_id','19')"); err != nil {
+ t.Fatal(err)
+ }
+ if err := EnqueueRole(ctx, 18, 7); err != nil {
+ t.Fatal(err)
+ }
+ if len(messages) != 1 {
+ t.Fatal("legacy local role must not overwrite configured hierarchy")
+ }
+ if err := EnqueueRole(ctx, 19, 7); err != nil {
+ t.Fatal(err)
+ }
+ if len(messages) != 2 || !messages[1].TemplateRole || messages[1].SourceRoleId != 19 {
+ t.Fatalf("configured template with is_pass=0 was not queued: %+v", messages)
+ }
+ t.Run("提交后请求取消不丢失下发且保留有限超时", func(t *testing.T) {
+ type requestKey struct{}
+ requestCtx, cancel := context.WithCancel(context.WithValue(ctx, requestKey{}, "actor"))
+ cancel()
+ published := false
+ publishRoleSync = func(publishCtx context.Context, message *model.AgentRoleSyncMessage) error {
+ if err := publishCtx.Err(); err != nil {
+ t.Fatalf("committed publication inherited cancellation: %v", err)
+ }
+ deadline, ok := publishCtx.Deadline()
+ if !ok || time.Until(deadline) <= 0 || time.Until(deadline) > 15*time.Second {
+ t.Fatalf("publication must have bounded timeout: %v %v", deadline, ok)
+ }
+ if publishCtx.Value(requestKey{}) != "actor" || message.ActorId != 7 {
+ t.Fatal("request values or actor lost")
+ }
+ published = true
+ return nil
+ }
+ if err := EnqueueRole(requestCtx, 19, 7); err != nil {
+ t.Fatal(err)
+ }
+ if !published {
+ t.Fatal("committed role was not published")
+ }
+ })
+}
diff --git a/server/internal/library/tdengine/config_test.go b/server/internal/library/tdengine/config_test.go
new file mode 100644
index 0000000..4475a7f
--- /dev/null
+++ b/server/internal/library/tdengine/config_test.go
@@ -0,0 +1,61 @@
+package tdengine
+
+import (
+ "context"
+ "testing"
+
+ "github.com/gogf/gf/v2/frame/g"
+ "github.com/gogf/gf/v2/os/gcfg"
+)
+
+func TestGetConfigDatabaseRouting(t *testing.T) {
+ previous := g.Cfg().GetAdapter()
+ t.Cleanup(func() { g.Cfg().SetAdapter(previous) })
+ for _, test := range []struct {
+ name string
+ fields string
+ fallback string
+ daily string
+ monthly string
+ }{
+ {"defaults", "", "iotlcm_daily", "iotlcm_daily", "iotlcm_daily"},
+ {"legacy", " dbName: legacy\n", "legacy", "legacy", "legacy"},
+ {"split", " dbName: legacy\n dailyDbName: iotlcm_daily\n monthlyDbName: iotlcm_monthly\n", "legacy", "iotlcm_daily", "iotlcm_monthly"},
+ {"daily_only", " dbName: legacy\n dailyDbName: iotlcm_daily\n", "legacy", "iotlcm_daily", "legacy"},
+ {"monthly_only", " dbName: legacy\n monthlyDbName: iotlcm_monthly\n", "legacy", "legacy", "iotlcm_monthly"},
+ } {
+ t.Run(test.name, func(t *testing.T) {
+ adapter, err := gcfg.NewAdapterContent("tsdb:\n database: TdEngine\n tdengine:\n batchSize: 20\n" + test.fields)
+ if err != nil {
+ t.Fatal(err)
+ }
+ g.Cfg().SetAdapter(adapter)
+ config := GetConfig(context.Background())
+ if config.DbName != test.fallback {
+ t.Fatalf("fallback=%q want=%q", config.DbName, test.fallback)
+ }
+ for _, stable := range []string{config.DailyStable, config.PoolDailyStable, config.PoolCardCountDailyStable} {
+ if got := config.DbNameFor(stable); got != test.daily {
+ t.Errorf("%s database=%q want=%q", stable, got, test.daily)
+ }
+ }
+ for _, stable := range []string{config.MonthlyStable, config.PoolMonthlyStable, config.PoolCardCountMonthlyStable} {
+ if got := config.DbNameFor(stable); got != test.monthly {
+ t.Errorf("%s database=%q want=%q", stable, got, test.monthly)
+ }
+ }
+ if got := config.DbNameFor("other_stable"); got != test.fallback {
+ t.Errorf("unknown stable database=%q", got)
+ }
+ })
+ }
+}
+
+func TestDatabaseRoutingEmptySplitNames(t *testing.T) {
+ config := Config{DbName: "legacy", DailyStable: "daily", MonthlyStable: "monthly"}
+ for _, stable := range []string{"daily", "monthly", "other"} {
+ if got := config.DbNameFor(stable); got != "legacy" {
+ t.Errorf("%s database=%q", stable, got)
+ }
+ }
+}
diff --git a/server/internal/library/websocket/client.go b/server/internal/library/websocket/client.go
index 7ea5ec5..286a80a 100644
--- a/server/internal/library/websocket/client.go
+++ b/server/internal/library/websocket/client.go
@@ -1,7 +1,7 @@
// Package websocket
// @Link https://github.com/xiujiecn/xiu-admin
// @Copyright Copyright (c) 2025 LiXiujie
-// @Author Lxj
+// @Author Lxj
// @License https://github.com/xiujiecn/xiu-admin/blob/master/LICENSE
package websocket
diff --git a/server/internal/library/websocket/client_manager.go b/server/internal/library/websocket/client_manager.go
index cd0a6c0..1182364 100644
--- a/server/internal/library/websocket/client_manager.go
+++ b/server/internal/library/websocket/client_manager.go
@@ -1,7 +1,7 @@
// Package websocket
// @Link https://github.com/xiujiecn/xiu-admin
// @Copyright Copyright (c) 2025 LiXiujie
-// @Author Lxj
+// @Author Lxj
// @License https://github.com/xiujiecn/xiu-admin/blob/master/LICENSE
package websocket
diff --git a/server/internal/library/websocket/controller.go b/server/internal/library/websocket/controller.go
index da2ebbc..c59daf2 100644
--- a/server/internal/library/websocket/controller.go
+++ b/server/internal/library/websocket/controller.go
@@ -1,7 +1,7 @@
// Package websocket
// @Link https://github.com/xiujiecn/xiu-admin
// @Copyright Copyright (c) 2025 LiXiujie
-// @Author Lxj
+// @Author Lxj
// @License https://github.com/xiujiecn/xiu-admin/blob/master/LICENSE
package websocket
diff --git a/server/internal/library/websocket/init.go b/server/internal/library/websocket/init.go
index db2f741..220782e 100644
--- a/server/internal/library/websocket/init.go
+++ b/server/internal/library/websocket/init.go
@@ -1,7 +1,7 @@
// Package websocket
// @Link https://github.com/xiujiecn/xiu-admin
// @Copyright Copyright (c) 2025 LiXiujie
-// @Author Lxj
+// @Author Lxj
// @License https://github.com/xiujiecn/xiu-admin/blob/master/LICENSE
package websocket
diff --git a/server/internal/library/websocket/model.go b/server/internal/library/websocket/model.go
index 0660961..5628580 100644
--- a/server/internal/library/websocket/model.go
+++ b/server/internal/library/websocket/model.go
@@ -1,7 +1,7 @@
// Package websocket
// @Link https://github.com/xiujiecn/xiu-admin
// @Copyright Copyright (c) 2025 LiXiujie
-// @Author Lxj
+// @Author Lxj
// @License https://github.com/xiujiecn/xiu-admin/blob/master/LICENSE
package websocket
diff --git a/server/internal/library/websocket/router.go b/server/internal/library/websocket/router.go
index c1aca1d..9dcf636 100644
--- a/server/internal/library/websocket/router.go
+++ b/server/internal/library/websocket/router.go
@@ -1,7 +1,7 @@
// Package websocket
// @Link https://github.com/xiujiecn/xiu-admin
// @Copyright Copyright (c) 2025 LiXiujie
-// @Author Lxj
+// @Author Lxj
// @License https://github.com/xiujiecn/xiu-admin/blob/master/LICENSE
package websocket
diff --git a/server/internal/library/worker/options.go b/server/internal/library/worker/options.go
index 880dc89..fed6898 100644
--- a/server/internal/library/worker/options.go
+++ b/server/internal/library/worker/options.go
@@ -1,7 +1,7 @@
// Package worker
// @Link https://github.com/xiujiecn/xiu-admin
// @Copyright Copyright (c) 2025 LiXiujie
-// @Author Lxj
+// @Author Lxj
// @License https://github.com/xiujiecn/xiu-admin/blob/master/LICENSE
package worker
diff --git a/server/internal/library/worker/work_proces.go b/server/internal/library/worker/work_proces.go
index 3e0d12f..0d5524b 100644
--- a/server/internal/library/worker/work_proces.go
+++ b/server/internal/library/worker/work_proces.go
@@ -1,7 +1,7 @@
// package worker
// @Link https://github.com/xiujiecn/xiu-admin
// @Copyright Copyright (c) 2025 LiXiujie
-// @Author Lxj
+// @Author Lxj
// @License https://github.com/xiujiecn/xiu-admin/blob/master/LICENSE
package worker
diff --git a/server/internal/library/worker/worker.go b/server/internal/library/worker/worker.go
index 8570aca..bc0c1b1 100644
--- a/server/internal/library/worker/worker.go
+++ b/server/internal/library/worker/worker.go
@@ -1,7 +1,7 @@
// package worker
// @Link https://github.com/xiujiecn/xiu-admin
// @Copyright Copyright (c) 2025 LiXiujie
-// @Author Lxj
+// @Author Lxj
// @License https://github.com/xiujiecn/xiu-admin/blob/master/LICENSE
package worker
diff --git a/server/internal/library/xgen/gen_consts/gen_codes.go b/server/internal/library/xgen/gen_consts/gen_codes.go
index f087eee..a7c6517 100644
--- a/server/internal/library/xgen/gen_consts/gen_codes.go
+++ b/server/internal/library/xgen/gen_consts/gen_codes.go
@@ -1,7 +1,7 @@
// package genconsts
// @Link https://github.com/xiujiecn/xiu-admin
// @Copyright Copyright (c) 2025 LiXiujie
-// @Author Lxj
+// @Author Lxj
// @License https://github.com/xiujiecn/xiu-admin/blob/master/LICENSE
package genconsts
diff --git a/server/internal/library/xgen/gen_consts/gen_config.go b/server/internal/library/xgen/gen_consts/gen_config.go
index 7726642..f354d20 100644
--- a/server/internal/library/xgen/gen_consts/gen_config.go
+++ b/server/internal/library/xgen/gen_consts/gen_config.go
@@ -1,7 +1,7 @@
// package genconsts
// @Link https://github.com/xiujiecn/xiu-admin
// @Copyright Copyright (c) 2025 LiXiujie
-// @Author Lxj
+// @Author Lxj
// @License https://github.com/xiujiecn/xiu-admin/blob/master/LICENSE
package genconsts
diff --git a/server/internal/library/xgen/gen_dao/gen_dao.go b/server/internal/library/xgen/gen_dao/gen_dao.go
index bc74176..6358203 100644
--- a/server/internal/library/xgen/gen_dao/gen_dao.go
+++ b/server/internal/library/xgen/gen_dao/gen_dao.go
@@ -1,7 +1,7 @@
// package gendao
// @Link https://github.com/xiujiecn/xiu-admin
// @Copyright Copyright (c) 2025 LiXiujie
-// @Author Lxj
+// @Author Lxj
// @License https://github.com/xiujiecn/xiu-admin/blob/master/LICENSE
package gendao
diff --git a/server/internal/library/xgen/gen_model/gen_codes.go b/server/internal/library/xgen/gen_model/gen_codes.go
index ea600a9..304fe29 100644
--- a/server/internal/library/xgen/gen_model/gen_codes.go
+++ b/server/internal/library/xgen/gen_model/gen_codes.go
@@ -1,7 +1,7 @@
// package genmodel
// @Link https://github.com/xiujiecn/xiu-admin
// @Copyright Copyright (c) 2025 LiXiujie
-// @Author Lxj
+// @Author Lxj
// @License https://github.com/xiujiecn/xiu-admin/blob/master/LICENSE
package genmodel
diff --git a/server/internal/library/xgen/gen_model/gen_config.go b/server/internal/library/xgen/gen_model/gen_config.go
index 3ac87ad..0f597b9 100644
--- a/server/internal/library/xgen/gen_model/gen_config.go
+++ b/server/internal/library/xgen/gen_model/gen_config.go
@@ -1,7 +1,7 @@
// package genmodel
// @Link https://github.com/xiujiecn/xiu-admin
// @Copyright Copyright (c) 2025 LiXiujie
-// @Author Lxj
+// @Author Lxj
// @License https://github.com/xiujiecn/xiu-admin/blob/master/LICENSE
package genmodel
diff --git a/server/internal/library/xgen/gen_model/gen_view.go b/server/internal/library/xgen/gen_model/gen_view.go
index 896e40f..5f42eef 100644
--- a/server/internal/library/xgen/gen_model/gen_view.go
+++ b/server/internal/library/xgen/gen_model/gen_view.go
@@ -1,7 +1,7 @@
// package genmodel
// @Link https://github.com/xiujiecn/xiu-admin
// @Copyright Copyright (c) 2025 LiXiujie
-// @Author Lxj
+// @Author Lxj
// @License https://github.com/xiujiecn/xiu-admin/blob/master/LICENSE
package genmodel
diff --git a/server/internal/library/xgen/gen_view/gen_column.go b/server/internal/library/xgen/gen_view/gen_column.go
index ea88987..7399a38 100644
--- a/server/internal/library/xgen/gen_view/gen_column.go
+++ b/server/internal/library/xgen/gen_view/gen_column.go
@@ -1,7 +1,7 @@
// package genview
// @Link https://github.com/xiujiecn/xiu-admin
// @Copyright Copyright (c) 2025 LiXiujie
-// @Author Lxj
+// @Author Lxj
// @License https://github.com/xiujiecn/xiu-admin/blob/master/LICENSE
package genview
diff --git a/server/internal/library/xgen/gen_view/gen_column_default.go b/server/internal/library/xgen/gen_view/gen_column_default.go
index b6ba55e..a0676c4 100644
--- a/server/internal/library/xgen/gen_view/gen_column_default.go
+++ b/server/internal/library/xgen/gen_view/gen_column_default.go
@@ -1,7 +1,7 @@
// package genview
// @Link https://github.com/xiujiecn/xiu-admin
// @Copyright Copyright (c) 2025 LiXiujie
-// @Author Lxj
+// @Author Lxj
// @License https://github.com/xiujiecn/xiu-admin/blob/master/LICENSE
package genview
diff --git a/server/internal/library/xgen/gen_view/gen_column_map.go b/server/internal/library/xgen/gen_view/gen_column_map.go
index a82bee6..92539a5 100644
--- a/server/internal/library/xgen/gen_view/gen_column_map.go
+++ b/server/internal/library/xgen/gen_view/gen_column_map.go
@@ -1,7 +1,7 @@
// package genview
// @Link https://github.com/xiujiecn/xiu-admin
// @Copyright Copyright (c) 2025 LiXiujie
-// @Author Lxj
+// @Author Lxj