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 // @License https://github.com/xiujiecn/xiu-admin/blob/master/LICENSE package genview diff --git a/server/internal/library/xgen/gen_view/gen_curd.go b/server/internal/library/xgen/gen_view/gen_curd.go index 381084e..b9f595f 100644 --- a/server/internal/library/xgen/gen_view/gen_curd.go +++ b/server/internal/library/xgen/gen_view/gen_curd.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_curd_input.go b/server/internal/library/xgen/gen_view/gen_curd_input.go index cb81afd..baada2f 100644 --- a/server/internal/library/xgen/gen_view/gen_curd_input.go +++ b/server/internal/library/xgen/gen_view/gen_curd_input.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_curd_logic.go b/server/internal/library/xgen/gen_view/gen_curd_logic.go index ec1b51f..431a38e 100644 --- a/server/internal/library/xgen/gen_view/gen_curd_logic.go +++ b/server/internal/library/xgen/gen_view/gen_curd_logic.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_curd_model.go b/server/internal/library/xgen/gen_view/gen_curd_model.go index 78427f4..c1c9fa9 100644 --- a/server/internal/library/xgen/gen_view/gen_curd_model.go +++ b/server/internal/library/xgen/gen_view/gen_curd_model.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_curd_web_edit.go b/server/internal/library/xgen/gen_view/gen_curd_web_edit.go index fb31f0c..0be3fab 100644 --- a/server/internal/library/xgen/gen_view/gen_curd_web_edit.go +++ b/server/internal/library/xgen/gen_view/gen_curd_web_edit.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_curd_web_index.go b/server/internal/library/xgen/gen_view/gen_curd_web_index.go index 44d9c8c..cb5dabc 100644 --- a/server/internal/library/xgen/gen_view/gen_curd_web_index.go +++ b/server/internal/library/xgen/gen_view/gen_curd_web_index.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_curd_web_model.go b/server/internal/library/xgen/gen_view/gen_curd_web_model.go index 3ee5f21..566184e 100644 --- a/server/internal/library/xgen/gen_view/gen_curd_web_model.go +++ b/server/internal/library/xgen/gen_view/gen_curd_web_model.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_curd_web_view.go b/server/internal/library/xgen/gen_view/gen_curd_web_view.go index bb81d66..3c55034 100644 --- a/server/internal/library/xgen/gen_view/gen_curd_web_view.go +++ b/server/internal/library/xgen/gen_view/gen_curd_web_view.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_util.go b/server/internal/library/xgen/gen_view/gen_util.go index 1d0b4d7..1a705e0 100644 --- a/server/internal/library/xgen/gen_view/gen_util.go +++ b/server/internal/library/xgen/gen_view/gen_util.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/gohtml/consts.go b/server/internal/library/xgen/gen_view/gohtml/consts.go index 049c060..34d7730 100644 --- a/server/internal/library/xgen/gen_view/gohtml/consts.go +++ b/server/internal/library/xgen/gen_view/gohtml/consts.go @@ -1,7 +1,7 @@ // package gohtml // @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 gohtml diff --git a/server/internal/library/xgen/gen_view/gohtml/doc.go b/server/internal/library/xgen/gen_view/gohtml/doc.go index 33246e1..d9aa5c6 100644 --- a/server/internal/library/xgen/gen_view/gohtml/doc.go +++ b/server/internal/library/xgen/gen_view/gohtml/doc.go @@ -1,6 +1,6 @@ // 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 gohtml diff --git a/server/internal/library/xgen/gen_view/gohtml/element.go b/server/internal/library/xgen/gen_view/gohtml/element.go index bbfad3a..e4c0f8d 100644 --- a/server/internal/library/xgen/gen_view/gohtml/element.go +++ b/server/internal/library/xgen/gen_view/gohtml/element.go @@ -1,7 +1,7 @@ // package gohtml // @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 gohtml diff --git a/server/internal/library/xgen/gen_view/gohtml/formatter.go b/server/internal/library/xgen/gen_view/gohtml/formatter.go index 2da0d63..c8ed2cf 100644 --- a/server/internal/library/xgen/gen_view/gohtml/formatter.go +++ b/server/internal/library/xgen/gen_view/gohtml/formatter.go @@ -1,7 +1,7 @@ // package gohtml // @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 gohtml diff --git a/server/internal/library/xgen/gen_view/gohtml/html_document.go b/server/internal/library/xgen/gen_view/gohtml/html_document.go index a9aa255..aeee7e5 100644 --- a/server/internal/library/xgen/gen_view/gohtml/html_document.go +++ b/server/internal/library/xgen/gen_view/gohtml/html_document.go @@ -1,7 +1,7 @@ // package gohtml // @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 gohtml diff --git a/server/internal/library/xgen/gen_view/gohtml/parser.go b/server/internal/library/xgen/gen_view/gohtml/parser.go index f96f782..aabf1a8 100644 --- a/server/internal/library/xgen/gen_view/gohtml/parser.go +++ b/server/internal/library/xgen/gen_view/gohtml/parser.go @@ -1,7 +1,7 @@ // package gohtml // @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 gohtml diff --git a/server/internal/library/xgen/gen_view/gohtml/tag_element.go b/server/internal/library/xgen/gen_view/gohtml/tag_element.go index f853161..34ed49e 100644 --- a/server/internal/library/xgen/gen_view/gohtml/tag_element.go +++ b/server/internal/library/xgen/gen_view/gohtml/tag_element.go @@ -1,7 +1,7 @@ // package gohtml // @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 gohtml diff --git a/server/internal/library/xgen/gen_view/gohtml/text_element.go b/server/internal/library/xgen/gen_view/gohtml/text_element.go index 45c2b8c..dc50b35 100644 --- a/server/internal/library/xgen/gen_view/gohtml/text_element.go +++ b/server/internal/library/xgen/gen_view/gohtml/text_element.go @@ -1,7 +1,7 @@ // package gohtml // @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 gohtml diff --git a/server/internal/library/xgen/gen_view/gohtml/utils.go b/server/internal/library/xgen/gen_view/gohtml/utils.go index dfd4efc..657334a 100644 --- a/server/internal/library/xgen/gen_view/gohtml/utils.go +++ b/server/internal/library/xgen/gen_view/gohtml/utils.go @@ -1,7 +1,7 @@ // package gohtml // @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 gohtml diff --git a/server/internal/library/xgen/gen_view/gohtml/writer.go b/server/internal/library/xgen/gen_view/gohtml/writer.go index 453e50d..484dbd4 100644 --- a/server/internal/library/xgen/gen_view/gohtml/writer.go +++ b/server/internal/library/xgen/gen_view/gohtml/writer.go @@ -1,7 +1,7 @@ // package gohtml // @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 gohtml diff --git a/server/internal/library/xgen/xgen_curd.go b/server/internal/library/xgen/xgen_curd.go index 558e6a7..98c6cca 100644 --- a/server/internal/library/xgen/xgen_curd.go +++ b/server/internal/library/xgen/xgen_curd.go @@ -1,7 +1,7 @@ // package xgen // @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 xgen diff --git a/server/internal/library/xgen/xgen_init.go b/server/internal/library/xgen/xgen_init.go index c9f0889..8706a48 100644 --- a/server/internal/library/xgen/xgen_init.go +++ b/server/internal/library/xgen/xgen_init.go @@ -1,7 +1,7 @@ // package xgen // @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 xgen diff --git a/server/internal/library/xgorm/dao.go b/server/internal/library/xgorm/dao.go index 5d6bfd0..162a637 100644 --- a/server/internal/library/xgorm/dao.go +++ b/server/internal/library/xgorm/dao.go @@ -1,7 +1,7 @@ // package xgorm // @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 xgorm diff --git a/server/internal/library/xgorm/dao_tree.go b/server/internal/library/xgorm/dao_tree.go index fd5af11..235a0f2 100644 --- a/server/internal/library/xgorm/dao_tree.go +++ b/server/internal/library/xgorm/dao_tree.go @@ -1,7 +1,7 @@ // package xgorm // @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 xgorm diff --git a/server/internal/library/xgorm/gorm_model/gorm_tree.go b/server/internal/library/xgorm/gorm_model/gorm_tree.go index ada0e31..3cb93c4 100644 --- a/server/internal/library/xgorm/gorm_model/gorm_tree.go +++ b/server/internal/library/xgorm/gorm_model/gorm_tree.go @@ -1,7 +1,7 @@ // package gormmodel // @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 gormmodel diff --git a/server/internal/library/xgorm/handler/filter_auth.go b/server/internal/library/xgorm/handler/filter_auth.go index 5c10755..3c56616 100644 --- a/server/internal/library/xgorm/handler/filter_auth.go +++ b/server/internal/library/xgorm/handler/filter_auth.go @@ -1,7 +1,7 @@ // package handler // @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 handler @@ -205,9 +205,16 @@ func GetOrgNextLevelDeptIds(ctx context.Context, deptId int64) (ids []int64) { } // GetDeptAndSub 获取指定部门的所有下级,含本部门 +// +// ancestors 的平台约定格式以逗号结尾(如 "0,1,172,"),但历史数据里存在 +// 由代理建企业路径写入的缺尾逗号形态(如 "0,1,172")。两种都要匹配, +// 否则这些机构的下级会在「本机构及以下」数据权限展开时被整体漏掉, +// 表现为上级机构看不到下级(例:一级代理选不到划拨目标客户)。 func GetDeptAndSub(ctx context.Context, deptId int64) (ids []int64) { + // likeTail = "%,172"(机构在祖先链末尾)、likeInner = "%,172,%"(机构在祖先链中间) + likeTail := fmt.Sprintf("%%,%d", deptId) array, err := g.Model("sys_dept"). - WhereLike("ancestors", "%,"+fmt.Sprintf("%d", deptId)+",%"). + Where("(ancestors LIKE ? OR ancestors LIKE ?)", likeTail+",%", likeTail). Fields("dept_id"). Array() if err != nil { diff --git a/server/internal/library/xgorm/handler/force_cache.go b/server/internal/library/xgorm/handler/force_cache.go index 3fc7e75..613a474 100644 --- a/server/internal/library/xgorm/handler/force_cache.go +++ b/server/internal/library/xgorm/handler/force_cache.go @@ -1,7 +1,7 @@ // package handler // @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 handler diff --git a/server/internal/library/xgorm/handler/handler.go b/server/internal/library/xgorm/handler/handler.go index e7903bb..189cfe5 100644 --- a/server/internal/library/xgorm/handler/handler.go +++ b/server/internal/library/xgorm/handler/handler.go @@ -1,7 +1,7 @@ // package handler // @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 handler diff --git a/server/internal/library/xgorm/handler/tenant.go b/server/internal/library/xgorm/handler/tenant.go index f185949..2dfff88 100644 --- a/server/internal/library/xgorm/handler/tenant.go +++ b/server/internal/library/xgorm/handler/tenant.go @@ -1,7 +1,7 @@ // package handler // @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 handler diff --git a/server/internal/library/xgorm/hook/member.go b/server/internal/library/xgorm/hook/member.go index 29a11fa..07d4eee 100644 --- a/server/internal/library/xgorm/hook/member.go +++ b/server/internal/library/xgorm/hook/member.go @@ -1,7 +1,7 @@ // package hook // @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 hook diff --git a/server/internal/logic/agent/agent_dept.go b/server/internal/logic/agent/agent_dept.go index 389ec62..306190f 100644 --- a/server/internal/logic/agent/agent_dept.go +++ b/server/internal/logic/agent/agent_dept.go @@ -42,11 +42,17 @@ func init() { } func (s *sAgentDept) DeptList(ctx context.Context, in *model.AgentDeptListParam) (list []*model.AgentEnterpriseCustomerModel, total int, err error) { + if in == nil { + in = &model.AgentDeptListParam{} + } + in.NormalizePagination() + list = make([]*model.AgentEnterpriseCustomerModel, 0) currentDeptId := contexts.GetDeptId(ctx) if currentDeptId == 0 { return nil, 0, gerror.New("当前用户未绑定组织") } m := s.deptModel(ctx). + Where(dao.SysDept.Columns().TenantId, contexts.GetTenantId(ctx)). Where(dao.SysDept.Columns().ParentId, currentDeptId). Where(dao.SysDept.Columns().DeptType, 1) if in.DeptName != "" { @@ -56,14 +62,15 @@ func (s *sAgentDept) DeptList(ctx context.Context, in *model.AgentDeptListParam) m = m.Where(dao.SysDept.Columns().Status, in.Status) } err = m.Order(dao.SysDept.Columns().DeptId, "ASC"). - Scan(&list) + Page(in.Page, in.PageSize). + ScanAndCount(&list, &total, false) if err != nil { return nil, 0, err } if err = s.fillEnterpriseDefaultUsers(ctx, list); err != nil { return nil, 0, err } - return list, len(list), nil + return list, total, nil } func (s *sAgentDept) DeptView(ctx context.Context, deptId int64) (res *model.AgentEnterpriseCustomerModel, err error) { @@ -124,63 +131,25 @@ func (s *sAgentDept) DeptSave(ctx context.Context, in *model.AgentDeptSaveParam) return deptId, nil } -// EnsureCurrentDeptDefaultRole upgrades legacy child enterprises that did not -// receive an inherited administrator role when they were created. func (s *sAgentDept) EnsureCurrentDeptDefaultRole(ctx context.Context) error { - user := contexts.GetUser(ctx) - if user == nil { + if contexts.GetUser(ctx) == nil { return gerror.New("登录已过期") } - deptId := contexts.GetDeptId(ctx) - if deptId == 0 { + deptID := contexts.GetDeptId(ctx) + if deptID <= 0 { return gerror.New("当前用户未绑定组织") } - var dept *entity.SysDept - if err := s.deptModel(ctx). - Where(dao.SysDept.Columns().DeptId, deptId). - Scan(&dept); err != nil { + if err := s.deptModel(ctx).Where(dao.SysDept.Columns().DeptId, deptID).Scan(&dept); err != nil { return err } - if dept == nil || dept.DeptId == 0 { + if dept == nil { return gerror.New("组织不存在") } - if dept.DeptType != 1 || dept.DeptRole != 0 { - return nil + if dept.DeptType == 1 && dept.DeptRole <= 0 { + return gerror.Newf("机构%d未关联默认角色,请联系平台管理员核对机构角色绑定", deptID) } - - sourceRole, menuIds, err := s.passRoleByDept(ctx, dept.ParentId) - if err != nil { - return err - } - return g.DB().Transaction(ctx, func(ctx context.Context, tx gdb.TX) error { - var current *entity.SysDept - if err := tx.Ctx(ctx).Model(dao.SysDept.Table()). - Where(dao.SysDept.Columns().DeptId, deptId). - Scan(¤t); err != nil { - return err - } - if current == nil || current.DeptId == 0 { - return gerror.New("组织不存在") - } - if current.DeptRole != 0 { - return nil - } - - roleId, err := s.createEnterpriseRole(ctx, tx, user, deptId, sourceRole, menuIds) - if err != nil { - return err - } - if err := s.updateDeptRole(ctx, tx, deptId, roleId, user.ID); err != nil { - return err - } - // Keep the legacy role for the active session. After re-login, this role - // becomes the department's selected default administrator role. - _, err = tx.Ctx(ctx).Model(dao.SysUserRole.Table()). - Data(do.SysUserRole{UserId: user.ID, RoleId: roleId}). - OmitNil().Insert() - return err - }) + return nil } func (s *sAgentDept) validateEnterpriseSave(ctx context.Context, tenantId string, in *model.AgentDeptSaveParam) error { @@ -498,7 +467,10 @@ func (s *sAgentDept) passRoleByDept(ctx context.Context, deptId int64) (*entity. if err != nil { return nil, nil, err } - if template != nil && contexts.GetTenantId(ctx) == template.TenantId { + if template != nil { + if contexts.GetTenantId(ctx) != template.TenantId { + return nil, nil, gerror.New("当前租户不属于平台代理模板授权范围") + } // 配置了平台模板后,下发来源不再依赖 is_pass=1: // 一级代理由平台模板推出,其默认角色是复制来的 is_pass=0 角色; // 各级代理再创建下级时,必须能从本机构默认角色(sys_dept.dept_role)继续传递, @@ -523,7 +495,7 @@ func (s *sAgentDept) passRoleByDept(ctx context.Context, deptId int64) (*entity. } } var menus []*entity.SysRoleMenu - if err := dao.SysRoleMenu.Ctx(ctx).Where("role_id", source.RoleId).Scan(&menus); err != nil { + if err := dao.SysRoleMenu.Ctx(ctx).Master().Where("tenant_id", template.TenantId).Where("role_id", source.RoleId).Scan(&menus); err != nil { return nil, nil, err } ids := make([]int64, 0, len(menus)) @@ -690,7 +662,11 @@ func (s *sAgentDept) childDeptAncestors(ctx context.Context, parentId int64) (st if ancestors == "" { ancestors = "0" } - return fmt.Sprintf("%s,%d", ancestors, parent.DeptId), nil + // ancestors 必须与平台约定格式一致:以本机构结尾并带尾逗号(如 "0,1,172,")。 + // 早期实现返回 "0,1,172"(缺尾逗号),而 GetDeptAndSub 按 '%,id,%' 展开 + // 「本机构及以下」数据权限,缺尾逗号时匹配不到,表现为上级机构看不到下级 + // (例:一级代理在卡片划拨里选不到下级客户)。 + return fmt.Sprintf("%s,%d,", ancestors, parent.DeptId), nil } func (s *sAgentDept) nextCustomerDeptKey(ctx context.Context) (string, error) { @@ -744,11 +720,68 @@ func (s *sAgentDept) ensureUserNameUnique(ctx context.Context, tenantId, userNam } func (s *sAgentDept) fillEnterpriseDefaultUsers(ctx context.Context, list []*model.AgentEnterpriseCustomerModel) error { + if len(list) == 0 { + return nil + } + var ( + deptIDs = make([]int64, 0, len(list)) + roleIDs = make([]int64, 0, len(list)) + bindings []struct { + RoleId int64 + UserId int64 + } + fallbacks []struct { + DeptId int64 + UserId int64 + } + ) for _, dept := range list { - user, err := s.defaultUserByDept(ctx, dept.DeptId, dept.DeptRole) - if err != nil { + deptIDs = append(deptIDs, dept.DeptId) + if dept.DeptRole > 0 { + roleIDs = append(roleIDs, dept.DeptRole) + } + } + tenantID := contexts.GetTenantId(ctx) + if len(roleIDs) > 0 { + if err := dao.SysUserRole.Ctx(ctx).Where("tenant_id", tenantID). + WhereIn("role_id", roleIDs).Fields("role_id", "MIN(user_id) AS user_id").Group("role_id").Scan(&bindings); err != nil { return err } + } + if err := s.userModel(ctx).Where("tenant_id", tenantID).WhereIn("dept_id", deptIDs). + Fields("dept_id", "MIN(user_id) AS user_id").Group("dept_id").Scan(&fallbacks); err != nil { + return err + } + var ( + roleUsers = make(map[int64]int64, len(bindings)) + deptUsers = make(map[int64]int64, len(fallbacks)) + userIDs = make([]int64, 0, len(bindings)+len(fallbacks)) + users []*entity.SysUser + ) + for _, binding := range bindings { + roleUsers[binding.RoleId] = binding.UserId + userIDs = append(userIDs, binding.UserId) + } + for _, fallback := range fallbacks { + deptUsers[fallback.DeptId] = fallback.UserId + userIDs = append(userIDs, fallback.UserId) + } + if len(userIDs) == 0 { + return nil + } + if err := s.userModel(ctx).Where("tenant_id", tenantID).WhereIn("dept_id", deptIDs). + WhereIn("user_id", userIDs).Fields("user_id", "dept_id", "user_name", "nick_name").Scan(&users); err != nil { + return err + } + usersByID := make(map[int64]*entity.SysUser, len(users)) + for _, user := range users { + usersByID[user.UserId] = user + } + for _, dept := range list { + user := usersByID[roleUsers[dept.DeptRole]] + if user == nil || user.DeptId != dept.DeptId { + user = usersByID[deptUsers[dept.DeptId]] + } if user == nil { continue } diff --git a/server/internal/logic/agent/agent_dept_pagination_test.go b/server/internal/logic/agent/agent_dept_pagination_test.go new file mode 100644 index 0000000..9a0d45e --- /dev/null +++ b/server/internal/logic/agent/agent_dept_pagination_test.go @@ -0,0 +1,72 @@ +package agent + +import ( + "context" + "testing" + + "git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/contexts" + "git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/xgorm/handler" + "git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/model" + "git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/service" + + _ "github.com/gogf/gf/contrib/drivers/sqlite/v2" + "github.com/gogf/gf/v2/database/gdb" + "github.com/gogf/gf/v2/frame/g" +) + +type paginationDeptService struct{ service.ISysDept } + +func (paginationDeptService) Model(ctx context.Context, _ ...*handler.Option) *gdb.Model { + return g.DB().Model("sys_dept").Ctx(ctx).Handler(handler.FilterTenant) +} + +type paginationUserService struct{ service.ISysUser } + +func (paginationUserService) Model(ctx context.Context, _ ...*handler.Option) *gdb.Model { + return g.DB().Model("sys_user").Ctx(ctx).Handler(handler.FilterTenant) +} + +func TestEnterpriseCustomerDatabasePaginationAndDefaultUsers(t *testing.T) { + setupAgentRoleSyncTestDB(t) + ctx := contexts.Set(context.Background(), &model.Context{User: &model.Identity{BaseClaims: model.BaseClaims{TenantId: "000000", DeptId: 1}}}) + service.RegisterSysDept(paginationDeptService{}) + service.RegisterSysUser(paginationUserService{}) + t.Cleanup(func() { + service.RegisterSysDept(nil) + service.RegisterSysUser(nil) + }) + statements := []string{ + "ALTER TABLE sys_dept ADD COLUMN dept_name TEXT", + "CREATE TABLE sys_user (user_id INTEGER PRIMARY KEY, tenant_id TEXT, dept_id INTEGER, user_name TEXT, nick_name TEXT, deleted_at DATETIME)", + "INSERT INTO sys_dept (dept_id,tenant_id,parent_id,dept_type,dept_role,dept_name) VALUES (2,'000000',1,1,12,'企业A'),(3,'000000',1,1,13,'企业B'),(4,'000000',1,1,14,'企业C'),(5,'000000',2,1,15,'孙级企业'),(6,'other',1,1,16,'其他租户'),(7,'000000',1,0,17,'内部部门'),(8,'000000',1,1,18,'已删除企业'),(9,'000000',1,1,0,'无账号企业')", + "UPDATE sys_dept SET deleted_at=CURRENT_TIMESTAMP WHERE dept_id=8", + "INSERT INTO sys_user VALUES (20,'000000',2,'fallback-a','回退A',NULL),(21,'000000',2,'admin-a','管理员A',NULL),(30,'000000',3,'deleted-admin','已删除',CURRENT_TIMESTAMP),(31,'000000',3,'fallback-b','回退B',NULL),(40,'000000',4,'fallback-c','回退C',NULL),(50,'other',4,'other-user','跨租户',NULL)", + "INSERT INTO sys_user_role (tenant_id,user_id,role_id) VALUES ('000000',21,12),('000000',30,13),('other',50,14)", + } + for _, statement := range statements { + mustExecAgentRoleSyncTest(t, ctx, statement) + } + subject := NewAgentDept() + params := &model.AgentDeptListParam{Page: 1, PageSize: 2} + list, total, err := subject.DeptList(ctx, params) + if err != nil || total != 4 || len(list) != 2 { + t.Fatalf("page 1: total=%d list=%+v err=%v", total, list, err) + } + if list[0].DeptId != 2 || list[0].UserName != "admin-a" || list[1].DeptId != 3 || list[1].UserName != "fallback-b" { + t.Fatalf("unexpected account selection: %+v %+v", list[0], list[1]) + } + params.Page = 2 + list, total, err = subject.DeptList(ctx, params) + if err != nil || total != 4 || len(list) != 2 || list[0].DeptId != 4 || list[0].UserName != "fallback-c" || list[1].DeptId != 9 || list[1].UserName != "" { + t.Fatalf("page 2: total=%d list=%+v err=%v", total, list, err) + } + params.Page = 3 + list, total, err = subject.DeptList(ctx, params) + if err != nil || total != 4 || len(list) != 0 || list == nil { + t.Fatalf("out of range: total=%d list=%+v err=%v", total, list, err) + } + list, total, err = subject.DeptList(ctx, &model.AgentDeptListParam{DeptName: "企业A", Status: "0"}) + if err != nil || total != 1 || len(list) != 1 || list[0].DeptId != 2 { + t.Fatalf("filtered list: total=%d list=%+v err=%v", total, list, err) + } +} diff --git a/server/internal/logic/agent/agent_dept_passrole_test.go b/server/internal/logic/agent/agent_dept_passrole_test.go index b25477b..cc21302 100644 --- a/server/internal/logic/agent/agent_dept_passrole_test.go +++ b/server/internal/logic/agent/agent_dept_passrole_test.go @@ -12,9 +12,10 @@ import ( "git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/consts" "git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/contexts" "git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/model" + "git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/service" _ "git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/logic/memorydb" - _ "git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/logic/system" + "git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/logic/system" ) // 复现生产形态:模板角色已配置(role 18 @ dept 100,is_pass=1), @@ -56,6 +57,8 @@ func TestPassRoleByDeptWorksForChildAgentWithoutTemplateFlag(t *testing.T) { func setupPassRoleTestDB(t *testing.T) { t.Helper() + service.RegisterSysDept(system.NewSysDept()) + service.RegisterSysRole(system.NewSysRole()) if err := gdb.SetConfig(gdb.Config{ "default": gdb.ConfigGroup{ {Type: "sqlite", Link: "sqlite::@file(:memory:)", Debug: false}, @@ -69,7 +72,7 @@ func setupPassRoleTestDB(t *testing.T) { } ctx := context.Background() t.Cleanup(func() { _ = g.DB().Close(ctx) }) - mustExec(t, ctx, `CREATE TABLE sys_dept (dept_id INTEGER PRIMARY KEY, tenant_id TEXT, parent_id INTEGER, dept_type INTEGER, status TEXT DEFAULT '0', dept_role INTEGER, deleted_at DATETIME)`) + mustExec(t, ctx, `CREATE TABLE sys_dept (dept_id INTEGER PRIMARY KEY, tenant_id TEXT, parent_id INTEGER, dept_name TEXT, dept_type INTEGER, status TEXT DEFAULT '0', dept_role INTEGER, deleted_at DATETIME)`) mustExec(t, ctx, `CREATE TABLE sys_config (config_id INTEGER PRIMARY KEY, tenant_id TEXT, config_name TEXT, config_key TEXT, config_value TEXT, config_type TEXT, created_dept INTEGER, created_by INTEGER, created_at DATETIME, updated_by INTEGER, updated_at DATETIME, remark TEXT)`) mustExec(t, ctx, `CREATE TABLE sys_role ( role_id INTEGER PRIMARY KEY AUTOINCREMENT, tenant_id TEXT, dept_id INTEGER, role_name TEXT, role_key TEXT, role_sort INTEGER, data_scope TEXT, diff --git a/server/internal/logic/agent/agent_export.go b/server/internal/logic/agent/agent_export.go new file mode 100644 index 0000000..99d5b8d --- /dev/null +++ b/server/internal/logic/agent/agent_export.go @@ -0,0 +1,169 @@ +package agent + +import ( + "context" + "encoding/json" + "strings" + + "git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/exportcenter" + "git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/model" + + "github.com/gogf/gf/v2/errors/gerror" +) + +// agentExportColumns 只允许列表字段,列标题和映射由服务端确定。 +func agentExportColumns(raw string, defaults []model.ExportColumn) ([]model.ExportColumn, error) { + if strings.TrimSpace(raw) == "" { + return nil, gerror.New("请选择导出列") + } + var requested []model.ExportColumn + if err := json.Unmarshal([]byte(raw), &requested); err != nil { + return nil, gerror.New("导出列格式错误") + } + allowed := make(map[string]model.ExportColumn, len(defaults)) + for _, col := range defaults { + allowed[col.Field] = col + } + result := make([]model.ExportColumn, 0, len(requested)) + seen := make(map[string]bool) + for _, col := range requested { + safe, ok := allowed[col.Field] + if !ok { + return nil, gerror.New("包含不允许导出的字段") + } + if !seen[col.Field] { + result = append(result, safe) + seen[col.Field] = true + } + } + if len(result) == 0 { + return nil, gerror.New("请选择导出列") + } + return result, nil +} + +func (s *sAgentUser) UserExport(ctx context.Context, in *model.AgentUserListParam) error { + columns, err := agentExportColumns(in.ExportColumnsJson, []model.ExportColumn{ + {Field: "userName", Label: "用户名称"}, {Field: "nickName", Label: "用户昵称"}, + {Field: "roles", Label: "角色"}, {Field: "phonenumber", Label: "联系电话"}, + {Field: "email", Label: "邮箱"}, {Field: "status", Label: "状态"}, {Field: "createdAt", Label: "创建时间"}, + }) + if err != nil { + return err + } + _, err = exportcenter.Submit(ctx, &exportcenter.Request{ + BizKey: "agentUser", BizName: "用户管理", PermCode: "cpm:agent:user:export", QueryParam: in, Columns: columns, + RowFields: agentExportFields(columns), + ToRow: func(value interface{}) []interface{} { + row := value.(map[string]interface{}) + values := make([]interface{}, 0, len(columns)) + for _, column := range columns { + values = append(values, row[column.Field]) + } + return values + }, + Tags: func() ([]string, error) { + labels := make([]string, 0, len(columns)) + for _, col := range columns { + labels = append(labels, col.Label) + } + return labels, nil + }, + Fetch: func(ctx context.Context, page, pageSize int) ([]interface{}, int, error) { + query := *in + query.Page = page + query.PageSize = pageSize + list, total, err := s.UserList(ctx, &query) + if err != nil { + return nil, 0, err + } + rows := make([]interface{}, 0, len(list)) + for _, item := range list { + roles := make([]string, 0, len(item.Roles)) + for _, role := range item.Roles { + if role != nil && role.RoleName != "" { + roles = append(roles, role.RoleName) + } + } + roleNames := strings.Join(roles, "、") + if roleNames == "" { + roleNames = "-" + } + createdAt := "" + if item.CreatedAt != nil { + createdAt = item.CreatedAt.Format("Y-m-d H:i:s") + } + rows = append(rows, map[string]interface{}{"userName": item.UserName, "nickName": item.NickName, "roles": roleNames, "phonenumber": item.Phonenumber, "email": item.Email, "status": agentExportStatus(item.Status), "createdAt": createdAt}) + } + return rows, total, nil + }, + }) + return err +} + +func (s *sAgentDept) DeptExport(ctx context.Context, in *model.AgentDeptListParam) error { + columns, err := agentExportColumns(in.ExportColumnsJson, []model.ExportColumn{ + {Field: "deptName", Label: "企业名称"}, {Field: "userName", Label: "登录账号"}, {Field: "nickName", Label: "负责人"}, + {Field: "phone", Label: "联系电话"}, {Field: "email", Label: "联系人邮箱"}, {Field: "areaCode", Label: "组织所在地"}, + {Field: "address", Label: "详细地址"}, {Field: "createdAt", Label: "创建时间"}, {Field: "status", Label: "状态"}, + }) + if err != nil { + return err + } + _, err = exportcenter.Submit(ctx, &exportcenter.Request{ + BizKey: "agentDept", BizName: "企业客户", PermCode: "cpm:agent:dept:export", QueryParam: in, Columns: columns, + RowFields: agentExportFields(columns), + ToRow: func(value interface{}) []interface{} { + row := value.(map[string]interface{}) + values := make([]interface{}, 0, len(columns)) + for _, column := range columns { + values = append(values, row[column.Field]) + } + return values + }, + Tags: func() ([]string, error) { + labels := make([]string, 0, len(columns)) + for _, col := range columns { + labels = append(labels, col.Label) + } + return labels, nil + }, + Fetch: func(ctx context.Context, page, pageSize int) ([]interface{}, int, error) { + query := *in + query.Page = page + query.PageSize = pageSize + list, total, err := s.DeptList(ctx, &query) + if err != nil { + return nil, 0, err + } + rows := make([]interface{}, 0, len(list)) + for _, item := range list { + createdAt := "" + if item.CreatedAt != nil { + createdAt = item.CreatedAt.Format("Y-m-d H:i:s") + } + rows = append(rows, map[string]interface{}{"deptName": item.DeptName, "userName": item.UserName, "nickName": item.NickName, "phone": item.Phone, "email": item.Email, "areaCode": item.AreaCode, "address": item.Address, "createdAt": createdAt, "status": agentExportStatus(item.Status)}) + } + return rows, total, nil + }, + }) + return err +} + +func agentExportFields(columns []model.ExportColumn) []string { + fields := make([]string, 0, len(columns)) + for _, column := range columns { + fields = append(fields, column.Field) + } + return fields +} + +func agentExportStatus(status string) string { + if status == "0" { + return "正常" + } + if status == "1" { + return "停用" + } + return "未知" +} diff --git a/server/internal/logic/agent/agent_menu.go b/server/internal/logic/agent/agent_menu.go index a04a1f8..0218255 100644 --- a/server/internal/logic/agent/agent_menu.go +++ b/server/internal/logic/agent/agent_menu.go @@ -46,17 +46,22 @@ func (s *sAgentMenu) AllowedMenuIdsByDeptRole(ctx context.Context, deptId int64) return []int64{}, nil } if dept.DeptRole == consts.SuperAdminRoleId { - return s.allMenuIds(ctx) + menuIds, err = s.allMenuIds(ctx) + } else { + var roleMenus []*entity.SysRoleMenu + roleMenus, err = service.SysRole().GetRoleListMenu(ctx, []int64{dept.DeptRole}) + for _, item := range roleMenus { + menuIds = append(menuIds, item.MenuId) + } } - roleMenus, err := service.SysRole().GetRoleListMenu(ctx, []int64{dept.DeptRole}) if err != nil { return nil, err } - for _, item := range roleMenus { - menuIds = append(menuIds, item.MenuId) - } slices.Sort(menuIds) menuIds = slices.Compact(menuIds) + if !contexts.IsSuperAdmin(ctx) { + return rolepolicy.RestrictAssignableMenus(ctx, dept.TenantId, dept.DeptId, contexts.GetUserId(ctx), menuIds) + } return rolepolicy.RestrictMenus(ctx, dept.TenantId, dept.DeptId, menuIds) } @@ -83,32 +88,6 @@ func (s *sAgentMenu) getRoleById(ctx context.Context, roleId int64) (role *entit return role, err } -// filterMenuIdsByAllowed 筛选出当前机构允许的菜单ID -func (s *sAgentMenu) filterMenuIdsByAllowed(ctx context.Context, deptId int64, menuIds []int64) (res []int64, err error) { - if deptId == 0 { - deptId = contexts.GetDeptId(ctx) - } - allowed, err := s.AllowedMenuIdsByDeptRole(ctx, deptId) - if err != nil { - return nil, err - } - allowed, err = s.withMenuAncestors(ctx, allowed) - if err != nil { - return nil, err - } - allowedMap := make(map[int64]struct{}, len(allowed)) - for _, menuId := range allowed { - allowedMap[menuId] = struct{}{} - } - for _, menuId := range menuIds { - if _, ok := allowedMap[menuId]; ok { - res = append(res, menuId) - } - } - slices.Sort(res) - return slices.Compact(res), nil -} - func (s *sAgentMenu) AllowedMenuTree(ctx context.Context, deptId int64) (tree []*model.SysMenuTreeModel, menuIds []int64, err error) { deptId = contexts.GetDeptId(ctx) menuIds, err = s.AllowedMenuIdsByDeptRole(ctx, deptId) @@ -118,13 +97,13 @@ func (s *sAgentMenu) AllowedMenuTree(ctx context.Context, deptId int64) (tree [] if len(menuIds) == 0 && !contexts.IsSuperAdmin(ctx) { return []*model.SysMenuTreeModel{}, []int64{}, nil } - menuIds, err = s.withMenuAncestors(ctx, menuIds) + visibleMenuIds, err := rolepolicy.WithMenuAncestors(ctx, menuIds) if err != nil { return nil, nil, err } mod := service.SysMenu().Model(ctx) if !contexts.IsSuperAdmin(ctx) { - mod = mod.WhereIn(dao.SysMenu.Columns().MenuId, menuIds) + mod = mod.WhereIn(dao.SysMenu.Columns().MenuId, visibleMenuIds) } var menus []*entity.SysMenu err = mod.Where(dao.SysMenu.Columns().Status, consts.SysMenuStatusNormal). @@ -134,43 +113,18 @@ func (s *sAgentMenu) AllowedMenuTree(ctx context.Context, deptId int64) (tree [] return nil, nil, err } tree = s.buildMenuList(menus) + activeMenuIds := make([]int64, 0, len(menus)) + for _, menu := range menus { + activeMenuIds = append(activeMenuIds, menu.MenuId) + } + if contexts.IsSuperAdmin(ctx) { + menuIds = activeMenuIds + } else { + menuIds = rolepolicy.Intersect(menuIds, activeMenuIds) + } return tree, menuIds, nil } -func (s *sAgentMenu) withMenuAncestors(ctx context.Context, menuIds []int64) ([]int64, error) { - seen := make(map[int64]struct{}, len(menuIds)) - queue := append([]int64{}, menuIds...) - for _, menuId := range menuIds { - seen[menuId] = struct{}{} - } - for len(queue) > 0 { - var menus []*entity.SysMenu - err := service.SysMenu().Model(ctx). - WhereIn(dao.SysMenu.Columns().MenuId, queue). - Scan(&menus) - if err != nil { - return nil, err - } - queue = queue[:0] - for _, menu := range menus { - if menu.ParentId == 0 { - continue - } - if _, ok := seen[menu.ParentId]; ok { - continue - } - seen[menu.ParentId] = struct{}{} - queue = append(queue, menu.ParentId) - } - } - res := make([]int64, 0, len(seen)) - for menuId := range seen { - res = append(res, menuId) - } - slices.Sort(res) - return res, nil -} - func (s *sAgentMenu) buildMenuList(menus []*entity.SysMenu) (list []*model.SysMenuTreeModel) { for _, menu := range menus { list = append(list, &model.SysMenuTreeModel{ diff --git a/server/internal/logic/agent/agent_role.go b/server/internal/logic/agent/agent_role.go index 4e04ffd..816ff1b 100644 --- a/server/internal/logic/agent/agent_role.go +++ b/server/internal/logic/agent/agent_role.go @@ -2,8 +2,6 @@ package agent import ( "context" - "slices" - "strings" "git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/consts" "git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/dao" @@ -112,7 +110,7 @@ func (s *sAgentRole) RoleView(ctx context.Context, roleId int64) (res *model.Age func (s *sAgentRole) RoleSave(ctx context.Context, in *model.AgentRoleSaveParam) (roleId int64, err error) { err = g.DB().Transaction(ctx, func(ctx context.Context, tx gdb.TX) error { var saveErr error - roleId, saveErr = s.saveTemplateRole(ctx, in) + roleId, saveErr = s.saveTemplateRole(rolesync.DeferRecording(ctx), in) if saveErr != nil { return saveErr } @@ -153,24 +151,17 @@ func (s *sAgentRole) saveTemplateRole(ctx context.Context, in *model.AgentRoleSa in.DataScope = consts.SysRoleDataScopeDeptAndBelow } if !contexts.IsSuperAdmin(ctx) { - allowed, err := s.filterMenuIdsByAllowed(ctx, in.DeptId, in.MenuIds) + allowed, err := rolepolicy.NormalizeAssignableMenus(ctx, user.TenantId, in.DeptId, user.ID, in.MenuIds) if err != nil { return 0, err } - for _, id := range in.MenuIds { - if !slices.Contains(allowed, id) { - return 0, gerror.New("角色包含超出本机构权限范围的菜单或按钮") - } - } + in.MenuIds = allowed switch in.DataScope { case consts.SysRoleDataScopeDept, consts.SysRoleDataScopeDeptAndBelow, consts.SysRoleDataScopePersonal, consts.SysRoleDataScopeDeptAndBelowOrPersonal, consts.SysRoleDataScopeOrgAndNextLevel, consts.SysRoleDataScopeOrgNextLevel: default: return 0, gerror.New("代理角色数据权限不能超出本机构及下级范围") } } - // 只有本次保存的目标角色是平台代理模板时才走模板语义; - // 普通角色(如「运营人员」)不能被强制改写成 is_pass=1, - // 否则任何已配置模板的机构都无法再保存第二个角色。 template, templateErr := rolepolicy.TemplateForRoles(ctx, in.RoleId) if templateErr != nil { return 0, templateErr @@ -181,7 +172,6 @@ func (s *sAgentRole) saveTemplateRole(ctx context.Context, in *model.AgentRoleSa if template != nil { in.DataScope = consts.SysRoleDataScopeDeptAndBelow } - // 未配置平台模板时,保留原有「本级唯一 is_pass=1 角色」下发流程。 legacyPass := in.IsPass == 1 if legacyPass { configured, err := dao.SysConfig.Ctx(ctx).Master().Where("tenant_id", consts.DefaultSystemTenantCode).Where("config_key", rolepolicy.TemplateConfigKey).Count() @@ -399,188 +389,3 @@ func (s *sAgentRole) inheritedRoleModel(ctx context.Context) *gdb.Model { } return m } - -func (s *sAgentRole) SyncInheritedRole(ctx context.Context, message *model.AgentRoleSyncMessage) error { - if message == nil || message.SourceRoleId <= 0 { - return gerror.New("invalid agent role sync message") - } - template, templateErr := rolepolicy.Template(ctx) - if templateErr != nil { - return templateErr - } - if template != nil && message.SourceDeptId == template.DeptId && (message.TenantId == template.TenantId || message.TenantId == "") { - return syncTemplateRoles(ctx, template, message.ActorId) - } - var source *entity.SysRole - sourceModel := dao.SysRole.Ctx(ctx).Where(dao.SysRole.Columns().RoleId, message.SourceRoleId) - if message.TenantId != "" { - sourceModel = sourceModel.Where(dao.SysRole.Columns().TenantId, message.TenantId) - } - if err := sourceModel.Scan(&source); err != nil { - return err - } - if source == nil || source.IsPass != 1 || source.DeletedAt != nil { - return nil - } - if message.SourceDeptId > 0 && message.SourceDeptId != source.DeptId { - return gerror.New("role sync source department changed") - } - var children []*entity.SysDept - if err := dao.SysDept.Ctx(ctx). - Where(dao.SysDept.Columns().TenantId, source.TenantId). - Where(dao.SysDept.Columns().ParentId, source.DeptId). - Where(dao.SysDept.Columns().DeptType, 1). - Scan(&children); err != nil { - return err - } - failed := 0 - var firstErr error - for _, child := range children { - err := g.DB().Transaction(ctx, func(ctx context.Context, tx gdb.TX) error { - return s.syncInheritedRoleChildrenTx(ctx, tx, message.SourceRoleId, source.TenantId, source.DeptId, message.ActorId, child.DeptId) - }) - if err != nil { - failed++ - if firstErr == nil { - firstErr = err - } - g.Log().Warningf(ctx, "agent role sync child failed, sourceRoleId:%d, childDeptId:%d, err:%v", source.RoleId, child.DeptId, err) - } - } - if firstErr != nil { - return gerror.Wrapf(firstErr, "agent role sync failed for %d of %d children", failed, len(children)) - } - return nil -} - -func (s *sAgentRole) syncInheritedRoleChildrenTx(ctx context.Context, tx gdb.TX, sourceRoleID int64, tenantId string, sourceDeptId, actorId, childDeptId int64) error { - if sourceRoleID == 0 { - return nil - } - var source *entity.SysRole - sourceModel := tx.Ctx(ctx).Model(dao.SysRole.Table()).Where(dao.SysRole.Columns().RoleId, sourceRoleID) - if tenantId != "" { - sourceModel = sourceModel.Where(dao.SysRole.Columns().TenantId, tenantId) - } - if !strings.EqualFold(g.DB().GetConfig().Type, "sqlite") { - sourceModel = sourceModel.LockUpdate() - } - if err := sourceModel.Scan(&source); err != nil { - return err - } - if source == nil || source.RoleId == 0 || source.DeletedAt != nil || source.IsPass != 1 { - return nil - } - if sourceDeptId > 0 && source.DeptId != sourceDeptId { - return gerror.New("role sync source department changed") - } - var childDepts []*entity.SysDept - childDeptModel := tx.Ctx(ctx).Model(dao.SysDept.Table()).Where(dao.SysDept.Columns().TenantId, source.TenantId).Where(dao.SysDept.Columns().ParentId, source.DeptId).Where(dao.SysDept.Columns().DeptType, 1).Where(dao.SysDept.Columns().DeptId, childDeptId) - if !strings.EqualFold(g.DB().GetConfig().Type, "sqlite") { - childDeptModel = childDeptModel.LockUpdate() - } - if err := childDeptModel.Scan(&childDepts); err != nil { - return err - } - var menus []*entity.SysRoleMenu - if err := tx.Ctx(ctx).Model(dao.SysRoleMenu.Table()).Where(dao.SysRoleMenu.Columns().RoleId, sourceRoleID).OrderAsc(dao.SysRoleMenu.Columns().MenuId).Scan(&menus); err != nil { - return err - } - menuIDs := make([]int64, 0, len(menus)) - for _, menu := range menus { - menuIDs = append(menuIDs, menu.MenuId) - } - allowed, err := rolepolicy.RestrictMenus(ctx, source.TenantId, source.DeptId, menuIDs) - if err != nil { - return err - } - template, templateErr := rolepolicy.Template(ctx) - if templateErr != nil { - return templateErr - } - filtered := make([]*entity.SysRoleMenu, 0, len(menus)) - for _, menu := range menus { - if slices.Contains(allowed, menu.MenuId) { - copy := *menu - if template != nil { - copy.DataScope = consts.SysRoleMenuDataScopeRole - } - filtered = append(filtered, ©) - } - } - menus = filtered - for _, childDept := range childDepts { - var children []*entity.SysRole - if err := tx.Ctx(ctx).Model(dao.SysRole.Table()+" r"). - Fields("r.*"). - Where("r.tenant_id", source.TenantId). - Where("r.dept_id", childDept.DeptId). - Where("r.created_dept", source.DeptId). - Where("r.role_id", childDept.DeptRole). - Where("r.deleted_at IS NULL"). - Scan(&children); err != nil { - return err - } - if len(children) > 1 { - return gerror.Newf("multiple inherited roles in department %d for role %d", childDept.DeptId, source.RoleId) - } - if len(children) == 0 { - var legacy *entity.SysRole - if childDept.DeptRole > 0 { - if err := tx.Ctx(ctx).Model(dao.SysRole.Table()). - Where(dao.SysRole.Columns().RoleId, childDept.DeptRole). - Where(dao.SysRole.Columns().DeptId, childDept.DeptId). - Where(dao.SysRole.Columns().TenantId, source.TenantId). - Scan(&legacy); err != nil { - return err - } - } - if legacy != nil && legacy.RoleId > 0 { - if legacy.CreatedDept != source.DeptId || legacy.RoleKey != source.RoleKey { - return gerror.Newf("cannot identify inherited role for department %d", childDept.DeptId) - } - children = []*entity.SysRole{legacy} - } else { - if childDept.DeptRole > 0 { - return gerror.Newf("missing default role for department %d", childDept.DeptId) - } - roleId, err := s.createInheritedRoleTx(ctx, tx, source, childDept.DeptId, menus, actorId) - if err != nil { - return err - } - children = []*entity.SysRole{{RoleId: roleId}} - } - } - for _, child := range children { - if _, err := tx.Ctx(ctx).Model(dao.SysRole.Table()).Where(dao.SysRole.Columns().RoleId, child.RoleId).Data(do.SysRole{RoleName: strings.TrimSpace(source.RoleName), RoleKey: source.RoleKey, RoleSort: source.RoleSort, DataScope: source.DataScope, MenuCheckStrictly: source.MenuCheckStrictly, DeptCheckStrictly: source.DeptCheckStrictly, Status: source.Status, Remark: source.Remark, UpdatedBy: actorId, UpdatedAt: gtime.Now()}).OmitNil().Update(); err != nil { - return err - } - if _, err := tx.Ctx(ctx).Model(dao.SysRoleMenu.Table()).Where(dao.SysRoleMenu.Columns().RoleId, child.RoleId).Delete(); err != nil { - return err - } - for _, menu := range menus { - if _, err := tx.Ctx(ctx).Model(dao.SysRoleMenu.Table()).Data(do.SysRoleMenu{TenantId: source.TenantId, RoleId: child.RoleId, MenuId: menu.MenuId, DataScope: menu.DataScope}).OmitNil().Insert(); err != nil { - return err - } - } - } - } - return nil -} - -func (s *sAgentRole) createInheritedRoleTx(ctx context.Context, tx gdb.TX, source *entity.SysRole, deptId int64, menus []*entity.SysRoleMenu, actorId int64) (int64, error) { - if actorId == 0 { - actorId = source.UpdatedBy - } - roleId, err := tx.Ctx(ctx).Model(dao.SysRole.Table()).Data(do.SysRole{TenantId: source.TenantId, DeptId: deptId, RoleName: strings.TrimSpace(source.RoleName), RoleKey: source.RoleKey, RoleSort: source.RoleSort, DataScope: source.DataScope, MenuCheckStrictly: source.MenuCheckStrictly, DeptCheckStrictly: source.DeptCheckStrictly, IsPass: 0, Status: source.Status, CreatedDept: source.DeptId, CreatedBy: actorId, CreatedAt: gtime.Now(), UpdatedBy: actorId, UpdatedAt: gtime.Now(), Remark: source.Remark}).OmitNil().InsertAndGetId() - if err != nil { - return 0, err - } - for _, menu := range menus { - if _, err = tx.Ctx(ctx).Model(dao.SysRoleMenu.Table()).Data(do.SysRoleMenu{TenantId: source.TenantId, RoleId: roleId, MenuId: menu.MenuId, DataScope: menu.DataScope}).OmitNil().Insert(); err != nil { - return 0, err - } - } - _, err = tx.Ctx(ctx).Model(dao.SysRoleDept.Table()).Data(do.SysRoleDept{RoleId: roleId, DeptId: deptId}).OmitNil().Insert() - return roleId, err -} diff --git a/server/internal/logic/agent/agent_role_queue.go b/server/internal/logic/agent/agent_role_queue.go deleted file mode 100644 index c43f2b8..0000000 --- a/server/internal/logic/agent/agent_role_queue.go +++ /dev/null @@ -1,83 +0,0 @@ -package agent - -import ( - "context" - "encoding/json" - "time" - - "git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/cmd/inithttp" - "git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/consts" - "git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/rabbitmq" - "git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/model" - "git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/service" - localqueue "git.5iot.com/iotlcm-public/iotlcm-admin-svr/utility/queue" - - "github.com/gogf/gf/v2/frame/g" - "github.com/gogf/gf/v2/os/gctx" -) - -type agentRoleSyncQueue struct{} - -func init() { - inithttp.RegisterHttpInitFunc("agentRoleSyncQueue", func(ctx context.Context) error { - if rabbitmq.Enabled(ctx) { - if err := rabbitmq.DeclareQueue(ctx, consts.QueueAgentRoleSyncDeadLetter); err != nil { - return err - } - return rabbitmq.ConsumeJSON[model.AgentRoleSyncMessage](ctx, consts.QueueAgentRoleSync, "iotlcm-agent-role-sync", handleAgentRoleSyncMessage) - } - if err := localqueue.NewConsumer(gctx.New(), &agentRoleSyncQueue{}, &localqueue.Config{Workers: 2}); err != nil { - return err - } - return localqueue.NewProducer(gctx.New(), localqueue.QueueTypeMemory, consts.QueueAgentRoleSync, &localqueue.Config{}) - }) -} - -func publishAgentRoleSync(ctx context.Context, message *model.AgentRoleSyncMessage) error { - if rabbitmq.Enabled(ctx) { - return rabbitmq.Publish(ctx, consts.QueueAgentRoleSync, message) - } - return localqueue.Push(ctx, consts.QueueAgentRoleSync, message) -} - -func handleAgentRoleSyncMessage(ctx context.Context, message model.AgentRoleSyncMessage) error { - if err := service.AgentRole().SyncInheritedRole(ctx, &message); err != nil { - g.Log().Errorf(ctx, "agent role sync failed, sourceRoleId:%d, retry:%d, err:%v", message.SourceRoleId, message.RetryCount, err) - if message.RetryCount < 3 { - message.RetryCount++ - return rabbitmq.PublishDelayed(ctx, consts.QueueAgentRoleSyncRetry, consts.QueueAgentRoleSync, time.Duration(message.RetryCount)*time.Second, message) - } - deadLetter := model.AgentRoleSyncDeadLetterMessage{Message: message, Error: err.Error(), FailedAt: time.Now().Format(time.RFC3339)} - if publishErr := rabbitmq.Publish(ctx, consts.QueueAgentRoleSyncDeadLetter, deadLetter); publishErr != nil { - return publishErr - } - g.Log().Errorf(ctx, "agent role sync moved to dead-letter queue, sourceRoleId:%d, err:%v", message.SourceRoleId, err) - return nil - } - g.Log().Infof(ctx, "agent role sync completed, sourceRoleId:%d", message.SourceRoleId) - return nil -} - -func (*agentRoleSyncQueue) GetTopic() string { return consts.QueueAgentRoleSync } - -func (*agentRoleSyncQueue) GetType() localqueue.QueueType { return localqueue.QueueTypeMemory } - -func (*agentRoleSyncQueue) Handle(ctx context.Context, payload localqueue.Payload) error { - var message model.AgentRoleSyncMessage - if err := json.Unmarshal(payload.Data, &message); err != nil { - return err - } - var err error - for attempt := 0; attempt <= 3; attempt++ { - err = service.AgentRole().SyncInheritedRole(ctx, &message) - if err == nil { - g.Log().Infof(ctx, "agent role sync completed, sourceRoleId:%d", message.SourceRoleId) - return nil - } - if attempt < 3 { - time.Sleep(time.Duration(attempt+1) * time.Second) - } - } - g.Log().Errorf(ctx, "agent role sync failed after retries, sourceRoleId:%d, err:%v", message.SourceRoleId, err) - return err -} diff --git a/server/internal/logic/agent/agent_role_save_test.go b/server/internal/logic/agent/agent_role_save_test.go index ebac46c..1d8ff72 100644 --- a/server/internal/logic/agent/agent_role_save_test.go +++ b/server/internal/logic/agent/agent_role_save_test.go @@ -9,9 +9,9 @@ import ( "github.com/gogf/gf/v2/frame/g" "github.com/gogf/gf/v2/os/gcfg" - "git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/contexts" _ "git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/logic/memorydb" _ "git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/logic/system" + "git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/contexts" "git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/model" ) diff --git a/server/internal/logic/agent/agent_role_sync_test.go b/server/internal/logic/agent/agent_role_sync_test.go new file mode 100644 index 0000000..876e6d9 --- /dev/null +++ b/server/internal/logic/agent/agent_role_sync_test.go @@ -0,0 +1,94 @@ +package agent + +import ( + "context" + "testing" + + "git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/consts" + "git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/contexts" + "git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/rolepolicy" + "git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/xgorm/handler" + "git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/model" + + _ "github.com/gogf/gf/contrib/drivers/sqlite/v2" + "github.com/gogf/gf/v2/database/gdb" + "github.com/gogf/gf/v2/frame/g" +) + +func TestEnsureRoleNotInheritedAllowsSuperAdmin(t *testing.T) { + ctx := contexts.Set(context.Background(), &model.Context{ + User: &model.Identity{BaseClaims: model.BaseClaims{ + TenantId: "000000", + Roles: []model.Role{{RoleId: consts.SuperAdminRoleId}}, + }}, + }) + + if err := NewAgentRole().ensureRoleNotInherited(ctx, 100); err != nil { + t.Fatalf("super admin should edit inherited roles, got error: %v", err) + } +} + +func TestCurrentSuperAdminChecksLiveRoleAssignment(t *testing.T) { + setupAgentRoleSyncTestDB(t) + ctx := context.Background() + mustExecAgentRoleSyncTest(t, ctx, "INSERT INTO sys_role (role_id,tenant_id,dept_id,status) VALUES (1,'000000',1,'0')") + mustExecAgentRoleSyncTest(t, ctx, "INSERT INTO sys_user_role (tenant_id,user_id,role_id) VALUES ('000000',99,1)") + isAdmin, err := rolepolicy.CurrentSuperAdmin(ctx, 99) + if err != nil || !isAdmin { + t.Fatalf("expected active administrator: %v %v", isAdmin, err) + } + mustExecAgentRoleSyncTest(t, ctx, "DELETE FROM sys_user_role WHERE user_id=99") + isAdmin, err = rolepolicy.CurrentSuperAdmin(ctx, 99) + if err != nil || isAdmin { + t.Fatalf("revoked administrator must lose access: %v %v", isAdmin, err) + } +} + +func TestNoActiveRolesCannotReadFilteredData(t *testing.T) { + setupAgentRoleSyncTestDB(t) + ctx := contexts.Set(context.Background(), &model.Context{User: &model.Identity{BaseClaims: model.BaseClaims{ID: 99, TenantId: "000000", DeptId: 2}}}) + mustExecAgentRoleSyncTest(t, ctx, "INSERT INTO sys_role (role_id,tenant_id,dept_id,status) VALUES (21,'000000',2,'0')") + count, err := g.DB().Model("sys_role").Ctx(ctx).Handler(handler.FilterAuth).Count() + if err != nil || count != 0 { + t.Fatalf("user without roles must not read filtered data: %d %v", count, err) + } +} + +func setupAgentRoleSyncTestDB(t *testing.T) { + t.Helper() + if err := gdb.SetConfig(gdb.Config{ + "default": gdb.ConfigGroup{ + {Type: "sqlite", Link: "sqlite::@file(:memory:)", Debug: false}, + }, + }); err != nil { + t.Fatalf("configure sqlite failed: %v", err) + } + ctx := context.Background() + t.Cleanup(func() { _ = g.DB().Close(ctx) }) + mustExecAgentRoleSyncTest(t, ctx, `CREATE TABLE sys_dept (dept_id INTEGER PRIMARY KEY, tenant_id TEXT, parent_id INTEGER, dept_type INTEGER, status TEXT DEFAULT '0', dept_role INTEGER, deleted_at DATETIME)`) + mustExecAgentRoleSyncTest(t, ctx, `CREATE TABLE sys_config (config_id INTEGER PRIMARY KEY, tenant_id TEXT, config_name TEXT, config_key TEXT, config_value TEXT, config_type TEXT, created_dept INTEGER, created_by INTEGER, created_at DATETIME, updated_by INTEGER, updated_at DATETIME, remark TEXT)`) + mustExecAgentRoleSyncTest(t, ctx, `CREATE TABLE sys_role ( + role_id INTEGER PRIMARY KEY AUTOINCREMENT, tenant_id TEXT, dept_id INTEGER, role_name TEXT, role_key TEXT, role_sort INTEGER, data_scope TEXT, + menu_check_strictly INTEGER, dept_check_strictly INTEGER, status TEXT, is_pass INTEGER, created_dept INTEGER, created_by INTEGER, created_at DATETIME, updated_by INTEGER, updated_at DATETIME, deleted_by INTEGER, deleted_at DATETIME, remark TEXT)`) + mustExecAgentRoleSyncTest(t, ctx, `CREATE TABLE sys_role_menu (tenant_id TEXT, role_id INTEGER, menu_id INTEGER, data_scope TEXT)`) + mustExecAgentRoleSyncTest(t, ctx, `CREATE TABLE sys_role_dept (tenant_id TEXT, role_id INTEGER, dept_id INTEGER)`) + mustExecAgentRoleSyncTest(t, ctx, `CREATE TABLE sys_user_role (tenant_id TEXT DEFAULT '000000', user_id INTEGER, role_id INTEGER)`) +} + +func mustExecAgentRoleSyncTest(t *testing.T, ctx context.Context, sql string) { + t.Helper() + if _, err := g.DB().Exec(ctx, sql); err != nil { + t.Fatalf("execute sql failed: %v", err) + } +} + +func assertAgentRoleSyncCount(t *testing.T, ctx context.Context, sql string, expected int) { + t.Helper() + count, err := g.DB().GetValue(ctx, sql) + if err != nil { + t.Fatalf("query count failed: %v", err) + } + if count.Int() != expected { + t.Fatalf("count = %d, want %d, sql=%s", count.Int(), expected, sql) + } +} diff --git a/server/internal/logic/agent/agent_template.go b/server/internal/logic/agent/agent_template.go deleted file mode 100644 index 4184bce..0000000 --- a/server/internal/logic/agent/agent_template.go +++ /dev/null @@ -1,113 +0,0 @@ -package agent - -import ( - "context" - "git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/consts" - "git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/dao" - "git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/event" - "git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/rolepolicy" - "git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/model/do" - "git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/model/entity" - "github.com/gogf/gf/v2/database/gdb" - "github.com/gogf/gf/v2/errors/gerror" - "github.com/gogf/gf/v2/frame/g" - "strconv" - "strings" -) - -func syncTemplateRoles(ctx context.Context, template *entity.SysRole, actorID int64) error { - queue := []int64{template.DeptId} - seen := make(map[int64]bool) - failed := 0 - var firstErr error - for len(queue) > 0 { - parentID := queue[0] - queue = queue[1:] - if seen[parentID] { - return gerror.New("机构上下级存在循环") - } - seen[parentID] = true - var children []*entity.SysDept - if err := dao.SysDept.Ctx(ctx).Where("tenant_id", template.TenantId).Where("parent_id", parentID).Where("dept_type", 1).Scan(&children); err != nil { - return err - } - for _, child := range children { - if child.DeptRole <= 0 { - g.Log().Warningf(ctx, "agent template sync skipped missing default role, deptId:%d", child.DeptId) - continue - } - err := g.DB().Transaction(ctx, func(ctx context.Context, tx gdb.TX) error { - lock := tx.Ctx(ctx).Model(dao.SysDept.Table()).Where("dept_id", child.DeptId).Where("tenant_id", template.TenantId) - if !strings.EqualFold(g.DB().GetConfig().Type, "sqlite") { - lock = lock.LockUpdate() - } - var locked *entity.SysDept - if err := lock.Scan(&locked); err != nil { - return err - } - if locked == nil || locked.DeptRole != child.DeptRole { - return gerror.New("下级机构角色绑定已变化") - } - var current *entity.SysRole - if err := tx.Ctx(ctx).Model(dao.SysRole.Table()).Where("role_id", child.DeptRole).Where("tenant_id", template.TenantId).Where("dept_id", child.DeptId).Scan(¤t); err != nil { - return err - } - if current == nil { - g.Log().Warningf(ctx, "agent template sync skipped missing or deleted role, deptId:%d, roleId:%d", child.DeptId, child.DeptRole) - return nil - } - configLock := tx.Ctx(ctx).Model(dao.SysConfig.Table()).Where("tenant_id", template.TenantId).Where("config_key", rolepolicy.TemplateConfigKey) - if !strings.EqualFold(g.DB().GetConfig().Type, "sqlite") { - configLock = configLock.LockUpdate() - } - var config *entity.SysConfig - if err := configLock.Scan(&config); err != nil { - return err - } - if config == nil || strings.TrimSpace(config.ConfigValue) != strconv.FormatInt(template.RoleId, 10) { - return gerror.New("模板已切换,请重新同步") - } - templateLock := tx.Ctx(ctx).Model(dao.SysRole.Table()).Where("role_id", template.RoleId) - if !strings.EqualFold(g.DB().GetConfig().Type, "sqlite") { - templateLock = templateLock.LockUpdate() - } - var latest *entity.SysRole - if err := templateLock.Scan(&latest); err != nil { - return err - } - if latest == nil || latest.Status != "0" { - return gerror.New("模板不可用") - } - var menus []*entity.SysRoleMenu - if err := tx.Ctx(ctx).Model(dao.SysRoleMenu.Table()).Where("tenant_id", template.TenantId).Where("role_id", template.RoleId).Scan(&menus); err != nil { - return err - } - if _, err := tx.Ctx(ctx).Model(dao.SysRole.Table()).Where("role_id", current.RoleId).Data(do.SysRole{CreatedDept: template.DeptId, DataScope: consts.SysRoleDataScopeDeptAndBelow, UpdatedBy: actorID}).Update(); err != nil { - return err - } - if _, err := tx.Ctx(ctx).Model(dao.SysRoleMenu.Table()).Where("role_id", current.RoleId).Delete(); err != nil { - return err - } - for _, menu := range menus { - if _, err := tx.Ctx(ctx).Model(dao.SysRoleMenu.Table()).Data(do.SysRoleMenu{TenantId: template.TenantId, RoleId: current.RoleId, MenuId: menu.MenuId, DataScope: consts.SysRoleMenuDataScopeRole}).Insert(); err != nil { - return err - } - } - return nil - }) - if err != nil { - failed++ - if firstErr == nil { - firstErr = err - } - g.Log().Errorf(ctx, "agent template sync child failed, deptId:%d, err:%v", child.DeptId, err) - continue - } - event.EventsInstance().Emit(ctx, consts.EventKeyDBSysRoleUpdate, child.DeptRole) - } - } - if firstErr != nil { - return gerror.Wrapf(firstErr, "代理模板同步失败企业数:%d", failed) - } - return nil -} diff --git a/server/internal/logic/agent/agent_template_source_test.go b/server/internal/logic/agent/agent_template_source_test.go new file mode 100644 index 0000000..00a52ab --- /dev/null +++ b/server/internal/logic/agent/agent_template_source_test.go @@ -0,0 +1,64 @@ +package agent + +import ( + "context" + "slices" + "testing" + + "github.com/gogf/gf/v2/database/gdb" + "github.com/gogf/gf/v2/frame/g" + "git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/contexts" + "git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/xgorm/handler" + "git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/model" + "git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/service" +) + +type templateRoleService struct{ service.ISysRole } + +func (templateRoleService) ModelQuery(ctx context.Context, _ ...*handler.Option) *gdb.Model { + return g.DB().Model("sys_role").Ctx(ctx).Handler(handler.FilterTenant) +} + +func TestConfiguredTemplateUsesInheritedDefaultAtEveryLevel(t *testing.T) { + setupAgentRoleSyncTestDB(t) + service.RegisterSysDept(paginationDeptService{}) + service.RegisterSysRole(templateRoleService{}) + t.Cleanup(func() { + service.RegisterSysDept(nil) + service.RegisterSysRole(nil) + }) + ctx := contexts.Set(context.Background(), &model.Context{User: &model.Identity{BaseClaims: model.BaseClaims{ID: 99, TenantId: "000000", DeptId: 2}}}) + for _, statement := range []string{ + "ALTER TABLE sys_dept ADD COLUMN dept_name TEXT", + "INSERT INTO sys_dept(dept_id,tenant_id,parent_id,dept_type,dept_role) VALUES(1,'000000',0,1,0),(2,'000000',1,1,21),(3,'000000',2,1,31)", + "INSERT INTO sys_role(role_id,tenant_id,dept_id,status,is_pass,created_dept,data_scope) VALUES(10,'000000',1,'0',0,1,'4'),(21,'000000',2,'0',0,1,'4'),(31,'000000',3,'0',0,2,'4')", + "INSERT INTO sys_config(tenant_id,config_key,config_value) VALUES('000000','agent.role.template.role_id','10')", + "INSERT INTO sys_role_menu(tenant_id,role_id,menu_id,data_scope) VALUES('000000',10,100,'0'),('000000',10,200,'0'),('000000',21,100,'0'),('000000',21,300,'0'),('000000',31,100,'0')", + } { + mustExecAgentRoleSyncTest(t, ctx, statement) + } + for _, deptID := range []int64{1, 2, 3} { + source, menus, err := NewAgentDept().passRoleByDept(ctx, deptID) + if err != nil || source == nil { + t.Fatalf("source for department %d: %+v %v", deptID, source, err) + } + expected := map[int64][]int64{1: {100, 200}, 2: {100, 300}, 3: {100}}[deptID] + got := make([]int64, 0, len(menus)) + for _, menu := range menus { + got = append(got, menu.MenuId) + } + slices.Sort(got) + if !slices.Equal(got, expected) { + t.Fatalf("department %d must copy its current default role including platform expansion: %v, want %v", deptID, got, expected) + } + } + mustExecAgentRoleSyncTest(t, ctx, "UPDATE sys_dept SET dept_role=0 WHERE dept_id=2") + if _, _, err := NewAgentDept().passRoleByDept(ctx, 2); err == nil { + t.Fatal("missing default role must not fall back to full platform permissions") + } + if err := NewAgentDept().EnsureCurrentDeptDefaultRole(ctx); err == nil { + t.Fatal("read request must report missing default role") + } + assertAgentRoleSyncCount(t, ctx, "SELECT COUNT(*) FROM sys_role", 3) + assertAgentRoleSyncCount(t, ctx, "SELECT COUNT(*) FROM sys_user_role", 0) +} diff --git a/server/internal/logic/agent/t_agent_enterprise.go b/server/internal/logic/agent/t_agent_enterprise.go index 9abe4c0..d239f5f 100644 --- a/server/internal/logic/agent/t_agent_enterprise.go +++ b/server/internal/logic/agent/t_agent_enterprise.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 agent diff --git a/server/internal/logic/agent/t_api.go b/server/internal/logic/agent/t_api.go index 9123ad1..321c6c2 100644 --- a/server/internal/logic/agent/t_api.go +++ b/server/internal/logic/agent/t_api.go @@ -1,29 +1,26 @@ // 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 agent import ( "context" - "fmt" "git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/dao" "git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/contexts" + "git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/exportcenter" "git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/xgorm/handler" "git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/xgorm/hook" "git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/model" "git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/service" "git.5iot.com/iotlcm-public/iotlcm-admin-svr/utility/convert" - "git.5iot.com/iotlcm-public/iotlcm-admin-svr/utility/excel" "github.com/gogf/gf/v2/database/gdb" "github.com/gogf/gf/v2/errors/gerror" "github.com/gogf/gf/v2/frame/g" - "github.com/gogf/gf/v2/os/gctx" "github.com/gogf/gf/v2/os/gtime" - "github.com/gogf/gf/v2/util/gconv" ) type sAgentTApi struct{} @@ -86,31 +83,32 @@ func (s *sAgentTApi) List(ctx context.Context, in *model.TApiListParam) (list [] return } -// Export 导出API集成 +// Export 导出API集成(异步:提交任务后到下载中心取文件) func (s *sAgentTApi) Export(ctx context.Context, in *model.TApiListParam) (err error) { - list, totalCount, err := s.List(ctx, in) - if err != nil { - return - } - - // 字段的排序是依据tags的字段顺序,如果你不想使用默认的排序方式,可以直接定义 tags = []string{"字段名称", "字段名称2", ...} - tags, err := convert.GetEntityDescTags(model.TApiExportModel{}) - if err != nil { - return - } - - var ( - fileName = "导出API集成-" + gctx.CtxId(ctx) - sheetName = fmt.Sprintf("索引条件共%v行,共%v页,当前导出是第%v页,本页共%v行", totalCount, (totalCount+in.PageSize-1)/in.PageSize, in.Page, len(list)) - exports []model.TApiExportModel - ) - - if err = gconv.Scan(list, &exports); err != nil { - return - } - - err = excel.ExportByStructs(ctx, tags, exports, fileName, sheetName) - return + _, err = exportcenter.Submit(ctx, &exportcenter.Request{ + BizKey: "tApi", + BizName: "API集成", + PermCode: "cpm:agent:tApi:export", + QueryParam: in, + Tags: func() ([]string, error) { + return convert.GetEntityDescTags(model.TApiListModel{}) + }, + Fetch: func(ctx context.Context, page, pageSize int) (rows []interface{}, total int, err error) { + query := *in + query.Page = page + query.PageSize = pageSize + list, count, err := s.List(ctx, &query) + if err != nil { + return nil, 0, err + } + rows = make([]interface{}, 0, len(list)) + for _, item := range list { + rows = append(rows, item) + } + return rows, count, nil + }, + }) + return err } // Edit 修改/新增API集成 diff --git a/server/internal/logic/agent/t_api_group.go b/server/internal/logic/agent/t_api_group.go index 6336ac1..d87d505 100644 --- a/server/internal/logic/agent/t_api_group.go +++ b/server/internal/logic/agent/t_api_group.go @@ -2,21 +2,19 @@ package agent import ( "context" - "fmt" "git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/dao" "git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/contexts" + "git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/exportcenter" "git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/xgorm" "git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/xgorm/handler" "git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/xgorm/hook" "git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/model" "git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/service" "git.5iot.com/iotlcm-public/iotlcm-admin-svr/utility/convert" - "git.5iot.com/iotlcm-public/iotlcm-admin-svr/utility/excel" "github.com/gogf/gf/v2/database/gdb" "github.com/gogf/gf/v2/errors/gerror" "github.com/gogf/gf/v2/frame/g" - "github.com/gogf/gf/v2/os/gctx" "github.com/gogf/gf/v2/os/gtime" "github.com/gogf/gf/v2/util/gconv" ) @@ -71,31 +69,32 @@ func (s *sAgentTApiGroup) List(ctx context.Context, in *model.TApiGroupListParam return } -// Export 导出开放接口权限组列表。 +// Export 导出开放接口权限组(异步:提交任务后到下载中心取文件) func (s *sAgentTApiGroup) Export(ctx context.Context, in *model.TApiGroupListParam) (err error) { - list, totalCount, err := s.List(ctx, in) - if err != nil { - return - } - - tags, err := convert.GetEntityDescTags(model.TApiGroupExportModel{}) - if err != nil { - return - } - - fileName := "导出开放接口权限组-" + gctx.CtxId(ctx) - sheetName := fmt.Sprintf( - "搜索条件共%v条 共%v页 当前导出第%v页 本页共%v条", - totalCount, - (totalCount+in.PageSize-1)/in.PageSize, - in.Page, - len(list), - ) - var exports []model.TApiGroupExportModel - if err = gconv.Scan(list, &exports); err != nil { - return - } - return excel.ExportByStructs(ctx, tags, exports, fileName, sheetName) + _, err = exportcenter.Submit(ctx, &exportcenter.Request{ + BizKey: "tApiGroup", + BizName: "开放接口权限组", + PermCode: "cpm:agent:tApiGroup:export", + QueryParam: in, + Tags: func() ([]string, error) { + return convert.GetEntityDescTags(model.TApiGroupListModel{}) + }, + Fetch: func(ctx context.Context, page, pageSize int) (rows []interface{}, total int, err error) { + query := *in + query.Page = page + query.PageSize = pageSize + list, count, err := s.List(ctx, &query) + if err != nil { + return nil, 0, err + } + rows = make([]interface{}, 0, len(list)) + for _, item := range list { + rows = append(rows, item) + } + return rows, count, nil + }, + }) + return err } // Edit 新增或修改开放接口权限组,并同步维护权限组与接口资源的关联。 diff --git a/server/internal/logic/agent/t_api_resource.go b/server/internal/logic/agent/t_api_resource.go index cdc079a..2f3a5ad 100644 --- a/server/internal/logic/agent/t_api_resource.go +++ b/server/internal/logic/agent/t_api_resource.go @@ -2,22 +2,20 @@ package agent import ( "context" - "fmt" "git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/dao" "git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/contexts" + "git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/exportcenter" "git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/xgorm" "git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/xgorm/handler" "git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/xgorm/hook" "git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/model" "git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/service" "git.5iot.com/iotlcm-public/iotlcm-admin-svr/utility/convert" - "git.5iot.com/iotlcm-public/iotlcm-admin-svr/utility/excel" "github.com/gogf/gf/v2/database/gdb" "github.com/gogf/gf/v2/errors/gerror" "github.com/gogf/gf/v2/frame/g" - "github.com/gogf/gf/v2/os/gctx" "github.com/gogf/gf/v2/os/gtime" "github.com/gogf/gf/v2/text/gstr" "github.com/gogf/gf/v2/util/gconv" @@ -120,30 +118,32 @@ func (s *sAgentTApiResource) List(ctx context.Context, in *model.TApiResourceLis return } -// Export 导出开放接口资源列表。 +// Export 导出开放接口资源(异步:提交任务后到下载中心取文件) func (s *sAgentTApiResource) Export(ctx context.Context, in *model.TApiResourceListParam) (err error) { - list, totalCount, err := s.List(ctx, in) - if err != nil { - return - } - - tags, err := convert.GetEntityDescTags(model.TApiResourceExportModel{}) - if err != nil { - return - } - - fileName := "export-open-api-resource-" + gctx.CtxId(ctx) - totalPage := 0 - if in.PageSize > 0 { - totalPage = (totalCount + in.PageSize - 1) / in.PageSize - } - sheetName := fmt.Sprintf("total %v pages %v current %v count %v", totalCount, totalPage, in.Page, len(list)) - - var exports []*model.TApiResourceExportModel - if err = gconv.Scan(list, &exports); err != nil { - return - } - return excel.ExportByStructs(ctx, tags, exports, fileName, sheetName) + _, err = exportcenter.Submit(ctx, &exportcenter.Request{ + BizKey: "tApiResource", + BizName: "开放接口资源", + PermCode: "cpm:agent:tApiResource:export", + QueryParam: in, + Tags: func() ([]string, error) { + return convert.GetEntityDescTags(model.TApiResourceListModel{}) + }, + Fetch: func(ctx context.Context, page, pageSize int) (rows []interface{}, total int, err error) { + query := *in + query.Page = page + query.PageSize = pageSize + list, count, err := s.List(ctx, &query) + if err != nil { + return nil, 0, err + } + rows = make([]interface{}, 0, len(list)) + for _, item := range list { + rows = append(rows, item) + } + return rows, count, nil + }, + }) + return err } // Edit 新增或修改开放接口资源。 diff --git a/server/internal/logic/agent/t_cust_withdraw_account.go b/server/internal/logic/agent/t_cust_withdraw_account.go index 9f0aa12..da5c243 100644 --- a/server/internal/logic/agent/t_cust_withdraw_account.go +++ b/server/internal/logic/agent/t_cust_withdraw_account.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 agent diff --git a/server/internal/logic/common/batch_card_status_test.go b/server/internal/logic/common/batch_card_status_test.go new file mode 100644 index 0000000..746c169 --- /dev/null +++ b/server/internal/logic/common/batch_card_status_test.go @@ -0,0 +1,59 @@ +package common + +import ( + "fmt" + "testing" + + "git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/consts" + "git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/model" + "git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/model/request" + + "github.com/gogf/gf/v2/frame/g" +) + +func TestBatchCardsFilterStatus(t *testing.T) { + ctx := setupBatchExecutionLogListDB(t) + for _, statement := range []string{ + "ALTER TABLE batch_main ADD target_dept_id INTEGER DEFAULT 0", + "ALTER TABLE batch_main ADD dept_id INTEGER DEFAULT 0", + "ALTER TABLE batch_main ADD updated_by INTEGER DEFAULT 0", + "ALTER TABLE batch_main ADD batch_async_status INTEGER DEFAULT 3", + } { + if _, err := g.DB().Exec(ctx, statement); err != nil { + t.Fatal(err) + } + } + for _, batchType := range []int{consts.BatchTypeInbound, consts.BatchTypeOutbound, consts.BatchTypeAllocate} { + batchNo := fmt.Sprintf("STATUS-%d", batchType) + if _, err := g.DB().Exec(ctx, "INSERT INTO batch_main(batch_no,batch_type,batch_info) VALUES(?,?,'{}')", batchNo, batchType); err != nil { + t.Fatal(err) + } + for _, status := range []int{0, 1, 2, 3, 4, 5} { + if _, err := g.DB().Exec(ctx, "INSERT INTO batch_detail(batch_no,batch_type,detail_status,row_no,item_id,error_reason,snapshot) VALUES(?,?,?,?,?,?,'{}')", batchNo, batchType, status, status+1, fmt.Sprintf("card-%d", status), fmt.Sprintf("reason-%d", status)); err != nil { + t.Fatal(err) + } + } + for _, status := range []int{0, 1, 2, 3, 4, 5} { + rows, total, err := NewCommonBatchMain().ListCards(ctx, &model.BatchCardListParam{BatchNo: batchNo, DetailStatus: &status, PageInfo: request.PageInfo{Page: 1, PageSize: 20}}) + if err != nil || total != 1 || len(rows) != 1 { + t.Fatalf("type=%d status=%d total=%d err=%v", batchType, status, total, err) + } + if rows[0].DetailStatus != status || rows[0].ErrorReason != fmt.Sprintf("reason-%d", status) { + t.Fatalf("incorrect detail: %+v", rows[0]) + } + } + for page, wantStatus := range []int{3, 0, 1, 2, 4, 5} { + rows, total, err := NewCommonBatchMain().ListCards(ctx, &model.BatchCardListParam{BatchNo: batchNo, PageInfo: request.PageInfo{Page: page + 1, PageSize: 1}}) + if err != nil || total != 6 || len(rows) != 1 || rows[0].DetailStatus != wantStatus { + t.Fatalf("type=%d page=%d status=%d total=%d err=%v", batchType, page+1, wantStatus, total, err) + } + } + if _, err := g.DB().Exec(ctx, "INSERT INTO batch_detail(batch_no,batch_type,detail_status,row_no,item_id,error_reason,snapshot) VALUES(?,?,?,?,?,?,'{}')", batchNo, batchType, consts.BatchDetailStatusFailed, 0, "cascade", "本批次存在失败明细,整批未执行"); err != nil { + t.Fatal(err) + } + rows, total, err := NewCommonBatchMain().ListCards(ctx, &model.BatchCardListParam{BatchNo: batchNo, PageInfo: request.PageInfo{Page: 1, PageSize: 1}}) + if err != nil || total != 7 || len(rows) != 1 || rows[0].Iccid != "card-3" { + t.Fatalf("type=%d: real failure should precede legacy cascade: rows=%+v total=%d err=%v", batchType, rows, total, err) + } + } +} diff --git a/server/internal/logic/common/batch_execution_log.go b/server/internal/logic/common/batch_execution_log.go index f696b1d..562caed 100644 --- a/server/internal/logic/common/batch_execution_log.go +++ b/server/internal/logic/common/batch_execution_log.go @@ -2,6 +2,8 @@ package common import ( "context" + "database/sql" + "errors" "fmt" "path/filepath" "strconv" @@ -11,6 +13,7 @@ import ( "git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/dao" "git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/allocationunits" "git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/contexts" + "git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/rabbitmq" "git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/xgorm/handler" "git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/xgorm/hook" "git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/model" @@ -20,6 +23,7 @@ import ( "github.com/gogf/gf/v2/database/gdb" "github.com/gogf/gf/v2/encoding/gjson" "github.com/gogf/gf/v2/errors/gerror" + "github.com/gogf/gf/v2/frame/g" "github.com/gogf/gf/v2/os/gtime" ) @@ -94,6 +98,17 @@ func (s *sCommonBatchExecutionLog) ossModel(ctx context.Context, option ...*hand return handler.Model(dao.SysOss.Ctx(ctx), option...) } +// exportTaskModel 导出任务表;导出批次的结果文件只登记在这里。 +func (s *sCommonBatchExecutionLog) exportTaskModel(ctx context.Context, option ...*handler.Option) *gdb.Model { + if len(option) == 0 { + option = append(option, &handler.Option{ + FilterTenant: true, + FilterAuth: false, + }) + } + return handler.Model(dao.TExportTask.Ctx(ctx), option...) +} + func (s *sCommonBatchExecutionLog) List(ctx context.Context, in *model.BatchExecutionLogListParam) (list []*model.BatchExecutionLogListModel, totalCount int, err error) { mod := s.batchModel(ctx). Fields(model.BatchExecutionLogListModel{}) @@ -142,6 +157,8 @@ func (s *sCommonBatchExecutionLog) List(ctx context.Context, in *model.BatchExec } fields := []*allocationunits.Fields{} for _, item := range list { + item.UnknownCount = batchExecutionLogUnknownCount(item.UnitInfo) + item.RestartPhase, item.RestartResumeAt = batchRestartPhase(item.UnitInfo) if item.BatchType == consts.BatchTypeAllocate { item.Fields = allocationunits.Resolve(item.UnitInfo, item.CreatedDept, item.StoredTargetDeptId) fields = append(fields, &item.Fields) @@ -187,9 +204,11 @@ func (s *sCommonBatchExecutionLog) Summary(ctx context.Context, in *model.BatchE RunResult: batch.RunResult, FailReason: batch.FailReason, TotalCount: batch.TotalCount, + UnknownCount: batchExecutionLogUnknownCount(batch.BatchInfo), StartTime: batch.StartTime, EndTime: batch.EndTime, } + res.RestartPhase, res.RestartResumeAt = batchRestartPhase(batch.BatchInfo) if batch.BatchType == consts.BatchTypeAllocate { res.Fields = allocationunits.Resolve(batch.BatchInfo, batch.CreatedDept, batch.TargetDeptId) @@ -204,11 +223,17 @@ func (s *sCommonBatchExecutionLog) Summary(ctx context.Context, in *model.BatchE res.SourceFile = sourceFile } - if resultFile, err := s.getResultFile(ctx, batch.BatchNo, batch.ResultOssId, batch.BatchInfo); err != nil { - return nil, err + // 导出任务的成品文件落在 t_export_task,不写 t_resource_batch_download,须分开取。 + var resultFile *model.BatchExecutionLogResultFileModel + if batch.BatchType == consts.BatchTypeExport { + resultFile, err = s.getExportResultFile(ctx, batch.BatchNo, batch.ResultOssId) } else { - res.ResultFile = resultFile + resultFile, err = s.getResultFile(ctx, batch.BatchNo, batch.ResultOssId, batch.BatchInfo) } + if err != nil { + return nil, err + } + res.ResultFile = resultFile return res, nil } @@ -263,7 +288,7 @@ func (s *sCommonBatchExecutionLog) DetailList(ctx context.Context, in *model.Bat } var details []*entity.BatchDetail - mod = mod.Page(in.Page, in.PageSize).OrderAsc(columns.RowNo) + mod = orderBatchCards(mod.Page(in.Page, in.PageSize), batch.BatchType) if err = mod.ScanAndCount(&details, &totalCount, false); err != nil { return nil, 0, gerror.Wrap(err, "获取批次执行明细列表失败,请稍后重试") } @@ -328,6 +353,65 @@ func (s *sCommonBatchExecutionLog) DetailList(ctx context.Context, in *model.Bat return } +// dataReviewQueryMessage 通知 Worker 对既有 operationId 做一次只读补查。 +type dataReviewQueryMessage struct { + BatchNo string `json:"batchNo"` + RequestedBy string `json:"requestedBy,omitempty"` +} + +var publishDataReviewQuery = rabbitmq.Publish + +// SyncReview 手动同步待核查批次的上游执行结果。 +// 只重查既有受理号(jobId),不重发办理请求、不清零自动补查计数; +// 上游仍无结论的明细继续保持待核查。 +func (s *sCommonBatchExecutionLog) SyncReview(ctx context.Context, in *model.BatchExecutionLogSyncReviewParam) (res *model.BatchExecutionLogSyncReviewModel, err error) { + if in == nil { + in = &model.BatchExecutionLogSyncReviewParam{} + } + batchNo := strings.TrimSpace(in.BatchNo) + reason := strings.TrimSpace(in.Reason) + if batchNo == "" { + return nil, gerror.New("批次编号不能为空") + } + + columns := dao.BatchMain.Columns() + batch := &entity.BatchMain{} + // 沿用执行日志列表的可见性口径(部门权限过滤)。 + if err = s.batchModel(ctx). + Fields(columns.Id, columns.BatchNo, columns.BatchType, columns.BatchAsyncStatus, columns.RunResult, columns.BatchInfo). + Where(columns.BatchNo, batchNo). + Scan(batch); err != nil { + return nil, gerror.Wrap(err, "查询批次信息失败") + } + if batch.Id == 0 { + return nil, gerror.New("批次不存在或无权限") + } + if batch.BatchAsyncStatus != consts.BatchAsyncStatusReview { + return nil, gerror.New("仅待核查批次支持手动同步上游结果") + } + unknownCount := batchExecutionLogUnknownCount(batch.BatchInfo) + if unknownCount <= 0 { + return nil, gerror.New("该批次没有待核查明细") + } + + message := dataReviewQueryMessage{BatchNo: batch.BatchNo, RequestedBy: contexts.GetUserName(ctx)} + if err = publishDataReviewQuery(ctx, consts.QueueDataReviewQuery, message); err != nil { + g.Log().Errorf(ctx, "手动同步上游结果消息发布失败, batchNo=%s, tenantId=%s, userId=%d, reason=%s, err=%+v", + batch.BatchNo, contexts.GetTenantId(ctx), contexts.GetUserId(ctx), reason, err) + return nil, gerror.Wrap(err, "发布上游结果同步消息失败") + } + g.Log().Infof(ctx, "手动同步上游结果消息已发布, batchNo=%s, batchType=%d, unknownCount=%d, tenantId=%s, userId=%d, reason=%s", + batch.BatchNo, batch.BatchType, unknownCount, contexts.GetTenantId(ctx), contexts.GetUserId(ctx), reason) + + return &model.BatchExecutionLogSyncReviewModel{ + BatchNo: batch.BatchNo, + BatchType: batch.BatchType, + BatchAsyncStatus: batch.BatchAsyncStatus, + UnknownCount: unknownCount, + Queued: true, + }, nil +} + func (s *sCommonBatchExecutionLog) getDetailBatch(ctx context.Context, batchNo string, batchType *int) (*entity.BatchMain, error) { if batchNo == "" { return nil, gerror.New("批次号不能为空") @@ -471,11 +555,13 @@ func (s *sCommonBatchExecutionLog) getSourceFile(ctx context.Context, sourceOssI func (s *sCommonBatchExecutionLog) getResultFile(ctx context.Context, batchNo string, resultOssId int64, batchInfo *gjson.Json) (*model.BatchExecutionLogResultFileModel, error) { download := &entity.TResourceBatchDownload{} + // 出库/划拨/导出等批次不写 t_resource_batch_download,查不到记录按「无结果文件」处理, + // 不能当异常抛给页面(否则详情弹窗整体报 sql: no rows in result set)。 if err := s.downloadModel(ctx). Where(dao.TResourceBatchDownload.Columns().BatchNo, batchNo). OrderDesc(dao.TResourceBatchDownload.Columns().Id). Limit(1). - Scan(download); err != nil { + Scan(download); err != nil && !errors.Is(err, sql.ErrNoRows) { return nil, gerror.Wrap(err, "获取结果文件信息失败,请稍后重试!") } ossId := resultOssId @@ -490,14 +576,16 @@ func (s *sCommonBatchExecutionLog) getResultFile(ctx context.Context, batchNo st } url := batchExecutionLogFileURL(ctx, download.FileUrl, download.FilePath) + originalName := "" if ossId > 0 { oss := &entity.SysOss{} - if err := s.ossModel(ctx). + if scanErr := s.ossModel(ctx). Where(dao.SysOss.Columns().OssId, ossId). - Scan(oss); err != nil { - return nil, gerror.Wrap(err, "获取结果文件OSS信息失败,请稍后重试!") + Scan(oss); scanErr != nil && !errors.Is(scanErr, sql.ErrNoRows) { + return nil, gerror.Wrap(scanErr, "获取结果文件OSS信息失败,请稍后重试!") } if oss.OssId > 0 { + originalName = oss.OriginalName if ossUrl := batchExecutionLogFileURL(ctx, oss.Url, oss.Path); ossUrl != "" { url = ossUrl } @@ -508,10 +596,49 @@ func (s *sCommonBatchExecutionLog) getResultFile(ctx context.Context, batchNo st DownloadId: download.Id, OssId: ossId, Url: url, + OriginalName: originalName, DownloadStatus: resultDownloadStatus(download), }, nil } +// getExportResultFile 只暴露任务 ID;文件必须经下载中心重新校验本人、权限和有效期。 +func (s *sCommonBatchExecutionLog) getExportResultFile(ctx context.Context, taskNo string, _ int64) (*model.BatchExecutionLogResultFileModel, error) { + task := &entity.TExportTask{} + if err := s.exportTaskModel(ctx). + Where(dao.TExportTask.Columns().TaskNo, taskNo). + Where(dao.TExportTask.Columns().OwnerUserId, contexts.GetUserId(ctx)). + Scan(task); err != nil && !errors.Is(err, sql.ErrNoRows) { + return nil, gerror.Wrap(err, "获取结果文件信息失败,请稍后重试!") + } + if task.Id <= 0 || task.DeletedTime != nil || task.Status != consts.DownloadStatusGenerated || task.OssId <= 0 || + (task.ExpireTime != nil && task.ExpireTime.Before(gtime.Now())) || !hasExportPermission(ctx, task.PermCode) { + return nil, nil + } + return &model.BatchExecutionLogResultFileModel{ + ExportTaskId: task.Id, + OriginalName: task.FileName, + DownloadStatus: consts.DownloadStatusGenerated, + }, nil +} + +func batchExecutionLogUnknownCount(batchInfo *gjson.Json) int { + if batchInfo == nil { + return 0 + } + return batchInfo.Get("apnUnknownCount").Int() +} + +// batchRestartPhase 从 batch_info 取出重启阶段与计划复机时间。 +// 重启批次停机完成后要等待若干分钟再复机,这段时间主状态仍是处理中, +// 明细却已落终态;前端据此显示「等待复机」,避免误以为卡住或已办结。 +func batchRestartPhase(batchInfo *gjson.Json) (phase, resumeAt string) { + if batchInfo == nil { + return "", "" + } + return strings.TrimSpace(batchInfo.Get("restart.phase").String()), + strings.TrimSpace(batchInfo.Get("restart.resumeAt").String()) +} + func legacySourceOssId(batchInfo *gjson.Json) int64 { if batchInfo == nil { return 0 diff --git a/server/internal/logic/common/batch_execution_log_test.go b/server/internal/logic/common/batch_execution_log_test.go new file mode 100644 index 0000000..60b30f3 --- /dev/null +++ b/server/internal/logic/common/batch_execution_log_test.go @@ -0,0 +1,499 @@ +package common + +import ( + "context" + "encoding/json" + "testing" + + "git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/consts" + "git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/model" + "git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/model/request" + + _ "github.com/gogf/gf/contrib/drivers/sqlite/v2" + "github.com/gogf/gf/v2/database/gdb" + "github.com/gogf/gf/v2/frame/g" +) + +func TestBatchExecutionLogDetailListPreservesUnknownStatusAndReason(t *testing.T) { + ctx := setupBatchExecutionLogListDB(t) + insertBatchExecutionLogDetail(t, ctx, "DATA-REVIEW", consts.BatchTypeDataStart, consts.BatchSourceTypeBatchInput, 1, "test-review-card") + const reason = "上游已受理,补查超时,执行结果待核查" + if _, err := g.DB().Exec(ctx, "UPDATE batch_detail SET detail_status = ?, error_reason = ? WHERE batch_no = ?", consts.BatchDetailStatusUnknown, reason, "DATA-REVIEW"); err != nil { + t.Fatal(err) + } + status := consts.BatchDetailStatusUnknown + list, total, err := NewCommonBatchExecutionLog().DetailList(ctx, &model.BatchDetailListParam{ + BatchNo: "DATA-REVIEW", DetailStatus: &status, + }) + if err != nil || total != 1 || len(list) != 1 { + t.Fatalf("读取待核查明细失败:total=%d,明细数=%d,错误:%v", total, len(list), err) + } + if list[0].DetailStatus != consts.BatchDetailStatusUnknown || list[0].Error == nil || list[0].Error.Reason != reason { + t.Fatalf("待核查状态或具体原因未保留:%+v", list[0]) + } + status = consts.BatchDetailStatusFailed + list, total, err = NewCommonBatchExecutionLog().DetailList(ctx, &model.BatchDetailListParam{ + BatchNo: "DATA-REVIEW", DetailStatus: &status, + }) + if err != nil || total != 0 || len(list) != 0 { + t.Fatalf("待核查明细被计入失败筛选:total=%d,明细数=%d,错误:%v", total, len(list), err) + } +} + +func TestBatchExecutionLogListPreservesReviewStatusAndResult(t *testing.T) { + ctx := setupBatchExecutionLogListDB(t) + if _, err := g.DB().Exec(ctx, "ALTER TABLE batch_main ADD batch_async_status INTEGER NOT NULL DEFAULT 0"); err != nil { + t.Fatal(err) + } + if _, err := g.DB().Exec(ctx, "INSERT INTO batch_main(batch_no, batch_type, batch_async_status, run_result) VALUES (?, ?, ?, ?), (?, ?, ?, ?)", + "DATA-REVIEW", consts.BatchTypeDataStart, consts.BatchAsyncStatusReview, consts.BatchRunResultReview, + "DATA-FAILED", consts.BatchTypeDataStop, consts.BatchAsyncStatusFailed, consts.BatchRunResultFailed, + ); err != nil { + t.Fatal(err) + } + status := consts.BatchAsyncStatusReview + list, total, err := NewCommonBatchExecutionLog().List(ctx, &model.BatchExecutionLogListParam{ + BatchAsyncStatus: &status, PageInfo: request.PageInfo{Page: 1, PageSize: 20}, + }) + if err != nil || total != 1 || len(list) != 1 { + t.Fatalf("读取待核查批次失败:total=%d,批次数=%d,错误:%v", total, len(list), err) + } + if list[0].BatchNo != "DATA-REVIEW" || list[0].BatchAsyncStatus != consts.BatchAsyncStatusReview || list[0].RunResult != consts.BatchRunResultReview { + t.Fatalf("待核查父状态或运行结果未保留:%+v", list[0]) + } +} + +func TestBatchExecutionLogUnknownCountFromBatchInfo(t *testing.T) { + ctx := setupBatchExecutionLogListDB(t) + for _, statement := range []string{ + `ALTER TABLE batch_main ADD COLUMN target_dept_id INTEGER NOT NULL DEFAULT 0`, + `ALTER TABLE batch_main ADD COLUMN batch_async_status INTEGER NOT NULL DEFAULT 0`, + `ALTER TABLE batch_main ADD COLUMN fail_reason TEXT NOT NULL DEFAULT ''`, + `ALTER TABLE batch_main ADD COLUMN source_oss_id INTEGER NOT NULL DEFAULT 0`, + `ALTER TABLE batch_main ADD COLUMN result_oss_id INTEGER NOT NULL DEFAULT 0`, + `CREATE TABLE t_resource_batch_download (id INTEGER PRIMARY KEY, batch_no TEXT)`, + `INSERT INTO t_resource_batch_download(id, batch_no) VALUES (1, 'DATA-REVIEW'), (2, 'DATA-LEGACY')`, + `INSERT INTO batch_main(batch_no, batch_type, batch_info, total_count) VALUES ('DATA-REVIEW', 109, '{"apnUnknownCount":2}', 3), ('DATA-LEGACY', 109, '{}', 1)`, + } { + if _, err := g.DB().Exec(ctx, statement); err != nil { + t.Fatal(err) + } + } + list, total, err := NewCommonBatchExecutionLog().List(ctx, &model.BatchExecutionLogListParam{PageInfo: request.PageInfo{Page: 1, PageSize: 20}}) + if err != nil || total != 2 || len(list) != 2 { + t.Fatalf("读取未知计数列表失败: total=%d len=%d err=%v", total, len(list), err) + } + counts := map[string]int{} + for _, item := range list { + counts[item.BatchNo] = item.UnknownCount + } + if counts["DATA-REVIEW"] != 2 || counts["DATA-LEGACY"] != 0 { + t.Fatalf("列表待核查数错误: %+v", counts) + } + for batchNo, want := range map[string]int{"DATA-REVIEW": 2, "DATA-LEGACY": 0} { + summary, err := NewCommonBatchExecutionLog().Summary(ctx, &model.BatchExecutionLogDetailParam{BatchNo: batchNo}) + if err != nil || summary == nil || summary.UnknownCount != want { + t.Fatalf("概要 %s 待核查数错误: summary=%+v err=%v", batchNo, summary, err) + } + } +} + +func TestBatchExecutionLogResultFilePreservesOriginalName(t *testing.T) { + ctx := setupBatchExecutionLogListDB(t) + for _, statement := range []string{ + `CREATE TABLE t_resource_batch_download (id INTEGER PRIMARY KEY, batch_no TEXT, oss_id INTEGER, file_url TEXT, file_path TEXT)`, + `CREATE TABLE sys_oss (oss_id INTEGER PRIMARY KEY, original_name TEXT, url TEXT, path TEXT)`, + `INSERT INTO t_resource_batch_download(id, batch_no, oss_id, file_url, file_path) VALUES (1, 'DATA-REVIEW', 1, '/upload/DATA-REVIEW-result-nonce.xlsx', ''), (2, 'DATA-LEGACY', 2, '/upload/legacy.xlsx', '')`, + `INSERT INTO sys_oss(oss_id, original_name, url, path) VALUES (1, 'DATA-REVIEW-result.xlsx', '/upload/DATA-REVIEW-result-nonce.xlsx', ''), (2, '', '/upload/legacy.xlsx', '')`, + } { + if _, err := g.DB().Exec(ctx, statement); err != nil { + t.Fatal(err) + } + } + for batchNo, want := range map[string]string{"DATA-REVIEW": "DATA-REVIEW-result.xlsx", "DATA-LEGACY": ""} { + result, err := NewCommonBatchExecutionLog().getResultFile(ctx, batchNo, 0, nil) + if err != nil || result == nil || result.OriginalName != want { + t.Fatalf("原始下载名未透传: batch=%s result=%+v err=%v", batchNo, result, err) + } + encoded, err := json.Marshal(result) + if err != nil { + t.Fatal(err) + } + var response map[string]any + if err := json.Unmarshal(encoded, &response); err != nil || response["originalName"] != want { + t.Fatalf("响应JSON丢失原始下载名: %s err=%v", encoded, err) + } + if batchNo == "DATA-REVIEW" && result.Url != "/upload/DATA-REVIEW-result-nonce.xlsx" { + t.Fatalf("原始下载名影响了物理文件定位: %s", result.Url) + } + } +} + +func TestBatchExecutionLogListReturnsTotalCount(t *testing.T) { + ctx := setupBatchExecutionLogListDB(t) + if _, err := g.DB().Exec(ctx, ` + INSERT INTO batch_main( + batch_no, batch_type, source_type, total_count, run_result, + created_dept, created_by, tenant_id + ) VALUES(?, ?, ?, ?, ?, ?, ?, ?) + `, "OUT-1", 2, 0, 7, 2, 0, 0, ""); err != nil { + t.Fatalf("insert batch: %v", err) + } + + list, total, err := NewCommonBatchExecutionLog().List(ctx, &model.BatchExecutionLogListParam{ + PageInfo: request.PageInfo{Page: 1, PageSize: 20}, + }) + if err != nil { + t.Fatalf("list execution logs: %v", err) + } + if total != 1 { + t.Fatalf("total rows = %d, want 1", total) + } + if len(list) != 1 { + t.Fatalf("list length = %d, want 1", len(list)) + } + if list[0].TotalCount != 7 { + t.Fatalf("totalCount = %d, want 7", list[0].TotalCount) + } +} + +func TestAllocateDetailInheritsHistoricalUnits(t *testing.T) { + ctx := setupBatchExecutionLogListDB(t) + for _, statement := range []string{ + `ALTER TABLE batch_main ADD target_dept_id INTEGER DEFAULT 0`, + `INSERT INTO batch_main(batch_no,batch_type,batch_info,created_dept,target_dept_id) VALUES('ALC-HISTORY',3,'{"sourceDeptId":10,"targetDeptId":20,"sourceDeptName":"历史划拨单位","targetDeptName":"历史接收单位"}',10,20)`, + `INSERT INTO batch_detail(batch_no,batch_type,item_id,snapshot) VALUES('ALC-HISTORY',3,'card','{}')`, + } { + if _, err := g.DB().Exec(ctx, statement); err != nil { + t.Fatal(err) + } + } + rows, total, err := NewCommonBatchExecutionLog().DetailList(ctx, &model.BatchDetailListParam{BatchNo: "ALC-HISTORY"}) + if err != nil || total != 1 || len(rows) != 1 { + t.Fatal("读取历史明细失败", total, err) + } + if rows[0].SourceDeptName != "历史划拨单位" || rows[0].TargetDeptName != "历史接收单位" { + t.Fatal("历史明细未优先继承批次快照", rows[0]) + } +} + +func TestBatchExecutionLogDetailListNormalizesFileRowNo(t *testing.T) { + ctx := setupBatchExecutionLogListDB(t) + insertBatchExecutionLogDetail(t, ctx, "INB-1", consts.BatchTypeInbound, consts.BatchSourceTypeFile, 2, "89860000000000000001") + + list, total, err := NewCommonBatchExecutionLog().DetailList(ctx, &model.BatchDetailListParam{ + BatchNo: "INB-1", + }) + if err != nil { + t.Fatalf("detail list: %v", err) + } + if total != 1 { + t.Fatalf("total rows = %d, want 1", total) + } + if len(list) != 1 { + t.Fatalf("list length = %d, want 1", len(list)) + } + if list[0].RowNo != 1 { + t.Fatalf("file batch rowNo = %d, want 1", list[0].RowNo) + } +} + +func TestBatchExecutionLogDetailListNormalizesManualRowNo(t *testing.T) { + ctx := setupBatchExecutionLogListDB(t) + insertBatchExecutionLogDetail(t, ctx, "INB-2", consts.BatchTypeInbound, consts.BatchSourceTypeBatchInput, 2, "89860000000000000002") + + list, total, err := NewCommonBatchExecutionLog().DetailList(ctx, &model.BatchDetailListParam{ + BatchNo: "INB-2", + }) + if err != nil { + t.Fatalf("detail list: %v", err) + } + if total != 1 { + t.Fatalf("total rows = %d, want 1", total) + } + if len(list) != 1 { + t.Fatalf("list length = %d, want 1", len(list)) + } + if list[0].RowNo != 1 { + t.Fatalf("manual batch rowNo = %d, want 1", list[0].RowNo) + } +} + +func TestBatchExecutionLogDetailListNumbersPages(t *testing.T) { + ctx := setupBatchExecutionLogListDB(t) + insertBatchExecutionLogDetail(t, ctx, "ROWS", consts.BatchTypeInbound, consts.BatchSourceTypeBatchSelect, 2, "first") + _, err := g.DB().Exec(ctx, "INSERT INTO batch_detail(batch_no, batch_type, detail_status, row_no, item_id) VALUES (?, ?, ?, ?, ?)", "ROWS", consts.BatchTypeInbound, consts.BatchDetailStatusFailed, 9, "second") + if err != nil { + t.Fatal(err) + } + list, total, err := NewCommonBatchExecutionLog().DetailList(ctx, &model.BatchDetailListParam{BatchNo: "ROWS", PageInfo: request.PageInfo{Page: 2, PageSize: 1}}) + if err != nil || total != 2 || len(list) != 1 || list[0].RowNo != 2 || list[0].Iccid != "first" { + t.Fatalf("paged rows: list=%+v total=%d err=%v", list, total, err) + } + list, total, err = NewCommonBatchExecutionLog().DetailList(ctx, &model.BatchDetailListParam{BatchNo: "ROWS", PageInfo: request.PageInfo{Page: 1, PageSize: 1}}) + if err != nil || total != 2 || len(list) != 1 || list[0].Iccid != "second" { + t.Fatalf("failed card not first: list=%+v total=%d err=%v", list, total, err) + } + failed := consts.BatchDetailStatusFailed + list, total, err = NewCommonBatchExecutionLog().DetailList(ctx, &model.BatchDetailListParam{BatchNo: "ROWS", DetailStatus: &failed, PageInfo: request.PageInfo{Page: 1, PageSize: 1}}) + if err != nil || total != 1 || len(list) != 1 || list[0].RowNo != 1 { + t.Fatalf("filtered rows: list=%+v total=%d err=%v", list, total, err) + } +} + +func TestBatchExecutionLogDetailListPrioritizesRealFailureOverCascade(t *testing.T) { + ctx := setupBatchExecutionLogListDB(t) + insertBatchExecutionLogDetail(t, ctx, "PRIORITY", consts.BatchTypeAllocate, consts.BatchSourceTypeBatchInput, 1, "cascade") + if _, err := g.DB().Exec(ctx, "UPDATE batch_detail SET detail_status = ?, error_reason = ? WHERE batch_no = ?", consts.BatchDetailStatusFailed, "本批次存在失败明细,整批未执行", "PRIORITY"); err != nil { + t.Fatal(err) + } + if _, err := g.DB().Exec(ctx, "INSERT INTO batch_detail(batch_no,batch_type,detail_status,row_no,item_id,error_reason) VALUES(?,?,?,?,?,?)", "PRIORITY", consts.BatchTypeAllocate, consts.BatchDetailStatusFailed, 2, "actual-error", "ICCID不在当前操作员授权范围内"); err != nil { + t.Fatal(err) + } + for page, want := range []string{"actual-error", "cascade"} { + rows, total, err := NewCommonBatchExecutionLog().DetailList(ctx, &model.BatchDetailListParam{BatchNo: "PRIORITY", PageInfo: request.PageInfo{Page: page + 1, PageSize: 1}}) + if err != nil || total != 2 || len(rows) != 1 || rows[0].Iccid != want { + t.Fatalf("page=%d want=%s rows=%+v total=%d err=%v", page+1, want, rows, total, err) + } + } +} + +func setupBatchExecutionLogListDB(t *testing.T) context.Context { + t.Helper() + ctx := context.Background() + if err := gdb.SetConfig(gdb.Config{ + "default": gdb.ConfigGroup{{ + Type: "sqlite", + Link: "sqlite::@file(:memory:)", + MaxOpenConnCount: 1, + }}, + }); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { + _ = g.DB().Close(ctx) + }) + if err := g.DB().GetCore().ClearTableFieldsAll(ctx); err != nil { + t.Fatal(err) + } + + if _, err := g.DB().Exec(ctx, ` + CREATE TABLE batch_main ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + batch_no TEXT NOT NULL, + batch_type INTEGER NOT NULL, + source_batch_no TEXT NOT NULL DEFAULT '', + source_type INTEGER, + source_channel TEXT NOT NULL DEFAULT 'legacy', execution_mode INTEGER NOT NULL DEFAULT 0, + batch_info TEXT, + total_count INTEGER NOT NULL DEFAULT 0, + run_result INTEGER NOT NULL DEFAULT 0, + start_time TEXT, + end_time TEXT, + created_dept INTEGER NOT NULL DEFAULT 0, + created_by INTEGER NOT NULL DEFAULT 0, + created_at TEXT, + tenant_id TEXT NOT NULL DEFAULT '' + ) + `); err != nil { + t.Fatal(err) + } + if _, err := g.DB().Exec(ctx, ` + CREATE TABLE batch_detail ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + batch_no TEXT NOT NULL, + batch_type INTEGER NOT NULL, + detail_status INTEGER NOT NULL DEFAULT 0, + row_no INTEGER NOT NULL DEFAULT 0, + item_id TEXT NOT NULL DEFAULT '', + error_reason TEXT NOT NULL DEFAULT '', + snapshot TEXT, + tenant_id TEXT NOT NULL DEFAULT '', + created_dept INTEGER NOT NULL DEFAULT 0, + created_by INTEGER NOT NULL DEFAULT 0 + ) + `); err != nil { + t.Fatal(err) + } + return ctx +} + +func TestBatchExecutionLogListHidesAllocateChildBatches(t *testing.T) { + ctx := setupBatchExecutionLogListDB(t) + if _, err := g.DB().Exec(ctx, ` + INSERT INTO batch_main( + batch_no, batch_type, source_batch_no, source_type, batch_info, total_count, + run_result, created_dept, created_by, tenant_id + ) VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?), (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `, + "ALC-PARENT", consts.BatchTypeAllocate, "", consts.BatchSourceTypeBatchInput, `{"jobRole":"allocate_parent"}`, 2, consts.BatchRunResultRunning, 0, 0, "", + "ALC-CHILD", consts.BatchTypeAllocate, "ALC-PARENT", consts.BatchSourceTypeBatchInput, `{"jobRole":"allocate_chunk"}`, 2, consts.BatchRunResultRunning, 0, 0, "", + ); err != nil { + t.Fatalf("insert allocate batches: %v", err) + } + + list, total, err := NewCommonBatchExecutionLog().List(ctx, &model.BatchExecutionLogListParam{ + PageInfo: request.PageInfo{Page: 1, PageSize: 20}, + }) + if err != nil { + t.Fatalf("list execution logs: %v", err) + } + if total != 1 || len(list) != 1 { + t.Fatalf("allocate child batch was not hidden: total=%d list=%d", total, len(list)) + } + if list[0].BatchNo != "ALC-PARENT" { + t.Fatalf("visible batch = %q, want parent", list[0].BatchNo) + } + if _, err := NewCommonBatchExecutionLog().Summary(ctx, &model.BatchExecutionLogDetailParam{BatchNo: "ALC-CHILD"}); err == nil { + t.Fatal("allocate child was exposed by summary") + } + if _, _, err := NewCommonBatchExecutionLog().DetailList(ctx, &model.BatchDetailListParam{BatchNo: "ALC-CHILD"}); err == nil { + t.Fatal("allocate child was exposed by detail list") + } +} + +func TestBatchExecutionLogDetailListShowsAllocateChildFailureReason(t *testing.T) { + ctx := setupBatchExecutionLogListDB(t) + if _, err := g.DB().Exec(ctx, ` + INSERT INTO batch_main(batch_no, batch_type, source_batch_no, source_type, batch_info, total_count, run_result, created_dept, created_by, tenant_id) + VALUES (?, ?, '', ?, ?, 2, ?, 0, 0, ''), (?, ?, ?, ?, ?, 2, ?, 0, 0, '') + `, "ALC-PARENT", consts.BatchTypeAllocate, consts.BatchSourceTypeBatchInput, `{"jobRole":"allocate_parent"}`, consts.BatchRunResultFailed, + "ALC-CHILD", consts.BatchTypeAllocate, "ALC-PARENT", consts.BatchSourceTypeBatchInput, `{"jobRole":"allocate_chunk"}`, consts.BatchRunResultFailed); err != nil { + t.Fatalf("insert allocate batches: %v", err) + } + if _, err := g.DB().Exec(ctx, ` + INSERT INTO batch_detail(batch_no, batch_type, detail_status, row_no, item_id, error_reason, snapshot, tenant_id, created_dept, created_by) + VALUES (?, ?, ?, 1, '89860000000000000001', ?, '{}', '', 0, 0), (?, ?, ?, 2, '89860000000000000002', '', '{}', '', 0, 0) + `, "ALC-CHILD", consts.BatchTypeAllocate, consts.BatchDetailStatusFailed, "套餐已过期,不能划拨", + "ALC-CHILD", consts.BatchTypeAllocate, consts.BatchDetailStatusSuccess); err != nil { + t.Fatalf("insert allocate details: %v", err) + } + failed := consts.BatchDetailStatusFailed + list, total, err := NewCommonBatchExecutionLog().DetailList(ctx, &model.BatchDetailListParam{ + PageInfo: request.PageInfo{Page: 1, PageSize: 20}, + BatchNo: "ALC-PARENT", + DetailStatus: &failed, + }) + if err != nil { + t.Fatalf("list parent failure details: %v", err) + } + if total != 1 || len(list) != 1 { + t.Fatalf("failed detail count = %d, list length = %d, want 1", total, len(list)) + } + if list[0].Iccid != "89860000000000000001" || list[0].Error == nil || list[0].Error.Reason != "套餐已过期,不能划拨" { + t.Fatalf("unexpected failure detail: %+v", list[0]) + } +} + +func TestBatchExecutionLogDetailListAggregatesAllAllocateChildren(t *testing.T) { + ctx := setupBatchExecutionLogListDB(t) + if _, err := g.DB().Exec(ctx, ` + INSERT INTO batch_main(batch_no, batch_type, source_batch_no, source_type, batch_info, total_count, run_result, created_dept, created_by, tenant_id) + VALUES (?, ?, '', ?, ?, 1, ?, 0, 0, ''), (?, ?, ?, ?, ?, 1, ?, 0, 0, ''), (?, ?, ?, ?, ?, 1, ?, 0, 0, '') + `, "ALC-PARENT", consts.BatchTypeAllocate, consts.BatchSourceTypeBatchInput, `{"jobRole":"allocate_parent"}`, consts.BatchRunResultFailed, + "ALC-CHILD", consts.BatchTypeAllocate, "ALC-PARENT", consts.BatchSourceTypeBatchInput, `{"jobRole":"allocate_chunk"}`, consts.BatchRunResultFailed, + "ALC-RELATED", consts.BatchTypeAllocate, "ALC-PARENT", consts.BatchSourceTypeBatchInput, `{}`, consts.BatchRunResultFailed); err != nil { + t.Fatalf("insert allocate batches: %v", err) + } + if _, err := g.DB().Exec(ctx, ` + INSERT INTO batch_detail(batch_no, batch_type, detail_status, row_no, item_id, error_reason, snapshot, tenant_id, created_dept, created_by) + VALUES (?, ?, ?, 1, 'parent-card', '', '{}', '', 0, 0), (?, ?, ?, 1, 'chunk-card', '', '{}', '', 0, 0), (?, ?, ?, 1, 'related-card', '', '{}', '', 0, 0) + `, "ALC-PARENT", consts.BatchTypeAllocate, consts.BatchDetailStatusSuccess, + "ALC-CHILD", consts.BatchTypeAllocate, consts.BatchDetailStatusSuccess, + "ALC-RELATED", consts.BatchTypeAllocate, consts.BatchDetailStatusSuccess); err != nil { + t.Fatalf("insert batch details: %v", err) + } + + list, total, err := NewCommonBatchExecutionLog().DetailList(ctx, &model.BatchDetailListParam{BatchNo: "ALC-PARENT"}) + if err != nil || total != 3 || len(list) != 3 { + t.Fatalf("unexpected parent details: list=%+v total=%d err=%v", list, total, err) + } + seen := map[string]bool{} + for _, item := range list { + seen[item.Iccid] = true + } + for _, iccid := range []string{"parent-card", "chunk-card", "related-card"} { + if !seen[iccid] { + t.Fatalf("parent details missing %s: %+v", iccid, list) + } + } +} +func TestBatchExecutionLogSelfReferencedParentAggregatesChildren(t *testing.T) { + ctx := setupBatchExecutionLogListDB(t) + if _, err := g.DB().Exec(ctx, ` + INSERT INTO batch_main(batch_no, batch_type, source_batch_no, batch_info) + VALUES ('SELF-PARENT', 3, 'SELF-PARENT', '{}'), ('SELF-CHILD', 3, 'SELF-PARENT', '{}') + `); err != nil { + t.Fatal(err) + } + if _, err := g.DB().Exec(ctx, ` + INSERT INTO batch_detail(batch_no, batch_type, detail_status, row_no, item_id, snapshot) + VALUES ('SELF-PARENT', 3, 2, 1, 'parent-card', '{}'), ('SELF-CHILD', 3, 2, 2, 'child-card', '{}') + `); err != nil { + t.Fatal(err) + } + list, total, err := NewCommonBatchExecutionLog().DetailList(ctx, &model.BatchDetailListParam{BatchNo: "SELF-PARENT"}) + if err != nil || total != 2 || len(list) != 2 || list[0].Iccid != "parent-card" || list[1].Iccid != "child-card" { + t.Fatalf("self-referenced parent aggregation: list=%+v total=%d err=%v", list, total, err) + } +} + +func insertBatchExecutionLogDetail(t *testing.T, ctx context.Context, batchNo string, batchType, sourceType, rowNo int, itemID string) { + t.Helper() + if _, err := g.DB().Exec(ctx, ` + INSERT INTO batch_main( + batch_no, batch_type, source_type, total_count, run_result, + created_dept, created_by, tenant_id + ) VALUES(?, ?, ?, ?, ?, ?, ?, ?) + `, batchNo, batchType, sourceType, 1, 2, 0, 0, ""); err != nil { + t.Fatalf("insert batch main: %v", err) + } + if _, err := g.DB().Exec(ctx, ` + INSERT INTO batch_detail( + batch_no, batch_type, detail_status, row_no, item_id, snapshot, + tenant_id, created_dept, created_by + ) VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?) + `, batchNo, batchType, consts.BatchDetailStatusSuccess, rowNo, itemID, `{"ICCID":"`+itemID+`"}`, "", 0, 0); err != nil { + t.Fatalf("insert batch detail: %v", err) + } +} + +// 出库等批次不写 t_resource_batch_download,详情查询查不到记录时 +// 必须按「无结果文件」返回,而不是把 sql: no rows in result set 抛给页面。 +func TestBatchExecutionLogSummaryTreatsMissingDownloadAsNoResultFile(t *testing.T) { + ctx := setupBatchExecutionLogListDB(t) + if _, err := g.DB().Exec(ctx, ` + CREATE TABLE t_resource_batch_download ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + batch_no TEXT NOT NULL DEFAULT '', + oss_id INTEGER NOT NULL DEFAULT 0, + file_url TEXT NOT NULL DEFAULT '', + file_path TEXT NOT NULL DEFAULT '', + created_at TEXT, + tenant_id TEXT NOT NULL DEFAULT '', + created_dept INTEGER NOT NULL DEFAULT 0, + created_by INTEGER NOT NULL DEFAULT 0 + ) + `); err != nil { + t.Fatalf("create download table: %v", err) + } + if _, err := g.DB().Exec(ctx, ` + INSERT INTO batch_main( + batch_no, batch_type, source_type, batch_info, total_count, + run_result, created_dept, created_by, tenant_id + ) VALUES(?, ?, ?, ?, 0, ?, 0, 0, '') + `, "OUT-NOFILE", 2, 0, `{}`, consts.BatchRunResultSuccess); err != nil { + t.Fatalf("insert outbound batch: %v", err) + } + + res, err := NewCommonBatchExecutionLog().Summary(ctx, &model.BatchExecutionLogDetailParam{BatchNo: "OUT-NOFILE"}) + if err != nil { + t.Fatalf("summary should not fail when result file is absent: %v", err) + } + if res == nil { + t.Fatal("summary returned nil result") + } + if res.ResultFile != nil { + t.Fatalf("resultFile = %v, want nil when no download record exists", res.ResultFile) + } +} diff --git a/server/internal/logic/common/batch_main.go b/server/internal/logic/common/batch_main.go index bb4653f..dd8aeb3 100644 --- a/server/internal/logic/common/batch_main.go +++ b/server/internal/logic/common/batch_main.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 @@ -16,6 +16,7 @@ import ( "git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/allocationunits" "git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/batchmetadata" "git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/contexts" + "git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/exportcenter" "git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/rabbitmq" "git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/xgorm" "git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/xgorm/handler" @@ -87,6 +88,8 @@ func validateRequeueBatch(batch *entity.BatchMain) error { return nil case consts.BatchAsyncStatusProcessing: return validateProcessingRequeueBatch(batch) + case consts.BatchAsyncStatusReview: + return gerror.New("待核查批次不支持安全重投,请先核对上游执行结果") case consts.BatchAsyncStatusFailed, consts.BatchAsyncStatusSuccess, consts.BatchAsyncStatusPartialSuccess: return gerror.New("终态批次不支持安全重投") default: @@ -248,7 +251,17 @@ func (s *sCommonBatchMain) SuccessList(ctx context.Context, in *model.BatchMainS if in.BatchType != 0 { requestedBatchType = &in.BatchType } - mod = visibleBatchTypeModel(mod, columns.BatchType, requestedBatchType) + mod = successListBatchTypeModel(mod, columns.BatchType, requestedBatchType) + + if chanKey := strings.TrimSpace(in.ChanKey); chanKey != "" { + extract := "JSON_UNQUOTE(JSON_EXTRACT(%s, '%s'))" + if strings.EqualFold(dao.BatchMain.DB().GetConfig().Type, "sqlite") { + extract = "JSON_EXTRACT(%s, '%s')" + } + nested := fmt.Sprintf(extract, columns.BatchInfo, "$.inboundOptions.chanKey") + legacy := fmt.Sprintf(extract, columns.BatchInfo, "$.chanKey") + mod = mod.Where(fmt.Sprintf("CASE WHEN JSON_VALID(%s) THEN COALESCE(NULLIF(TRIM(%s), ''), TRIM(%s)) END = ?", columns.BatchInfo, nested, legacy), chanKey) + } if in.BatchNo != "" { mod = mod.Where(columns.BatchNo+" LIKE ?", "%"+in.BatchNo+"%") @@ -343,6 +356,7 @@ func (s *sCommonBatchMain) ListCards(ctx context.Context, in *model.BatchCardLis } details, totalCount, err := s.listBatchDetails(ctx, batch, batchDetailQuery{ + DetailStatus: in.DetailStatus, BatchNo: in.BatchNo, BatchType: in.BatchType, Iccid: in.Iccid, @@ -370,6 +384,7 @@ func (s *sCommonBatchMain) ListCards(ctx context.Context, in *model.BatchCardLis Msisdn: msisdn, Imsi: imsi, DetailStatus: detail.DetailStatus, + ErrorReason: detail.ErrorReason, } if batch.BatchType == consts.BatchTypeAllocate { item.Fields = allocationunits.Resolve(detail.Snapshot, parentUnits.SourceDeptId, parentUnits.TargetDeptId) @@ -439,13 +454,22 @@ func (s *sCommonBatchMain) listBatchDetails(ctx context.Context, batch *entity.B mod = onlyCurrentStockDetails(mod) } - mod = mod.Page(query.Page, query.PageSize).OrderAsc(columns.RowNo) + mod = orderBatchCards(mod.Page(query.Page, query.PageSize), batch.BatchType) if err = mod.ScanAndCount(&list, &totalCount, false); err != nil { return nil, 0, gerror.Wrap(err, "获取批次明细列表失败,请稍后重试") } return } +func orderBatchCards(mod *gdb.Model, batchType int) *gdb.Model { + columns := dao.BatchDetail.Columns() + if batchType == consts.BatchTypeInbound || batchType == consts.BatchTypeOutbound || batchType == consts.BatchTypeAllocate { + order := fmt.Sprintf("CASE WHEN %s = %d AND COALESCE(%s, '') NOT LIKE '本批%%整批未执行%%' AND COALESCE(%s, '') NOT LIKE '同批%%本行未执行%%' AND COALESCE(%s, '') NOT LIKE '同批%%整批未入库%%' THEN 0 ELSE 1 END, %s ASC, %s ASC", columns.DetailStatus, consts.BatchDetailStatusFailed, columns.ErrorReason, columns.ErrorReason, columns.ErrorReason, columns.RowNo, columns.Id) + return mod.Order(gdb.Raw(order)) + } + return mod.OrderAsc(columns.RowNo).OrderAsc(columns.Id) +} + func onlyCurrentStockDetails(mod *gdb.Model) *gdb.Model { detailTable := dao.BatchDetail.Table() stockTable := dao.TSimCardStock.Table() @@ -508,6 +532,41 @@ func batchDetailSnapshotCards(detail *entity.BatchDetail) (iccid, msisdn, imsi s return } +// ExportWarehousing 导出入库批次列表(异步:提交任务后到下载中心取文件)。 +// 数据来源与入库管理页保持一致:成功批次列表(batch_type=入库)。 +func (s *sCommonBatchMain) ExportWarehousing(ctx context.Context, in *model.BatchMainSuccessListParam) (err error) { + if in == nil { + in = &model.BatchMainSuccessListParam{} + } + // 与页面 WarehousingList 一致:固定只导入库批次,不接受调用方传其它类型 + in.BatchType = consts.BatchTypeInbound + + _, err = exportcenter.Submit(ctx, &exportcenter.Request{ + BizKey: "batchMainWarehousing", + BizName: "入库管理", + PermCode: "cpm:iot:tSimCardStock:export", + QueryParam: in, + Tags: func() ([]string, error) { + return convert.GetEntityDescTags(model.BatchMainSuccessListModel{}) + }, + Fetch: func(ctx context.Context, page, pageSize int) (rows []interface{}, total int, err error) { + query := *in + query.Page = page + query.PageSize = pageSize + list, count, err := s.SuccessList(ctx, &query) + if err != nil { + return nil, 0, err + } + rows = make([]interface{}, 0, len(list)) + for _, item := range list { + rows = append(rows, item) + } + return rows, count, nil + }, + }) + return err +} + // Export 导出批次主表 func (s *sCommonBatchMain) Export(ctx context.Context, in *model.BatchMainListParam) (err error) { list, totalCount, err := s.List(ctx, in) diff --git a/server/internal/logic/common/batch_main_channel_test.go b/server/internal/logic/common/batch_main_channel_test.go new file mode 100644 index 0000000..808d88a --- /dev/null +++ b/server/internal/logic/common/batch_main_channel_test.go @@ -0,0 +1,74 @@ +package common + +import ( + "testing" + + "github.com/gogf/gf/v2/frame/g" + "git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/consts" + "git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/model" + "git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/model/request" +) + +func TestInboundBatchChannelFiltering(t *testing.T) { + ctx := setupBatchExecutionLogListDB(t) + for _, statement := range []string{ + "ALTER TABLE batch_main ADD target_dept_id INTEGER DEFAULT 0", + "CREATE TABLE t_sim_card_stock(batch_no TEXT,tenant_id TEXT)", + "INSERT INTO t_sim_card_stock VALUES('A-NESTED',''),('A-NESTED','')", + } { + if _, err := g.DB().Exec(ctx, statement); err != nil { + t.Fatal(err) + } + } + fixtures := []struct { + name string + info string + batchType int + runResult int + }{ + {"A-NESTED", `{"inboundOptions":{"chanKey":"A"}}`, 1, consts.BatchRunResultSuccess}, + {"A-LEGACY", `{"chanKey":"A"}`, 1, consts.BatchRunResultSuccess}, + {"B-NESTED", `{"inboundOptions":{"chanKey":"B"},"chanKey":"A"}`, 1, consts.BatchRunResultSuccess}, + {"A-EMPTY-NESTED", `{"inboundOptions":{"chanKey":""},"chanKey":"A"}`, 1, consts.BatchRunResultSuccess}, + {"NO-CHANNEL", "{}", 1, consts.BatchRunResultSuccess}, + {"INVALID-JSON", "broken", 1, consts.BatchRunResultSuccess}, + {"OTHER-TYPE", `{"chanKey":"A"}`, 2, consts.BatchRunResultSuccess}, + {"NOT-SUCCESS", `{"chanKey":"A"}`, 1, consts.BatchRunResultFailed}, + } + for _, fixture := range fixtures { + if _, err := g.DB().Exec(ctx, "INSERT INTO batch_main(batch_no,batch_type,batch_info,total_count,run_result) VALUES(?,?,?,?,?)", fixture.name, fixture.batchType, fixture.info, 2, fixture.runResult); err != nil { + t.Fatal(err) + } + } + for _, test := range []struct { + name string + channel string + page int + size int + wantTotal int + wantRows int + }{ + {"渠道A兼容新旧格式", "A", 1, 20, 3, 3}, + {"渠道B优先使用嵌套编码", "B", 1, 20, 1, 1}, + {"清空恢复全部入库批次", "", 1, 20, 6, 6}, + {"前后空白不影响筛选", " A ", 1, 20, 3, 3}, + {"总数按渠道过滤后分页", "A", 2, 2, 3, 1}, + {"不存在的渠道返回空", "missing", 1, 20, 0, 0}, + {"特殊字符作为参数而不是SQL", "A' OR 1=1 --", 1, 20, 0, 0}, + } { + t.Run(test.name, func(t *testing.T) { + rows, total, err := NewCommonBatchMain().SuccessList(ctx, &model.BatchMainSuccessListParam{BatchType: consts.BatchTypeInbound, ChanKey: test.channel, PageInfo: request.PageInfo{Page: test.page, PageSize: test.size}}) + if err != nil || total != test.wantTotal || len(rows) != test.wantRows { + t.Fatalf("total=%d rows=%d err=%v", total, len(rows), err) + } + for _, row := range rows { + if row.BatchNo == "A-NESTED" && row.StockCount != 2 { + t.Fatalf("stock count changed: %+v", row) + } + if test.channel == "B" && row.BatchNo != "B-NESTED" { + t.Fatalf("nested channel priority violated: %+v", row) + } + } + }) + } +} diff --git a/server/internal/logic/common/batch_main_metadata_test.go b/server/internal/logic/common/batch_main_metadata_test.go new file mode 100644 index 0000000..5adb15f --- /dev/null +++ b/server/internal/logic/common/batch_main_metadata_test.go @@ -0,0 +1,85 @@ +package common + +import ( + "context" + "testing" + + "git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/consts" + "git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/batchmetadata" + "git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/contexts" + "git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/model" + "git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/model/entity" + + "github.com/gogf/gf/v2/frame/g" +) + +func TestBatchMainEditRejectsMissingCreationMetadata(t *testing.T) { + for _, mode := range []int{batchmetadata.Unknown, -1, 3} { + if err := NewCommonBatchMain().Edit(context.Background(), &model.BatchMainEditParam{ExecutionMode: &mode}); err == nil { + t.Fatalf("accepted execution mode %d", mode) + } + } + for _, input := range []*model.BatchMainEditParam{nil, {}} { + if err := NewCommonBatchMain().Edit(context.Background(), input); err == nil { + t.Fatal("accepted missing creation metadata") + } + } + mode := batchmetadata.Single + quantity := 2 + if err := NewCommonBatchMain().Edit(context.Background(), &model.BatchMainEditParam{ExecutionMode: &mode, TotalCount: &quantity}); err == nil { + t.Fatal("accepted multiple cards as a single-card batch") + } +} + +func TestBatchMainEditWritesTrustedCreationMetadata(t *testing.T) { + ctx := setupBatchExecutionLogListDB(t) + for _, column := range []string{"updated_by INTEGER", "updated_at TEXT"} { + if _, err := g.DB().Exec(ctx, "ALTER TABLE batch_main ADD COLUMN "+column); err != nil { + t.Fatal(err) + } + } + if err := g.DB().GetCore().ClearTableFieldsAll(ctx); err != nil { + t.Fatal(err) + } + fields, err := g.Model("batch_main").Ctx(ctx).TableFields("batch_main") + if err != nil { + t.Fatal(err) + } + fields["id"].Key = "PRI" + if _, err := g.DB().GetCore().GetTablesWithCache(); err != nil { + t.Fatal(err) + } + ctx = contexts.Set(ctx, &model.Context{User: &model.Identity{BaseClaims: model.BaseClaims{ID: 7, TenantId: "A"}}}) + batchType := consts.BatchTypeInbound + quantity := 1 + claimedUser := int64(999) + claimedTenant := "B" + for _, mode := range []int{batchmetadata.Single, batchmetadata.Bulk} { + batchNo := "META-EDIT-SINGLE" + if mode == batchmetadata.Bulk { + batchNo = "META-EDIT-BULK" + } + input := &model.BatchMainEditParam{BatchNo: &batchNo, BatchType: &batchType, ExecutionMode: &mode, TotalCount: &quantity, CreatedBy: &claimedUser, TenantId: &claimedTenant} + if err := NewCommonBatchMain().Edit(ctx, input); err != nil { + t.Fatal(err) + } + var batch *entity.BatchMain + if err := g.Model("batch_main").Ctx(ctx).Where("batch_no", batchNo).Scan(&batch); err != nil { + t.Fatal(err) + } + if batch == nil || batch.SourceChannel != batchmetadata.Console || batch.ExecutionMode != uint(mode) || batch.CreatedBy != 7 || batch.TenantId != "A" { + t.Fatalf("unexpected batch: %+v", batch) + } + otherMode := 3 - mode + if err := NewCommonBatchMain().Edit(ctx, &model.BatchMainEditParam{Id: batch.Id, BatchNo: &batchNo, ExecutionMode: &otherMode}); err != nil { + t.Fatal(err) + } + var updated *entity.BatchMain + if err := g.Model("batch_main").Ctx(ctx).Where("id", batch.Id).Scan(&updated); err != nil { + t.Fatal(err) + } + if updated.SourceChannel != batchmetadata.Console || updated.ExecutionMode != uint(mode) { + t.Fatalf("editing changed attribution: %+v", updated) + } + } +} diff --git a/server/internal/logic/common/batch_main_requeue_test.go b/server/internal/logic/common/batch_main_requeue_test.go new file mode 100644 index 0000000..94d1fa4 --- /dev/null +++ b/server/internal/logic/common/batch_main_requeue_test.go @@ -0,0 +1,185 @@ +package common + +import ( + "context" + "errors" + "testing" + "time" + + "git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/consts" + "git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/model" + + _ "github.com/gogf/gf/contrib/drivers/sqlite/v2" + "github.com/gogf/gf/v2/database/gdb" + "github.com/gogf/gf/v2/frame/g" + "github.com/gogf/gf/v2/os/gtime" +) + +func TestBatchMainRequeuePublishesPendingBatch(t *testing.T) { + ctx := setupBatchMainRequeueDB(t) + insertRequeueBatch(t, ctx, "PENDING-1", consts.BatchTypeInbound, consts.BatchAsyncStatusPending, consts.BatchRunResultPending, `{}`) + + var published []batchExecuteMessage + restore := replaceBatchRequeuePublisher(func(_ context.Context, queue string, data any) error { + if queue != consts.QueueBatchExecute { + t.Fatalf("queue = %s, want %s", queue, consts.QueueBatchExecute) + } + message, ok := data.(batchExecuteMessage) + if !ok { + t.Fatalf("message type = %T, want batchExecuteMessage", data) + } + published = append(published, message) + return nil + }) + defer restore() + + res, err := NewCommonBatchMain().Requeue(ctx, &model.BatchMainRequeueParam{BatchNo: "PENDING-1", Reason: "message lost"}) + if err != nil { + t.Fatalf("Requeue() error = %v", err) + } + if res == nil || !res.Requeued || res.BatchNo != "PENDING-1" || res.BatchType != consts.BatchTypeInbound { + t.Fatalf("unexpected response: %+v", res) + } + if len(published) != 1 || published[0].BatchNo != "PENDING-1" || published[0].BatchType != consts.BatchTypeInbound { + t.Fatalf("unexpected published messages: %+v", published) + } +} + +func TestBatchMainRequeuePublishesDueRestartWaitingResume(t *testing.T) { + ctx := setupBatchMainRequeueDB(t) + resumeAt := gtime.Now().Add(-time.Minute).String() + insertRequeueBatch(t, ctx, "RST-DUE", consts.BatchTypeRestart, consts.BatchAsyncStatusProcessing, consts.BatchRunResultRunning, `{"restart":{"phase":"waiting_resume","resumeAt":"`+resumeAt+`"}}`) + + var published []batchExecuteMessage + restore := replaceBatchRequeuePublisher(func(_ context.Context, _ string, data any) error { + published = append(published, data.(batchExecuteMessage)) + return nil + }) + defer restore() + + res, err := NewCommonBatchMain().Requeue(ctx, &model.BatchMainRequeueParam{BatchNo: "RST-DUE", Reason: "delayed message lost"}) + if err != nil { + t.Fatalf("Requeue() error = %v", err) + } + if res == nil || res.BatchNo != "RST-DUE" || res.BatchType != consts.BatchTypeRestart { + t.Fatalf("unexpected response: %+v", res) + } + if len(published) != 1 || published[0].BatchNo != "RST-DUE" || published[0].BatchType != consts.BatchTypeRestart { + t.Fatalf("unexpected published messages: %+v", published) + } +} + +func TestBatchMainRequeueRejectsUnsafeStatuses(t *testing.T) { + ctx := setupBatchMainRequeueDB(t) + future := gtime.Now().Add(time.Hour).String() + cases := []struct { + name string + batchNo string + batchType int + status int + runResult int + info string + }{ + {name: "failed", batchNo: "FAILED-1", batchType: consts.BatchTypeInbound, status: consts.BatchAsyncStatusFailed, runResult: consts.BatchRunResultFailed, info: `{}`}, + {name: "success", batchNo: "SUCCESS-1", batchType: consts.BatchTypeInbound, status: consts.BatchAsyncStatusSuccess, runResult: consts.BatchRunResultSuccess, info: `{}`}, + {name: "partial", batchNo: "PARTIAL-1", batchType: consts.BatchTypeInbound, status: consts.BatchAsyncStatusPartialSuccess, runResult: consts.BatchRunResultPartialSuccess, info: `{}`}, + {name: "review", batchNo: "REVIEW-1", batchType: consts.BatchTypeDataStart, status: consts.BatchAsyncStatusReview, runResult: consts.BatchRunResultReview, info: `{}`}, + {name: "normal processing", batchNo: "PROCESSING-1", batchType: consts.BatchTypeInbound, status: consts.BatchAsyncStatusProcessing, runResult: consts.BatchRunResultRunning, info: `{}`}, + {name: "restart future resume", batchNo: "RST-FUTURE", batchType: consts.BatchTypeRestart, status: consts.BatchAsyncStatusProcessing, runResult: consts.BatchRunResultRunning, info: `{"restart":{"phase":"waiting_resume","resumeAt":"` + future + `"}}`}, + } + for _, tc := range cases { + insertRequeueBatch(t, ctx, tc.batchNo, tc.batchType, tc.status, tc.runResult, tc.info) + } + + var published int + restore := replaceBatchRequeuePublisher(func(context.Context, string, any) error { + published++ + return nil + }) + defer restore() + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + _, err := NewCommonBatchMain().Requeue(ctx, &model.BatchMainRequeueParam{BatchNo: tc.batchNo, Reason: "operator retry"}) + if err == nil { + t.Fatal("Requeue() error = nil, want error") + } + }) + } + if published != 0 { + t.Fatalf("published messages = %d, want 0", published) + } +} + +func TestBatchMainRequeueHandlesMissingBatchAndPublishFailure(t *testing.T) { + ctx := setupBatchMainRequeueDB(t) + insertRequeueBatch(t, ctx, "PENDING-FAIL-PUBLISH", consts.BatchTypeInbound, consts.BatchAsyncStatusPending, consts.BatchRunResultPending, `{}`) + + restore := replaceBatchRequeuePublisher(func(context.Context, string, any) error { + return errors.New("rabbitmq unavailable") + }) + defer restore() + + if _, err := NewCommonBatchMain().Requeue(ctx, &model.BatchMainRequeueParam{BatchNo: "PENDING-FAIL-PUBLISH", Reason: "message lost"}); err == nil { + t.Fatal("Requeue() publish failure error = nil, want error") + } + if _, err := NewCommonBatchMain().Requeue(ctx, &model.BatchMainRequeueParam{BatchNo: "MISSING", Reason: "message lost"}); err == nil { + t.Fatal("Requeue() missing batch error = nil, want error") + } +} + +func setupBatchMainRequeueDB(t *testing.T) context.Context { + t.Helper() + ctx := context.Background() + if err := gdb.SetConfig(gdb.Config{ + "default": gdb.ConfigGroup{{ + Type: "sqlite", + Link: "sqlite::@file(:memory:)", + MaxOpenConnCount: 1, + }}, + }); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { + _ = g.DB().Close(ctx) + }) + // GoFrame caches table fields by group and table, across SQLite instances. + if err := g.DB().GetCore().ClearTableFieldsAll(ctx); err != nil { + t.Fatal(err) + } + if _, err := g.DB().Exec(ctx, `CREATE TABLE batch_main ( + source_channel TEXT NOT NULL DEFAULT 'legacy', + execution_mode INTEGER NOT NULL DEFAULT 0, + id INTEGER PRIMARY KEY AUTOINCREMENT, + batch_no TEXT NOT NULL, + batch_type INTEGER NOT NULL, + batch_async_status INTEGER NOT NULL, + run_result INTEGER NOT NULL, + batch_info TEXT, + tenant_id TEXT NOT NULL DEFAULT '', + dept_id INTEGER NOT NULL DEFAULT 0, + created_dept INTEGER NOT NULL DEFAULT 0, + created_by INTEGER NOT NULL DEFAULT 0 + )`); err != nil { + t.Fatal(err) + } + return ctx +} + +func insertRequeueBatch(t *testing.T, ctx context.Context, batchNo string, batchType, status, runResult int, batchInfo string) { + t.Helper() + if _, err := g.DB().Exec(ctx, + `INSERT INTO batch_main(batch_no, batch_type, batch_async_status, run_result, batch_info, tenant_id) VALUES(?, ?, ?, ?, ?, '')`, + batchNo, batchType, status, runResult, batchInfo, + ); err != nil { + t.Fatal(err) + } +} + +func replaceBatchRequeuePublisher(fn func(context.Context, string, any) error) func() { + original := publishBatchExecuteMessage + publishBatchExecuteMessage = fn + return func() { + publishBatchExecuteMessage = original + } +} diff --git a/server/internal/logic/common/batch_main_units_test.go b/server/internal/logic/common/batch_main_units_test.go new file mode 100644 index 0000000..57d5896 --- /dev/null +++ b/server/internal/logic/common/batch_main_units_test.go @@ -0,0 +1,79 @@ +package common + +import ( + "context" + "encoding/json" + "github.com/gogf/gf/v2/frame/g" + "git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/consts" + "git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/model" + "git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/model/request" + "strings" + "testing" +) + +func TestBatchManagementAllocationUnits(t *testing.T) { + ctx := setupBatchExecutionLogListDB(t) + for _, statement := range []string{ + "ALTER TABLE batch_main ADD target_dept_id INTEGER DEFAULT 0", + "ALTER TABLE batch_main ADD dept_id INTEGER DEFAULT 0", + "ALTER TABLE batch_main ADD updated_by INTEGER DEFAULT 0", + "ALTER TABLE batch_main ADD batch_async_status INTEGER DEFAULT 3", + "CREATE TABLE sys_dept(dept_id INTEGER,tenant_id TEXT,dept_key TEXT,dept_name TEXT,deleted_at TEXT)", + "INSERT INTO sys_dept VALUES(10,'','source','当前划拨名称',NULL),(20,'','target','当前接收名称',NULL)", + `INSERT INTO batch_main(batch_no,batch_type,batch_info,created_dept,target_dept_id,total_count,run_result) VALUES('ALC-PARENT',3,'{"jobRole":"allocate_parent","sourceDeptId":10,"targetDeptId":20,"sourceDeptName":"历史划拨单位","targetDeptName":"历史接收单位"}',10,20,1,2),('ALC-LEGACY',3,'{}',10,20,1,2),('ALC-PER-ROW',3,'{"sourceDeptId":10,"sourceDeptName":"历史划拨单位","targetDeptMode":"per_row"}',10,0,1,2)`, + `INSERT INTO batch_main(batch_no,batch_type,source_batch_no,batch_info,created_dept,target_dept_id,total_count,run_result) VALUES('ALC-CHILD',3,'ALC-PARENT','{"jobRole":"allocate_chunk"}',10,20,1,2)`, + `INSERT INTO batch_detail(batch_no,batch_type,item_id,snapshot,created_dept) VALUES('ALC-CHILD',3,'card','{}',10)`, + } { + if _, err := g.DB().Exec(ctx, statement); err != nil { + t.Fatal(err) + } + } + service := NewCommonBatchMain() + rows, _, err := service.SuccessList(ctx, &model.BatchMainSuccessListParam{BatchType: consts.BatchTypeAllocate, PageInfo: request.PageInfo{Page: 1, PageSize: 20}}) + if err != nil { + t.Fatal(err) + } + seen := map[string]bool{} + for _, row := range rows { + seen[row.BatchNo] = true + switch row.BatchNo { + case "ALC-PARENT": + if row.SourceDeptName != "历史划拨单位" || row.TargetDeptName != "历史接收单位" { + t.Fatal(row) + } + encoded, err := json.Marshal(row) + if err != nil || !strings.Contains(string(encoded), `"targetDeptId":20`) || strings.Contains(string(encoded), "StoredTargetDeptId") { + t.Fatal("接口契约错误", string(encoded), err) + } + case "ALC-LEGACY": + if row.SourceDeptName != "当前划拨名称" || row.TargetDeptName != "当前接收名称" { + t.Fatal(row) + } + case "ALC-PER-ROW": + if row.TargetDeptMode != "per_row" || row.TargetDeptName != "" { + t.Fatal(row) + } + } + } + if !seen["ALC-PARENT"] || !seen["ALC-LEGACY"] || !seen["ALC-PER-ROW"] { + t.Fatal(seen) + } + all, _, err := service.List(ctx, &model.BatchMainListParam{BatchNo: "ALC-PARENT", PageInfo: request.PageInfo{Page: 1, PageSize: 20}}) + if err != nil || len(all) != 1 || all[0].TargetDeptName != "历史接收单位" { + t.Fatal("主表列表单位缺失", all, err) + } + view, err := service.View(ctx, &model.BatchMainViewParam{Id: all[0].Id}) + if err != nil || view.SourceDeptName != "历史划拨单位" || view.TargetDeptName != "历史接收单位" { + t.Fatal("批次详情单位缺失", view, err) + } + cards, total, err := service.ListCards(ctx, &model.BatchCardListParam{BatchNo: "ALC-PARENT", PageInfo: request.PageInfo{Page: 1, PageSize: 20}}) + if err != nil || total != 1 || len(cards) != 1 { + t.Fatal("父批次未展示子批次卡清单", total, err) + } + if cards[0].Iccid != "card" || cards[0].SourceDeptName != "历史划拨单位" || cards[0].TargetDeptName != "历史接收单位" { + t.Fatal(cards[0]) + } + if _, _, err := service.ListCards(context.Background(), &model.BatchCardListParam{BatchNo: "missing"}); err == nil { + t.Fatal("不存在批次被放行") + } +} diff --git a/server/internal/logic/common/batch_metadata_filter_test.go b/server/internal/logic/common/batch_metadata_filter_test.go new file mode 100644 index 0000000..81ab305 --- /dev/null +++ b/server/internal/logic/common/batch_metadata_filter_test.go @@ -0,0 +1,33 @@ +package common + +import ( + "github.com/gogf/gf/v2/frame/g" + "git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/consts" + "git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/model" + "git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/model/request" + "testing" +) + +func TestExecutionLogMetadataFilters(t *testing.T) { + ctx := setupBatchExecutionLogListDB(t) + insertBatchExecutionLogDetail(t, ctx, "META-SINGLE", consts.BatchTypeInbound, 0, 1, "card1") + insertBatchExecutionLogDetail(t, ctx, "META-BULK", consts.BatchTypeInbound, 3, 1, "card2") + for _, sql := range []string{ + "UPDATE batch_main SET source_channel='mini_program',execution_mode=1 WHERE batch_no='META-SINGLE'", + "UPDATE batch_main SET source_channel='console',execution_mode=2 WHERE batch_no='META-BULK'", + } { + if _, err := g.DB().Exec(ctx, sql); err != nil { + t.Fatal(err) + } + } + mode := 1 + list, total, err := NewCommonBatchExecutionLog().List(ctx, &model.BatchExecutionLogListParam{PageInfo: request.PageInfo{Page: 1, PageSize: 20}, SourceChannel: "mini_program", ExecutionMode: &mode}) + if err != nil || total != 1 || len(list) != 1 || list[0].BatchNo != "META-SINGLE" || list[0].SourceChannel != "mini_program" || list[0].ExecutionMode != 1 { + t.Fatalf("%+v %d %v", list, total, err) + } + mode = 2 + _, total, err = NewCommonBatchExecutionLog().List(ctx, &model.BatchExecutionLogListParam{PageInfo: request.PageInfo{Page: 1, PageSize: 20}, SourceChannel: "mini_program", ExecutionMode: &mode}) + if err != nil || total != 0 { + t.Fatal(total, err) + } +} diff --git a/server/internal/logic/common/batch_visibility.go b/server/internal/logic/common/batch_visibility.go index a6fdd4b..d3f3fd9 100644 --- a/server/internal/logic/common/batch_visibility.go +++ b/server/internal/logic/common/batch_visibility.go @@ -22,3 +22,13 @@ func visibleBatchTypeMatches(actual int, requested *int) bool { } return requested == nil || actual == *requested } + +// successListBatchTypeModel keeps export execution logs out of business batch selectors. +func successListBatchTypeModel(mod *gdb.Model, column string, requested *int) *gdb.Model { + mod = visibleBatchTypeModel(mod, column, requested) + return mod.Where(column+" <> ?", consts.BatchTypeExport) +} + +func successListBatchTypeMatches(actual int, requested *int) bool { + return actual != consts.BatchTypeExport && visibleBatchTypeMatches(actual, requested) +} diff --git a/server/internal/logic/common/batch_visibility_test.go b/server/internal/logic/common/batch_visibility_test.go new file mode 100644 index 0000000..e2e8dc8 --- /dev/null +++ b/server/internal/logic/common/batch_visibility_test.go @@ -0,0 +1,36 @@ +package common + +import ( + "testing" + + "git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/consts" +) + +func TestSuccessListBatchTypeMatches(t *testing.T) { + prepare := consts.BatchTypePrepare + renew := consts.BatchTypeRenew + export := consts.BatchTypeExport + + tests := []struct { + name string + actual int + requested *int + want bool + }{ + {name: "unfiltered business batch", actual: consts.BatchTypeChange, want: true}, + {name: "matching business filter", actual: consts.BatchTypeRenew, requested: &renew, want: true}, + {name: "different business filter", actual: consts.BatchTypeChange, requested: &renew, want: false}, + {name: "unfiltered prepare batch", actual: consts.BatchTypePrepare, want: false}, + {name: "explicit prepare filter remains hidden", actual: consts.BatchTypePrepare, requested: &prepare, want: false}, + {name: "unfiltered export batch remains hidden", actual: consts.BatchTypeExport, want: false}, + {name: "explicit export filter remains hidden", actual: consts.BatchTypeExport, requested: &export, want: false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := successListBatchTypeMatches(tt.actual, tt.requested); got != tt.want { + t.Fatalf("successListBatchTypeMatches(%d) = %v, want %v", tt.actual, got, tt.want) + } + }) + } +} diff --git a/server/internal/logic/common/download_center.go b/server/internal/logic/common/download_center.go new file mode 100644 index 0000000..29ddad5 --- /dev/null +++ b/server/internal/logic/common/download_center.go @@ -0,0 +1,341 @@ +package common + +import ( + "context" + "strconv" + "strings" + + "github.com/gogf/gf/v2/database/gdb" + "github.com/gogf/gf/v2/errors/gerror" + "github.com/gogf/gf/v2/os/gtime" + + "git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/consts" + "git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/dao" + "git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/contexts" + "git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/exportcenter" + "git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/model" + "git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/model/entity" + "git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/service" +) + +type sCommonDownloadCenter struct{} + +func init() { + service.RegisterCommonDownloadCenter(NewCommonDownloadCenter()) +} + +func NewCommonDownloadCenter() *sCommonDownloadCenter { + return &sCommonDownloadCenter{} +} + +// List 下载中心列表:K2 仅本人 + K1 业务权限过滤 +func (s *sCommonDownloadCenter) List(ctx context.Context, in *model.DownloadCenterListParam) (list []*model.DownloadCenterListModel, totalCount int, err error) { + user := contexts.GetUser(ctx) + if user == nil || user.ID <= 0 { + return nil, 0, gerror.New("登录状态已失效,请重新登录") + } + + mod := dao.TExportTask.Ctx(ctx). + Fields(model.DownloadCenterListModel{}). + Where(dao.TExportTask.Columns().OwnerUserId, user.ID) + mod = applyExportPermissionFilter(ctx, mod) + + if keyword := strings.TrimSpace(in.Keyword); keyword != "" { + like := "%" + keyword + "%" + mod = mod.Where( + "("+dao.TExportTask.Columns().TaskNo+" LIKE ? OR "+ + dao.TExportTask.Columns().FileName+" LIKE ? OR "+ + dao.TExportTask.Columns().BizName+" LIKE ?)", + like, like, like, + ) + } + if in.BizKey != "" { + mod = mod.Where(dao.TExportTask.Columns().BizKey, in.BizKey) + } + if statusIds := parseStatusIds(in.Status); len(statusIds) > 0 { + mod = mod.WhereIn(dao.TExportTask.Columns().Status, statusIds) + } + if len(in.CreatedAt) == 2 { + in.CreatedAt[1] = in.CreatedAt[1].EndOfDay() + mod = mod.WhereBetween(dao.TExportTask.Columns().CreatedAt, in.CreatedAt[0], in.CreatedAt[1]) + } + + err = mod.Page(in.Page, in.PageSize). + OrderDesc(dao.TExportTask.Columns().Id). + ScanAndCount(&list, &totalCount, false) + if err != nil { + return nil, 0, gerror.Wrap(err, "获取导出任务列表失败,请稍后重试") + } + if list == nil { + list = []*model.DownloadCenterListModel{} + } + return +} + +// Recent 右上角全局入口:最近任务与进行中/已完成数量 +func (s *sCommonDownloadCenter) Recent(ctx context.Context, in *model.DownloadCenterRecentParam) (res *model.DownloadCenterRecentModel, err error) { + user := contexts.GetUser(ctx) + if user == nil || user.ID <= 0 { + return nil, gerror.New("登录状态已失效,请重新登录") + } + limit := in.Limit + if limit <= 0 || limit > 20 { + limit = 5 + } + + items := make([]*model.DownloadCenterListModel, 0, limit) + mod := dao.TExportTask.Ctx(ctx). + WhereNull(dao.TExportTask.Columns().DeletedTime). + Fields(model.DownloadCenterListModel{}). + Where(dao.TExportTask.Columns().OwnerUserId, user.ID) + mod = applyExportPermissionFilter(ctx, mod) + if err = mod.OrderDesc(dao.TExportTask.Columns().Id).Limit(limit).Scan(&items); err != nil { + return nil, gerror.Wrap(err, "获取最近导出任务失败") + } + + running, err := s.countByStatus(ctx, user.ID, []int{consts.DownloadStatusPending, consts.DownloadStatusRunning}) + if err != nil { + return nil, err + } + finished, err := s.countByStatus(ctx, user.ID, []int{consts.DownloadStatusGenerated}) + if err != nil { + return nil, err + } + + return &model.DownloadCenterRecentModel{ + Items: items, + RunningCount: running, + FinishedCount: finished, + }, nil +} + +func (s *sCommonDownloadCenter) countByStatus(ctx context.Context, userId int64, status []int) (int, error) { + mod := dao.TExportTask.Ctx(ctx). + WhereNull(dao.TExportTask.Columns().DeletedTime). + Where(dao.TExportTask.Columns().OwnerUserId, userId). + WhereIn(dao.TExportTask.Columns().Status, status) + mod = applyExportPermissionFilter(ctx, mod) + count, err := mod.Count() + if err != nil { + return 0, gerror.Wrap(err, "统计导出任务数量失败") + } + return count, nil +} + +// Download 下载导出文件:重新校验 K1+K2,然后直接写文件流 +func (s *sCommonDownloadCenter) Download(ctx context.Context, in *model.DownloadCenterTaskParam) (err error) { + task, err := s.loadOwnedTask(ctx, in.TaskId) + if err != nil { + return err + } + if !hasExportPermission(ctx, task.PermCode) { + return gerror.New("你的导出权限已变更,无法下载该文件") + } + if task.DeletedTime != nil { + return gerror.New("文件已删除或已过期清理,请重新导出") + } + if task.Status != consts.DownloadStatusGenerated { + return gerror.New("文件尚未生成完成,请稍后再试") + } + if task.ExpireTime == nil || !task.ExpireTime.After(gtime.Now()) { + return gerror.New("文件已过期,请重新导出") + } + if task.OssId <= 0 { + return gerror.New("文件不存在") + } + + oss := &entity.SysOss{} + if err = dao.SysOss.Ctx(ctx).Where(dao.SysOss.Columns().OssId, task.OssId).Scan(oss); err != nil { + return gerror.Wrap(err, "获取文件信息失败") + } + if oss.OssId <= 0 { + return gerror.New("文件不存在或已被清理") + } + + return service.SysOss().DownloadLocal(ctx, oss, func() error { + columns := dao.TExportTask.Columns() + // 先计算有效期再递增次数,避免 MySQL 从左到右赋值影响首次下载判断。 + affected, updateErr := dao.TExportTask.Ctx(ctx). + Data("expire_time = CASE WHEN download_count = 0 THEN LEAST(expire_time, DATE_ADD(NOW(), INTERVAL 1 HOUR)) ELSE expire_time END, last_download_time = NOW(), download_count = download_count + 1"). + Where(columns.Id, task.Id). + WhereNull(columns.DeletedTime). + Where(columns.OwnerUserId, task.OwnerUserId). + Where(columns.Status, consts.DownloadStatusGenerated). + Where(columns.OssId, oss.OssId). + Where(columns.ExpireTime + " > NOW()"). + UpdateAndGetAffected() + if updateErr != nil { + return gerror.Wrap(updateErr, "更新下载有效期失败,请稍后重试") + } + if affected != 1 { + return gerror.New("文件已过期或状态已变更,请刷新后重试") + } + return nil + }) +} + +// Remove 物理清理文件与OSS记录,软删除并保留导出任务历史 +func (s *sCommonDownloadCenter) Remove(ctx context.Context, in *model.DownloadCenterTaskParam) (err error) { + task, err := s.loadOwnedTask(ctx, in.TaskId) + if err != nil { + return err + } + if task.Status == consts.DownloadStatusRunning { + return gerror.New("文件正在生成中,暂时不能删除") + } + if !hasExportPermission(ctx, task.PermCode) { + return gerror.New("你的导出权限已变更,无法删除该任务") + } + + userId := contexts.GetUserId(ctx) + _, err = exportcenter.RemoveTaskFile(ctx, task.Id, "user", func(locked *entity.TExportTask) error { + if locked.OwnerUserId != userId { + return gerror.New("无权访问该导出任务") + } + if locked.Status == consts.DownloadStatusRunning { + return gerror.New("任务已开始生成,暂时不能删除") + } + if !hasExportPermission(ctx, locked.PermCode) { + return gerror.New("你的导出权限已变更,无法删除该任务") + } + return nil + }) + if err != nil { + return gerror.Wrap(err, "删除导出任务失败") + } + return nil +} + +// loadOwnedTask 取任务并校验归属(K2:仅本人) +func (s *sCommonDownloadCenter) loadOwnedTask(ctx context.Context, taskId int64) (*entity.TExportTask, error) { + if taskId <= 0 { + return nil, gerror.New("任务ID不能为空") + } + task := &entity.TExportTask{} + if err := dao.TExportTask.Ctx(ctx). + Where(dao.TExportTask.Columns().Id, taskId). + Scan(task); err != nil { + return nil, gerror.Wrap(err, "获取导出任务失败") + } + if task.Id <= 0 { + return nil, gerror.New("导出任务不存在") + } + + user := contexts.GetUser(ctx) + if user == nil || user.ID <= 0 { + return nil, gerror.New("登录状态已失效,请重新登录") + } + if task.OwnerUserId != user.ID { + return nil, gerror.New("无权访问该导出任务") + } + return task, nil +} + +// applyExportPermissionFilter K1:只显示「我仍持有对应导出权限」的任务 +func applyExportPermissionFilter(ctx context.Context, mod *gdb.Model) *gdb.Model { + if contexts.IsSuperAdmin(ctx) { + return mod + } + codes := exportPermissionCodes(contexts.GetAccessCodeList(ctx)) + if len(codes) == 0 { + return mod.Where("1 = 0") + } + + exact := make([]string, 0, len(codes)) + prefixes := make([]string, 0, len(codes)) + for _, code := range codes { + if strings.HasSuffix(code, "*") { + prefixes = append(prefixes, strings.TrimSuffix(code, "*")) + continue + } + exact = append(exact, code) + } + + conditions := make([]string, 0, len(exact)+len(prefixes)) + args := make([]interface{}, 0, len(exact)+len(prefixes)) + column := dao.TExportTask.Columns().PermCode + if len(exact) > 0 { + conditions = append(conditions, column+" IN (?)") + args = append(args, exact) + } + for _, prefix := range prefixes { + conditions = append(conditions, column+" LIKE ?") + args = append(args, prefix+"%") + } + if len(conditions) == 0 { + return mod.Where("1 = 0") + } + return mod.Where("("+strings.Join(conditions, " OR ")+")", args...) +} + +// hasExportPermission 下载/删除时的二次校验 +func hasExportPermission(ctx context.Context, required string) bool { + if contexts.IsSuperAdmin(ctx) { + return true + } + if strings.TrimSpace(required) == "" { + // 提交时接口未声明权限码时,退化为只校验归属,避免历史任务全部不可用 + return true + } + for _, code := range contexts.GetAccessCodeList(ctx) { + if matchExportCode(code, required) { + return true + } + } + return false +} + +func matchExportCode(pattern, code string) bool { + if pattern == code { + return true + } + if strings.HasSuffix(pattern, "*") { + return strings.HasPrefix(code, strings.TrimSuffix(pattern, "*")) + } + return false +} + +// exportPermissionCodes 取「可用于判断导出任务可见性」的权限码。 +// +// 历史实现只认 :export 结尾,但导出任务的 perm_code 是提交时声明的权限码, +// 不同业务可能声明为 :view / :download / :list(例如卡基础信息查询、月历史用量查询 +// 把查询与导出合并成一个 :view 码)。只认 :export 会让这些任务对提交人自己也不可见。 +// +// 放宽到常见后缀不影响安全:真正下载时 hasExportPermission 会拿任务记录的 perm_code +// 再校验一次,用户失去权限后即使看得到记录也拿不到文件。 +func exportPermissionCodes(list []string) []string { + suffixes := []string{":export", ":view", ":download", ":list"} + out := make([]string, 0, len(list)) + for _, code := range list { + if strings.HasSuffix(code, "*") { + out = append(out, code) + continue + } + for _, suffix := range suffixes { + if strings.HasSuffix(code, suffix) { + out = append(out, code) + break + } + } + } + return out +} + +// parseStatusIds 解析逗号分隔的状态筛选 +func parseStatusIds(raw string) []int { + raw = strings.TrimSpace(raw) + if raw == "" { + return nil + } + ids := make([]int, 0, 4) + for _, part := range strings.Split(raw, ",") { + part = strings.TrimSpace(part) + if part == "" { + continue + } + if v, err := strconv.Atoi(part); err == nil { + ids = append(ids, v) + } + } + return ids +} diff --git a/server/internal/logic/common/t_agent_balance.go b/server/internal/logic/common/t_agent_balance.go index 9851d38..5f44883 100644 --- a/server/internal/logic/common/t_agent_balance.go +++ b/server/internal/logic/common/t_agent_balance.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/logic/common/t_balance_detail.go b/server/internal/logic/common/t_balance_detail.go index f3f9f9e..304c384 100644 --- a/server/internal/logic/common/t_balance_detail.go +++ b/server/internal/logic/common/t_balance_detail.go @@ -1,30 +1,27 @@ // 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 import ( "context" - "fmt" "git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/dao" "git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/contexts" + "git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/exportcenter" "git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/xgorm/handler" "git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/xgorm/hook" "git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/model" "git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/service" "git.5iot.com/iotlcm-public/iotlcm-admin-svr/utility/convert" - "git.5iot.com/iotlcm-public/iotlcm-admin-svr/utility/excel" "time" "github.com/gogf/gf/v2/database/gdb" "github.com/gogf/gf/v2/errors/gerror" "github.com/gogf/gf/v2/frame/g" - "github.com/gogf/gf/v2/os/gctx" "github.com/gogf/gf/v2/os/gtime" - "github.com/gogf/gf/v2/util/gconv" ) type sCommonTBalanceDetail struct{} @@ -137,31 +134,38 @@ func (s *sCommonTBalanceDetail) List(ctx context.Context, in *model.TBalanceDeta return } -// Export 导出余额明细表 +// Export 导出余额明细(异步:提交任务后到下载中心取文件) func (s *sCommonTBalanceDetail) Export(ctx context.Context, in *model.TBalanceDetailListParam) (err error) { - list, totalCount, err := s.List(ctx, in) - if err != nil { - return + var req exportcenter.Request + req = exportcenter.Request{ + BizKey: "tBalanceDetail", + BizName: "余额明细", + PermCode: "cpm:common:tBalanceDetail:export", + QueryParam: in, + Tags: func() ([]string, error) { + return convert.GetEntityDescTags(model.TBalanceDetailListModel{}) + }, + Fetch: func(ctx context.Context, page, pageSize int) (rows []interface{}, total int, err error) { + query := *in + query.Page = page + query.PageSize = pageSize + list, count, err := s.List(ctx, &query) + if err != nil { + return nil, 0, err + } + rows = make([]interface{}, 0, len(list)) + for _, item := range list { + // 与列表 getDisplayTransactionChannel 保持一致,先归一化历史渠道值。 + if len(req.Columns) > 0 && (item.TransactionChannel == 4 || (item.TransactionChannel == 0 && item.IncomeExpenseType == 1 && item.TransactionType == 1)) { + item.TransactionChannel = 3 + } + rows = append(rows, item) + } + return rows, count, nil + }, } - - // 字段的排序是依据tags的字段顺序,如果你不想使用默认的排序方式,可以直接定义 tags = []string{"字段名称", "字段名称2", ...} - tags, err := convert.GetEntityDescTags(model.TBalanceDetailExportModel{}) - if err != nil { - return - } - - var ( - fileName = "导出余额明细表-" + gctx.CtxId(ctx) - sheetName = fmt.Sprintf("索引条件共%v行,共%v页,当前导出是第%v页,本页共%v行", totalCount, (totalCount+in.PageSize-1)/in.PageSize, in.Page, len(list)) - exports []model.TBalanceDetailExportModel - ) - - if err = gconv.Scan(list, &exports); err != nil { - return - } - - err = excel.ExportByStructs(ctx, tags, exports, fileName, sheetName) - return + _, err = exportcenter.Submit(ctx, &req) + return err } // Edit 修改/新增余额明细表 diff --git a/server/internal/logic/common/t_consume_account_month_bill.go b/server/internal/logic/common/t_consume_account_month_bill.go index cd34fd4..35cd794 100644 --- a/server/internal/logic/common/t_consume_account_month_bill.go +++ b/server/internal/logic/common/t_consume_account_month_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/logic/common/t_credit_detail.go b/server/internal/logic/common/t_credit_detail.go index 0789662..bd534f0 100644 --- a/server/internal/logic/common/t_credit_detail.go +++ b/server/internal/logic/common/t_credit_detail.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/logic/common/t_division_code.go b/server/internal/logic/common/t_division_code.go index 492edb9..e886022 100644 --- a/server/internal/logic/common/t_division_code.go +++ b/server/internal/logic/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/logic/common/t_fin_commission_detail.go b/server/internal/logic/common/t_fin_commission_detail.go index 341b301..7fcc4f0 100644 --- a/server/internal/logic/common/t_fin_commission_detail.go +++ b/server/internal/logic/common/t_fin_commission_detail.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/logic/common/t_fin_withdrawable_change.go b/server/internal/logic/common/t_fin_withdrawable_change.go index 022c796..e203d89 100644 --- a/server/internal/logic/common/t_fin_withdrawable_change.go +++ b/server/internal/logic/common/t_fin_withdrawable_change.go @@ -1,29 +1,26 @@ // 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 import ( "context" - "fmt" "git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/dao" "git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/contexts" + "git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/exportcenter" "git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/xgorm/handler" "git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/xgorm/hook" "git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/model" "git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/service" "git.5iot.com/iotlcm-public/iotlcm-admin-svr/utility/convert" - "git.5iot.com/iotlcm-public/iotlcm-admin-svr/utility/excel" "github.com/gogf/gf/v2/database/gdb" "github.com/gogf/gf/v2/errors/gerror" "github.com/gogf/gf/v2/frame/g" - "github.com/gogf/gf/v2/os/gctx" "github.com/gogf/gf/v2/os/gtime" - "github.com/gogf/gf/v2/util/gconv" ) type sCommonTFinWithdrawableChange struct{} @@ -113,31 +110,32 @@ func (s *sCommonTFinWithdrawableChange) List(ctx context.Context, in *model.TFin return } -// Export 导出可提现变动明细表 +// Export 导出可提现变动明细(异步:提交任务后到下载中心取文件) func (s *sCommonTFinWithdrawableChange) Export(ctx context.Context, in *model.TFinWithdrawableChangeListParam) (err error) { - list, totalCount, err := s.List(ctx, in) - if err != nil { - return - } - - // 字段的排序是依据tags的字段顺序,如果你不想使用默认的排序方式,可以直接定义 tags = []string{"字段名称", "字段名称2", ...} - tags, err := convert.GetEntityDescTags(model.TFinWithdrawableChangeExportModel{}) - if err != nil { - return - } - - var ( - fileName = "导出可提现变动明细表-" + gctx.CtxId(ctx) - sheetName = fmt.Sprintf("索引条件共%v行,共%v页,当前导出是第%v页,本页共%v行", totalCount, (totalCount+in.PageSize-1)/in.PageSize, in.Page, len(list)) - exports []model.TFinWithdrawableChangeExportModel - ) - - if err = gconv.Scan(list, &exports); err != nil { - return - } - - err = excel.ExportByStructs(ctx, tags, exports, fileName, sheetName) - return + _, err = exportcenter.Submit(ctx, &exportcenter.Request{ + BizKey: "tFinWithdrawableChange", + BizName: "可提现变动明细", + PermCode: "cpm:common:tFinWithdrawableChange:export", + QueryParam: in, + Tags: func() ([]string, error) { + return convert.GetEntityDescTags(model.TFinWithdrawableChangeListModel{}) + }, + Fetch: func(ctx context.Context, page, pageSize int) (rows []interface{}, total int, err error) { + query := *in + query.Page = page + query.PageSize = pageSize + list, count, err := s.List(ctx, &query) + if err != nil { + return nil, 0, err + } + rows = make([]interface{}, 0, len(list)) + for _, item := range list { + rows = append(rows, item) + } + return rows, count, nil + }, + }) + return err } // Edit 修改/新增可提现变动明细表 diff --git a/server/internal/logic/common/t_finance_apply.go b/server/internal/logic/common/t_finance_apply.go index 7c7c719..49bcd8f 100644 --- a/server/internal/logic/common/t_finance_apply.go +++ b/server/internal/logic/common/t_finance_apply.go @@ -1,16 +1,16 @@ // 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 import ( "context" - "fmt" "git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/dao" "git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/contexts" + "git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/exportcenter" "git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/xgorm" "git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/xgorm/handler" "git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/xgorm/hook" @@ -18,14 +18,11 @@ import ( "git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/model/entity" "git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/service" "git.5iot.com/iotlcm-public/iotlcm-admin-svr/utility/convert" - "git.5iot.com/iotlcm-public/iotlcm-admin-svr/utility/excel" "github.com/gogf/gf/v2/database/gdb" "github.com/gogf/gf/v2/errors/gerror" "github.com/gogf/gf/v2/frame/g" - "github.com/gogf/gf/v2/os/gctx" "github.com/gogf/gf/v2/os/gtime" - "github.com/gogf/gf/v2/util/gconv" ) type sCommonTFinanceApply struct{} @@ -105,31 +102,32 @@ func (s *sCommonTFinanceApply) List(ctx context.Context, in *model.TFinanceApply return } -// Export 导出财务申请表 +// Export 导出财务申请(异步:提交任务后到下载中心取文件) func (s *sCommonTFinanceApply) Export(ctx context.Context, in *model.TFinanceApplyListParam) (err error) { - list, totalCount, err := s.List(ctx, in) - if err != nil { - return - } - - // 字段的排序是依据tags的字段顺序,如果你不想使用默认的排序方式,可以直接定义 tags = []string{"字段名称", "字段名称2", ...} - tags, err := convert.GetEntityDescTags(model.TFinanceApplyExportModel{}) - if err != nil { - return - } - - var ( - fileName = "导出财务申请表-" + gctx.CtxId(ctx) - sheetName = fmt.Sprintf("索引条件共%v行,共%v页,当前导出是第%v页,本页共%v行", totalCount, (totalCount+in.PageSize-1)/in.PageSize, in.Page, len(list)) - exports []model.TFinanceApplyExportModel - ) - - if err = gconv.Scan(list, &exports); err != nil { - return - } - - err = excel.ExportByStructs(ctx, tags, exports, fileName, sheetName) - return + _, err = exportcenter.Submit(ctx, &exportcenter.Request{ + BizKey: "tFinanceApply", + BizName: "财务申请", + PermCode: "cpm:common:tFinanceApply:export", + QueryParam: in, + Tags: func() ([]string, error) { + return convert.GetEntityDescTags(model.TFinanceApplyListModel{}) + }, + Fetch: func(ctx context.Context, page, pageSize int) (rows []interface{}, total int, err error) { + query := *in + query.Page = page + query.PageSize = pageSize + list, count, err := s.List(ctx, &query) + if err != nil { + return nil, 0, err + } + rows = make([]interface{}, 0, len(list)) + for _, item := range list { + rows = append(rows, item) + } + return rows, count, nil + }, + }) + return err } // Edit 修改/新增财务申请表 diff --git a/server/internal/logic/common/t_finance_audit_record.go b/server/internal/logic/common/t_finance_audit_record.go index b0478e2..226aae1 100644 --- a/server/internal/logic/common/t_finance_audit_record.go +++ b/server/internal/logic/common/t_finance_audit_record.go @@ -1,30 +1,27 @@ // 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 import ( "context" - "fmt" "git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/dao" "git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/contexts" + "git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/exportcenter" "git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/xgorm" "git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/xgorm/handler" "git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/xgorm/hook" "git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/model" "git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/service" "git.5iot.com/iotlcm-public/iotlcm-admin-svr/utility/convert" - "git.5iot.com/iotlcm-public/iotlcm-admin-svr/utility/excel" "github.com/gogf/gf/v2/database/gdb" "github.com/gogf/gf/v2/errors/gerror" "github.com/gogf/gf/v2/frame/g" - "github.com/gogf/gf/v2/os/gctx" "github.com/gogf/gf/v2/os/gtime" - "github.com/gogf/gf/v2/util/gconv" ) type sCommonTFinanceAuditRecord struct{} @@ -98,31 +95,32 @@ func (s *sCommonTFinanceAuditRecord) List(ctx context.Context, in *model.TFinanc return } -// Export 导出财务审核记录表 +// Export 导出财务审核记录(异步:提交任务后到下载中心取文件) func (s *sCommonTFinanceAuditRecord) Export(ctx context.Context, in *model.TFinanceAuditRecordListParam) (err error) { - list, totalCount, err := s.List(ctx, in) - if err != nil { - return - } - - // 字段的排序是依据tags的字段顺序,如果你不想使用默认的排序方式,可以直接定义 tags = []string{"字段名称", "字段名称2", ...} - tags, err := convert.GetEntityDescTags(model.TFinanceAuditRecordExportModel{}) - if err != nil { - return - } - - var ( - fileName = "导出财务审核记录表-" + gctx.CtxId(ctx) - sheetName = fmt.Sprintf("索引条件共%v行,共%v页,当前导出是第%v页,本页共%v行", totalCount, (totalCount+in.PageSize-1)/in.PageSize, in.Page, len(list)) - exports []model.TFinanceAuditRecordExportModel - ) - - if err = gconv.Scan(list, &exports); err != nil { - return - } - - err = excel.ExportByStructs(ctx, tags, exports, fileName, sheetName) - return + _, err = exportcenter.Submit(ctx, &exportcenter.Request{ + BizKey: "tFinanceAuditRecord", + BizName: "财务审核记录", + PermCode: "cpm:common:tFinanceAuditRecord:export", + QueryParam: in, + Tags: func() ([]string, error) { + return convert.GetEntityDescTags(model.TFinanceAuditRecordListModel{}) + }, + Fetch: func(ctx context.Context, page, pageSize int) (rows []interface{}, total int, err error) { + query := *in + query.Page = page + query.PageSize = pageSize + list, count, err := s.List(ctx, &query) + if err != nil { + return nil, 0, err + } + rows = make([]interface{}, 0, len(list)) + for _, item := range list { + rows = append(rows, item) + } + return rows, count, nil + }, + }) + return err } // Edit 修改/新增财务审核记录表 diff --git a/server/internal/logic/common/t_message_log.go b/server/internal/logic/common/t_message_log.go index 9c5cf95..eed8946 100644 --- a/server/internal/logic/common/t_message_log.go +++ b/server/internal/logic/common/t_message_log.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 @@ -11,6 +11,7 @@ import ( "fmt" "git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/dao" "git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/contexts" + "git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/exportcenter" "git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/websocket" "git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/xgorm/handler" "git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/xgorm/hook" @@ -18,13 +19,11 @@ import ( "git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/model/entity" "git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/service" "git.5iot.com/iotlcm-public/iotlcm-admin-svr/utility/convert" - "git.5iot.com/iotlcm-public/iotlcm-admin-svr/utility/excel" "strings" "github.com/gogf/gf/v2/database/gdb" "github.com/gogf/gf/v2/errors/gerror" "github.com/gogf/gf/v2/frame/g" - "github.com/gogf/gf/v2/os/gctx" "github.com/gogf/gf/v2/os/gtime" "github.com/gogf/gf/v2/util/gconv" ) @@ -123,31 +122,32 @@ func (s *sCommonTMessageLog) List(ctx context.Context, in *model.TMessageLogList return } -// Export 导出消息日志 +// Export 导出消息日志(异步:提交任务后到下载中心取文件) func (s *sCommonTMessageLog) Export(ctx context.Context, in *model.TMessageLogListParam) (err error) { - list, totalCount, err := s.List(ctx, in) - if err != nil { - return - } - - // 字段的排序是依据tags的字段顺序,如果你不想使用默认的排序方式,可以直接定义 tags = []string{"字段名称", "字段名称2", ...} - tags, err := convert.GetEntityDescTags(model.TMessageLogExportModel{}) - if err != nil { - return - } - - var ( - fileName = "导出消息日志-" + gctx.CtxId(ctx) - sheetName = fmt.Sprintf("索引条件共%v行,共%v页,当前导出是第%v页,本页共%v行", totalCount, (totalCount+in.PageSize-1)/in.PageSize, in.Page, len(list)) - exports []model.TMessageLogExportModel - ) - - if err = gconv.Scan(list, &exports); err != nil { - return - } - - err = excel.ExportByStructs(ctx, tags, exports, fileName, sheetName) - return + _, err = exportcenter.Submit(ctx, &exportcenter.Request{ + BizKey: "tMessageLog", + BizName: "消息日志", + PermCode: "cpm:common:tMessageLog:export", + QueryParam: in, + Tags: func() ([]string, error) { + return convert.GetEntityDescTags(model.TMessageLogListModel{}) + }, + Fetch: func(ctx context.Context, page, pageSize int) (rows []interface{}, total int, err error) { + query := *in + query.Page = page + query.PageSize = pageSize + list, count, err := s.List(ctx, &query) + if err != nil { + return nil, 0, err + } + rows = make([]interface{}, 0, len(list)) + for _, item := range list { + rows = append(rows, item) + } + return rows, count, nil + }, + }) + return err } // Edit 修改/新增消息日志 @@ -321,3 +321,119 @@ func (s *sCommonTMessageLog) View(ctx context.Context, in *model.TMessageLogView return } + +// 未读通知的默认与最大返回条数,避免一次拉取过多消息。 +const ( + messageUnreadDefaultLimit = 50 + messageUnreadMaxLimit = 200 +) + +// unreadQuery 构造当前用户未读消息查询。 +// 以用户消息表为准:只查本人的未读记录,关联消息日志排除已撤回消息, +// 消息日志已删除(关联为空)时也不提醒。 +// 这里不按 send_user_type 过滤:收件人 user_id 全部来自 sys_user 单表, +// 该字段只表示消息面向哪个控制台,按其过滤会漏掉真正发给本人的消息。 +func (s *sCommonTMessageLog) unreadQuery(ctx context.Context, userId int64) *gdb.Model { + var ( + userTable = dao.TUserMessage.Table() + logTable = dao.TMessageLog.Table() + userColumns = dao.TUserMessage.Columns() + logColumns = dao.TMessageLog.Columns() + ) + return dao.TUserMessage.Ctx(ctx). + LeftJoin(logTable+" ON "+logTable+"."+logColumns.MessageLogId+" = "+userTable+"."+userColumns.MessageLogId). + Where(userTable+"."+userColumns.UserId, userId). + Where(userTable+"."+userColumns.IsRead, 0). + Where(logTable+"."+logColumns.IsRecall, 0) +} + +// UnreadList 获取当前登录用户的未读消息通知。 +// 这是右上角消息提醒的数据来源:登录、刷新、断线重连后都必须重新拉取, +// 否则离线期间的消息会因错过 websocket 推送而不再出现提醒。 +func (s *sCommonTMessageLog) UnreadList(ctx context.Context, in *model.TMessageLogUnreadListParam) (list []*model.TMessageLogUnreadListModel, totalCount int, err error) { + userId := contexts.GetUserId(ctx) + if userId <= 0 { + err = gerror.New("未获取到当前登录用户信息") + return + } + + limit := messageUnreadDefaultLimit + if in != nil && in.Limit > 0 { + limit = in.Limit + } + if limit > messageUnreadMaxLimit { + limit = messageUnreadMaxLimit + } + + var ( + userTable = dao.TUserMessage.Table() + logTable = dao.TMessageLog.Table() + userColumn = dao.TUserMessage.Columns() + logColumn = dao.TMessageLog.Columns() + ) + if err = s.unreadQuery(ctx, userId). + Fields(userTable + "." + userColumn.MessageLogId + "," + + logTable + "." + logColumn.MessageType + "," + + logTable + "." + logColumn.SendUserType + "," + + logTable + "." + logColumn.MessageTitle + "," + + logTable + "." + logColumn.MessageContent + "," + + logTable + "." + logColumn.MessageLink + "," + + logTable + "." + logColumn.SendTime + "," + + userTable + "." + userColumn.IsRead). + OrderDesc(userTable + "." + userColumn.MessageId). + Limit(limit). + Scan(&list); err != nil { + err = gerror.Wrap(err, "获取未读消息失败,请稍后重试!") + return + } + if list == nil { + list = []*model.TMessageLogUnreadListModel{} + } + // 总数不受列表条数上限影响,用于判断是否还有更多未读。 + if totalCount, err = s.unreadQuery(ctx, userId).Count(); err != nil { + err = gerror.Wrap(err, "获取未读消息数量失败,请稍后重试!") + } + return +} + +// Read 标记当前登录用户的指定消息为已读。 +// 只允许操作本人的用户消息记录,避免越权修改他人已读状态。 +func (s *sCommonTMessageLog) Read(ctx context.Context, in *model.TMessageLogReadParam) (err error) { + userId := contexts.GetUserId(ctx) + if userId <= 0 { + return gerror.New("未获取到当前登录用户信息") + } + if in == nil || in.MessageLogId <= 0 { + return gerror.New("消息ID不能为空") + } + if _, err = dao.TUserMessage.Ctx(ctx).Where(g.Map{ + dao.TUserMessage.Columns().MessageLogId: in.MessageLogId, + dao.TUserMessage.Columns().UserId: userId, + }).Data(g.Map{ + dao.TUserMessage.Columns().IsRead: 1, + dao.TUserMessage.Columns().UpdatedBy: userId, + dao.TUserMessage.Columns().UpdatedAt: gtime.Now(), + }).Update(); err != nil { + err = gerror.Wrap(err, "标记消息已读失败,请稍后重试!") + } + return +} + +// ReadAll 标记当前登录用户的全部未读消息为已读。 +func (s *sCommonTMessageLog) ReadAll(ctx context.Context, in *model.TMessageLogReadAllParam) (err error) { + userId := contexts.GetUserId(ctx) + if userId <= 0 { + return gerror.New("未获取到当前登录用户信息") + } + if _, err = dao.TUserMessage.Ctx(ctx). + Where(dao.TUserMessage.Columns().UserId, userId). + Where(dao.TUserMessage.Columns().IsRead, 0). + Data(g.Map{ + dao.TUserMessage.Columns().IsRead: 1, + dao.TUserMessage.Columns().UpdatedBy: userId, + dao.TUserMessage.Columns().UpdatedAt: gtime.Now(), + }).Update(); err != nil { + err = gerror.Wrap(err, "标记全部消息已读失败,请稍后重试!") + } + return +} diff --git a/server/internal/logic/common/t_notify_log.go b/server/internal/logic/common/t_notify_log.go index ebb456e..ec0b186 100644 --- a/server/internal/logic/common/t_notify_log.go +++ b/server/internal/logic/common/t_notify_log.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 @@ -11,6 +11,7 @@ import ( "fmt" "git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/dao" "git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/contexts" + "git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/exportcenter" "git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/websocket" "git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/xgorm/handler" "git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/xgorm/hook" @@ -18,13 +19,11 @@ import ( "git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/model/entity" "git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/service" "git.5iot.com/iotlcm-public/iotlcm-admin-svr/utility/convert" - "git.5iot.com/iotlcm-public/iotlcm-admin-svr/utility/excel" "strings" "github.com/gogf/gf/v2/database/gdb" "github.com/gogf/gf/v2/errors/gerror" "github.com/gogf/gf/v2/frame/g" - "github.com/gogf/gf/v2/os/gctx" "github.com/gogf/gf/v2/os/gtime" "github.com/gogf/gf/v2/util/gconv" ) @@ -120,31 +119,32 @@ func (s *sCommonTNotifyLog) List(ctx context.Context, in *model.TNotifyLogListPa return } -// Export 导出通知日志 +// Export 导出通知日志(异步:提交任务后到下载中心取文件) func (s *sCommonTNotifyLog) Export(ctx context.Context, in *model.TNotifyLogListParam) (err error) { - list, totalCount, err := s.List(ctx, in) - if err != nil { - return - } - - // 字段的排序是依据tags的字段顺序,如果你不想使用默认的排序方式,可以直接定义 tags = []string{"字段名称", "字段名称2", ...} - tags, err := convert.GetEntityDescTags(model.TNotifyLogExportModel{}) - if err != nil { - return - } - - var ( - fileName = "导出通知日志-" + gctx.CtxId(ctx) - sheetName = fmt.Sprintf("索引条件共%v行,共%v页,当前导出是第%v页,本页共%v行", totalCount, (totalCount+in.PageSize-1)/in.PageSize, in.Page, len(list)) - exports []*model.TNotifyLogExportModel - ) - - if err = gconv.Scan(list, &exports); err != nil { - return - } - - err = excel.ExportByStructs(ctx, tags, exports, fileName, sheetName) - return + _, err = exportcenter.Submit(ctx, &exportcenter.Request{ + BizKey: "tNotifyLog", + BizName: "通知日志", + PermCode: "cpm:common:tNotifyLog:export", + QueryParam: in, + Tags: func() ([]string, error) { + return convert.GetEntityDescTags(model.TNotifyLogListModel{}) + }, + Fetch: func(ctx context.Context, page, pageSize int) (rows []interface{}, total int, err error) { + query := *in + query.Page = page + query.PageSize = pageSize + list, count, err := s.List(ctx, &query) + if err != nil { + return nil, 0, err + } + rows = make([]interface{}, 0, len(list)) + for _, item := range list { + rows = append(rows, item) + } + return rows, count, nil + }, + }) + return err } // Edit 修改/新增通知日志 diff --git a/server/internal/logic/common/t_profit_account_month_bill.go b/server/internal/logic/common/t_profit_account_month_bill.go index db1b4b2..ce55d2d 100644 --- a/server/internal/logic/common/t_profit_account_month_bill.go +++ b/server/internal/logic/common/t_profit_account_month_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/logic/common/t_wo_communication.go b/server/internal/logic/common/t_wo_communication.go index 86d1722..304cf04 100644 --- a/server/internal/logic/common/t_wo_communication.go +++ b/server/internal/logic/common/t_wo_communication.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/logic/common/t_wo_main.go b/server/internal/logic/common/t_wo_main.go index f014541..e264800 100644 --- a/server/internal/logic/common/t_wo_main.go +++ b/server/internal/logic/common/t_wo_main.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 @@ -277,6 +277,7 @@ func (s *sCommonTWoMain) Edit(ctx context.Context, in *model.TWoMainEditParam) ( return service.CommonTWoRecord().AddOperation(ctx, &model.TWoRecordAddOperationParam{ WoNo: *in.WoNo, OperationType: workOrderOperationCreate, + AfterStatus: currentStatus, }) }) } diff --git a/server/internal/logic/common/t_wo_record.go b/server/internal/logic/common/t_wo_record.go index 2883304..c5062e0 100644 --- a/server/internal/logic/common/t_wo_record.go +++ b/server/internal/logic/common/t_wo_record.go @@ -1,29 +1,26 @@ // 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 import ( "context" - "fmt" "git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/dao" "git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/contexts" + "git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/exportcenter" "git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/xgorm/handler" "git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/xgorm/hook" "git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/model" "git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/service" "git.5iot.com/iotlcm-public/iotlcm-admin-svr/utility/convert" - "git.5iot.com/iotlcm-public/iotlcm-admin-svr/utility/excel" "github.com/gogf/gf/v2/database/gdb" "github.com/gogf/gf/v2/errors/gerror" "github.com/gogf/gf/v2/frame/g" - "github.com/gogf/gf/v2/os/gctx" "github.com/gogf/gf/v2/os/gtime" - "github.com/gogf/gf/v2/util/gconv" ) type sCommonTWoRecord struct{} @@ -135,31 +132,32 @@ func (s *sCommonTWoRecord) List(ctx context.Context, in *model.TWoRecordListPara return } -// Export 导出工单记录表 +// Export 导出工单记录(异步:提交任务后到下载中心取文件) func (s *sCommonTWoRecord) Export(ctx context.Context, in *model.TWoRecordListParam) (err error) { - list, totalCount, err := s.List(ctx, in) - if err != nil { - return - } - - // 字段的排序是依据tags的字段顺序,如果你不想使用默认的排序方式,可以直接定义 tags = []string{"字段名称", "字段名称2", ...} - tags, err := convert.GetEntityDescTags(model.TWoRecordExportModel{}) - if err != nil { - return - } - - var ( - fileName = "导出工单记录表-" + gctx.CtxId(ctx) - sheetName = fmt.Sprintf("索引条件共%v行,共%v页,当前导出是第%v页,本页共%v行", totalCount, (totalCount+in.PageSize-1)/in.PageSize, in.Page, len(list)) - exports []model.TWoRecordExportModel - ) - - if err = gconv.Scan(list, &exports); err != nil { - return - } - - err = excel.ExportByStructs(ctx, tags, exports, fileName, sheetName) - return + _, err = exportcenter.Submit(ctx, &exportcenter.Request{ + BizKey: "tWoRecord", + BizName: "工单记录", + PermCode: "cpm:gen:tWoRecord:export", + QueryParam: in, + Tags: func() ([]string, error) { + return convert.GetEntityDescTags(model.TWoRecordListModel{}) + }, + Fetch: func(ctx context.Context, page, pageSize int) (rows []interface{}, total int, err error) { + query := *in + query.Page = page + query.PageSize = pageSize + list, count, err := s.List(ctx, &query) + if err != nil { + return nil, 0, err + } + rows = make([]interface{}, 0, len(list)) + for _, item := range list { + rows = append(rows, item) + } + return rows, count, nil + }, + }) + return err } // Edit 修改/新增工单记录表 @@ -205,17 +203,13 @@ func (s *sCommonTWoRecord) AddOperation(ctx context.Context, in *model.TWoRecord OperationType: &in.OperationType, OperationDeptId: &operationDeptId, OperationUserId: &operationUserId, + BeforeStatus: &in.BeforeStatus, + AfterStatus: &in.AfterStatus, } if in.TargetDeptId > 0 { param.TargetDeptId = &in.TargetDeptId param.TargetUserId = &in.TargetUserId } - if in.BeforeStatus > 0 { - param.BeforeStatus = &in.BeforeStatus - } - if in.AfterStatus > 0 { - param.AfterStatus = &in.AfterStatus - } return s.Edit(ctx, param) } diff --git a/server/internal/logic/common/work_order_access_test.go b/server/internal/logic/common/work_order_access_test.go new file mode 100644 index 0000000..46a11e4 --- /dev/null +++ b/server/internal/logic/common/work_order_access_test.go @@ -0,0 +1,37 @@ +package common + +import ( + _ "github.com/gogf/gf/contrib/drivers/sqlite/v2" + "github.com/gogf/gf/v2/frame/g" + "git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/contexts" + "git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/model" + "testing" +) + +func TestWorkOrderAccess(t *testing.T) { + ctx := setupBatchExecutionLogListDB(t) + if _, err := g.DB().Exec(ctx, "CREATE TABLE t_wo_main (id INTEGER, wo_no TEXT, wo_type TEXT, related_id TEXT, title TEXT, content TEXT, attachment TEXT, creator_type TEXT, creator_dept_id TEXT, creator_user_id INTEGER, creator_name TEXT, creator_phone TEXT, assign_dept_id TEXT, assign_user_id TEXT, current_status TEXT, handler_dept_id INTEGER, handler_user_id INTEGER, first_response_time TEXT, resolve_time TEXT, close_time TEXT, satisfaction_level TEXT, eval_content TEXT, eval_time TEXT, remark TEXT, tenant_id TEXT, created_dept TEXT, created_by TEXT, created_at TEXT, updated_by TEXT, updated_at TEXT)"); err != nil { + t.Fatal(err) + } + if _, err := g.DB().Exec(ctx, "INSERT INTO t_wo_main (id,wo_no,tenant_id,creator_user_id,handler_user_id,handler_dept_id) VALUES (1, 'WO', 'A', 10, 20, 30)"); err != nil { + t.Fatal(err) + } + for _, item := range []struct { + name, tenant string + user, dept int64 + allowed bool + }{ + {"creator", "A", 10, 40, true}, {"handler", "A", 20, 40, true}, {"handler department", "A", 21, 30, true}, {"unrelated", "A", 11, 40, false}, {"other tenant", "B", 10, 30, false}, {"anonymous", "A", 0, 30, false}, + } { + t.Run(item.name, func(t *testing.T) { + scoped := contexts.Set(ctx, &model.Context{User: &model.Identity{BaseClaims: model.BaseClaims{ID: item.user, DeptId: item.dept, TenantId: item.tenant}}}) + _, err := getWorkOrder(scoped, "WO") + if (err == nil) != item.allowed { + t.Fatalf("allowed=%v error=%v", item.allowed, err) + } + }) + } + t.Run("状态记录", func(t *testing.T) { + testWorkOrderRecordStatuses(t, ctx) + }) +} diff --git a/server/internal/logic/common/work_order_notice.go b/server/internal/logic/common/work_order_notice.go index 5a336fb..9716f4d 100644 --- a/server/internal/logic/common/work_order_notice.go +++ b/server/internal/logic/common/work_order_notice.go @@ -2,13 +2,13 @@ package common import ( "context" - "git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/dao" - "git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/model/do" - "git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/model/entity" "github.com/gogf/gf/v2/database/gdb" "github.com/gogf/gf/v2/encoding/gjson" "github.com/gogf/gf/v2/errors/gerror" "github.com/gogf/gf/v2/os/gtime" + "git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/dao" + "git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/model/do" + "git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/model/entity" "net/url" ) diff --git a/server/internal/logic/common/work_order_record_test.go b/server/internal/logic/common/work_order_record_test.go new file mode 100644 index 0000000..dba9470 --- /dev/null +++ b/server/internal/logic/common/work_order_record_test.go @@ -0,0 +1,84 @@ +package common + +import ( + "context" + "testing" + + "git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/dao" + "git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/contexts" + "git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/model" + "git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/model/do" + "git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/model/entity" + + "github.com/gogf/gf/v2/frame/g" +) + +func testWorkOrderRecordStatuses(t *testing.T, ctx context.Context) { + t.Helper() + for _, statement := range []string{ + "CREATE TABLE sys_dept (dept_id INTEGER PRIMARY KEY, parent_id INTEGER, leader TEXT, tenant_id TEXT)", + "CREATE TABLE t_wo_record (id INTEGER PRIMARY KEY, wo_no TEXT, operation_type INTEGER, operation_dept_id INTEGER, operation_user_id INTEGER, target_dept_id INTEGER, target_user_id INTEGER, before_status INTEGER, after_status INTEGER, tenant_id TEXT, created_dept INTEGER, created_by INTEGER, created_at TEXT, updated_by INTEGER, updated_at TEXT)", + } { + if _, err := g.DB().Exec(ctx, statement); err != nil { + t.Fatal(err) + } + } + if err := g.DB().GetCore().ClearTableFieldsAll(ctx); err != nil { + t.Fatal(err) + } + ctx = contexts.Set(ctx, &model.Context{User: &model.Identity{BaseClaims: model.BaseClaims{ID: 7, DeptId: 10, TenantId: "A"}}}) + if _, err := dao.SysDept.Ctx(ctx).Data(do.SysDept{DeptId: 10, ParentId: 0, Leader: "7", TenantId: "A"}).Insert(); err != nil { + t.Fatal(err) + } + + t.Run("创建记录与主表状态一致", func(t *testing.T) { + title := "工单状态回归" + input := &model.TWoMainEditParam{Title: &title} + if err := NewCommonTWoMain().Edit(ctx, input); err != nil { + t.Fatal(err) + } + var main entity.TWoMain + if err := dao.TWoMain.Ctx(ctx).Where(dao.TWoMain.Columns().WoNo, *input.WoNo).Scan(&main); err != nil { + t.Fatal(err) + } + var record entity.TWoRecord + if err := dao.TWoRecord.Ctx(ctx).Where(dao.TWoRecord.Columns().WoNo, *input.WoNo).Scan(&record); err != nil { + t.Fatal(err) + } + if main.CurrentStatus != workOrderStatusProcessing || record.AfterStatus != main.CurrentStatus || record.BeforeStatus != 0 || record.OperationType != workOrderOperationCreate { + t.Fatalf("主表与创建记录状态不一致: main=%+v record=%+v", main, record) + } + if record.OperationUserId != 7 || record.OperationDeptId != 10 || record.TenantId != "A" { + t.Fatalf("操作人或租户信息错误: %+v", record) + } + missing, err := dao.TWoRecord.Ctx(ctx).Where(dao.TWoRecord.Columns().Id, record.Id).WhereNull(dao.TWoRecord.Columns().BeforeStatus).Count() + if err != nil || missing != 0 { + t.Fatalf("创建前状态应显式写入0: missing=%d err=%v", missing, err) + } + }) + + for _, item := range []struct { + name string + operation, before, after int + }{ + {"分派", workOrderOperationAssign, workOrderStatusAssigned, workOrderStatusProcessing}, + {"处理", workOrderOperationProcess, workOrderStatusResolved, workOrderStatusProcessing}, + {"完成", workOrderOperationFinish, workOrderStatusProcessing, workOrderStatusResolved}, + {"关单", workOrderOperationClose, workOrderStatusResolved, workOrderStatusClosed}, + {"取消", workOrderOperationCancel, workOrderStatusProcessing, workOrderStatusCanceled}, + } { + t.Run(item.name, func(t *testing.T) { + input := &model.TWoRecordAddOperationParam{WoNo: item.name, OperationType: item.operation, BeforeStatus: item.before, AfterStatus: item.after} + if err := NewCommonTWoRecord().AddOperation(ctx, input); err != nil { + t.Fatal(err) + } + var record entity.TWoRecord + if err := dao.TWoRecord.Ctx(ctx).Where(dao.TWoRecord.Columns().WoNo, item.name).Scan(&record); err != nil { + t.Fatal(err) + } + if record.OperationType != item.operation || record.BeforeStatus != item.before || record.AfterStatus != item.after { + t.Fatalf("操作记录状态错误: %+v", record) + } + }) + } +} diff --git a/server/internal/logic/gen/test_demo.go b/server/internal/logic/gen/test_demo.go index 27b708f..a3639fa 100644 --- a/server/internal/logic/gen/test_demo.go +++ b/server/internal/logic/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/logic/gen_codes/gen_selects.go b/server/internal/logic/gen_codes/gen_selects.go index 4f8b648..5c7de13 100644 --- a/server/internal/logic/gen_codes/gen_selects.go +++ b/server/internal/logic/gen_codes/gen_selects.go @@ -1,6 +1,6 @@ // package gen_codes // @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 gen_codes diff --git a/server/internal/logic/gen_codes/sys_gen_table.go b/server/internal/logic/gen_codes/sys_gen_table.go index 7b59dcb..030cb56 100644 --- a/server/internal/logic/gen_codes/sys_gen_table.go +++ b/server/internal/logic/gen_codes/sys_gen_table.go @@ -1,7 +1,7 @@ // package gen_codes // @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 gen_codes diff --git a/server/internal/logic/imei_test.go b/server/internal/logic/imei_test.go new file mode 100644 index 0000000..6e73456 --- /dev/null +++ b/server/internal/logic/imei_test.go @@ -0,0 +1,13 @@ +package logic + +import ( + "testing" + + "git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/service" +) + +func TestImeiQueueRegisteredAtApplicationStartup(t *testing.T) { + if service.ImeiQueue() == nil { + t.Fatal("IMEI queue implementation is not registered") + } +} diff --git a/server/internal/logic/imeiqueue/queue_test.go b/server/internal/logic/imeiqueue/queue_test.go new file mode 100644 index 0000000..db6e901 --- /dev/null +++ b/server/internal/logic/imeiqueue/queue_test.go @@ -0,0 +1,99 @@ +package imeiqueue + +import ( + "context" + "encoding/json" + "errors" + "reflect" + "testing" + "time" + + "git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/consts" + "git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/model" +) + +func stubImeiTrigger(t *testing.T) { + t.Helper() + var ( + now = imeiNow + runID = imeiRunID + enabled = imeiRabbitEnabled + publish = imeiPublish + ) + t.Cleanup(func() { + imeiNow, imeiRunID, imeiRabbitEnabled, imeiPublish = now, runID, enabled, publish + }) + imeiNow = func() time.Time { return time.Date(2026, 10, 13, 4, 0, 0, 0, time.FixedZone("UTC+8", 8*60*60)) } + imeiRunID = func() string { return "test-run" } + imeiRabbitEnabled = func(context.Context) bool { return true } +} + +func TestImeiTriggerPublishesOneRabbitMQStartWithScope(t *testing.T) { + stubImeiTrigger(t) + if consts.QueueImeiQueueStart != "iotlcm.imei.queue.start" { + t.Fatal("IMEI start queue does not match Worker") + } + calls := 0 + var messages []model.ImeiQueueStartMessage + imeiPublish = func(ctx context.Context, queue string, data any) error { + calls++ + if ctx == nil || queue != consts.QueueImeiQueueStart { + t.Errorf("queue=%q, context=%v", queue, ctx) + } + messages = append(messages, data.(model.ImeiQueueStartMessage)) + return nil + } + for _, scope := range []*model.SyncScope{nil, {Channels: []string{}}, {Channels: []string{"b", "a", "b"}, TriggerSource: model.TriggerSourceManual}} { + runID, err := New().StartImeiRunScoped(nil, scope) + if err != nil || runID != "imei-20261013040000-test-run" { + t.Fatalf("run=%q, error=%v", runID, err) + } + } + if calls != 3 || messages[0].Scope != nil || messages[1].Scope != nil { + t.Fatalf("calls=%d, messages=%+v", calls, messages) + } + message := messages[2] + if message.Operation != "imei_full_sync" || message.TriggeredAt != "2026-10-13T04:00:00+08:00" || !reflect.DeepEqual(message.Scope.Channels, []string{"a", "b"}) || message.Scope.TriggerSource != model.TriggerSourceManual { + t.Fatalf("message=%+v", message) + } + for i, message := range messages { + data, err := json.Marshal(message) + if err != nil { + t.Fatal(err) + } + var body map[string]any + if err := json.Unmarshal(data, &body); err != nil { + t.Fatal(err) + } + if body["runId"] != message.RunID || body["operation"] != "imei_full_sync" || body["triggeredAt"] != message.TriggeredAt { + t.Fatalf("Worker JSON contract: %s", data) + } + if _, present := body["scope"]; present != (i == 2) { + t.Fatalf("full/scoped message compatibility: %s", data) + } + } +} + +func TestImeiTriggerRejectsInvalidScopeDisabledMQAndPublishFailure(t *testing.T) { + stubImeiTrigger(t) + calls := 0 + publishErr := errors.New("broker unavailable") + imeiPublish = func(context.Context, string, any) error { calls++; return publishErr } + if _, err := New().StartImeiRunScoped(context.Background(), &model.SyncScope{Channels: []string{"bad/channel"}}); err == nil { + t.Fatal("invalid scope accepted") + } + imeiRabbitEnabled = func(context.Context) bool { return false } + if _, err := New().StartImeiRun(context.Background()); err == nil { + t.Fatal("disabled RabbitMQ accepted") + } + if calls != 0 { + t.Fatal("invalid scope or disabled RabbitMQ invoked publisher") + } + imeiRabbitEnabled = func(context.Context) bool { return true } + if runID, err := New().StartImeiRun(context.Background()); runID != "" || !errors.Is(err, publishErr) { + t.Fatalf("run=%q, error=%v", runID, err) + } + if calls != 1 { + t.Fatalf("failed publish retried unexpectedly: %d", calls) + } +} diff --git a/server/internal/logic/iot/cardpackage/card_update.go b/server/internal/logic/iot/cardpackage/card_update.go index 35488fd..3875983 100644 --- a/server/internal/logic/iot/cardpackage/card_update.go +++ b/server/internal/logic/iot/cardpackage/card_update.go @@ -25,7 +25,7 @@ func (s *Service) UpdateCardsAfterRenew(ctx context.Context, tx gdb.TX, renew *R if bind == nil { return gerror.Newf("ICCID续费订购记录不存在:%s", iccid) } - packageStatus := CalcPackageStatus(now, card.FlowPkgBeginAt, bind.ExpectedExpireTime) + packageStatus, _ := calcPackageStatuses(now, bind.EffectiveTime, bind.ExpectedExpireTime) result, err := tx.Ctx(ctx).Model(dao.TSimCardInfo.Table()). Where(dao.TSimCardInfo.Columns().Iccid, iccid). Data(do.TSimCardInfo{ diff --git a/server/internal/logic/iot/cardpackage/renew.go b/server/internal/logic/iot/cardpackage/renew.go index 27d83e3..fa27b40 100644 --- a/server/internal/logic/iot/cardpackage/renew.go +++ b/server/internal/logic/iot/cardpackage/renew.go @@ -187,6 +187,7 @@ func (s *Service) RenewCurrentPackage(ctx context.Context, tx gdb.TX, in *RenewC latestBind := latestBindsByIccid[iccid] periodBegin := renewPeriodBeginAt(renewBaseExpireTime(card, latestBind), in.PackageBeginTime) periodEnd := addMonths(periodBegin, durationMonths+giftDurationMonths) + _, effectiveStatus := calcPackageStatuses(now, periodBegin, periodEnd) subscriptionNo, err := batchimport.NextRandomNo(subscriptionNoPrefix) if err != nil { return nil, err @@ -201,7 +202,7 @@ func (s *Service) RenewCurrentPackage(ctx context.Context, tx gdb.TX, in *RenewC PackageCode: pkg.PackageCode, PreviousPackageCode: currentPackageCode, BusinessType: consts.PackageBusinessTypeRenew, - EffectiveStatus: consts.EffectiveStatusPending, + EffectiveStatus: effectiveStatus, HandleTime: now, EffectiveTime: periodBegin, ExpectedExpireTime: periodEnd, @@ -227,7 +228,7 @@ func (s *Service) RenewCurrentPackage(ctx context.Context, tx gdb.TX, in *RenewC PackageCode: pkg.PackageCode, PreviousPackageCode: currentPackageCode, BusinessType: consts.PackageBusinessTypeRenew, - EffectiveStatus: consts.EffectiveStatusPending, + EffectiveStatus: effectiveStatus, HandleTime: now, EffectiveTime: periodBegin, ExpectedExpireTime: periodEnd, diff --git a/server/internal/logic/iot/cardpackage/renew_test.go b/server/internal/logic/iot/cardpackage/renew_test.go new file mode 100644 index 0000000..df46550 --- /dev/null +++ b/server/internal/logic/iot/cardpackage/renew_test.go @@ -0,0 +1,21 @@ +package cardpackage + +import ( + "strings" + "testing" + + "git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/consts" + "git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/model/entity" +) + +func TestValidateRenewCardStatusRejectsStoppedCard(t *testing.T) { + err := validateRenewCardStatus("89860401788404184188", &entity.TSimCardInfo{ + BossCardStatus: consts.V3ApiSimBossCardStatusStopped, + }) + if err == nil { + t.Fatal("validateRenewCardStatus() error = nil, want stopped card error") + } + if !strings.Contains(err.Error(), "当前为停机状态") { + t.Fatalf("validateRenewCardStatus() error = %q, want stopped card error", err.Error()) + } +} diff --git a/server/internal/logic/iot/cardpackage/repository_test.go b/server/internal/logic/iot/cardpackage/repository_test.go new file mode 100644 index 0000000..0526cdf --- /dev/null +++ b/server/internal/logic/iot/cardpackage/repository_test.go @@ -0,0 +1,13 @@ +package cardpackage + +import "testing" + +func TestCardPackageBindChunkSizeSupportsFiftyThousandCards(t *testing.T) { + const totalCards = 50000 + if cardPackageBindInsertBatchSize <= 0 || cardPackageBindInsertBatchSize > 500 { + t.Fatalf("cardPackageBindInsertBatchSize = %d, want 1..500", cardPackageBindInsertBatchSize) + } + if chunks := (totalCards + cardPackageBindInsertBatchSize - 1) / cardPackageBindInsertBatchSize; chunks != 100 { + t.Fatalf("bind insert chunks = %d, want 100", chunks) + } +} diff --git a/server/internal/logic/iot/cardpackage/subscribe.go b/server/internal/logic/iot/cardpackage/subscribe.go index 1d15e77..5d52dc8 100644 --- a/server/internal/logic/iot/cardpackage/subscribe.go +++ b/server/internal/logic/iot/cardpackage/subscribe.go @@ -506,6 +506,10 @@ func CalcPackageStatus(now, beginAt, endAt *gtime.Time) int { return consts.PackageStatusEffective } +func calcPackageStatuses(now, beginAt, endAt *gtime.Time) (packageStatus, effectiveStatus int) { + return CalcPackageStatus(now, beginAt, endAt), CalcEffectiveStatus(now, beginAt, endAt) +} + func CalcEffectiveStatus(now, beginAt, endAt *gtime.Time) int { if beginAt != nil && beginAt.After(now) { return consts.EffectiveStatusPending diff --git a/server/internal/logic/iot/cardpackage/subscribe_test.go b/server/internal/logic/iot/cardpackage/subscribe_test.go new file mode 100644 index 0000000..87ad627 --- /dev/null +++ b/server/internal/logic/iot/cardpackage/subscribe_test.go @@ -0,0 +1,70 @@ +package cardpackage + +import ( + "testing" + + "git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/consts" + + "github.com/gogf/gf/v2/os/gtime" +) + +func TestOrderTypeForBusinessType(t *testing.T) { + tests := []struct { + name string + businessType int + want int + }{ + {name: "subscribe", businessType: consts.PackageBusinessTypeSubscribe, want: consts.OrderTypeCardSubscribe}, + {name: "renew", businessType: consts.PackageBusinessTypeRenew, want: consts.OrderTypeCardRenew}, + {name: "change", businessType: consts.PackageBusinessTypeChange, want: consts.OrderTypeCardChange}, + {name: "unknown defaults to subscribe", businessType: 99, want: consts.OrderTypeCardSubscribe}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + if got := orderTypeForBusinessType(test.businessType); got != test.want { + t.Fatalf("orderTypeForBusinessType(%d) = %d, want %d", test.businessType, got, test.want) + } + }) + } +} + +func TestCalcPackageStatuses(t *testing.T) { + now := gtime.NewFromStr("2026-10-27 12:00:00") + tests := []struct { + name string + beginAt, endAt *gtime.Time + wantPackage, wantBind int + }{ + { + name: "renew period already effective", + beginAt: gtime.NewFromStr("2026-09-30 23:59:59"), + endAt: gtime.NewFromStr("2027-09-30 23:59:59"), + wantPackage: consts.PackageStatusEffective, + wantBind: consts.EffectiveStatusEffective, + }, + { + name: "renew period starts in future", + beginAt: gtime.NewFromStr("2026-11-01 23:59:59"), + endAt: gtime.NewFromStr("2027-11-01 23:59:59"), + wantPackage: consts.PackageStatusPending, + wantBind: consts.EffectiveStatusPending, + }, + { + name: "renew period already expired", + beginAt: gtime.NewFromStr("2025-09-30 23:59:59"), + endAt: gtime.NewFromStr("2026-09-30 23:59:59"), + wantPackage: consts.PackageStatusExpired, + wantBind: consts.EffectiveStatusExpired, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + packageStatus, effectiveStatus := calcPackageStatuses(now, test.beginAt, test.endAt) + if packageStatus != test.wantPackage || effectiveStatus != test.wantBind { + t.Fatalf("calcPackageStatuses() = (%d, %d), want (%d, %d)", packageStatus, effectiveStatus, test.wantPackage, test.wantBind) + } + }) + } +} diff --git a/server/internal/logic/iot/channel_display.go b/server/internal/logic/iot/channel_display.go new file mode 100644 index 0000000..6784c58 --- /dev/null +++ b/server/internal/logic/iot/channel_display.go @@ -0,0 +1,85 @@ +package iot + +import ( + "context" + "fmt" + "strings" + + "git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/dao" + "git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/contexts" + "git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/model" + "git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/model/entity" + + "github.com/gogf/gf/v2/frame/g" +) + +type channelDisplayScope struct { + TenantID string + DeptID int64 + FullName bool +} + +// Channel names are private to the tenant's top organization. A broad data +// scope or an administrator role does not grant downstream organizations names. +func canViewChannelFullName(ctx context.Context) bool { + return resolveChannelDisplayScope(ctx, func() (*entity.SysDept, error) { + var dept *entity.SysDept + cols := dao.SysDept.Columns() + err := dao.SysDept.Ctx(ctx).Fields(cols.DeptId, cols.ParentId, cols.Ancestors). + Where(cols.TenantId, contexts.GetTenantId(ctx)). + Where(cols.DeptId, contexts.GetDeptId(ctx)).Scan(&dept) + return dept, err + }) +} + +func resolveChannelDisplayScope(ctx context.Context, load func() (*entity.SysDept, error)) bool { + user := contexts.GetUser(ctx) + if user == nil || user.DeptId <= 0 || strings.TrimSpace(user.TenantId) == "" { + return false + } + const key = "iot.channelDisplayScope" + if cached, ok := contexts.GetDataValue(ctx, key).(channelDisplayScope); ok && cached.TenantID == user.TenantId && cached.DeptID == user.DeptId { + return cached.FullName + } + dept, err := load() + fullName := err == nil && dept != nil && dept.DeptId == user.DeptId && + (dept.ParentId == 0 || strings.Trim(dept.Ancestors, ",") == "0") + if err != nil { + g.Log().Warningf(ctx, "resolve channel display organization failed: %v", err) + } + contexts.SetDataValue(ctx, key, channelDisplayScope{TenantID: user.TenantId, DeptID: user.DeptId, FullName: fullName}) + return fullName +} + +func channelDisplayNameColumn(ctx context.Context) string { + if canViewChannelFullName(ctx) { + return dao.TSimChannels.Columns().ChanName + } + return dao.TSimChannels.Columns().ChanAlias +} + +func channelDisplayNameSQL(ctx context.Context, table string) string { + return fmt.Sprintf("COALESCE(NULLIF(TRIM(%s.%s), ''), '--')", table, channelDisplayNameColumn(ctx)) +} + +func channelDisplayName(fullName bool, name, alias string) string { + if fullName { + return name + } + if alias = strings.TrimSpace(alias); alias != "" { + return alias + } + return "--" +} + +func applyCardChannelDisplayNames(ctx context.Context, list []*model.TSimCardInfoListModel) { + if len(list) == 0 { + return + } + fullName := canViewChannelFullName(ctx) + for _, item := range list { + if item != nil { + item.ChanName = channelDisplayName(fullName, item.ChanName, item.ChanAlias) + } + } +} diff --git a/server/internal/logic/iot/channel_display_test.go b/server/internal/logic/iot/channel_display_test.go new file mode 100644 index 0000000..bdc7e48 --- /dev/null +++ b/server/internal/logic/iot/channel_display_test.go @@ -0,0 +1,232 @@ +package iot + +import ( + "context" + "encoding/json" + "errors" + "strings" + "testing" + + _ "github.com/gogf/gf/contrib/drivers/sqlite/v2" + "github.com/gogf/gf/v2/database/gdb" + "github.com/gogf/gf/v2/frame/g" + "git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/consts" + "git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/contexts" + "git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/model" + "git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/model/entity" +) + +func channelDisplayTestContext(tenant string, dept int64) context.Context { + return contexts.Set(context.Background(), &model.Context{User: &model.Identity{BaseClaims: model.BaseClaims{ + ID: 1, TenantId: tenant, DeptId: dept, + Roles: []model.Role{{RoleId: consts.SuperAdminRoleId, DataScope: consts.SysRoleDataScopeAll}}, + }}}) +} + +func TestChannelDisplayScope(t *testing.T) { + for _, tc := range []struct { + name string + dept *entity.SysDept + err error + want bool + }{ + {name: "top organization", dept: &entity.SysDept{DeptId: 100, ParentId: 0}, want: true}, + {name: "top organization legacy ancestors", dept: &entity.SysDept{DeptId: 100, ParentId: 1, Ancestors: "0"}, want: true}, + {name: "downstream super admin still uses alias", dept: &entity.SysDept{DeptId: 100, ParentId: 50, Ancestors: "0,50"}}, + {name: "missing organization"}, + {name: "different organization", dept: &entity.SysDept{DeptId: 999, ParentId: 0}}, + {name: "lookup failure", err: errors.New("lookup failed")}, + } { + t.Run(tc.name, func(t *testing.T) { + ctx := channelDisplayTestContext("tenant-A", 100) + got := resolveChannelDisplayScope(ctx, func() (*entity.SysDept, error) { return tc.dept, tc.err }) + if got != tc.want { + t.Fatalf("full name = %v, want %v", got, tc.want) + } + if got := canViewChannelFullName(ctx); got != tc.want { + t.Fatalf("cached scope = %v", got) + } + }) + } + for _, ctx := range []context.Context{context.Background(), channelDisplayTestContext("", 100), channelDisplayTestContext("tenant-A", 0)} { + if resolveChannelDisplayScope(ctx, func() (*entity.SysDept, error) { + t.Fatal("invalid identity should not query organizations") + return nil, nil + }) { + t.Fatal("invalid identity got full names") + } + } +} + +func TestChannelDisplayScopeCacheIdentity(t *testing.T) { + ctx := channelDisplayTestContext("tenant-A", 100) + calls := 0 + load := func() (*entity.SysDept, error) { + calls++ + return &entity.SysDept{DeptId: contexts.GetDeptId(ctx), ParentId: 0}, nil + } + if !resolveChannelDisplayScope(ctx, load) || !resolveChannelDisplayScope(ctx, load) || calls != 1 { + t.Fatal("scope not cached for same identity") + } + contexts.GetUser(ctx).DeptId = 200 + resolveChannelDisplayScope(ctx, load) + contexts.GetUser(ctx).TenantId = "tenant-B" + resolveChannelDisplayScope(ctx, load) + if calls != 3 { + t.Fatalf("scope reused across organization or tenant: %d calls", calls) + } +} + +func TestChannelDisplayAliasAndExport(t *testing.T) { + ctx := channelDisplayTestContext("tenant-A", 200) + resolveChannelDisplayScope(ctx, func() (*entity.SysDept, error) { + return &entity.SysDept{DeptId: 200, ParentId: 100, Ancestors: "0,100"}, nil + }) + list := []*model.TSimCardInfoListModel{ + {ChanName: "PRIVATE UPSTREAM NAME", ChanAlias: " 中国移动223 "}, + {ChanName: "PRIVATE UPSTREAM NAME", ChanAlias: " "}, + nil, + } + applyCardChannelDisplayNames(ctx, list) + if list[0].ChanName != "中国移动223" || list[1].ChanName != "--" { + t.Fatalf("name leak: %+v %+v", list[0], list[1]) + } + for _, card := range list[:2] { + row := cancellationExportRow(&model.TSimCardCancellationListModel{ChanName: card.ChanName}) + if row[7] != card.ChanName { + t.Fatalf("export name differs: %v", row[7]) + } + } + if channelDisplayName(true, "full name", "alias") != "full name" { + t.Fatal("top organization name changed") + } +} + +func TestChannelDisplaySQL(t *testing.T) { + db, err := gdb.New(gdb.ConfigNode{Type: "sqlite", Link: "sqlite::@file(:memory:)", MaxOpenConnCount: 1}) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = db.Close(context.Background()) }) + if _, err = db.Exec(context.Background(), "CREATE TABLE channels(chan_name TEXT,chan_alias TEXT)"); err != nil { + t.Fatal(err) + } + for _, alias := range []interface{}{"中国移动223", "", " ", nil} { + if _, err = db.Exec(context.Background(), "INSERT INTO channels VALUES(?,?)", "PRIVATE UPSTREAM NAME", alias); err != nil { + t.Fatal(err) + } + } + for _, top := range []bool{false, true} { + ctx := channelDisplayTestContext("tenant-A", 100) + parent := int64(50) + if top { + parent = 0 + } + resolveChannelDisplayScope(ctx, func() (*entity.SysDept, error) { + return &entity.SysDept{DeptId: 100, ParentId: parent, Ancestors: "0,50"}, nil + }) + values, err := db.GetArray(ctx, "SELECT "+channelDisplayNameSQL(ctx, "channels")+" FROM channels ORDER BY rowid") + if err != nil { + t.Fatal(err) + } + for i, v := range values { + want := "--" + if i == 0 { + want = "中国移动223" + } + if top { + want = "PRIVATE UPSTREAM NAME" + } + if v.String() != want { + t.Fatalf("top=%v row=%d got=%q want=%q", top, i, v.String(), want) + } + } + } +} + +// Exercise the real list query and export formatter against an isolated database. +func TestChannelDisplayCancellationList(t *testing.T) { + previous := gdb.GetConfig("default") + if err := gdb.SetConfig(gdb.Config{"default": gdb.ConfigGroup{{Type: "sqlite", Link: "sqlite::@file(:memory:)", MaxOpenConnCount: 1}}}); err != nil { + t.Fatal(err) + } + db := g.DB() + t.Cleanup(func() { _ = db.Close(context.Background()); _ = gdb.SetConfig(gdb.Config{"default": previous}) }) + statements := []string{ + "CREATE TABLE sys_dept(dept_id INTEGER,tenant_id TEXT,parent_id INTEGER,ancestors TEXT,dept_name TEXT,deleted_at TEXT)", + "INSERT INTO sys_dept VALUES(100,'tenant-A',0,'0','Top',NULL),(200,'tenant-A',100,'0,100','Customer',NULL)", + "CREATE TABLE t_sim_channels(id INTEGER,temp_key TEXT,chan_key TEXT,tenant_id TEXT,chan_name TEXT,chan_alias TEXT)", + "CREATE TABLE t_sim_channel_template(temp_key TEXT,temp_name TEXT,temp_model TEXT)", + "INSERT INTO t_sim_channels(id,chan_key,tenant_id,chan_name,chan_alias) VALUES(1,'SHARED','tenant-A','PRIVATE NAME A','中国移动223'),(2,'SHARED','tenant-B','PRIVATE NAME B','Other tenant alias')", + "CREATE TABLE t_sim_flow_package_info(package_code TEXT,package_name TEXT,tenant_id TEXT)", + "INSERT INTO t_sim_flow_package_info VALUES('PKG','套餐A','tenant-A'),('PKG','套餐B','tenant-B')", + "CREATE TABLE t_sim_card_cancellation(id INTEGER,iccid TEXT,msisdn TEXT,imsi TEXT,imei TEXT,dept_id INTEGER,chan_key TEXT,cancel_batch_no TEXT,cancelled_at TEXT,boss_card_status INTEGER,current_package_code TEXT,inbound_batch_no TEXT,outbound_batch_no TEXT,allocation_batch_no TEXT,card_type INTEGER,net_model INTEGER,remark TEXT,created_by INTEGER,tenant_id TEXT)", + "INSERT INTO t_sim_card_cancellation(id,iccid,dept_id,chan_key,created_by,tenant_id,current_package_code) VALUES(1,'CARD-A',200,'SHARED',0,'tenant-A','PKG'),(2,'CARD-B',200,'SHARED',0,'tenant-B','PKG')", + } + for _, sql := range statements { + if _, err := db.Exec(context.Background(), sql); err != nil { + t.Fatal(err) + } + } + for _, dept := range []int64{200, 100} { + ctx := channelDisplayTestContext("tenant-A", dept) + contexts.GetUser(ctx).Roles = []model.Role{{RoleId: 99, DataScope: consts.SysRoleDataScopeAll}} + in := &model.TSimCardCancellationListParam{} + in.Page = 1 + in.PageSize = 20 + list, total, err := NewIotTSimCardCancellation().List(ctx, in) + if err != nil { + t.Fatal(err) + } + if total != 1 || len(list) != 1 || list[0].Iccid != "CARD-A" { + t.Fatalf("cross tenant rows: total=%d list=%+v", total, list) + } + want := "中国移动223" + if dept == 100 { + want = "PRIVATE NAME A" + } + if list[0].PackageName != "套餐A" { + t.Fatalf("cross tenant package: %q", list[0].PackageName) + } + if list[0].ChanName != want { + t.Fatalf("dept=%d name=%q want=%q", dept, list[0].ChanName, want) + } + if row := cancellationExportRow(list[0]); row[7] != want { + t.Fatalf("export name=%v", row[7]) + } + channelQuery := &model.TSimChannelsListParam{} + channelQuery.Page = 1 + channelQuery.PageSize = 20 + channelQuery.ChanName = want + channels, count, err := NewIotTSimChannels().List(ctx, channelQuery) + if err != nil { + t.Fatal(err) + } + if count != 1 || len(channels) != 1 || channels[0].ChanName != want { + t.Fatalf("channel options: count=%d rows=%+v", count, channels) + } + stockNames, err := loadStockChannelNames(ctx, []string{"SHARED"}) + if err != nil || stockNames["SHARED"] != want { + t.Fatalf("stock channel name=%v err=%v", stockNames, err) + } + channelView, err := NewIotTSimChannels().View(ctx, &model.TSimChannelsViewParam{Id: 1}) + if err != nil || channelView == nil || channelView.ChanName != want { + t.Fatalf("channel view=%+v err=%v", channelView, err) + } + } +} + +func TestChannelDisplayCancellationMissingAlias(t *testing.T) { + ctx := channelDisplayTestContext("tenant-A", 200) + resolveChannelDisplayScope(ctx, func() (*entity.SysDept, error) { return &entity.SysDept{DeptId: 200, ParentId: 100}, nil }) + for _, alias := range []string{"", " "} { + name := channelDisplayName(canViewChannelFullName(ctx), "PRIVATE NAME", alias) + encoded, err := json.Marshal(&model.TSimCardCancellationListModel{ChanName: name}) + if err != nil { + t.Fatal(err) + } + if strings.Contains(string(encoded), "PRIVATE NAME") || name != "--" { + t.Fatalf("response exposes full name: %s", encoded) + } + } +} diff --git a/server/internal/logic/iot/package_price_effective_test.go b/server/internal/logic/iot/package_price_effective_test.go new file mode 100644 index 0000000..34f1a04 --- /dev/null +++ b/server/internal/logic/iot/package_price_effective_test.go @@ -0,0 +1,34 @@ +package iot + +import ( + "context" + "testing" +) + +func TestResolvePackageEffectiveSalePriceUsesSystemDefaults(t *testing.T) { + got, err := resolvePackageEffectiveSalePrice( + context.Background(), + nil, + "tenant-1", + 0, + 0, + packagePriceSource{ + PackageCode: "PKG-1", + CostPrice: 10, + TerminalCost: 20, + SysDefaultRatio: 80, + SysDefaultProfit: 1, + SysDefaultTerminalRatio: 50, + SysDefaultTerminalProfit: 2, + }, + ) + if err != nil { + t.Fatalf("resolvePackageEffectiveSalePrice() error = %v", err) + } + if got.AgentShipmentPrice != 9 { + t.Fatalf("agent shipment price = %v, want 9", got.AgentShipmentPrice) + } + if got.TerminalSalePrice != 12 { + t.Fatalf("terminal sale price = %v, want 12", got.TerminalSalePrice) + } +} diff --git a/server/internal/logic/iot/packageprice/effective_test.go b/server/internal/logic/iot/packageprice/effective_test.go new file mode 100644 index 0000000..92931f2 --- /dev/null +++ b/server/internal/logic/iot/packageprice/effective_test.go @@ -0,0 +1,91 @@ +package packageprice + +import ( + "context" + "testing" + + "git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/model/entity" +) + +type fakePriceStore struct { + parents map[int64]int64 + prices map[priceKey]*entity.TSimFlowPackagePrice +} + +type priceKey struct { + deptID int64 + scopeType int + targetAgentID int64 + packageCode string +} + +func (s fakePriceStore) ParentDeptID(ctx context.Context, tenantID string, deptID int64) (int64, error) { + return s.parents[deptID], nil +} + +func (s fakePriceStore) PriceOverride(ctx context.Context, tenantID string, deptID int64, scopeType int, targetAgentID int64, packageCode string) (*entity.TSimFlowPackagePrice, error) { + return s.prices[priceKey{ + deptID: deptID, + scopeType: scopeType, + targetAgentID: targetAgentID, + packageCode: packageCode, + }], nil +} + +func TestResolveEffectiveSalePriceUsesRecursiveParentCost(t *testing.T) { + store := fakePriceStore{ + parents: map[int64]int64{ + 117: 100, + 131: 117, + }, + prices: map[priceKey]*entity.TSimFlowPackagePrice{ + {deptID: 100, scopeType: 1, targetAgentID: 117, packageCode: "yd-100m-3y"}: { + Id: 86, + AgentCostRatio: 1, + AgentCostProfit: 4, + UserCostRatio: 1, + UserCostProfit: 4, + }, + {deptID: 117, scopeType: 0, targetAgentID: 0, packageCode: "yd-100m-3y"}: { + Id: 91, + AgentCostRatio: 1, + AgentCostProfit: 1, + UserCostRatio: 1, + UserCostProfit: 1, + }, + {deptID: 117, scopeType: 1, targetAgentID: 131, packageCode: "yd-100m-3y"}: { + Id: 92, + AgentCostRatio: 1, + AgentCostProfit: 5, + UserCostRatio: 1, + UserCostProfit: 5, + }, + }, + } + + got, err := resolveEffectiveSalePrice( + context.Background(), + store, + "000000", + 117, + 131, + Source{ + PackageCode: "yd-100m-3y", + CostPrice: 10, + TerminalCost: 10, + SysDefaultRatio: 1, + }, + ) + if err != nil { + t.Fatalf("resolveEffectiveSalePrice() error = %v", err) + } + if got.Base.AgentCost != 14 { + t.Fatalf("base agent cost = %.2f, want 14.00", got.Base.AgentCost) + } + if got.AgentShipmentPrice != 19 { + t.Fatalf("agent shipment price = %.2f, want 19.00", got.AgentShipmentPrice) + } + if got.Rule.PriceSource != "target" || got.Rule.TargetPriceId != 92 { + t.Fatalf("rule = %+v, want target price 92", got.Rule) + } +} diff --git a/server/internal/logic/iot/simcardbatch/allocate/processor_test.go b/server/internal/logic/iot/simcardbatch/allocate/processor_test.go new file mode 100644 index 0000000..a6fa99f --- /dev/null +++ b/server/internal/logic/iot/simcardbatch/allocate/processor_test.go @@ -0,0 +1,80 @@ +package allocate + +import ( + "context" + "path/filepath" + "reflect" + "testing" + + "github.com/xuri/excelize/v2" +) + +func TestProcessorTemplateHeaders(t *testing.T) { + t.Parallel() + + expected := []string{"ICCID", "MSISDN", "IMSI", "目标客户名称"} + if !reflect.DeepEqual(NewProcessor().Config().TemplateHeaders, expected) { + t.Fatalf("unexpected headers: %#v", NewProcessor().Config().TemplateHeaders) + } +} + +func TestParseExcelFileRowsCustomerFacingColumns(t *testing.T) { + t.Parallel() + + filePath := filepath.Join(t.TempDir(), "allocate.xlsx") + f := excelize.NewFile() + headers := []any{"ICCID", "MSISDN", "IMSI", "目标客户名称"} + if err := f.SetSheetRow("Sheet1", "A1", &headers); err != nil { + t.Fatalf("set headers failed: %v", err) + } + row := []any{ + "8986000000000000001", + "13800008000", + "460001234567890", + "示例企业", + } + if err := f.SetSheetRow("Sheet1", "A2", &row); err != nil { + t.Fatalf("set row failed: %v", err) + } + if err := f.SaveAs(filePath); err != nil { + t.Fatalf("save excel failed: %v", err) + } + _ = f.Close() + + rows, err := ParseExcelFileRows(context.Background(), filePath) + if err != nil { + t.Fatalf("parse excel failed: %v", err) + } + if len(rows) != 1 { + t.Fatalf("unexpected row count: %d", len(rows)) + } + rowModel, ok := rows[0].(*Row) + if !ok || rowModel == nil { + t.Fatalf("unexpected row type: %#v", rows[0]) + } + if rowModel.Iccid != "8986000000000000001" { + t.Fatalf("unexpected iccid: %s", rowModel.Iccid) + } + if rowModel.Msisdn != "13800008000" { + t.Fatalf("unexpected msisdn: %s", rowModel.Msisdn) + } + if rowModel.Imsi != "460001234567890" { + t.Fatalf("unexpected imsi: %s", rowModel.Imsi) + } + if rowModel.TargetDeptName != "示例企业" { + t.Fatalf("unexpected target dept name: %s", rowModel.TargetDeptName) + } + if rowModel.Remark != "" { + t.Fatalf("unexpected remark: %s", rowModel.Remark) + } +} + +func TestProcessorConfigLimitsAllocationRows(t *testing.T) { + config := NewProcessor().Config() + if config.MaxRows != 10000 { + t.Fatalf("MaxRows = %d, want 10000", config.MaxRows) + } + if config.MaxFileSize != 50<<20 { + t.Fatalf("MaxFileSize = %d, want %d", config.MaxFileSize, 50<<20) + } +} diff --git a/server/internal/logic/iot/simcardbatch/allocate/splitter.go b/server/internal/logic/iot/simcardbatch/allocate/splitter.go index a5990da..157e1b8 100644 --- a/server/internal/logic/iot/simcardbatch/allocate/splitter.go +++ b/server/internal/logic/iot/simcardbatch/allocate/splitter.go @@ -102,6 +102,7 @@ func createPendingSplit( if err := createAllocateParent(ctx, req, p, parentBatchNo, importOss, chunkSize); err != nil { return nil, err } + children := make([]*batchimport.Result, 0) if _, err := stream(ctx, func(rows []batchimport.Row) error { if len(rows) == 0 { @@ -115,16 +116,20 @@ func createPendingSplit( return err } } - if err := markAllocateCrossChunkDuplicates(ctx, parentBatchNo, req.Operator.TenantId, rows); err != nil { + priorFailures, err := markAllocateCrossChunkDuplicates(ctx, parentBatchNo, req.Operator.TenantId, rows) + if err != nil { return err } + state.Valid -= priorFailures + state.Failed += priorFailures state.ChunkCount++ result, err := createAllocateChunk(ctx, req, p, parentBatchNo, state.ChunkCount, rows, importOss, state) if err != nil { return err } - return common.PublishBatchMessageStrict(ctx, result, consts.QueueBatchExecute, "allocate") + children = append(children, result) + return nil }); err != nil { markAllocateParentFailed(ctx, parentBatchNo, err.Error()) return nil, err @@ -138,6 +143,12 @@ func createPendingSplit( markAllocateParentFailed(ctx, parentBatchNo, err.Error()) return nil, err } + for _, child := range children { + if err := common.PublishBatchMessageStrict(ctx, child, consts.QueueBatchExecute, "allocate"); err != nil { + markAllocateParentFailed(ctx, parentBatchNo, err.Error()) + return nil, err + } + } parentMessage := &batchimport.Result{ BatchNo: parentBatchNo, @@ -397,9 +408,9 @@ func countBatchRows(rows []batchimport.Row) (valid, failed int) { return } -func markAllocateCrossChunkDuplicates(ctx context.Context, parentBatchNo, tenantID string, rows []batchimport.Row) error { +func markAllocateCrossChunkDuplicates(ctx context.Context, parentBatchNo, tenantID string, rows []batchimport.Row) (int, error) { if strings.TrimSpace(parentBatchNo) == "" || len(rows) == 0 { - return nil + return 0, nil } columns := dao.BatchDetail.Columns() itemIDs := make([]string, 0, len(rows)) @@ -420,24 +431,65 @@ func markAllocateCrossChunkDuplicates(ctx context.Context, parentBatchNo, tenant itemIDs = append(itemIDs, itemID) } if len(itemIDs) == 0 { - return nil + return 0, nil } var existing []struct { - ItemId string `orm:"item_id"` + Id int64 `orm:"id"` + BatchNo string `orm:"batch_no"` + ItemId string `orm:"item_id"` + DetailStatus int `orm:"detail_status"` } query := g.DB().Model(dao.BatchDetail.Table()). - Fields(columns.ItemId). + Fields(columns.Id, columns.BatchNo, columns.ItemId, columns.DetailStatus). Where(columns.BatchType, consts.BatchTypeAllocate). Where(columns.TenantId, tenantID). WhereIn(columns.ItemId, itemIDs). Where("batch_no IN (SELECT batch_no FROM batch_main WHERE source_batch_no = ? AND batch_type = ? AND tenant_id = ?)", parentBatchNo, consts.BatchTypeAllocate, tenantID) if err := query.Scan(&existing); err != nil { - return gerror.Wrap(err, "check duplicate allocate ICCIDs across chunks failed") + return 0, gerror.Wrap(err, "check duplicate allocate ICCIDs across chunks failed") } if len(existing) == 0 { + return 0, nil + } + + priorFailures := 0 + err := g.DB().Transaction(ctx, func(ctx context.Context, tx gdb.TX) error { + for _, detail := range existing { + if detail.DetailStatus != consts.BatchDetailStatusPending { + continue + } + result, err := tx.Ctx(ctx).Model(dao.BatchDetail.Table()). + Where(columns.Id, detail.Id). + Where(columns.TenantId, tenantID). + Where(columns.DetailStatus, consts.BatchDetailStatusPending). + Data(do.BatchDetail{DetailStatus: consts.BatchDetailStatusFailed, ErrorReason: "ICCID在划拨批次内重复"}).Update() + if err != nil { + return gerror.Wrap(err, "mark prior duplicate allocation detail failed") + } + affected, err := result.RowsAffected() + if err != nil { + return err + } + if affected == 0 { + continue + } + priorFailures++ + mainColumns := dao.BatchMain.Columns() + for _, batchNo := range []string{detail.BatchNo, parentBatchNo} { + if _, err := tx.Ctx(ctx).Model(dao.BatchMain.Table()). + Where(mainColumns.BatchNo, batchNo). + Where(mainColumns.TenantId, tenantID). + Data(do.BatchMain{ValidCount: gdb.Raw("valid_count - 1"), FailCount: gdb.Raw("fail_count + 1")}).Update(); err != nil { + return gerror.Wrap(err, "update cross-chunk duplicate counts failed") + } + } + } return nil + }) + if err != nil { + return 0, err } duplicates := make(map[string]struct{}, len(existing)) @@ -452,10 +504,10 @@ func markAllocateCrossChunkDuplicates(ctx context.Context, parentBatchNo, tenant continue } if _, exists := duplicates[strings.TrimSpace(row.Iccid)]; exists { - row.AddErrorReason("ICCID appears in an earlier allocation chunk") + row.AddErrorReason("ICCID在划拨批次内重复") } } - return nil + return priorFailures, nil } func withAllocateInfo(info *gjson.Json, values map[string]any) *gjson.Json { diff --git a/server/internal/logic/iot/simcardbatch/allocate/splitter_metadata_test.go b/server/internal/logic/iot/simcardbatch/allocate/splitter_metadata_test.go new file mode 100644 index 0000000..69e346f --- /dev/null +++ b/server/internal/logic/iot/simcardbatch/allocate/splitter_metadata_test.go @@ -0,0 +1,93 @@ +package allocate + +import ( + "context" + "reflect" + "strings" + "testing" + + "git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/consts" + "git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/batchimport" + "git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/batchmetadata" + "git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/model/entity" + + "github.com/gogf/gf/contrib/drivers/sqlite/v2" + "github.com/gogf/gf/v2/database/gdb" + "github.com/gogf/gf/v2/frame/g" +) + +type allocateMetadataSQLiteDriver struct { + *sqlite.Driver +} + +func (driver *allocateMetadataSQLiteDriver) New(core *gdb.Core, _ *gdb.ConfigNode) (gdb.DB, error) { + return &allocateMetadataSQLiteDriver{Driver: &sqlite.Driver{Core: core}}, nil +} + +func (driver *allocateMetadataSQLiteDriver) DoSelect(ctx context.Context, link gdb.Link, query string, args ...any) (gdb.Result, error) { + return driver.Core.DoSelect(ctx, link, strings.TrimSuffix(query, " FOR UPDATE"), args...) +} + +func TestAllocateSplitInheritsRequestMetadata(t *testing.T) { + if err := gdb.Register("allocate_metadata_sqlite", &allocateMetadataSQLiteDriver{}); err != nil { + t.Fatal(err) + } + ctx := context.Background() + if err := gdb.SetConfig(gdb.Config{"default": gdb.ConfigGroup{{Type: "allocate_metadata_sqlite", Name: ":memory:", MaxOpenConnCount: 1}}}); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = g.DB().Close(ctx) }) + if err := g.DB().GetCore().ClearTableFieldsAll(ctx); err != nil { + t.Fatal(err) + } + for table, record := range map[string]any{"batch_main": entity.BatchMain{}, "batch_detail": entity.BatchDetail{}} { + recordType := reflect.TypeOf(record) + columns := make([]string, 0, recordType.NumField()) + for fieldIndex := 0; fieldIndex < recordType.NumField(); fieldIndex++ { + field := recordType.Field(fieldIndex) + column := field.Tag.Get("orm") + if column == "id" { + columns = append(columns, "id INTEGER PRIMARY KEY AUTOINCREMENT") + } else { + columns = append(columns, column+" TEXT") + } + } + if _, err := g.DB().Exec(ctx, "CREATE TABLE "+table+" ("+strings.Join(columns, ",")+")"); err != nil { + t.Fatal(err) + } + if _, err := g.Model(table).Ctx(ctx).TableFields(table); err != nil { + t.Fatal(err) + } + } + if _, err := g.DB().GetCore().GetTablesWithCache(); err != nil { + t.Fatal(err) + } + for _, mode := range []int{batchmetadata.Single, batchmetadata.Bulk} { + parentNo := "META-SPLIT-SINGLE" + if mode == batchmetadata.Bulk { + parentNo = "META-SPLIT-BULK" + } + req := batchimport.SubmitRequest{SourceChannel: batchmetadata.Console, ExecutionMode: mode, SourceType: consts.BatchSourceTypeBatchInput, Operator: batchimport.OperatorContext{TenantId: "A", UserId: 7}} + processor := NewProcessor() + if err := createAllocateParent(ctx, req, processor, parentNo, nil, 100); err != nil { + t.Fatal(err) + } + rows := []batchimport.Row{&Row{RowNo: 1, Iccid: "card1"}} + result, err := createAllocateChunk(ctx, req, processor, parentNo, 1, rows, nil, nil) + if err != nil { + t.Fatal(err) + } + for _, batchNo := range []string{parentNo, result.BatchNo} { + var batch *entity.BatchMain + if err := g.Model("batch_main").Ctx(ctx).Where("batch_no", batchNo).Scan(&batch); err != nil { + t.Fatal(err) + } + if batch == nil || batch.SourceChannel != req.SourceChannel || batch.ExecutionMode != uint(mode) || batch.SourceType != req.SourceType || batch.TotalCount != 1 { + t.Fatalf("metadata not inherited: %+v", batch) + } + if batchNo != parentNo && batch.SourceBatchNo != parentNo { + t.Fatalf("child lost parent association: %+v", batch) + } + } + } +} diff --git a/server/internal/logic/iot/simcardbatch/change/processor_test.go b/server/internal/logic/iot/simcardbatch/change/processor_test.go new file mode 100644 index 0000000..605ce1a --- /dev/null +++ b/server/internal/logic/iot/simcardbatch/change/processor_test.go @@ -0,0 +1,183 @@ +package change + +import ( + "context" + "os" + "path/filepath" + "testing" + + "git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/batchimport" + "git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/model" + "git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/model/entity" + + "github.com/xuri/excelize/v2" +) + +func TestExcelTemplateHeaders(t *testing.T) { + got := ExcelTemplateHeaders() + want := []string{"ICCID", "MSISDN", "IMSI", "newPackageCode", "durationMonths", "giftDurationMonths", "effectiveMode", "offlineContractCompleted"} + if len(got) != len(want) { + t.Fatalf("headers len = %d, want %d", len(got), len(want)) + } + for i := range want { + if got[i] != want[i] { + t.Fatalf("header[%d] = %q, want %q", i, got[i], want[i]) + } + } +} + +func TestParseExcelFileRows(t *testing.T) { + path := buildTestExcelFile(t, [][]string{ + ExcelTemplateHeaders(), + {"8986001", "", "", "PKG_NEW", "12", "", "immediate", "true"}, + }) + + rows, opts, err := ParseExcelFileRows(context.Background(), path) + if err != nil { + t.Fatalf("ParseExcelFileRows error = %v", err) + } + if len(rows) != 1 { + t.Fatalf("rows len = %d, want 1", len(rows)) + } + if rows[0].GiftDurationMonths != 0 { + t.Fatalf("giftDurationMonths = %d, want 0", rows[0].GiftDurationMonths) + } + if opts == nil || opts.GiftDurationMonths != 0 || !opts.OfflineContractCompleted { + t.Fatalf("opts = %#v", opts) + } +} + +func TestParseExcelFileRowsRequireTrueOfflineContractCompleted(t *testing.T) { + path := buildTestExcelFile(t, [][]string{ + ExcelTemplateHeaders(), + {"8986001", "", "", "PKG_NEW", "12", "", "immediate", "false"}, + }) + + rows, _, err := ParseExcelFileRows(context.Background(), path) + if err != nil { + t.Fatalf("ParseExcelFileRows error = %v", err) + } + if len(rows) != 1 { + t.Fatalf("rows len = %d, want 1", len(rows)) + } + if rows[0].Reason == "" { + t.Fatal("expected row error when offlineContractCompleted is false") + } +} + +func TestValidateChangeRowSourceBatchOptional(t *testing.T) { + operator := batchimport.OperatorContext{DeptId: 100} + row := &Row{Iccid: "8986001"} + card := &entity.TSimCardInfo{ + Iccid: "8986001", + Msisdn: "13800008000", + Imsi: "460001234567890", + DeptId: 100, + CurrentPackageCode: "PKG_OLD", + PackageStatus: 1, + } + bind := &entity.TSimCardBindPkg{ + Iccid: "8986001", + Msisdn: "13800008000", + PackageCode: "PKG_OLD", + BatchNo: "BATCH-OTHER", + } + + if reason := validateChangeRow("", operator, row, card, bind); reason != "" { + t.Fatalf("validateChangeRow without sourceBatchNo = %q, want empty", reason) + } + if reason := validateChangeRow("BATCH-REQ", operator, row, card, bind); reason == "" { + t.Fatal("expected sourceBatchNo mismatch error") + } +} + +func TestValidateChangeRowIdentityMatch(t *testing.T) { + operator := batchimport.OperatorContext{DeptId: 100} + card := &entity.TSimCardInfo{ + Iccid: "8986001", + Msisdn: "13800008000", + Imsi: "460001234567890", + DeptId: 100, + CurrentPackageCode: "PKG_OLD", + PackageStatus: 1, + } + bind := &entity.TSimCardBindPkg{ + Iccid: "8986001", + Msisdn: "13800008000", + PackageCode: "PKG_OLD", + } + + if reason := validateChangeRow("", operator, &Row{Iccid: "8986001", Msisdn: "bad"}, card, bind); reason == "" { + t.Fatal("expected MSISDN mismatch error") + } + if reason := validateChangeRow("", operator, &Row{Iccid: "8986001", Imsi: "bad"}, card, bind); reason == "" { + t.Fatal("expected IMSI mismatch error") + } +} + +func TestBuildBatchInfoOmitsEmptySourceBatchNo(t *testing.T) { + p := NewProcessor() + info := p.BuildBatchInfo(context.Background(), batchimport.SubmitRequest{ + BizParams: &model.TSimCardChangeBatchSubmitParam{ + NewPackageCode: "PKG_NEW", + DurationMonths: 12, + GiftDurationMonths: 0, + EffectiveMode: "immediate", + OfflineContractCompleted: true, + }, + ExecutionResult: &CompleteOutput{ + SourceBatchNo: "", + SourceMode: 0, + NewPackageCode: "PKG_NEW", + NewPackageName: "New Package", + OldPackageCode: "PKG_OLD", + OldPackageName: "Old Package", + DurationMonths: 12, + GiftDurationMonths: 0, + EffectiveMode: "immediate", + OfflineContractCompleted: true, + }, + }, nil, nil, nil, 0) + + if info == nil { + t.Fatal("info is nil") + } + data := info.Map() + if _, ok := data["sourceBatchNo"]; ok { + t.Fatalf("sourceBatchNo should be omitted when empty: %#v", data) + } + if _, ok := data["remark"]; ok { + t.Fatalf("remark should not exist: %#v", data) + } + if _, ok := data["buyerNote"]; ok { + t.Fatalf("buyerNote should not exist: %#v", data) + } + if _, ok := data["adminNote"]; ok { + t.Fatalf("adminNote should not exist: %#v", data) + } +} + +func buildTestExcelFile(t *testing.T, rows [][]string) string { + t.Helper() + + dir := t.TempDir() + path := filepath.Join(dir, "change.xlsx") + f := excelize.NewFile() + for i, row := range rows { + cell, err := excelize.CoordinatesToCellName(1, i+1) + if err != nil { + t.Fatalf("CoordinatesToCellName error = %v", err) + } + if err = f.SetSheetRow("Sheet1", cell, &row); err != nil { + t.Fatalf("SetSheetRow error = %v", err) + } + } + if err := f.SaveAs(path); err != nil { + t.Fatalf("SaveAs error = %v", err) + } + _ = f.Close() + if _, err := os.Stat(path); err != nil { + t.Fatalf("Stat error = %v", err) + } + return path +} diff --git a/server/internal/logic/iot/simcardbatch/lifecycle/executor.go b/server/internal/logic/iot/simcardbatch/lifecycle/executor.go index 1107437..5d20a41 100644 --- a/server/internal/logic/iot/simcardbatch/lifecycle/executor.go +++ b/server/internal/logic/iot/simcardbatch/lifecycle/executor.go @@ -5,6 +5,7 @@ import ( "sort" "strings" + "git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/batchimport" "git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/model" "git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/model/entity" "git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/service" @@ -135,7 +136,7 @@ func validateTask(task *Task, cards map[string]*entity.TSimCardInfo) bool { if hasFailure { for _, row := range task.Rows { if row != nil && row.Reason == "" { - row.AddErrorReason("同批次存在校验失败卡,本卡未请求上游") + row.AddErrorReason(batchimport.NotExecutedReasonPrefix + "本卡校验通过") } } task.FailureReason = "整批业务校验失败,未请求上游" diff --git a/server/internal/logic/iot/simcardbatch/lifecycle/executor_test.go b/server/internal/logic/iot/simcardbatch/lifecycle/executor_test.go new file mode 100644 index 0000000..7c412ea --- /dev/null +++ b/server/internal/logic/iot/simcardbatch/lifecycle/executor_test.go @@ -0,0 +1,290 @@ +package lifecycle + +import ( + "context" + "sync" + "testing" + "time" + + "git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/consts" + "git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/model" + "git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/model/entity" +) + +type fakeExecutorRepository struct { + mu sync.Mutex + claims []bool + claimCalls int + loadedBatchNos []string + task *Task + cards map[string]*entity.TSimCardInfo + validationFailureCalls int + outcomes []Outcome + finalizeCalls int + markFailedCalls int +} + +func (r *fakeExecutorRepository) ClaimBatch(context.Context, string) (bool, error) { + r.mu.Lock() + defer r.mu.Unlock() + index := r.claimCalls + r.claimCalls++ + if index >= len(r.claims) { + return false, nil + } + return r.claims[index], nil +} + +func (r *fakeExecutorRepository) LoadTask(_ context.Context, batchNo string) (*Task, error) { + r.loadedBatchNos = append(r.loadedBatchNos, batchNo) + return r.task, nil +} + +func (r *fakeExecutorRepository) LoadCards(context.Context, *Task) (map[string]*entity.TSimCardInfo, error) { + return r.cards, nil +} + +func (r *fakeExecutorRepository) SaveValidationFailure(context.Context, *Task) error { + r.validationFailureCalls++ + return nil +} + +func (r *fakeExecutorRepository) SaveOutcome(_ context.Context, _ *Task, _ *Row, _ *entity.TSimCardInfo, outcome Outcome) error { + r.outcomes = append(r.outcomes, outcome) + return nil +} + +func (r *fakeExecutorRepository) SaveDetailFailure(context.Context, *Task, *Row) error { + return nil +} + +func (r *fakeExecutorRepository) FinalizeBatch(context.Context, *Task) error { + r.finalizeCalls++ + return nil +} + +func (r *fakeExecutorRepository) MarkBatchFailed(context.Context, string, string) error { + r.markFailedCalls++ + return nil +} + +type fakeStatusChanger struct { + calls int + requests []*model.SimCardStatusChangeInput + response []model.SimBaseRes[model.SimCardStatusChangeItem] + err error +} + +func (c *fakeStatusChanger) ChangeSimStatus(_ context.Context, input *model.SimCardStatusChangeInput) ([]model.SimBaseRes[model.SimCardStatusChangeItem], error) { + c.calls++ + c.requests = append(c.requests, input) + return c.response, c.err +} + +func TestActionOperTypeMapping(t *testing.T) { + stop, err := OperationSuspend.Config() + if err != nil { + t.Fatal(err) + } + start, err := OperationResume.Config() + if err != nil { + t.Fatal(err) + } + if stop.OperType != "0" || stop.BatchType != consts.BatchTypeStop { + t.Fatalf("unexpected stop mapping: %+v", stop) + } + if start.OperType != "1" || start.BatchType != consts.BatchTypeStart { + t.Fatalf("unexpected start mapping: %+v", start) + } +} + +func TestExecuteValidationFailureSkipsUpstream(t *testing.T) { + task := newTestTask(t, OperationSuspend, []*Row{ + {RowNo: 1, Iccid: "missing", Authorized: true}, + {RowNo: 2, Iccid: "valid", Authorized: true}, + }) + repo := &fakeExecutorRepository{ + claims: []bool{true}, + task: task, + cards: map[string]*entity.TSimCardInfo{ + "valid": {Id: 2, Iccid: "valid", ChanKey: "channel-a", BossCardStatus: consts.V3ApiSimBossCardStatusActivated}, + }, + } + upstream := &fakeStatusChanger{} + if err := newExecutor(repo, upstream).Execute(context.Background(), "STP-test"); err != nil { + t.Fatal(err) + } + if upstream.calls != 0 { + t.Fatalf("upstream called %d times for invalid batch", upstream.calls) + } + if repo.validationFailureCalls != 1 || repo.finalizeCalls != 1 { + t.Fatalf("validation/finalize calls = %d/%d", repo.validationFailureCalls, repo.finalizeCalls) + } + if task.Rows[0].Reason == "" || task.Rows[1].Reason == "" { + t.Fatalf("expected all rows to retain failure reasons: %+v", task.Rows) + } +} + +func TestValidateStopStatus(t *testing.T) { + t.Skip("TODO: 恢复卡状态限制后启用此测试") + tests := []struct { + name string + status int + valid bool + }{ + {name: "activated", status: consts.V3ApiSimBossCardStatusActivated, valid: true}, + {name: "pending", status: consts.V3ApiSimBossCardStatusPendingActivate}, + {name: "testable", status: consts.V3ApiSimBossCardStatusTestable}, + {name: "stock", status: consts.V3ApiSimBossCardStatusStock}, + {name: "stopped", status: consts.V3ApiSimBossCardStatusStopped}, + {name: "pre-cancel", status: consts.V3ApiSimBossCardStatusPreCancel}, + {name: "canceled", status: consts.V3ApiSimBossCardStatusCanceled}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + row := &Row{Iccid: "card-1"} + validateCardStatus(OperationSuspend, row, &entity.TSimCardInfo{BossCardStatus: tt.status}) + if got := row.Reason == ""; got != tt.valid { + t.Fatalf("status %d valid=%v, reason=%q", tt.status, got, row.Reason) + } + }) + } +} + +func TestExecuteMixedStopStatusSkipsUpstream(t *testing.T) { + t.Skip("TODO: 恢复卡状态限制后启用此测试") + task := newTestTask(t, OperationSuspend, []*Row{ + {RowNo: 1, Iccid: "activated", Authorized: true}, + {RowNo: 2, Iccid: "stock", Authorized: true}, + }) + repo := &fakeExecutorRepository{ + claims: []bool{true}, + task: task, + cards: map[string]*entity.TSimCardInfo{ + "activated": {Id: 1, Iccid: "activated", ChanKey: "channel-a", BossCardStatus: consts.V3ApiSimBossCardStatusActivated}, + "stock": {Id: 2, Iccid: "stock", ChanKey: "channel-a", BossCardStatus: consts.V3ApiSimBossCardStatusStock}, + }, + } + upstream := &fakeStatusChanger{} + if err := newExecutor(repo, upstream).Execute(context.Background(), "STP-mixed"); err != nil { + t.Fatal(err) + } + if upstream.calls != 0 { + t.Fatalf("upstream called %d times for mixed status batch", upstream.calls) + } + if repo.validationFailureCalls != 1 || repo.finalizeCalls != 1 { + t.Fatalf("validation/finalize calls = %d/%d", repo.validationFailureCalls, repo.finalizeCalls) + } + if task.Rows[0].Reason == "" || task.Rows[1].Reason == "" { + t.Fatalf("expected both rows to be failed without upstream call: %+v", task.Rows) + } +} + +func TestExecuteRestoresByBatchNoAndMapsPerCardResults(t *testing.T) { + task := newTestTask(t, OperationSuspend, []*Row{ + {RowNo: 1, Iccid: "card-1", Authorized: true}, + {RowNo: 2, Iccid: "card-2", Authorized: true}, + }) + repo := &fakeExecutorRepository{ + claims: []bool{true}, + task: task, + cards: map[string]*entity.TSimCardInfo{ + "card-1": {Id: 1, Iccid: "card-1", ChanKey: "channel-a", BossCardStatus: consts.V3ApiSimBossCardStatusActivated}, + "card-2": {Id: 2, Iccid: "card-2", ChanKey: "channel-a", BossCardStatus: consts.V3ApiSimBossCardStatusActivated}, + }, + } + upstream := &fakeStatusChanger{response: []model.SimBaseRes[model.SimCardStatusChangeItem]{ + {Code: 0, SimKey: &model.SimKey{ICCID: "card-1"}, ResData: model.SimCardStatusChangeItem{OrderNum: "order-1"}}, + {Code: 500, Message: "operator rejected", SimKey: &model.SimKey{ICCID: "card-2"}}, + }} + if err := newExecutor(repo, upstream).Execute(context.Background(), "STP-restore"); err != nil { + t.Fatal(err) + } + if len(repo.loadedBatchNos) != 1 || repo.loadedBatchNos[0] != "STP-restore" { + t.Fatalf("executor did not restore by batchNo: %+v", repo.loadedBatchNos) + } + if upstream.calls != 1 || len(upstream.requests) != 1 { + t.Fatalf("unexpected upstream calls: %d", upstream.calls) + } + if upstream.requests[0].ReqData.OperType != "0" { + t.Fatalf("unexpected operType: %s", upstream.requests[0].ReqData.OperType) + } + if len(repo.outcomes) != 2 || !repo.outcomes[0].Success || repo.outcomes[1].Success { + t.Fatalf("unexpected per-card outcomes: %+v", repo.outcomes) + } + if repo.outcomes[0].OrderNum != "order-1" || repo.outcomes[1].Message != "operator rejected" { + t.Fatalf("upstream details were not preserved: %+v", repo.outcomes) + } +} + +func TestExecuteDuplicateClaimRunsOnlyOnce(t *testing.T) { + task := newTestTask(t, OperationResume, []*Row{{RowNo: 1, Iccid: "card-1", Authorized: true}}) + repo := &fakeExecutorRepository{ + claims: []bool{true, false}, + task: task, + cards: map[string]*entity.TSimCardInfo{ + "card-1": {Id: 1, Iccid: "card-1", ChanKey: "channel-a", BossCardStatus: consts.V3ApiSimBossCardStatusStopped}, + }, + } + upstream := &fakeStatusChanger{response: []model.SimBaseRes[model.SimCardStatusChangeItem]{ + {Code: 0, SimKey: &model.SimKey{ICCID: "card-1"}}, + }} + executor := newExecutor(repo, upstream) + if err := executor.Execute(context.Background(), "STR-once"); err != nil { + t.Fatal(err) + } + if err := executor.Execute(context.Background(), "STR-once"); err != nil { + t.Fatal(err) + } + if upstream.calls != 1 || len(repo.loadedBatchNos) != 1 || repo.finalizeCalls != 1 { + t.Fatalf("duplicate claim executed twice: upstream=%d load=%d finalize=%d", upstream.calls, len(repo.loadedBatchNos), repo.finalizeCalls) + } + if upstream.requests[0].ReqData.OperType != "1" { + t.Fatalf("unexpected start operType: %s", upstream.requests[0].ReqData.OperType) + } +} + +type blockingExecutor struct { + started chan struct{} + release chan struct{} +} + +func (e *blockingExecutor) Execute(context.Context, string) error { + close(e.started) + <-e.release + return nil +} + +func (e *blockingExecutor) MarkFailed(context.Context, string, string) error { + return nil +} + +func TestDispatchReturnsBeforeExecutorCompletes(t *testing.T) { + executor := &blockingExecutor{started: make(chan struct{}), release: make(chan struct{})} + Dispatch(executor, "STP-async") + select { + case <-executor.started: + case <-time.After(time.Second): + t.Fatal("background executor did not start") + } + select { + case <-executor.release: + t.Fatal("release channel should still be blocked") + default: + } + close(executor.release) +} + +func newTestTask(t *testing.T, operation Operation, rows []*Row) *Task { + t.Helper() + config, err := operation.Config() + if err != nil { + t.Fatal(err) + } + return &Task{ + Batch: &entity.BatchMain{BatchNo: "batch-test", BatchType: config.BatchType, TenantId: "tenant-1"}, + Operation: operation, + Config: config, + Rows: rows, + } +} diff --git a/server/internal/logic/iot/simcardbatch/lifecycle/message_test.go b/server/internal/logic/iot/simcardbatch/lifecycle/message_test.go new file mode 100644 index 0000000..3bc47a7 --- /dev/null +++ b/server/internal/logic/iot/simcardbatch/lifecycle/message_test.go @@ -0,0 +1,20 @@ +package lifecycle + +import ( + "encoding/json" + "testing" +) + +func TestBatchExecuteMessageJSONContract(t *testing.T) { + body, err := json.Marshal(BatchExecuteMessage{ + BatchNo: "STP202607170001", + BatchType: 104, + }) + if err != nil { + t.Fatalf("marshal batch execute message: %v", err) + } + const expected = `{"batchNo":"STP202607170001","batchType":104}` + if string(body) != expected { + t.Fatalf("unexpected batch execute message: %s", body) + } +} diff --git a/server/internal/logic/iot/simcardbatch/lifecycle/parser_test.go b/server/internal/logic/iot/simcardbatch/lifecycle/parser_test.go new file mode 100644 index 0000000..7fd1aa0 --- /dev/null +++ b/server/internal/logic/iot/simcardbatch/lifecycle/parser_test.go @@ -0,0 +1,92 @@ +package lifecycle + +import ( + "context" + "strings" + "testing" + + "git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/batchimport" +) + +func TestValidateBasicRowsRejectsDuplicateIccid(t *testing.T) { + rows := []batchimport.Row{ + &Row{RowNo: 2, Iccid: " 8986001 "}, + &Row{RowNo: 3, Iccid: "8986001"}, + } + err := validateBasicRows(rows) + if err == nil || !strings.Contains(err.Error(), "ICCID") || !strings.Contains(err.Error(), "重复") { + t.Fatalf("expected readable duplicate ICCID error, got %v", err) + } +} + +func TestValidateBasicRowsRejectsEveryEmptyIccid(t *testing.T) { + rows := []batchimport.Row{ + &Row{RowNo: 2, Iccid: ""}, + &Row{RowNo: 5, Iccid: " "}, + } + err := validateBasicRows(rows) + if err == nil { + t.Fatal("expected empty ICCID validation error") + } + if !strings.Contains(err.Error(), "第2行") || !strings.Contains(err.Error(), "第5行") { + t.Fatalf("expected all empty ICCID rows in error, got %v", err) + } +} + +func TestValidateBasicRowsRejectsWrongFieldCount(t *testing.T) { + rows, err := ParseInputRows(context.Background(), "8986001 13800008000") + if err != nil { + t.Fatal(err) + } + err = validateBasicRows(rows) + if err == nil || !strings.Contains(err.Error(), "格式") { + t.Fatalf("expected wrong field count error, got %v", err) + } +} + +func TestInvalidBasicRowsStopBeforePendingPreparation(t *testing.T) { + tests := []struct { + name string + rows []batchimport.Row + }{ + { + name: "duplicate", + rows: []batchimport.Row{ + &Row{RowNo: 2, Iccid: "8986001"}, + &Row{RowNo: 3, Iccid: "8986001"}, + }, + }, + { + name: "multiple empty iccid", + rows: []batchimport.Row{ + &Row{RowNo: 2}, + &Row{RowNo: 3, Iccid: " "}, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + processor, err := NewProcessor(OperationSuspend) + if err != nil { + t.Fatal(err) + } + prepared := false + result, err := batchimport.CreatePendingRows( + context.Background(), + batchimport.SubmitRequest{}, + processor, + tt.rows, + batchimport.PendingOptions{PrepareRows: func(context.Context, []batchimport.Row) error { + prepared = true + return nil + }}, + ) + if err == nil || result != nil { + t.Fatalf("expected pre-persistence validation error, result=%+v err=%v", result, err) + } + if prepared { + t.Fatal("invalid rows reached pending preparation") + } + }) + } +} diff --git a/server/internal/logic/iot/simcardbatch/lifecycle/repository.go b/server/internal/logic/iot/simcardbatch/lifecycle/repository.go index 3230bf4..73769a6 100644 --- a/server/internal/logic/iot/simcardbatch/lifecycle/repository.go +++ b/server/internal/logic/iot/simcardbatch/lifecycle/repository.go @@ -155,11 +155,15 @@ func (r *Repository) SaveValidationFailure(ctx context.Context, task *Task) erro if row == nil { continue } + status := consts.BatchDetailStatusFailed + if batchimport.IsNotExecuted(row) { + status = consts.BatchDetailStatusNotExecuted + } if _, err := tx.Ctx(ctx).Model(dao.BatchDetail.Table()). Where(dao.BatchDetail.Columns().Id, row.DetailID). Where(dao.BatchDetail.Columns().BatchNo, task.Batch.BatchNo). Data(do.BatchDetail{ - DetailStatus: consts.BatchDetailStatusFailed, + DetailStatus: status, ErrorReason: row.Reason, Snapshot: row.Snapshot(), UpdatedBy: task.Operator.UserId, @@ -255,21 +259,23 @@ func (r *Repository) FinalizeBatch(ctx context.Context, task *Task) error { } rows := make([]batchimport.Row, 0, len(task.Rows)) successCount := 0 + failCount := 0 for _, row := range task.Rows { rows = append(rows, row) - if row != nil && row.Reason == "" { + if row != nil && row.Reason == "" && task.ValidationOK { successCount++ + } else if row == nil || !batchimport.IsNotExecuted(row) { + failCount++ } } - failCount := len(task.Rows) - successCount - validCount := 0 + validCount := len(task.Rows) - failCount if task.ValidationOK { validCount = len(task.Rows) } importStatus := consts.ImportStatusSuccess batchStatus := consts.BatchAsyncStatusSuccess runResult := consts.BatchRunResultSuccess - if failCount > 0 { + if failCount > 0 || !task.ValidationOK { importStatus = consts.ImportStatusFailed batchStatus = consts.BatchAsyncStatusFailed runResult = consts.BatchRunResultFailed diff --git a/server/internal/logic/iot/simcardbatch/lifecycle/repository_test.go b/server/internal/logic/iot/simcardbatch/lifecycle/repository_test.go new file mode 100644 index 0000000..fd73ced --- /dev/null +++ b/server/internal/logic/iot/simcardbatch/lifecycle/repository_test.go @@ -0,0 +1,101 @@ +package lifecycle + +import ( + "context" + "testing" + + "git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/consts" + "git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/batchimport" + "git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/model/entity" + + _ "github.com/gogf/gf/contrib/drivers/sqlite/v2" + "github.com/gogf/gf/v2/database/gdb" + "github.com/gogf/gf/v2/frame/g" +) + +func TestRepositorySaveOutcomeUpdatesOnlySuccessfulCard(t *testing.T) { + ctx := context.Background() + if err := gdb.SetConfig(gdb.Config{ + "default": gdb.ConfigGroup{{ + Type: "sqlite", + Link: "sqlite::@file(:memory:)", + MaxOpenConnCount: 1, + }}, + }); err != nil { + t.Fatal(err) + } + defer g.DB().Close(ctx) + + statements := []string{ + `CREATE TABLE t_sim_card_info ( + id INTEGER PRIMARY KEY, + iccid TEXT NOT NULL, + boss_card_status INTEGER NOT NULL, + status_sync_time DATETIME, + tenant_id TEXT NOT NULL, + updated_by INTEGER, + updated_at DATETIME + )`, + `CREATE TABLE batch_detail ( + id INTEGER PRIMARY KEY, + batch_no TEXT NOT NULL, + detail_status INTEGER NOT NULL, + error_reason TEXT, + snapshot TEXT, + updated_by INTEGER, + updated_at DATETIME + )`, + `INSERT INTO t_sim_card_info(id, iccid, boss_card_status, tenant_id) VALUES + (1, 'card-success', 2, 'tenant-1'), + (2, 'card-failed', 2, 'tenant-1')`, + `INSERT INTO batch_detail(id, batch_no, detail_status) VALUES + (1, 'STP-test', 1), + (2, 'STP-test', 1)`, + } + for _, statement := range statements { + if _, err := g.DB().Exec(ctx, statement); err != nil { + t.Fatal(err) + } + } + + config, err := OperationSuspend.Config() + if err != nil { + t.Fatal(err) + } + task := &Task{ + Batch: &entity.BatchMain{BatchNo: "STP-test", TenantId: "tenant-1"}, + Config: config, + Operator: batchimport.OperatorContext{TenantId: "tenant-1", UserId: 10}, + } + repository := NewRepository() + successRow := &Row{DetailID: 1, Iccid: "card-success", StatusBefore: consts.V3ApiSimBossCardStatusActivated} + failedRow := &Row{DetailID: 2, Iccid: "card-failed", StatusBefore: consts.V3ApiSimBossCardStatusActivated} + if err = repository.SaveOutcome(ctx, task, successRow, &entity.TSimCardInfo{Id: 1}, Outcome{Success: true, OrderNum: "order-1"}); err != nil { + t.Fatal(err) + } + if err = repository.SaveOutcome(ctx, task, failedRow, &entity.TSimCardInfo{Id: 2}, Outcome{Code: 500, Message: "rejected"}); err != nil { + t.Fatal(err) + } + + var cards []struct { + Iccid string `orm:"iccid"` + BossCardStatus int `orm:"boss_card_status"` + } + if err = g.DB().Model("t_sim_card_info").OrderAsc("id").Scan(&cards); err != nil { + t.Fatal(err) + } + if len(cards) != 2 || cards[0].BossCardStatus != consts.V3ApiSimBossCardStatusStopped || cards[1].BossCardStatus != consts.V3ApiSimBossCardStatusActivated { + t.Fatalf("unexpected local card statuses: %+v", cards) + } + + var details []struct { + DetailStatus int `orm:"detail_status"` + ErrorReason string `orm:"error_reason"` + } + if err = g.DB().Model("batch_detail").OrderAsc("id").Scan(&details); err != nil { + t.Fatal(err) + } + if len(details) != 2 || details[0].DetailStatus != consts.BatchDetailStatusSuccess || details[1].DetailStatus != consts.BatchDetailStatusFailed || details[1].ErrorReason != "rejected" { + t.Fatalf("unexpected detail results: %+v", details) + } +} diff --git a/server/internal/logic/iot/simcardbatch/outbound/async.go b/server/internal/logic/iot/simcardbatch/outbound/async.go index 3e98e34..55e2798 100644 --- a/server/internal/logic/iot/simcardbatch/outbound/async.go +++ b/server/internal/logic/iot/simcardbatch/outbound/async.go @@ -2,6 +2,7 @@ package outbound import ( "context" + "errors" "git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/batchmetadata" "strings" @@ -26,6 +27,10 @@ type AsyncMessage struct { BatchNo string `json:"batchNo"` } +var errOutboundValidationRejected = errors.New("存在不满足出库条件的卡,本次未执行") + +const outboundNotExecutedReason = "校验通过,但本批次存在失败明细,整批未执行出库" + func SubmitExcelAsync(ctx context.Context, file *ghttp.UploadFile, in *model.TSimCardOutboundExcelSubmitParam) (*batchimport.Result, error) { operator := batchimport.OperatorContext{TenantId: contexts.GetTenantId(ctx), DeptId: contexts.GetDeptId(ctx), UserId: contexts.GetUserId(ctx)} processor := NewProcessor(PreviewOptions{DeptId: operator.DeptId}) @@ -119,8 +124,9 @@ func handleAsyncMessage(ctx context.Context, message AsyncMessage) error { requiredSource = strings.TrimSpace(main.BatchInfo.Get("inboundBatchNo").String()) } var ( - claimed bool - output *CompleteOutput + claimed bool + output *CompleteOutput + validationRejected bool ) err = g.DB().Transaction(ctx, func(ctx context.Context, tx gdb.TX) error { claimResult, claimErr := tx.Ctx(ctx).Model(dao.BatchMain.Table()). @@ -153,6 +159,7 @@ func handleAsyncMessage(ctx context.Context, message AsyncMessage) error { var executeErr error output, executeErr = processor.ExecutePending(ctx, tx, message.BatchNo, param, rows, operator, requiredSource) if executeErr != nil { + validationRejected = errors.Is(executeErr, errOutboundValidationRejected) return executeErr } if _, updateErr := tx.Ctx(ctx).Model(dao.BatchMain.Table()).Where(dao.BatchMain.Columns().BatchNo, message.BatchNo).Data(do.BatchMain{ @@ -169,6 +176,9 @@ func handleAsyncMessage(ctx context.Context, message AsyncMessage) error { return nil }) if err != nil { + if validationRejected { + return markAsyncValidationFailed(ctx, message.BatchNo, main.TenantId, rows, err) + } return markAsyncFailed(ctx, message.BatchNo, err) } if !claimed { @@ -186,6 +196,43 @@ func handleAsyncMessage(ctx context.Context, message AsyncMessage) error { return nil } +func markAsyncValidationFailed(ctx context.Context, batchNo, tenantID string, rows []*Row, cause error) error { + validCount, failCount := 0, 0 + for _, row := range rows { + if row == nil { + continue + } + if row.Reason == outboundNotExecutedReason { + validCount++ + } else { + failCount++ + } + } + return g.DB().Transaction(ctx, func(ctx context.Context, tx gdb.TX) error { + for _, row := range rows { + if row == nil { + continue + } + status := consts.BatchDetailStatusFailed + if row.Reason == outboundNotExecutedReason { + status = consts.BatchDetailStatusNotExecuted + } + if _, err := tx.Ctx(ctx).Model(dao.BatchDetail.Table()). + Where(dao.BatchDetail.Columns().BatchNo, batchNo). + Where(dao.BatchDetail.Columns().TenantId, tenantID). + Where(dao.BatchDetail.Columns().RowNo, row.RowNo). + Data(do.BatchDetail{DetailStatus: status, ErrorReason: row.Reason}).Update(); err != nil { + return gerror.Wrap(err, "保存出库校验明细失败") + } + } + _, err := tx.Ctx(ctx).Model(dao.BatchMain.Table()). + Where(dao.BatchMain.Columns().BatchNo, batchNo). + Where(dao.BatchMain.Columns().TenantId, tenantID). + Data(do.BatchMain{BatchAsyncStatus: consts.BatchAsyncStatusFailed, RunResult: consts.BatchRunResultFailed, ValidCount: validCount, SuccessCount: 0, FailCount: failCount, FailReason: asyncFailureReason(cause), EndTime: gtime.Now()}).Update() + return gerror.Wrap(err, "保存出库校验结果失败") + }) +} + func markAsyncFailed(ctx context.Context, batchNo string, err error) error { reason := asyncFailureReason(err) if _, updateErr := g.DB().Model(dao.BatchMain.Table()).Ctx(ctx).Where(dao.BatchMain.Columns().BatchNo, batchNo).Data(do.BatchMain{ diff --git a/server/internal/logic/iot/simcardbatch/outbound/async_test.go b/server/internal/logic/iot/simcardbatch/outbound/async_test.go new file mode 100644 index 0000000..c678ed9 --- /dev/null +++ b/server/internal/logic/iot/simcardbatch/outbound/async_test.go @@ -0,0 +1,72 @@ +package outbound + +import ( + "errors" + "strings" + "testing" + + "git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/batchimport" + "git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/model/entity" +) + +func TestValidateOutboundRowCollectsIndependentFailures(t *testing.T) { + row := &Row{Iccid: "card", Msisdn: "wrong", Imsi: "wrong"} + stock := &entity.TSimCardStock{CreatedDept: 200, BatchNo: "other", Msisdn: "13800008000", Imsi: "460001234567890"} + reason := validateOutboundRow(row, stock, true, batchimport.OperatorContext{DeptId: 100}, true, "required") + for _, want := range []string{"不属于当前机构库存", "不属于指定库存批次", "已存在正式卡记录", "MSISDN与库存记录不一致", "IMSI与库存记录不一致"} { + if !strings.Contains(reason, want) { + t.Fatalf("missing %q in %q", want, reason) + } + } + rows := []*Row{{Reason: reason}, {}, {Reason: "ICCID不存在库存记录,不能出库"}} + markRemainingRowsSkipped(rows) + if rows[0].Reason != reason || rows[1].Reason != outboundNotExecutedReason || rows[2].Reason != "ICCID不存在库存记录,不能出库" { + t.Fatalf("per-card validation results were overwritten: %+v", rows) + } +} + +func TestOutboundChunkSizesSupportFiftyThousandCards(t *testing.T) { + const totalCards = 50000 + if outboundDatabaseInsertChunkSize <= 0 || outboundDatabaseInsertChunkSize > 500 { + t.Fatalf("outboundDatabaseInsertChunkSize = %d, want 1..500", outboundDatabaseInsertChunkSize) + } + if chunks := (totalCards + outboundDatabaseInsertChunkSize - 1) / outboundDatabaseInsertChunkSize; chunks != 100 { + t.Fatalf("card insert chunks = %d, want 100", chunks) + } +} + +func TestAsyncFailureReasonSummarizesPlaceholderOverflow(t *testing.T) { + reason := asyncFailureReason(errors.New("INSERT INTO t_sim_card_info (...) Error 1390: Prepared statement contains too many placeholders")) + if reason != "新增正式卡失败:单次批量写入参数数量超过数据库限制" { + t.Fatalf("unexpected reason: %q", reason) + } +} + +func TestAsyncFailureReasonFitsBatchDetailColumn(t *testing.T) { + reason := asyncFailureReason(errors.New(strings.Repeat("失败原因", 300))) + if len([]rune(reason)) > maxAsyncFailureReasonLength { + t.Fatalf("reason length = %d, want <= %d", len([]rune(reason)), maxAsyncFailureReasonLength) + } +} + +func TestValidateOutboundIdentifiersReportsEveryDuplicate(t *testing.T) { + rows := []*Row{ + {RowNo: 1, Iccid: " 8986000000000000001 "}, + {RowNo: 2, Iccid: "8986000000000000001"}, + {RowNo: 3, Iccid: ""}, + {RowNo: 4, Iccid: "8986000000000000002"}, + } + iccids, err := validateOutboundIdentifiers(rows) + if err != nil || len(iccids) != 1 || iccids[0] != rows[3].Iccid { + t.Fatalf("valid identifiers = %v, err = %v", iccids, err) + } + for index := 0; index < 3; index++ { + if rows[index].Reason == "" { + t.Fatalf("row %d lost its validation reason", index+1) + } + } + markRemainingRowsSkipped(rows) + if rows[3].Reason != outboundNotExecutedReason { + t.Fatalf("valid row reason = %q", rows[3].Reason) + } +} diff --git a/server/internal/logic/iot/simcardbatch/outbound/processor.go b/server/internal/logic/iot/simcardbatch/outbound/processor.go index 248a851..d216616 100644 --- a/server/internal/logic/iot/simcardbatch/outbound/processor.go +++ b/server/internal/logic/iot/simcardbatch/outbound/processor.go @@ -2,6 +2,7 @@ package outbound import ( "context" + "fmt" "strings" "git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/consts" @@ -211,10 +212,13 @@ func (p *Processor) validateExecuteContext(ctx context.Context, tx gdb.TX, in *m if err := p.validateSelectablePackage(ctx, tx, in.PackageCode, operator.TenantId); err != nil { return err } - iccids, err := strictRowsToIccids(rows) + iccids, err := validateOutboundIdentifiers(rows) if err != nil { return err } + if len(iccids) == 0 { + return errOutboundValidationRejected + } stockByIccid, err := p.repo.LockStockCardsByIccids(ctx, tx, iccids) if err != nil { return gerror.Wrap(err, "校验出库库存卡失败") @@ -237,11 +241,38 @@ func (p *Processor) validateExecuteContext(ctx context.Context, tx gdb.TX, in *m if hasInvalidRows(rows) { markRemainingRowsSkipped(rows) - return gerror.New("存在不满足出库条件的卡,本次未执行") + return errOutboundValidationRejected } return nil } +func validateOutboundIdentifiers(rows []*Row) ([]string, error) { + seen := make(map[string]*Row, len(rows)) + for _, row := range rows { + if row == nil { + return nil, gerror.New("出库行不能为空") + } + row.Iccid = strings.TrimSpace(row.Iccid) + if row.Iccid == "" { + row.AddReason("ICCID不能为空") + continue + } + if first, exists := seen[row.Iccid]; exists { + row.AddReason(fmt.Sprintf("ICCID与第%d行重复", first.RowNo)) + first.AddReason(fmt.Sprintf("ICCID与第%d行重复", row.RowNo)) + continue + } + seen[row.Iccid] = row + } + iccids := make([]string, 0, len(seen)) + for iccid, row := range seen { + if row.Reason == "" { + iccids = append(iccids, iccid) + } + } + return iccids, nil +} + func (p *Processor) validateSelectablePackage(ctx context.Context, tx gdb.TX, packageCode, tenantId string) error { pkg, err := p.repo.LoadPackage(ctx, tx, tenantId, packageCode) if err != nil { @@ -329,27 +360,28 @@ func (p *Processor) submitParams(params any) *model.TSimCardOutboundSubmitParam } func validateOutboundRow(row *Row, stock *entity.TSimCardStock, formalExists bool, operator batchimport.OperatorContext, strictStockIdentity bool, requiredSourceBatchNo string) string { + var reasons []string if stock == nil { return "ICCID不存在库存记录,不能出库" } if stock.CreatedDept != operator.DeptId { - return "ICCID不属于当前机构库存,不能出库" + reasons = append(reasons, "ICCID不属于当前机构库存,不能出库") } if requiredSourceBatchNo != "" && strings.TrimSpace(stock.BatchNo) != requiredSourceBatchNo { - return "ICCID不属于指定库存批次,不能出库" + reasons = append(reasons, "ICCID不属于指定库存批次,不能出库") } if formalExists { - return "ICCID已存在正式卡记录,不能出库" + reasons = append(reasons, "ICCID已存在正式卡记录,不能出库") } if strictStockIdentity { if strings.TrimSpace(row.Msisdn) != strings.TrimSpace(stock.Msisdn) { - return "MSISDN与库存记录不一致,不能出库" + reasons = append(reasons, "MSISDN与库存记录不一致,不能出库") } if strings.TrimSpace(row.Imsi) != strings.TrimSpace(stock.Imsi) { - return "IMSI与库存记录不一致,不能出库" + reasons = append(reasons, "IMSI与库存记录不一致,不能出库") } } - return "" + return strings.Join(reasons, ";") } func hasInvalidRows(rows []*Row) bool { @@ -364,7 +396,7 @@ func hasInvalidRows(rows []*Row) bool { func markRemainingRowsSkipped(rows []*Row) { for _, row := range rows { if row != nil && row.Reason == "" { - row.AddReason("同批存在不满足出库条件的卡,本行未执行") + row.AddReason(outboundNotExecutedReason) } } } diff --git a/server/internal/logic/iot/simcardbatch/renew/parser_test.go b/server/internal/logic/iot/simcardbatch/renew/parser_test.go new file mode 100644 index 0000000..35caca3 --- /dev/null +++ b/server/internal/logic/iot/simcardbatch/renew/parser_test.go @@ -0,0 +1,384 @@ +package renew + +import ( + "context" + "path/filepath" + "reflect" + "testing" + + "git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/consts" + "git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/batchinput/text" + "git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/model" + "git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/model/entity" + + "github.com/xuri/excelize/v2" +) + +func TestExcelTemplateHeaders(t *testing.T) { + t.Parallel() + + expected := []string{ + "ICCID", + "MSISDN", + "IMSI", + "续费年限", + "是否走线下", + } + if !reflect.DeepEqual(ExcelTemplateHeaders(), expected) { + t.Fatalf("unexpected headers: %#v", ExcelTemplateHeaders()) + } +} + +func TestRenewDurationValueToMonths(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + unit string + value int + want int + }{ + {name: "one month", unit: consts.RenewDurationUnitMonth, value: 1, want: 1}, + {name: "one year", unit: consts.RenewDurationUnitYear, value: 1, want: 12}, + {name: "unknown unit defaults to year", unit: "unknown", value: 2, want: 24}, + } + + for _, test := range tests { + test := test + t.Run(test.name, func(t *testing.T) { + t.Parallel() + if got := consts.DurationValueToMonths(test.unit, test.value); got != test.want { + t.Fatalf("DurationValueToMonths(%q, %d) = %d, want %d", test.unit, test.value, got, test.want) + } + }) + } +} + +func TestRenewDisplayDurationAndAmount(t *testing.T) { + t.Parallel() + + yearSnapshot := &model.TSimCardRenewPriceSnapshot{ + RenewDurationUnit: consts.RenewDurationUnitYear, + RenewUnitPrice: 10, + } + yearParam := &model.TSimCardRenewBatchSubmitParam{DurationYears: 1} + if got := renewDisplayDurationValue(yearParam); got != 1 { + t.Fatalf("renewDisplayDurationValue(year) = %d, want 1", got) + } + if got := renewActualDurationMonths(yearSnapshot, renewDisplayDurationValue(yearParam)); got != 12 { + t.Fatalf("renewActualDurationMonths(year) = %d, want 12", got) + } + yearAmount, err := resolveRenewAmount(yearSnapshot, yearParam, 2) + if err != nil { + t.Fatalf("resolveRenewAmount(year) failed: %v", err) + } + if yearAmount != 20 { + t.Fatalf("resolveRenewAmount(year) = %.2f, want 20.00", yearAmount) + } + + monthSnapshot := &model.TSimCardRenewPriceSnapshot{ + RenewDurationUnit: consts.RenewDurationUnitMonth, + RenewUnitPrice: 10, + } + monthParam := &model.TSimCardRenewBatchSubmitParam{DurationMonths: 1} + if got := renewActualDurationMonths(monthSnapshot, renewDisplayDurationValue(monthParam)); got != 12 { + t.Fatalf("renewActualDurationMonths(month snapshot) = %d, want 12", got) + } + actualMonthParam := &model.TSimCardRenewBatchSubmitParam{DurationMonths: 12} + if got := renewDisplayDurationValue(actualMonthParam); got != 1 { + t.Fatalf("renewDisplayDurationValue(actual months legacy) = %d, want 1", got) + } + monthAmount, err := resolveRenewAmount(monthSnapshot, monthParam, 2) + if err != nil { + t.Fatalf("resolveRenewAmount(month) failed: %v", err) + } + if monthAmount != 20 { + t.Fatalf("resolveRenewAmount(month) = %.2f, want 20.00", monthAmount) + } +} + +func TestApplyExecutionOptionsKeepsDisplayDurationValue(t *testing.T) { + t.Parallel() + + in := &model.TSimCardRenewBatchSubmitParam{} + applyExecutionOptions(in, &executionOptions{ + DurationYears: 2, + DurationMonths: 2, + PaymentMode: renewPaymentModeBalance, + }) + if in.DurationYears != 2 || in.DurationMonths != 2 { + t.Fatalf("duration should keep display value: %#v", in) + } + if got := renewDisplayDurationValue(in); got != 2 { + t.Fatalf("renewDisplayDurationValue = %d, want 2", got) + } +} + +func TestRenewDeptAncestorsContain(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + ancestors string + deptID int64 + want bool + }{ + {name: "contains direct ancestor", ancestors: "0,100,200", deptID: 100, want: true}, + {name: "contains with spaces", ancestors: "0, 100 ,200", deptID: 100, want: true}, + {name: "does not match partial id", ancestors: "0,1001,200", deptID: 100, want: false}, + {name: "empty ancestors", ancestors: "", deptID: 100, want: false}, + {name: "invalid dept", ancestors: "0,100,200", deptID: 0, want: false}, + } + for _, test := range tests { + test := test + t.Run(test.name, func(t *testing.T) { + t.Parallel() + if got := renewDeptAncestorsContain(test.ancestors, test.deptID); got != test.want { + t.Fatalf("renewDeptAncestorsContain(%q, %d) = %v, want %v", test.ancestors, test.deptID, got, test.want) + } + }) + } +} + +func TestParseExcelFileRowsAndOptions(t *testing.T) { + t.Parallel() + + filePath := filepath.Join(t.TempDir(), "renew.xlsx") + f := excelize.NewFile() + headers := ExcelTemplateHeaders() + if err := f.SetSheetRow("Sheet1", "A1", &headers); err != nil { + t.Fatalf("set headers failed: %v", err) + } + row := []any{ + "8986000000000000001", + "13800008000", + "460001234567890", + "1", + "是", + } + if err := f.SetSheetRow("Sheet1", "A2", &row); err != nil { + t.Fatalf("set row failed: %v", err) + } + if err := f.SaveAs(filePath); err != nil { + t.Fatalf("save excel failed: %v", err) + } + _ = f.Close() + + rows, options, err := ParseExcelFileRows(context.Background(), filePath) + if err != nil { + t.Fatalf("parse excel failed: %v", err) + } + if len(rows) != 1 { + t.Fatalf("unexpected row count: %d", len(rows)) + } + if rows[0].Reason != "" { + t.Fatalf("unexpected row reason: %s", rows[0].Reason) + } + if rows[0].Msisdn != "13800008000" { + t.Fatalf("unexpected msisdn: %s", rows[0].Msisdn) + } + if rows[0].Imsi != "460001234567890" { + t.Fatalf("unexpected imsi: %s", rows[0].Imsi) + } + if options == nil { + t.Fatal("options should not be nil") + } + if options.DurationYears != 1 { + t.Fatalf("unexpected durationYears: %d", options.DurationYears) + } + if options.PaymentMode != "offline" { + t.Fatalf("unexpected paymentMode: %s", options.PaymentMode) + } + + submitParam := options.ToBatchSubmitParam() + if submitParam.DurationYears != 1 || submitParam.DurationMonths != 12 { + t.Fatalf("unexpected submit duration fields: %#v", submitParam) + } + if submitParam.PaymentMode != "offline" { + t.Fatalf("unexpected submit paymentMode: %#v", submitParam) + } + if !submitParam.OfflineContractCompleted { + t.Fatalf("excel submit should mark offline contract as confirmed: %#v", submitParam) + } + if submitParam.SourceBatchNo != "" || submitParam.BuyerNote != "" || submitParam.AdminNote != "" || submitParam.Remark != "" { + t.Fatalf("excel submit param should not carry removed fields: %#v", submitParam) + } + + confirmParam := options.ToBatchConfirmParam("RB20260706001") + if confirmParam.BatchNo != "RB20260706001" { + t.Fatalf("unexpected batchNo: %s", confirmParam.BatchNo) + } + if confirmParam.DurationYears != 1 || confirmParam.DurationMonths != 12 { + t.Fatalf("unexpected confirm duration fields: %#v", confirmParam) + } + if confirmParam.PaymentMode != "offline" { + t.Fatalf("unexpected confirm paymentMode: %#v", confirmParam) + } +} + +func TestParseExcelFileRowsRejectsMultipleSheetsWithData(t *testing.T) { + t.Parallel() + + filePath := filepath.Join(t.TempDir(), "renew-multi.xlsx") + f := excelize.NewFile() + headers := ExcelTemplateHeaders() + if err := f.SetSheetRow("Sheet1", "A1", &headers); err != nil { + t.Fatalf("set headers failed: %v", err) + } + row := []any{ + "8986000000000000001", + "13800008000", + "460001234567890", + "1", + "是", + } + if err := f.SetSheetRow("Sheet1", "A2", &row); err != nil { + t.Fatalf("set row failed: %v", err) + } + _, err := f.NewSheet("Sheet2") + if err != nil { + t.Fatalf("new sheet failed: %v", err) + } + if err := f.SetSheetRow("Sheet2", "A1", &headers); err != nil { + t.Fatalf("set sheet2 headers failed: %v", err) + } + if err := f.SetSheetRow("Sheet2", "A2", &row); err != nil { + t.Fatalf("set sheet2 row failed: %v", err) + } + if err := f.SaveAs(filePath); err != nil { + t.Fatalf("save excel failed: %v", err) + } + _ = f.Close() + + if _, _, err := ParseExcelFileRows(context.Background(), filePath); err == nil { + t.Fatal("expected multiple-sheet excel to be rejected") + } +} + +func TestPrepareExcelProcessorUsesPrepareBatchType(t *testing.T) { + t.Parallel() + + processor := NewPrepareExcelProcessor() + cfg := processor.Config() + if cfg.BatchType != consts.BatchTypePrepare { + t.Fatalf("prepare excel batch type = %d, want %d", cfg.BatchType, consts.BatchTypePrepare) + } + if cfg.BatchNoPrefix != consts.BatchNoPrefixRenew { + t.Fatalf("prepare excel batch prefix = %q, want %q", cfg.BatchNoPrefix, consts.BatchNoPrefixRenew) + } +} + +func TestParseRenewInputLineSupportsSingleAndTripleFields(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + line text.FieldLine + wantCode string + wantIccid string + wantMsisdn string + wantImsi string + wantReason string + }{ + { + name: "single value", + line: text.FieldLine{RowNo: 2, Fields: []string{"89860401786597927028"}}, + wantCode: "89860401786597927028", + wantIccid: "89860401786597927028", + }, + { + name: "triple values", + line: text.FieldLine{RowNo: 3, Fields: []string{"89860401786597927028", "14100007028", "460046597927028"}}, + wantCode: "89860401786597927028", + wantIccid: "89860401786597927028", + wantMsisdn: "14100007028", + wantImsi: "460046597927028", + }, + { + name: "invalid format", + line: text.FieldLine{RowNo: 4, Fields: []string{"a", "b"}}, + wantReason: "格式错误", + }, + } + + for _, test := range tests { + test := test + t.Run(test.name, func(t *testing.T) { + t.Parallel() + row := parseRenewInputLine(test.line) + if row.InputCode != test.wantCode { + t.Fatalf("input code = %q, want %q", row.InputCode, test.wantCode) + } + if row.Iccid != test.wantIccid { + t.Fatalf("iccid = %q, want %q", row.Iccid, test.wantIccid) + } + if row.Msisdn != test.wantMsisdn { + t.Fatalf("msisdn = %q, want %q", row.Msisdn, test.wantMsisdn) + } + if row.Imsi != test.wantImsi { + t.Fatalf("imsi = %q, want %q", row.Imsi, test.wantImsi) + } + if test.wantReason != "" && row.Reason == "" { + t.Fatal("expected row reason") + } + }) + } +} + +func TestRenewIdentityResolverResolvesAnyCardIdentity(t *testing.T) { + t.Parallel() + + resolver := newRenewIdentityResolverFromCards([]*entity.TSimCardInfo{ + { + Id: 1, + Iccid: "89860401786597927028", + Msisdn: "14100007028", + Imsi: "460046597927028", + }, + { + Id: 2, + Iccid: "89860401786597927029", + Msisdn: "14100007029", + Imsi: "460046597927029", + }, + }) + + row := &Row{RowNo: 2, InputCode: "14100007028", Msisdn: "14100007028"} + resolver.resolveRow(row) + if row.Reason != "" { + t.Fatalf("unexpected reason: %s", row.Reason) + } + if row.Iccid != "89860401786597927028" || row.Msisdn != "14100007028" || row.Imsi != "460046597927028" { + t.Fatalf("unexpected resolved row: %#v", row) + } + + row = &Row{RowNo: 3, InputCode: "460046597927029", Imsi: "460046597927029"} + resolver.resolveRow(row) + if row.Reason != "" { + t.Fatalf("unexpected reason for imsi row: %s", row.Reason) + } + if row.Iccid != "89860401786597927029" || row.Msisdn != "14100007029" || row.Imsi != "460046597927029" { + t.Fatalf("unexpected imsi resolved row: %#v", row) + } +} + +func TestRenewIdentityResolverRejectsAmbiguousOrMismatchedIdentity(t *testing.T) { + t.Parallel() + + resolver := newRenewIdentityResolverFromCards([]*entity.TSimCardInfo{ + {Id: 1, Iccid: "89860401786597927028", Msisdn: "14100007028", Imsi: "460046597927028"}, + {Id: 2, Iccid: "89860401786597927029", Msisdn: "89860401786597927028", Imsi: "460046597927029"}, + }) + + ambiguous := &Row{RowNo: 2, InputCode: "89860401786597927028", Iccid: "89860401786597927028"} + resolver.resolveRow(ambiguous) + if ambiguous.Reason == "" { + t.Fatal("expected ambiguous reason") + } + + mismatched := &Row{RowNo: 3, InputCode: "89860401786597927028", Iccid: "89860401786597927028", Msisdn: "00000000000"} + resolver.resolveRow(mismatched) + if mismatched.Reason == "" { + t.Fatal("expected mismatch reason") + } +} diff --git a/server/internal/logic/iot/simcardbatch/renew/processor_test.go b/server/internal/logic/iot/simcardbatch/renew/processor_test.go new file mode 100644 index 0000000..7548a27 --- /dev/null +++ b/server/internal/logic/iot/simcardbatch/renew/processor_test.go @@ -0,0 +1,187 @@ +package renew + +import ( + "context" + "testing" + + "git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/consts" + "git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/batchimport" + "git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/model" + "git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/model/entity" + + "github.com/gogf/gf/v2/os/gtime" +) + +func TestBuildBatchInfoOmitsRemovedEmptyFields(t *testing.T) { + t.Parallel() + + info := NewProcessor().BuildBatchInfo(context.Background(), batchimport.SubmitRequest{ + BizParams: &model.TSimCardRenewBatchSubmitParam{ + DurationMonths: 12, + GiftDurationMonths: 2, + Amount: "100.00", + OfflineContractCompleted: true, + }, + ExecutionResult: &CompleteOutput{ + Quantity: 1, + PackageCode: "PKG001", + OrderSn: "ORDER001", + }, + }, &batchimport.Result{}, nil, nil, 0) + + data := info.Map() + for _, key := range []string{"sourceBatchNo", "buyerNote", "adminNote", "remark"} { + if _, ok := data[key]; ok { + t.Fatalf("batch info should omit %s when empty: %#v", key, data) + } + } + if data["durationMonths"] != 12 { + t.Fatalf("unexpected durationMonths: %#v", data["durationMonths"]) + } + if data["giftDurationMonths"] != 2 { + t.Fatalf("unexpected giftDurationMonths: %#v", data["giftDurationMonths"]) + } + if data["offlineContractCompleted"] != true { + t.Fatalf("unexpected offlineContractCompleted: %#v", data["offlineContractCompleted"]) + } +} + +func TestCurrentPackagePeriodKey(t *testing.T) { + t.Parallel() + + bind := &entity.TSimCardBindPkg{ + EffectiveTime: gtime.NewFromStr("2026-01-01"), + ExpectedExpireTime: gtime.NewFromStr("2026-12-31"), + } + if got, want := currentPackagePeriodKey(bind), "2026-01-01|2026-12-31"; got != want { + t.Fatalf("currentPackagePeriodKey() = %q, want %q", got, want) + } + if got := currentPackagePeriodKey(&entity.TSimCardBindPkg{}); got != "" { + t.Fatalf("currentPackagePeriodKey() for empty bind = %q, want empty", got) + } +} + +func TestRenewBaseExpireTimePrefersLaterCardExpire(t *testing.T) { + t.Parallel() + + card := &entity.TSimCardInfo{ + FlowPkgEndAt: gtime.NewFromStr("2026-10-31 00:00:00"), + } + bind := &entity.TSimCardBindPkg{ + ExpectedExpireTime: gtime.NewFromStr("2026-08-31 00:00:00"), + } + + if got, want := renewBaseExpireTime(card, bind).Format("Y-m-d"), "2026-10-31"; got != want { + t.Fatalf("renewBaseExpireTime() = %q, want %q", got, want) + } +} + +func TestRenewBeginDateFromExpireUsesExpireDate(t *testing.T) { + t.Parallel() + + beginAt := renewBeginDateFromExpire(gtime.NewFromStr("2026-08-31 23:59:59")) + if beginAt == nil { + t.Fatal("renew begin date is nil") + } + if got, want := beginAt.Format("Y-m-d H:i:s"), "2026-08-31 23:59:59"; got != want { + t.Fatalf("renewBeginDateFromExpire() = %q, want %q", got, want) + } +} + +func TestValidateRenewRowRejectsExpireAfterSixMonths(t *testing.T) { + t.Parallel() + + card := &entity.TSimCardInfo{ + CurrentPackageCode: "PKG001", + PackageStatus: consts.PackageStatusEffective, + } + bind := &entity.TSimCardBindPkg{ + PackageCode: "PKG001", + ExpectedExpireTime: gtime.Now().AddDate(0, 6, 1), + EffectiveTime: gtime.Now().AddDate(-1, 0, 0), + EffectiveStatus: consts.EffectiveStatusEffective, + } + + if got, want := validateRenewRow("", batchimport.OperatorContext{}, card, bind), "距离到期超过6个月,不能续费"; got != want { + t.Fatalf("validateRenewRow() = %q, want %q", got, want) + } +} + +func TestValidateRenewRowAllowsExpireWithinSixMonths(t *testing.T) { + t.Parallel() + + card := &entity.TSimCardInfo{ + CurrentPackageCode: "PKG001", + PackageStatus: consts.PackageStatusEffective, + } + bind := &entity.TSimCardBindPkg{ + PackageCode: "PKG001", + ExpectedExpireTime: gtime.Now().AddDate(0, 6, 0), + EffectiveTime: gtime.Now().AddDate(-1, 0, 0), + EffectiveStatus: consts.EffectiveStatusEffective, + } + + if got := validateRenewRow("", batchimport.OperatorContext{}, card, bind); got != "" { + t.Fatalf("validateRenewRow() = %q, want empty", got) + } +} + +func TestValidateRenewRowRejectsStoppedCard(t *testing.T) { + t.Parallel() + + card := &entity.TSimCardInfo{ + BossCardStatus: consts.V3ApiSimBossCardStatusStopped, + CurrentPackageCode: "PKG001", + PackageStatus: consts.PackageStatusEffective, + } + bind := &entity.TSimCardBindPkg{ + PackageCode: "PKG001", + } + + if got, want := validateRenewRow("", batchimport.OperatorContext{}, card, bind), "ICCID当前为停机状态,不能续费"; got != want { + t.Fatalf("validateRenewRow() = %q, want %q", got, want) + } +} + +func TestRenewPrepareSubmitParamUsesPrepareSourceBatchNo(t *testing.T) { + t.Parallel() + + req := &batchimport.SubmitRequest{ + BizParams: &model.TSimCardRenewBatchPrepareParam{SourceBatchNo: " OUT20260807001 "}, + } + got := renewPrepareSubmitParam(req) + if got.SourceBatchNo != "OUT20260807001" { + t.Fatalf("SourceBatchNo = %q, want OUT20260807001", got.SourceBatchNo) + } +} + +func TestRenewPrepareSubmitParamFallsBackToRequestSourceBatchNo(t *testing.T) { + t.Parallel() + + req := &batchimport.SubmitRequest{ + BizParams: &model.TSimCardRenewBatchSubmitParam{}, + SourceBatchNo: " OUT20260807002 ", + } + got := renewPrepareSubmitParam(req) + if got.SourceBatchNo != "OUT20260807002" { + t.Fatalf("SourceBatchNo = %q, want OUT20260807002", got.SourceBatchNo) + } +} + +func TestPrepareProcessorValidateRowsDoesNotBlockSubmit(t *testing.T) { + t.Parallel() + + rows := []batchimport.Row{ + &Row{RowNo: 1, Iccid: "8986001"}, + &Row{RowNo: 2, Iccid: "8986001"}, + } + if err := NewPrepareProcessor().ValidateRows(context.Background(), rows); err != nil { + t.Fatalf("ValidateRows() error = %v", err) + } + for _, item := range rows { + row := item.(*Row) + if row.Reason != "" { + t.Fatalf("row %d reason = %q, want empty before worker prepare", row.RowNo, row.Reason) + } + } +} diff --git a/server/internal/logic/iot/simcardbatch/unsub/parser.go b/server/internal/logic/iot/simcardbatch/unsub/parser.go index b3e5011..736c151 100644 --- a/server/internal/logic/iot/simcardbatch/unsub/parser.go +++ b/server/internal/logic/iot/simcardbatch/unsub/parser.go @@ -2,9 +2,9 @@ package unsub import ( "context" + "io" "git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/batchimport" "git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/batchinput/excel" - "io" ) func ParseExcelFileRows(ctx context.Context, filePath string) ([]batchimport.Row, error) { diff --git a/server/internal/logic/iot/simcardbatch/unsub/processor_test.go b/server/internal/logic/iot/simcardbatch/unsub/processor_test.go new file mode 100644 index 0000000..2729d78 --- /dev/null +++ b/server/internal/logic/iot/simcardbatch/unsub/processor_test.go @@ -0,0 +1,47 @@ +package unsub + +import ( + "context" + "testing" + + "git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/batchimport" + "git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/model" +) + +func TestProcessorConfigTemplateHeaders(t *testing.T) { + config := NewProcessor().Config() + expected := []string{"ICCID", "MSISDN", "IMSI"} + if len(config.TemplateHeaders) != len(expected) { + t.Fatalf("unexpected template header count: got %d want %d", len(config.TemplateHeaders), len(expected)) + } + for i, header := range expected { + if config.TemplateHeaders[i] != header { + t.Fatalf("unexpected template header at %d: got %q want %q", i, config.TemplateHeaders[i], header) + } + } +} + +func TestBuildBatchInfoExcelDoesNotIncludeRemarkOrSourceBatchNo(t *testing.T) { + processor := NewProcessor() + info := processor.BuildBatchInfo(context.Background(), batchimport.SubmitRequest{ + BizParams: &model.TSimCardUnsubExcelSubmitParam{}, + }, nil, nil, nil, 0) + + if info.Get("remark").String() != "" { + t.Fatalf("excel batch info should not include remark") + } + if info.Get("sourceBatchNo").String() != "" { + t.Fatalf("excel batch info should not include sourceBatchNo") + } +} + +func TestBuildBatchInfoInputKeepsRemark(t *testing.T) { + processor := NewProcessor() + info := processor.BuildBatchInfo(context.Background(), batchimport.SubmitRequest{ + BizParams: &model.TSimCardUnsubInputSubmitParam{Remark: " note "}, + }, nil, nil, nil, 0) + + if got := info.Get("remark").String(); got != "note" { + t.Fatalf("unexpected remark: got %q", got) + } +} diff --git a/server/internal/logic/iot/simcardquery/columns.go b/server/internal/logic/iot/simcardquery/columns.go new file mode 100644 index 0000000..93a0d60 --- /dev/null +++ b/server/internal/logic/iot/simcardquery/columns.go @@ -0,0 +1,330 @@ +package simcardquery + +import ( + "context" + "math" + "strings" + "time" + + "git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/model" + "git.5iot.com/iotlcm-public/iotlcm-admin-svr/utility/iot_excelize" + + "github.com/gogf/gf/v2/encoding/gjson" + "github.com/gogf/gf/v2/errors/gerror" + "github.com/gogf/gf/v2/frame/g" + "github.com/xuri/excelize/v2" +) + +// 导出列白名单。 +// +// 只放行白名单内的字段:前端传什么就导出什么,但传了名单外的字段一律忽略, +// 避免误把内部字段(如 id、tenantId)带进对客户的导出文件。 +var baseColumnWhitelist = map[string]model.ExportColumn{ + "iccid": { + Field: "iccid", Label: "ICCID", + }, + "msisdn": { + Field: "msisdn", Label: "MSISDN", + }, + "imsi": { + Field: "imsi", Label: "IMSI", + }, + "imei": { + Field: "imei", Label: "IMEI", + }, + "usedFlowMb": { + Field: "usedFlowMb", Label: "当月用量(MB)", + }, + "remainFlowMb": { + Field: "remainFlowMb", Label: "当月剩余用量(MB)", + }, + "packageTotalMb": { + Field: "packageTotalMb", Label: "套餐总流量(MB)", + }, + "cardStatus": { + Field: "cardStatus", Label: "卡状态", DictType: "t_boss_card_status", + }, + "netModel": { + Field: "netModel", Label: "网络类型", DictType: "t_net_model", + }, + "operators": { + Field: "operators", Label: "运营商", + }, + "activateAt": { + Field: "activateAt", Label: "激活时间", + }, + "outStockTime": { + Field: "outStockTime", Label: "出库时间", + }, + "expireAt": { + Field: "expireAt", Label: "到期时间", + }, + "outboundBatch": { + Field: "outboundBatch", Label: "出库批次", + }, + "inboundBatch": { + Field: "inboundBatch", Label: "入库批次", + }, + "packageName": { + Field: "packageName", Label: "当前套餐名称", + }, + "deptName": { + Field: "deptName", Label: "所属企业", + }, + "remark": { + Field: "remark", Label: "备注", + }, + "extraRemark": { + Field: "extraRemark", Label: "其他备注", + }, +} + +// baseDefaultOrder 未勾选列时的默认导出顺序,与卡信息列表的常用列保持一致。 +var baseDefaultOrder = []string{ + "iccid", "msisdn", "imsi", "imei", "usedFlowMb", "remainFlowMb", "packageTotalMb", + "cardStatus", "netModel", "operators", "activateAt", "outStockTime", "expireAt", + "outboundBatch", "inboundBatch", "packageName", "deptName", "remark", "extraRemark", +} + +// 查询结果与失败原因固定追加在末尾,保证「部分成功」时每条失败可追溯。 +const ( + queryResultField = "queryResult" + failReasonField = "failReason" +) + +func baseDefaultTags() []string { + tags := make([]string, 0, len(baseDefaultOrder)+2) + for _, field := range baseDefaultOrder { + tags = append(tags, baseColumnWhitelist[field].Label) + } + return append(tags, "查询结果", "失败原因") +} + +// baseMandatoryOrder 三码固定导出列。 +// 这三列是对账的锚点,文件里没有 ICCID/MSISDN/IMSI 就无法定位到卡, +// 因此既不允许前端取消勾选,也不允许服务端被传参绕过。 +var baseMandatoryOrder = []string{"iccid", "msisdn", "imsi"} + +// resolveBaseColumns 归一化前端传入的导出列:三码强制在前,其余去重后按白名单追加,并固定追加结果列。 +func resolveBaseColumns(in *model.SimCardQueryExportParam) ([]model.ExportColumn, error) { + requested := decodeExportColumns(in) + columns := make([]model.ExportColumn, 0, len(requested)+5) + seen := make(map[string]struct{}, len(requested)+len(baseMandatoryOrder)) + for _, field := range baseMandatoryOrder { + seen[field] = struct{}{} + columns = append(columns, baseColumnWhitelist[field]) + } + for _, column := range requested { + field := strings.TrimSpace(column.Field) + allowed, ok := baseColumnWhitelist[field] + if !ok { + continue + } + if _, dup := seen[field]; dup { + continue + } + seen[field] = struct{}{} + // 表头以服务端白名单为准,忽略前端自定义文案,避免导出列与页面口径不一致。 + columns = append(columns, allowed) + } + // 未勾选任何可选列时,把剩余默认列补齐;三码已在前面,不会重复。 + if len(columns) == len(baseMandatoryOrder) { + for _, field := range baseDefaultOrder { + if _, dup := seen[field]; dup { + continue + } + seen[field] = struct{}{} + columns = append(columns, baseColumnWhitelist[field]) + } + } + // 结果列不允许被去掉:部分成功时用户必须能看到每条失败的原因。 + columns = append(columns, + model.ExportColumn{Field: queryResultField, Label: "查询结果"}, + model.ExportColumn{Field: failReasonField, Label: "失败原因"}, + ) + return columns, nil +} + +// decodeExportColumns 解析前端传入的导出列。 +// 优先解析 JSON 字符串(表单提交时数组套对象会被拆散),失败再退回对象数组。 +func decodeExportColumns(in *model.SimCardQueryExportParam) []model.ExportColumn { + if in == nil { + return nil + } + if raw := strings.TrimSpace(in.ExportColumnsJson); raw != "" { + cols := make([]model.ExportColumn, 0) + if err := gjson.New(raw).Scan(&cols); err == nil && len(cols) > 0 { + return cols + } + } + return in.ExportColumns +} + +// baseQuerySnapshot 落库的筛选条件快照。 +// 只留模式、条数和前若干条样本,不存全量号码,避免 query_params 膨胀。 +func baseQuerySnapshot(in *model.SimCardQueryExportParam, inputRows int) g.Map { + snapshot := g.Map{ + "mode": in.Mode, + "inputRows": inputRows, + } + switch in.Mode { + case model.SimCardQueryModeRange: + snapshot["dimension"] = in.Dimension + snapshot["startNum"] = in.StartNum + snapshot["endNum"] = in.EndNum + case model.SimCardQueryModeInput: + snapshot["batchInputPreview"] = previewText(in.BatchInput, 5) + } + return snapshot +} + +func previewText(text string, limit int) []string { + lines := strings.Split(strings.ReplaceAll(text, "\r\n", "\n"), "\n") + out := make([]string, 0, limit) + for _, line := range lines { + line = strings.TrimSpace(line) + if line == "" { + continue + } + out = append(out, line) + if len(out) >= limit { + break + } + } + return out +} + +// roundMb 统一保留 3 位小数,与库表 decimal(18,3) 精度一致。 +func roundMb(value float64) float64 { + return math.Round(value*1000) / 1000 +} + +// usageHistoryWhitelist 月历史用量查询允许勾选的固定列。 +// 月份列是动态生成的,直接按月份值作 Field,不走白名单。 +var usageHistoryWhitelist = map[string]model.ExportColumn{ + "iccid": {Field: "iccid", Label: "ICCID"}, + "msisdn": {Field: "msisdn", Label: "MSISDN"}, + "imsi": {Field: "imsi", Label: "IMSI"}, + "outStockTime": {Field: "outStockTime", Label: "出库时间"}, + "expireAt": {Field: "expireAt", Label: "到期时间"}, +} + +// usageHistoryOptionalFields 月历史用量里可勾选的固定列(三码固定导出,不在此列)。 +var usageHistoryOptionalFields = []string{"outStockTime", "expireAt"} + +// resolveUsageHistoryColumns 生成月历史用量导出列。 +// 三码固定在最前且不可取消;月份列固定跟随导出口径,不允许前端自行增删。 +func resolveUsageHistoryColumns(in *model.SimCardUsageHistoryExportParam, months []string) []model.ExportColumn { + requested := decodeExportColumns(&in.SimCardQueryExportParam) + allowed := make(map[string]struct{}, len(requested)) + for _, column := range requested { + field := strings.TrimSpace(column.Field) + if _, ok := usageHistoryWhitelist[field]; ok { + allowed[field] = struct{}{} + } + } + columns := make([]model.ExportColumn, 0, len(usageHistoryFixedFields)+len(months)+2) + for _, field := range baseMandatoryOrder { + columns = append(columns, usageHistoryWhitelist[field]) + } + // 未勾选任何可选列时按默认全出,命中筛选时按固定顺序保留。 + if len(allowed) == 0 { + for _, field := range usageHistoryOptionalFields { + columns = append(columns, usageHistoryWhitelist[field]) + } + } else { + for _, field := range usageHistoryOptionalFields { + if _, ok := allowed[field]; ok { + columns = append(columns, usageHistoryWhitelist[field]) + } + } + } + for _, month := range months { + columns = append(columns, model.ExportColumn{Field: month, Label: month}) + } + columns = append(columns, + model.ExportColumn{Field: queryResultField, Label: "查询结果"}, + model.ExportColumn{Field: failReasonField, Label: "失败原因"}, + ) + return columns +} + +func usageHistoryTags(months []string) []string { + tags := make([]string, 0, len(usageHistoryFixedFields)+len(months)+2) + for _, field := range usageHistoryFixedFields { + tags = append(tags, usageHistoryWhitelist[field].Label) + } + tags = append(tags, months...) + return append(tags, "查询结果", "失败原因") +} + +func usageHistorySnapshot(in *model.SimCardUsageHistoryExportParam, months []string, rows int) g.Map { + snapshot := g.Map{ + "mode": in.Mode, + "months": months, + "inputRows": rows, + } + if in.Mode == model.SimCardQueryModeDept { + snapshot["deptId"] = in.DeptId + } + return snapshot +} + +// UsageHistoryTemplate 生成月历史用量查询模板(与卡基础信息查询同一份模板口径)。 +func (s *Service) UsageHistoryTemplate(ctx context.Context) (*excelize.File, string, error) { + return s.BaseTemplate(ctx) +} + +// BaseColumnOptions 卡基础信息查询可选导出列,供前端渲染勾选框。 +func (s *Service) BaseColumnOptions() []model.ExportColumn { + options := make([]model.ExportColumn, 0, len(baseDefaultOrder)) + for _, field := range baseDefaultOrder { + options = append(options, baseColumnWhitelist[field]) + } + return options +} + +// UsageHistoryColumnOptions 月历史用量查询可选固定列(不含动态月份列)。 +func (s *Service) UsageHistoryColumnOptions() []model.ExportColumn { + options := make([]model.ExportColumn, 0, len(usageHistoryFixedFields)) + for _, field := range usageHistoryFixedFields { + options = append(options, usageHistoryWhitelist[field]) + } + return options +} + +// UsageHistoryMonths 当前导出口径的月份清单,前端用它预览列顺序。 +func (s *Service) UsageHistoryMonths(now time.Time) []string { + return LastCompleteMonths(now, usageHistoryMonths) +} + +// BaseTemplate 生成卡基础信息查询模板。 +// 三列都允许为空,但至少填一个;表头与导入解析的 RequiredHeaders 保持一致。 +func (s *Service) BaseTemplate(ctx context.Context) (*excelize.File, string, error) { + f := excelize.NewFile() + sheet := "卡基础信息查询" + index, err := f.NewSheet(sheet) + if err != nil { + return nil, "", gerror.Wrap(err, "创建模板工作表失败") + } + f.SetActiveSheet(index) + if err := f.DeleteSheet("Sheet1"); err != nil { + return nil, "", gerror.Wrap(err, "清理默认工作表失败") + } + + headers := []string{"ICCID", "MSISDN", "IMSI"} + for i, header := range headers { + cell, _ := excelize.CoordinatesToCellName(i+1, 1) + if err = f.SetCellValue(sheet, cell, header); err != nil { + return nil, "", gerror.Wrap(err, "写入模板表头失败") + } + } + if err = f.SetColWidth(sheet, "A", "C", 26); err != nil { + return nil, "", gerror.Wrap(err, "设置模板列宽失败") + } + if err = iot_excelize.ApplyHeaderColumnsTextStyle(f); err != nil { + g.Log().Warningf(ctx, "apply template style failed: %v", err) + } + + return f, "卡基础信息查询模板", nil +} diff --git a/server/internal/logic/iot/simcardquery/dept.go b/server/internal/logic/iot/simcardquery/dept.go new file mode 100644 index 0000000..7579ed0 --- /dev/null +++ b/server/internal/logic/iot/simcardquery/dept.go @@ -0,0 +1,152 @@ +package simcardquery + +import ( + "context" + "fmt" + + "git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/dao" + "git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/xgorm/handler" + "git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/model" + + "github.com/gogf/gf/v2/database/gdb" + "github.com/gogf/gf/v2/errors/gerror" +) + +// DeptOptions 所属公司下拉项(树形)。 +// +// 两点关键口径: +// 1. 可见范围交给 handler.FilterAuth:顶级看全部,其他角色按自身数据范围。 +// 2. CardCount 是「含下级」的累计量,不是本部门直接挂的卡。 +// 原因:顶级公司(如示例企业)自身往往不挂卡,卡都在下级代理名下, +// 只统计本部门会显示 0,看起来像没数据。 +func (s *Service) DeptOptions(ctx context.Context) ([]*model.SimCardQueryDeptOption, error) { + deptColumns := dao.SysDept.Columns() + depts := make([]*struct { + DeptId int64 `orm:"dept_id"` + DeptName string `orm:"dept_name"` + ParentId int64 `orm:"parent_id"` + }, 0, 256) + + err := handler.Model(dao.SysDept.Ctx(ctx), &handler.Option{ + FilterTenant: true, + FilterAuth: true, + }). + Fields(deptColumns.DeptId, deptColumns.DeptName, deptColumns.ParentId). + Where(deptColumns.Status, 0). + WhereNull(deptColumns.DeletedAt). + OrderAsc(deptColumns.OrderNum). + OrderAsc(deptColumns.DeptId). + Scan(&depts) + if err != nil { + return nil, gerror.Wrap(err, "获取可选公司失败,请稍后重试") + } + if len(depts) == 0 { + return []*model.SimCardQueryDeptOption{}, nil + } + + direct, err := directCardCountByDept(ctx) + if err != nil { + return nil, err + } + subtree := accumulateSubtreeCount(depts, direct) + + options := make([]*model.SimCardQueryDeptOption, 0, len(depts)) + for _, dept := range depts { + options = append(options, &model.SimCardQueryDeptOption{ + DeptId: dept.DeptId, + DeptName: dept.DeptName, + CardCount: subtree[dept.DeptId], + ParentId: dept.ParentId, + }) + } + return options, nil +} + +// directCardCountByDept 按部门统计直接挂载的卡数量。 +// 卡表同样过数据权限,防止公司列表被收窄、但计数把无权卡的量带出来。 +func directCardCountByDept(ctx context.Context) (map[int64]int64, error) { + cardColumns := dao.TSimCardInfo.Columns() + rows := make([]*struct { + DeptId int64 `orm:"dept_id"` + Total int64 `orm:"total"` + }, 0, 256) + err := handler.Model(dao.TSimCardInfo.Ctx(ctx), &handler.Option{ + FilterTenant: true, + FilterAuth: true, + }). + Fields(fmt.Sprintf("%s AS dept_id, COUNT(1) AS total", cardColumns.DeptId)). + Group(cardColumns.DeptId). + Scan(&rows) + if err != nil { + return nil, gerror.Wrap(err, "统计公司卡数量失败") + } + result := make(map[int64]int64, len(rows)) + for _, row := range rows { + result[row.DeptId] = row.Total + } + return result, nil +} + +// accumulateSubtreeCount 把「本部门直接卡数」沿部门树向上累加,得到含下级的卡数。 +// +// 只累加本次查询可见的部门:无权部门根本不在 depts 里,其卡数也不会被计入, +// 因此不会通过父级汇总把无权数据带出来。 +func accumulateSubtreeCount( + depts []*struct { + DeptId int64 `orm:"dept_id"` + DeptName string `orm:"dept_name"` + ParentId int64 `orm:"parent_id"` + }, + direct map[int64]int64, +) map[int64]int64 { + visible := make(map[int64]struct{}, len(depts)) + children := make(map[int64][]int64, len(depts)) + roots := make([]int64, 0, len(depts)) + for _, dept := range depts { + visible[dept.DeptId] = struct{}{} + } + for _, dept := range depts { + if _, ok := visible[dept.ParentId]; ok && dept.ParentId != dept.DeptId { + children[dept.ParentId] = append(children[dept.ParentId], dept.DeptId) + continue + } + roots = append(roots, dept.DeptId) + } + + subtree := make(map[int64]int64, len(depts)) + // 部门树很浅,递归深度可控;用显式栈避免深层树爆栈。 + type frame struct { + id int64 + child int + amount int64 + } + for _, rootID := range roots { + stack := []*frame{{id: rootID, amount: direct[rootID]}} + for len(stack) > 0 { + top := stack[len(stack)-1] + if kids := children[top.id]; top.child < len(kids) { + childID := kids[top.child] + top.child++ + stack = append(stack, &frame{id: childID, amount: direct[childID]}) + continue + } + subtree[top.id] = top.amount + stack = stack[:len(stack)-1] + if len(stack) > 0 { + stack[len(stack)-1].amount += top.amount + } + } + } + return subtree +} + +// deptSubtreeIDs 返回某部门及其全部下级的 dept_id 子查询。 +// 与 DeptOptions 的统计口径保持一致:选中公司即包含下级公司。 +func deptSubtreeIDs(ctx context.Context, deptId int64) *gdb.Model { + deptColumns := dao.SysDept.Columns() + return handler.Model(dao.SysDept.Ctx(ctx), &handler.Option{ + FilterTenant: true, + }).Fields(deptColumns.DeptId). + Where(fmt.Sprintf("%s = ? OR %s LIKE ?", deptColumns.DeptId, deptColumns.Ancestors), + deptId, fmt.Sprintf("%%,%d,%%", deptId)) +} diff --git a/server/internal/logic/iot/simcardquery/export_cost_test.go b/server/internal/logic/iot/simcardquery/export_cost_test.go new file mode 100644 index 0000000..80d3c1f --- /dev/null +++ b/server/internal/logic/iot/simcardquery/export_cost_test.go @@ -0,0 +1,82 @@ +package simcardquery + +import ( + "strconv" + "testing" + "time" + + "github.com/xuri/excelize/v2" +) + +// buildRows 造 n 行「卡基础信息」级别的数据:22 列、字段长短接近真实。 +func buildRows(n int) [][]interface{} { + rows := make([][]interface{}, n) + for i := 0; i < n; i++ { + id := strconv.Itoa(8986000000000000000 + i) + rows[i] = []interface{}{ + id, + "186" + strconv.Itoa(10000000+i), + "46000" + strconv.Itoa(100000000+i), + "86000000000000" + strconv.Itoa(i%100), + float64(i % 5000), + float64(5000 - i%5000), + 10240.0, + 2, + 4, + "中国移动", + "2026-01-01 00:00:00", + "2025-12-01 00:00:00", + "2027-12-31 23:59:59", + "OUT20260101", + "IN20251201", + "标准套餐A", + "示例企业", + "备注内容示例", + "", + "成功", + "", + "", + } + } + return rows +} + +// TestExportWriteCost 量不同行数下「写 Excel + 生成文件」的真实耗时与体积。 +// 用来判断 20000 这个上限是否偏离实际能力,而不是拍脑袋。 +func TestExportWriteCost(t *testing.T) { + for _, n := range []int{20000, 50000, 100000} { + rows := buildRows(n) + start := time.Now() + + f := excelize.NewFile() + sheet := f.GetSheetName(0) + stream, err := f.NewStreamWriter(sheet) + if err != nil { + t.Fatalf("new stream writer: %v", err) + } + header := make([]interface{}, 22) + for i := range header { + header[i] = "列" + strconv.Itoa(i) + } + if err = stream.SetRow("A1", header); err != nil { + t.Fatalf("write header: %v", err) + } + for i, row := range rows { + cell, _ := excelize.CoordinatesToCellName(1, i+2) + if err = stream.SetRow(cell, row); err != nil { + t.Fatalf("write row %d: %v", i, err) + } + } + if err = stream.Flush(); err != nil { + t.Fatalf("flush: %v", err) + } + buf, err := f.WriteToBuffer() + if err != nil { + t.Fatalf("write to buffer: %v", err) + } + _ = f.Close() + + elapsed := time.Since(start) + t.Logf("rows=%6d write=%8.2fs size=%6.1fMB", n, elapsed.Seconds(), float64(buf.Len())/(1<<20)) + } +} diff --git a/server/internal/logic/iot/simcardquery/service.go b/server/internal/logic/iot/simcardquery/service.go new file mode 100644 index 0000000..346fb2f --- /dev/null +++ b/server/internal/logic/iot/simcardquery/service.go @@ -0,0 +1,857 @@ +// Package simcardquery 卡基础信息查询 与 月历史用量查询。 +// +// 两个功能都是「按号码批量查卡 → 异步导出」,共用同一套输入解析与导出列机制: +// - 输入来源:Excel 模板导入 / 多行文本批量输入 / 连号号段; +// - 号码识别:每行 1 个值(ICCID、MSISDN、IMSI 任意一种)或 3 个值(ICCID MSISDN IMSI), +// 单值行在三列之间做 OR 匹配,因此不需要用户先选「用三码还是只用 ICCID」; +// - 导出走通用异步导出中心,文件末尾固定带「查询结果」「失败原因」两列, +// 未命中的输入也会出现在文件里,便于对账。 +package simcardquery + +import ( + "context" + "errors" + "fmt" + "io" + "math" + "math/big" + "strings" + "sync" + + "git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/consts" + "git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/dao" + "git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/batchinput/excel" + "git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/batchinput/text" + "git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/exportcenter" + "git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/xgorm/handler" + "git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/logic/cardflow" + "git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/model" + + "github.com/gogf/gf/v2/errors/gerror" + "github.com/gogf/gf/v2/os/gtime" +) + +// 查询结果列取值,导出文件与执行日志共用同一套文案。 +const ( + queryResultHit = "成功" + queryResultMiss = "失败" +) + +// chunkSize 单次 IN 查询的号码条数。 +// 1000 是 MySQL 解析大量占位符仍能稳定走索引的量级,也和现有批量查询上限一致。 +const chunkSize = 1000 + +type Service struct{} + +func New() *Service { + return &Service{} +} + +// inputLine 一行输入:可能是 1 个号码,也可能是 ICCID/MSISDN/IMSI 三码。 +type inputLine struct { + RowNo int + Values []string +} + +// cardBrief 查询命中的卡基础字段,按号码索引后复用。 +type cardBrief struct { + Id int64 `orm:"id"` + Iccid string `orm:"iccid"` + Msisdn string `orm:"msisdn"` + Imsi string `orm:"imsi"` + Imei string `orm:"imei"` + UsedFlowMb float64 `orm:"used_flow_mb"` + PackageTotalMb float64 `orm:"query_total_flow_mb"` + CardStatus int `orm:"boss_card_status"` + NetModel int `orm:"net_model"` + ChanKey string `orm:"chan_key"` + ActivateAt *gtime.Time `orm:"boss_activate_at"` + OutStockTime *gtime.Time `orm:"out_stock_time"` + ExpireAt *gtime.Time `orm:"flow_pkg_end_at"` + OutboundBatch string `orm:"outbound_batch_no"` + InboundBatch string `orm:"inbound_batch_no"` + PackageCode string `orm:"current_package_code"` + DeptId int64 `orm:"dept_id"` + Remark string `orm:"remark"` +} + +// ExcelInput 上传文件的读取器工厂。 +// 每次调用返回新的读取器,保证后台解析时读到完整内容。 +type ExcelInput func() (io.Reader, error) + +// ExportBase 卡基础信息查询导出。 +func (s *Service) ExportBase(ctx context.Context, in *model.SimCardQueryExportParam, input ExcelInput) (*exportcenter.SubmitResult, error) { + lines, err := parseInputs(ctx, in, input) + if err != nil { + return nil, err + } + columns, err := resolveBaseColumns(in) + if err != nil { + return nil, err + } + + stats := &resolveStats{} + once := &sync.Once{} + var cached []*model.SimCardBaseQueryRow + var cacheErr error + resolve := func(ctx context.Context) ([]*model.SimCardBaseQueryRow, error) { + once.Do(func() { + if in.Mode == model.SimCardQueryModeRange { + cached, cacheErr = s.resolveRangeRows(ctx, in, stats) + return + } + cached, cacheErr = s.resolveBaseRows(ctx, lines, stats) + }) + return cached, cacheErr + } + + return exportcenter.Submit(ctx, &exportcenter.Request{ + BizKey: "tSimCardBaseQuery", + BizName: "卡基础信息查询", + PermCode: "cpm:iot:tSimCardBaseQuery:view", + SheetName: "卡基础信息", + QueryParam: baseQuerySnapshot(in, len(lines)), + Columns: columns, + Tags: func() ([]string, error) { + return baseDefaultTags(), nil + }, + Fetch: func(ctx context.Context, page, pageSize int) ([]interface{}, int, error) { + // exportcenter 提交时会用 pageSize=1 做一次「总数探针」。 + // 直接返回输入行数(号段模式未知条数,返回 1 占位),让提交接口立即返回, + // 真正的查库放到后台协程里做。 + if pageSize <= 1 && page <= 1 { + if len(lines) > 0 { + return nil, len(lines), nil + } + // 号段模式:号段包含多少个号码就是预计条数,与「查不查得到」无关。 + // 这样用户输入 10 个连续号,即使一条都查不到,页面也会显示「预计 10 条」。 + size, sizeErr := rangeSize(in) + if sizeErr != nil { + return nil, 0, sizeErr + } + return nil, int(size), nil + } + rows, err := resolve(ctx) + if err != nil { + return nil, 0, err + } + total := len(rows) + start := (page - 1) * pageSize + if start >= total { + return nil, total, nil + } + end := start + pageSize + if end > total { + end = total + } + out := make([]interface{}, 0, end-start) + for _, row := range rows[start:end] { + out = append(out, row) + } + return out, total, nil + }, + Result: stats.resultSummary, + }) +} + +// parseInputs 按模式解析输入号码。 +func parseInputs(ctx context.Context, in *model.SimCardQueryExportParam, input ExcelInput) ([]inputLine, error) { + switch in.Mode { + case model.SimCardQueryModeInput: + return parseTextInputs(ctx, in.BatchInput) + case model.SimCardQueryModeExcel: + if input == nil { + return nil, gerror.New("请先上传 Excel 文件") + } + reader, err := input() + if err != nil { + return nil, err + } + if reader == nil { + return nil, gerror.New("请先上传 Excel 文件") + } + lines, err := streamExcelInputs(ctx, reader) + if err != nil { + return nil, err + } + if len(lines) == 0 { + return nil, gerror.New("Excel 中没有可解析的号码") + } + return lines, nil + case model.SimCardQueryModeRange: + // 号段模式不展开成逐个号码:连号可能跨上百万个号,展开会把内存打满。 + // 只做校验,实际取数交给 resolveRangeRows 用 BETWEEN 条件分页查。 + return nil, parseRangeInputs(in) + default: + return nil, gerror.New("不支持的查询方式") + } +} + +// parseRangeInputs 校验连号号段参数。 +func parseRangeInputs(in *model.SimCardQueryExportParam) error { + start := strings.TrimSpace(in.StartNum) + end := strings.TrimSpace(in.EndNum) + if start == "" || end == "" { + return gerror.New("连号查询需同时填写起始和结束号码") + } + if len(start) != len(end) || !isDigits(start) || !isDigits(end) { + return gerror.New("起止号码必须为等长数字") + } + if start > end { + return gerror.New("起始号码不能大于结束号码") + } + _, err := rangeColumn(in.Dimension) + if err != nil { + return err + } + // 号段按号码逐个输出(查到出行数据、没查到出失败行),条数必须与号段大小一致。 + // 因此号段不能超过单次上限,否则无法完整展开。 + size, err := rangeSize(in) + if err != nil { + return err + } + if size > model.SimCardQueryMaxRows { + return gerror.Newf("号段内号码数量超过单次上限 %d 个,请缩小范围或分批查询", model.SimCardQueryMaxRows) + } + return nil +} + +// parseTextInputs 解析多行文本。每行 1~3 个空白分隔的号码值。 +func isDigits(value string) bool { + if value == "" { + return false + } + for _, r := range value { + if r < '0' || r > '9' { + return false + } + } + return true +} + +func parseTextInputs(ctx context.Context, input string) ([]inputLine, error) { + lines, err := text.ParseWhitespaceFields(ctx, input) + if err != nil { + if errors.Is(err, text.ErrEmptyInput) { + return nil, gerror.New("批量输入内容不能为空") + } + return nil, err + } + if len(lines) > model.SimCardQueryMaxRows { + return nil, gerror.Newf("批量输入 %d 行,超过单次上限 %d 行,请拆分后分批查询", len(lines), model.SimCardQueryMaxRows) + } + out := make([]inputLine, 0, len(lines)) + for _, line := range lines { + if len(line.Fields) > 3 { + return nil, gerror.Newf("第 %d 行格式错误:每行支持 1 至 3 个号码值", line.RowNo) + } + out = append(out, inputLine{RowNo: line.RowNo, Values: line.Fields}) + } + return out, nil +} + +// streamExcelInputs 流式解析 Excel 上传。ICCID / MSISDN / IMSI 三列任填其一即可。 +func streamExcelInputs(ctx context.Context, reader io.Reader) ([]inputLine, error) { + out := make([]inputLine, 0) + _, err := excel.StreamReader(ctx, reader, excel.ParseOptions{ + SingleSheet: true, + MaxDataRows: model.SimCardQueryMaxRows, + }, func(row excel.ParsedRow) error { + values := make([]string, 0, 3) + for _, name := range []string{"ICCID", "MSISDN", "IMSI"} { + if value := excel.Field(row, name); value != "" { + values = append(values, value) + } + } + if len(values) == 0 { + // 空行由解析库跳过;这里保留为失败行,便于用户知道模板里哪一行没填。 + values = append(values, "") + } + out = append(out, inputLine{RowNo: row.RowNo, Values: values}) + return nil + }) + if err != nil { + return nil, err + } + if len(out) == 0 { + return nil, gerror.New("Excel 中没有可解析的号码") + } + if len(out) > model.SimCardQueryMaxRows { + return nil, gerror.Newf("Excel 有效数据 %d 行,超过单次上限 %d 行,请拆分后分批查询", len(out), model.SimCardQueryMaxRows) + } + return out, nil +} + +// resolveStats 记录命中/未命中/去重数量,用于导出结束后的执行日志汇总。 +type resolveStats struct { + mu sync.Mutex + hit int + miss int + dup int +} + +func (s *resolveStats) add(hit bool) { + s.mu.Lock() + defer s.mu.Unlock() + if hit { + s.hit++ + return + } + s.miss++ +} + +// addDup 记录一条因重复而合并的输入。 +// 同一张卡可能是同一号码写了两遍,也可能一行 ICCID、另一行 MSISDN 指向同一张卡。 +func (s *resolveStats) addDup() { + s.mu.Lock() + defer s.mu.Unlock() + s.dup++ +} + +func (s *resolveStats) resultSummary(_ int) (int, string) { + s.mu.Lock() + defer s.mu.Unlock() + total := s.hit + s.miss + if total == 0 { + return consts.BatchRunResultFailed, "查询结果:没有可导出的数据" + } + dupText := "" + if s.dup > 0 { + dupText = fmt.Sprintf(",重复 %d 条已合并", s.dup) + } + if s.miss == 0 { + return consts.BatchRunResultSuccess, + fmt.Sprintf("查询完成:成功 %d 条,失败 0 条%s", s.hit, dupText) + } + if s.hit == 0 { + return consts.BatchRunResultFailed, + fmt.Sprintf("查询失败:成功 0 条,失败 %d 条%s;失败原因见导出文件「失败原因」列", s.miss, dupText) + } + return consts.BatchRunResultPartialSuccess, + fmt.Sprintf("部分成功:成功 %d 条,失败 %d 条%s;失败原因见导出文件「失败原因」列", s.hit, s.miss, dupText) +} + +// resolveBaseRows 把输入号码解析成导出行,未命中的行也保留并写明原因。 +func (s *Service) resolveBaseRows(ctx context.Context, lines []inputLine, stats *resolveStats) ([]*model.SimCardBaseQueryRow, error) { + values := collectValues(lines) + cards, err := queryCardsByValues(ctx, values) + if err != nil { + return nil, err + } + byIccid := indexCards(cards, func(card *cardBrief) string { return card.Iccid }) + byMsisdn := indexCards(cards, func(card *cardBrief) string { return card.Msisdn }) + byImsi := indexCards(cards, func(card *cardBrief) string { return card.Imsi }) + + display := newDisplayResolver(ctx, cards) + rows := make([]*model.SimCardBaseQueryRow, 0, len(lines)) + // 同一张卡在输入里出现多次时只导出一次: + // 可能是同一号码重复填写,也可能一行是 ICCID、另一行是它的 MSISDN。 + exported := make(map[int64]struct{}, len(lines)) + for _, line := range lines { + row := &model.SimCardBaseQueryRow{} + card, reason := matchCard(line, byIccid, byMsisdn, byImsi) + if card == nil { + // 未命中时把输入的第一列原样回填,方便用户定位是哪一条没查到。 + row.Iccid = firstValue(line.Values) + row.QueryResult = queryResultMiss + row.FailReason = inputFailure(line, reason) + stats.add(false) + rows = append(rows, row) + continue + } + if _, ok := exported[card.Id]; ok { + stats.addDup() + continue + } + exported[card.Id] = struct{}{} + display.fill(row, card) + row.QueryResult = queryResultHit + stats.add(true) + rows = append(rows, row) + } + return rows, nil +} + +// resolveRangeRows 号段模式:按起止号段分页取卡,取到的卡全部视为命中。 +// 取满上限即停,避免上百万号的号段把导出任务拖死。 +func (s *Service) resolveRangeRows(ctx context.Context, in *model.SimCardQueryExportParam, stats *resolveStats) ([]*model.SimCardBaseQueryRow, error) { + cards, err := queryCardsByRange(ctx, in) + if err != nil { + return nil, err + } + return buildRangeRows(ctx, in, cards, stats), nil +} + +// setRangeNumber 按号段维度把号码写进对应列。 +func setRangeNumber(row *model.SimCardBaseQueryRow, dimension, number string) { + switch dimension { + case "msisdn": + row.Msisdn = number + case "imsi": + row.Imsi = number + default: + row.Iccid = number + } +} + +// rangeCardIndex 按号段维度值索引命中的卡。 +func rangeCardIndex(cards []*cardBrief, dimension string) map[string]*cardBrief { + index := make(map[string]*cardBrief, len(cards)) + for _, card := range cards { + var value string + switch dimension { + case "msisdn": + value = card.Msisdn + case "imsi": + value = card.Imsi + default: + value = card.Iccid + } + if value != "" { + index[value] = card + } + } + return index +} + +// buildRangeRows 号段模式:按号段逐个号码输出一行,命中出行数据、未命中出失败行。 +// +// 不能只输出命中的卡:号段里可能一部分在当前账号可见范围内、一部分不在, +// 只输出命中部分会让导出条数与号段大小对不上,客户无法按号对账。 +func buildRangeRows(ctx context.Context, in *model.SimCardQueryExportParam, cards []*cardBrief, stats *resolveStats) []*model.SimCardBaseQueryRow { + dimension := strings.TrimSpace(in.Dimension) + index := rangeCardIndex(cards, dimension) + size, err := rangeSize(in) + if err != nil { + size = int64(len(cards)) + } + numbers := rangeNumbers(in, size) + display := newDisplayResolver(ctx, cards) + rows := make([]*model.SimCardBaseQueryRow, 0, len(numbers)) + for _, number := range numbers { + if card, ok := index[number]; ok { + row := &model.SimCardBaseQueryRow{QueryResult: queryResultHit} + display.fill(row, card) + stats.add(true) + rows = append(rows, row) + continue + } + row := &model.SimCardBaseQueryRow{ + QueryResult: queryResultMiss, + FailReason: rangeMissReason, + } + setRangeNumber(row, dimension, number) + stats.add(false) + rows = append(rows, row) + } + return rows +} + +// rangeMissReason 号段/号码查不到数据时的统一说明。 +// 不能写「无权访问」:是否因为权限被过滤属于内部口径,不应暴露给客户。 +const rangeMissReason = "未查询到数据" + +// rangeSize 号段包含的号码个数,用于提交时给出「预计 N 条」。 +// ICCID 有 20 位,超过 int64 表示范围,用 big.Int 计算。 +func rangeSize(in *model.SimCardQueryExportParam) (int64, error) { + start := strings.TrimSpace(in.StartNum) + end := strings.TrimSpace(in.EndNum) + s, ok := new(big.Int).SetString(start, 10) + if !ok { + return 0, gerror.New("起始号码格式不正确") + } + e, ok := new(big.Int).SetString(end, 10) + if !ok { + return 0, gerror.New("结束号码格式不正确") + } + if e.Cmp(s) < 0 { + return 0, gerror.New("起始号码不能大于结束号码") + } + span := new(big.Int).Sub(e, s) + span.Add(span, big.NewInt(1)) + if !span.IsInt64() { + return math.MaxInt64, nil + } + return span.Int64(), nil +} + +// padNumber 保持与输入等长,避免前导零被 big.Int 吃掉后与用户输入对不上。 +func padNumber(num string, width int) string { + if len(num) >= width { + return num + } + return strings.Repeat("0", width-len(num)) + num +} + +// rangeNumbers 按号段依次产出号码,最多 limit 个。 +func rangeNumbers(in *model.SimCardQueryExportParam, limit int64) []string { + start := strings.TrimSpace(in.StartNum) + s, ok := new(big.Int).SetString(start, 10) + if !ok { + return nil + } + out := make([]string, 0, limit) + cur := new(big.Int).Set(s) + for i := int64(0); i < limit; i++ { + out = append(out, padNumber(cur.String(), len(start))) + cur.Add(cur, big.NewInt(1)) + } + return out +} + +// rangeColumn 号段维度对应的表字段。 +// 只允许三码维度,避免把任意字段拼进 SQL。 +func rangeColumn(dimension string) (string, error) { + columns := dao.TSimCardInfo.Columns() + switch strings.TrimSpace(dimension) { + case "iccid": + return columns.Iccid, nil + case "msisdn": + return columns.Msisdn, nil + case "imsi": + return columns.Imsi, nil + default: + return "", gerror.New("连号维度只能选 ICCID、MSISDN 或 IMSI") + } +} + +// queryCardsByRange 按号段取卡;命中超过上限时拒绝,避免静默截断。 +func queryCardsByRange(ctx context.Context, in *model.SimCardQueryExportParam) ([]*cardBrief, error) { + column, err := rangeColumn(in.Dimension) + if err != nil { + return nil, err + } + columns := dao.TSimCardInfo.Columns() + result := make([]*cardBrief, 0) + for start := 0; start < model.SimCardQueryMaxRows; start += chunkSize { + list := make([]*cardBrief, 0, chunkSize) + err := handler.Model(dao.TSimCardInfo.Ctx(ctx), &handler.Option{ + FilterTenant: true, + FilterAuth: true, + }). + Fields( + columns.Id, columns.Iccid, columns.Msisdn, columns.Imsi, columns.Imei, + columns.UsedFlowMb, columns.PackageTotalFlowMb, columns.BossCardStatus, + columns.NetModel, columns.ChanKey, columns.BossActivateAt, columns.OutStockTime, + columns.FlowPkgEndAt, columns.OutboundBatchNo, columns.InboundBatchNo, + columns.CurrentPackageCode, columns.DeptId, columns.Remark, + ). + Fields(cardflow.TotalFlowExpr(dao.TSimCardInfo.Table(), "")+" AS query_total_flow_mb"). + Where(column+" >= ?", strings.TrimSpace(in.StartNum)). + Where(column+" <= ?", strings.TrimSpace(in.EndNum)). + Where(column+" != ?", ""). + OrderAsc(columns.Id). + Limit(chunkSize).Offset(start). + Scan(&list) + if err != nil { + return nil, gerror.Wrap(err, "查询卡信息失败,请稍后重试") + } + result = append(result, list...) + if len(list) < chunkSize { + break + } + if len(result) == model.SimCardQueryMaxRows { + var extra int + extra, err = handler.Model(dao.TSimCardInfo.Ctx(ctx), &handler.Option{ + FilterTenant: true, + FilterAuth: true, + }).Where(column+" >= ?", strings.TrimSpace(in.StartNum)). + Where(column+" <= ?", strings.TrimSpace(in.EndNum)). + Where(column+" != ?", "").Count() + if err != nil { + return nil, gerror.Wrap(err, "校验号段查询上限失败") + } + if extra > model.SimCardQueryMaxRows { + return nil, gerror.Newf("号段命中超过单次上限 %d 张卡,请缩小范围", model.SimCardQueryMaxRows) + } + } + } + return result, nil +} + +type cardIndex map[string][]*cardBrief + +func indexCards(cards []*cardBrief, field func(*cardBrief) string) cardIndex { + index := make(cardIndex, len(cards)) + for _, card := range cards { + if card != nil { + if value := field(card); value != "" { + index[value] = append(index[value], card) + } + } + } + return index +} + +// matchCard 对每个号码保留全部候选卡,再求交集;重复 IMSI 和跨字段冲突不按顺序择卡。 +func matchCard(line inputLine, byIccid, byMsisdn, byImsi cardIndex) (*cardBrief, string) { + var candidates map[int64]*cardBrief + for _, value := range line.Values { + value = strings.TrimSpace(value) + matches := make(map[int64]*cardBrief) + for _, index := range []cardIndex{byIccid, byMsisdn, byImsi} { + for _, card := range index[value] { + matches[card.Id] = card + } + } + if value == "" || len(matches) == 0 { + return nil, "未查询到数据:" + value + } + if candidates == nil { + candidates = matches + continue + } + for id := range candidates { + if _, ok := matches[id]; !ok { + delete(candidates, id) + } + } + } + if len(candidates) == 0 { + return nil, "同一行内的号码未指向同一张卡" + } + if len(candidates) > 1 { + return nil, "号码匹配到多张卡,请补充同一卡的 ICCID/MSISDN/IMSI 消除歧义" + } + for _, card := range candidates { + return card, "" + } + return nil, rangeMissReason +} + +func inputFailure(line inputLine, reason string) string { + return fmt.Sprintf("第 %d 行,原始输入 [%s]:%s", line.RowNo, strings.Join(line.Values, " | "), reason) +} + +func firstValue(values []string) string { + if len(values) == 0 { + return "" + } + return values[0] +} + +// collectValues 汇总需要查库的号码值,去重后返回。 +func collectValues(lines []inputLine) []string { + seen := make(map[string]struct{}, len(lines)*3) + out := make([]string, 0, len(lines)*3) + for _, line := range lines { + for _, value := range line.Values { + value = strings.TrimSpace(value) + if value == "" { + continue + } + if _, ok := seen[value]; ok { + continue + } + seen[value] = struct{}{} + out = append(out, value) + } + } + return out +} + +// queryCardsByValues 分批按号码值查卡。 +// 单值在三列之间 OR 匹配(iccid 唯一索引 + msisdn 唯一索引 + imsi 索引), +// 因此用户不需要提前声明用的是 ICCID 还是三码。 +func queryCardsByValues(ctx context.Context, values []string) ([]*cardBrief, error) { + if len(values) == 0 { + return nil, nil + } + columns := dao.TSimCardInfo.Columns() + cardTable := dao.TSimCardInfo.Table() + result := make([]*cardBrief, 0, len(values)) + seen := make(map[int64]struct{}, len(values)) + + for start := 0; start < len(values); start += chunkSize { + end := start + chunkSize + if end > len(values) { + end = len(values) + } + chunk := values[start:end] + placeholders := strings.TrimSuffix(strings.Repeat("?,", len(chunk)), ",") + condition := fmt.Sprintf("(%s.%s IN (%s) OR %s.%s IN (%s) OR %s.%s IN (%s))", + cardTable, columns.Iccid, placeholders, + cardTable, columns.Msisdn, placeholders, + cardTable, columns.Imsi, placeholders, + ) + args := make([]interface{}, 0, len(chunk)*3) + for repeat := 0; repeat < 3; repeat++ { + for _, value := range chunk { + args = append(args, value) + } + } + + list := make([]*cardBrief, 0, len(chunk)) + err := handler.Model(dao.TSimCardInfo.Ctx(ctx), &handler.Option{ + FilterTenant: true, + FilterAuth: true, + }). + Fields( + columns.Id, + columns.Iccid, + columns.Msisdn, + columns.Imsi, + columns.Imei, + columns.UsedFlowMb, + columns.PackageTotalFlowMb, + columns.BossCardStatus, + columns.NetModel, + columns.ChanKey, + columns.BossActivateAt, + columns.OutStockTime, + columns.FlowPkgEndAt, + columns.OutboundBatchNo, + columns.InboundBatchNo, + columns.CurrentPackageCode, + columns.DeptId, + columns.Remark, + ). + Fields(cardflow.TotalFlowExpr(cardTable, "")+" AS query_total_flow_mb"). + Where(condition, args...). + Scan(&list) + if err != nil { + return nil, gerror.Wrap(err, "查询卡信息失败,请稍后重试") + } + for _, card := range list { + if _, ok := seen[card.Id]; ok { + continue + } + seen[card.Id] = struct{}{} + result = append(result, card) + } + } + return result, nil +} + +// displayResolver 把 chan_key / package_code / dept_id 批量翻译成展示名,避免逐行子查询。 +type displayResolver struct { + chanNames map[string]string + packageNames map[string]string + packageFlows map[string]float64 + deptNames map[string]string +} + +func newDisplayResolver(ctx context.Context, cards []*cardBrief) *displayResolver { + r := &displayResolver{ + chanNames: make(map[string]string), + packageNames: make(map[string]string), + packageFlows: make(map[string]float64), + deptNames: make(map[string]string), + } + if len(cards) == 0 { + return r + } + chanKeys := make([]string, 0) + packageCodes := make([]string, 0) + deptIds := make([]int64, 0) + seenChan := map[string]struct{}{} + seenPackage := map[string]struct{}{} + seenDept := map[int64]struct{}{} + for _, card := range cards { + if card.ChanKey != "" { + if _, ok := seenChan[card.ChanKey]; !ok { + seenChan[card.ChanKey] = struct{}{} + chanKeys = append(chanKeys, card.ChanKey) + } + } + if card.PackageCode != "" { + if _, ok := seenPackage[card.PackageCode]; !ok { + seenPackage[card.PackageCode] = struct{}{} + packageCodes = append(packageCodes, card.PackageCode) + } + } + if card.DeptId > 0 { + if _, ok := seenDept[card.DeptId]; !ok { + seenDept[card.DeptId] = struct{}{} + deptIds = append(deptIds, card.DeptId) + } + } + } + + if len(chanKeys) > 0 { + type chanRow struct { + ChanKey string `orm:"chan_key"` + Operator int `orm:"operator"` + } + rows := make([]*chanRow, 0, len(chanKeys)) + if err := dao.TSimChannels.Ctx(ctx). + Fields("chan_key, operator"). + WhereIn("chan_key", chanKeys). + Scan(&rows); err == nil { + for _, row := range rows { + switch row.Operator { + case 1: + r.chanNames[row.ChanKey] = "中国移动" + case 2: + r.chanNames[row.ChanKey] = "中国联通" + case 3: + r.chanNames[row.ChanKey] = "中国电信" + default: + r.chanNames[row.ChanKey] = "未知" + } + } + } + } + if len(packageCodes) > 0 { + type pkgRow struct { + PackageCode string `orm:"package_code"` + PackageName string `orm:"package_name"` + FlowMb float64 `orm:"flow_mb"` + } + rows := make([]*pkgRow, 0, len(packageCodes)) + if err := dao.TSimFlowPackageInfo.Ctx(ctx). + Fields("package_code, package_name, flow_mb"). + WhereIn("package_code", packageCodes). + Scan(&rows); err == nil { + for _, row := range rows { + r.packageNames[row.PackageCode] = row.PackageName + r.packageFlows[row.PackageCode] = row.FlowMb + } + } + } + if len(deptIds) > 0 { + type deptRow struct { + DeptId int64 `orm:"dept_id"` + DeptName string `orm:"dept_name"` + } + rows := make([]*deptRow, 0, len(deptIds)) + if err := dao.SysDept.Ctx(ctx). + Fields("dept_id, dept_name"). + WhereIn("dept_id", deptIds). + WhereNull("deleted_at"). + Scan(&rows); err == nil { + for _, row := range rows { + r.deptNames[fmt.Sprintf("%d", row.DeptId)] = row.DeptName + } + } + } + return r +} + +// fill 把库里字段落到导出行。 +func (r *displayResolver) fill(row *model.SimCardBaseQueryRow, card *cardBrief) { + row.Iccid = card.Iccid + row.Msisdn = card.Msisdn + row.Imsi = card.Imsi + row.Imei = card.Imei + row.UsedFlowMb = roundMb(card.UsedFlowMb) + row.CardStatus = card.CardStatus + row.NetModel = card.NetModel + row.Operators = r.chanNames[card.ChanKey] + row.OutboundBatch = card.OutboundBatch + row.InboundBatch = card.InboundBatch + row.PackageName = r.packageNames[card.PackageCode] + row.DeptName = r.deptNames[fmt.Sprintf("%d", card.DeptId)] + row.Remark = card.Remark + row.ActivateAt = card.ActivateAt + row.OutStockTime = card.OutStockTime + row.ExpireAt = card.ExpireAt + + // 复用 cardflow.TotalFlowExpr:当前主套餐(或卡冗余值)加生效中的单卡加油包。 + row.PackageTotalMb = roundMb(card.PackageTotalMb) + remain := card.PackageTotalMb - card.UsedFlowMb + if remain < 0 { + remain = 0 + } + row.RemainFlowMb = roundMb(remain) +} diff --git a/server/internal/logic/iot/simcardquery/service_test.go b/server/internal/logic/iot/simcardquery/service_test.go new file mode 100644 index 0000000..44a3dcc --- /dev/null +++ b/server/internal/logic/iot/simcardquery/service_test.go @@ -0,0 +1,271 @@ +package simcardquery + +import ( + "context" + "strings" + "testing" + "time" + + "github.com/xuri/excelize/v2" + "git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/consts" + "git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/model" +) + +func TestTemplateRoundTrip(t *testing.T) { + ctx := context.Background() + f, _, err := New().BaseTemplate(ctx) + if err != nil { + t.Fatal(err) + } + defer f.Close() + sheet := f.GetSheetName(0) + if err := f.SetCellStr(sheet, "A2", "89860000000000000001"); err != nil { + t.Fatal(err) + } + b, err := f.WriteToBuffer() + if err != nil { + t.Fatal(err) + } + lines, err := streamExcelInputs(ctx, b) + if err != nil { + t.Fatal(err) + } + if len(lines) != 1 || lines[0].Values[0] != "89860000000000000001" { + t.Fatalf("unexpected lines: %+v", lines) + } +} + +func TestExcelInputLimit(t *testing.T) { + f := excelize.NewFile() + defer f.Close() + sheet := f.GetSheetName(0) + if err := f.SetCellStr(sheet, "A1", "ICCID"); err != nil { + t.Fatal(err) + } + for i := 2; i <= model.SimCardQueryMaxRows+2; i++ { + cell, _ := excelize.CoordinatesToCellName(1, i) + if err := f.SetCellStr(sheet, cell, "89860000000000000001"); err != nil { + t.Fatal(err) + } + } + b, err := f.WriteToBuffer() + if err != nil { + t.Fatal(err) + } + if _, err := streamExcelInputs(context.Background(), b); err == nil { + t.Fatal("expected over-limit error") + } +} + +func TestMatchCardAmbiguity(t *testing.T) { + cards := []*cardBrief{ + {Id: 1, Iccid: "89860001", Msisdn: "10001", Imsi: "46000001"}, + {Id: 2, Iccid: "89860002", Msisdn: "10002", Imsi: "46000001"}, + } + a := indexCards(cards, func(c *cardBrief) string { return c.Iccid }) + b := indexCards(cards, func(c *cardBrief) string { return c.Msisdn }) + c := indexCards(cards, func(c *cardBrief) string { return c.Imsi }) + if card, _ := matchCard(inputLine{Values: []string{"46000001"}}, a, b, c); card != nil { + t.Fatal("ambiguous IMSI must fail") + } + if card, reason := matchCard(inputLine{Values: []string{"89860002", "46000001"}}, a, b, c); card == nil || card.Id != 2 { + t.Fatalf("expected second card, got %+v %s", card, reason) + } + if card, _ := matchCard(inputLine{Values: []string{"89860001", "10002"}}, a, b, c); card != nil { + t.Fatal("mixed card values must fail") + } +} + +func TestRangeValidation(t *testing.T) { + for _, pair := range [][3]string{{"1", "22", "iccid"}, {"aa", "bb", "iccid"}, {"22", "11", "iccid"}, {"11", "22", "imei"}} { + if parseRangeInputs(&model.SimCardQueryExportParam{StartNum: pair[0], EndNum: pair[1], Dimension: pair[2]}) == nil { + t.Fatalf("expected error: %v", pair) + } + } + if err := parseRangeInputs(&model.SimCardQueryExportParam{StartNum: "0011", EndNum: "0022", Dimension: "iccid"}); err != nil { + t.Fatal(err) + } +} + +func TestIsSafeIccid(t *testing.T) { + ok := []string{"89860000000000000001", "8986abc001", "ABC123"} + for _, v := range ok { + if !isSafeIccid(v) { + t.Fatalf("expected safe: %s", v) + } + } + // 单引号、空格、分号、括号都必须拒绝,否则拼进 SQL 会破坏语句结构。 + bad := []string{"", "8986'001", "8986 001", "8986;001", "8986)001", strings.Repeat("1", 33)} + for _, v := range bad { + if isSafeIccid(v) { + t.Fatalf("expected unsafe: %q", v) + } + } +} + +func TestFormatMonthValuesThreeStates(t *testing.T) { + months := []string{"2026-09", "2026-08", "2026-07"} + + // 查询成功:9 月有量、8 月记录为 0、7 月无记录 → 数值 / 0 / "-" + ok := &model.SimCardUsageHistoryRow{ + MonthValues: map[string]float64{"2026-09": 12.3456, "2026-08": 0}, + } + got := formatMonthValues(ok, months) + if got[0] != 12.346 { + t.Fatalf("9 月应导出数值,实际 %v", got[0]) + } + if got[1] != float64(0) { + t.Fatalf("8 月零用量应导出 0,实际 %v", got[1]) + } + if got[2] != usageNoDataText { + t.Fatalf("7 月无记录应导出 %q,实际 %v", usageNoDataText, got[2]) + } + + // 整卡查询失败:月份列一律留空(保证该列是纯数值),原因由「失败原因」列承载 + failed := &model.SimCardUsageHistoryRow{ + MonthValues: map[string]float64{"2026-09": 99}, + UsageQueryFailed: true, + } + got = formatMonthValues(failed, months) + for i, v := range got { + if v != "" { + t.Fatalf("第 %d 列应留空,实际 %v", i+1, v) + } + } + if !strings.Contains(usageQueryFailedNote, "查询失败") { + t.Fatalf("失败原因文案应说明查询失败:%s", usageQueryFailedNote) + } +} + +// TestFormatMonthValuesPartialHistory 覆盖「平台只同步了几个月」和「新卡没历史」两种常见情况: +// 这类卡不是查询失败,缺的月份必须显示 "-",不能整行留空或判失败。 +func TestFormatMonthValuesPartialHistory(t *testing.T) { + months := []string{"2026-09", "2026-08", "2026-07", "2026-06", "2026-05", "2026-04"} + + // 只有最近 3 个月有数据(新卡 / 平台同步只跑了 3 个月) + partial := &model.SimCardUsageHistoryRow{ + MonthValues: map[string]float64{ + "2026-09": 120.5, + "2026-08": 98, + "2026-07": 0, + }, + } + got := formatMonthValues(partial, months) + if len(got) != len(months) { + t.Fatalf("列数应与月份数一致,实际 %d", len(got)) + } + if got[0] != 120.5 { + t.Fatalf("有数据的月份应导出数值,实际 %v", got[0]) + } + if got[2] != float64(0) { + t.Fatalf("有记录但用量为 0 应导出 0,实际 %v", got[2]) + } + for i := 3; i < len(got); i++ { + if got[i] != usageNoDataText { + t.Fatalf("第 %d 列无记录应为 %q,实际 %v", i+1, usageNoDataText, got[i]) + } + } + // 部分缺失不能被当成查询失败 + if partial.UsageQueryFailed { + t.Fatal("缺月份不等于查询失败") + } + + // 全新卡:12 个月一条记录都没有 → 全部 "-",仍然算查询成功 + brandNew := &model.SimCardUsageHistoryRow{MonthValues: map[string]float64{}} + got = formatMonthValues(brandNew, months) + for i, v := range got { + if v != usageNoDataText { + t.Fatalf("新卡第 %d 列应为 %q,实际 %v", i+1, usageNoDataText, v) + } + } +} + +// TestBuildRangeRowsKeepsOneRowPerNumber 号段里只有部分号码可见时, +// 导出必须仍然每个号码一行:可见的出行数据,不可见的出「未查询到数据」, +// 总条数等于号段大小,客户才能按号对账。 +func TestBuildRangeRowsKeepsOneRowPerNumber(t *testing.T) { + in := &model.SimCardQueryExportParam{ + Dimension: "iccid", + StartNum: "89860000000000000001", + EndNum: "89860000000000000010", + } + // 10 个号里只有第 3、5、8 个在可见范围内 + cards := []*cardBrief{ + {Id: 1, Iccid: "89860000000000000003"}, + {Id: 2, Iccid: "89860000000000000005"}, + {Id: 3, Iccid: "89860000000000000008"}, + } + stats := &resolveStats{} + rows := buildRangeRows(context.Background(), in, cards, stats) + + if len(rows) != 10 { + t.Fatalf("应输出 10 行(与号段大小一致),实际 %d", len(rows)) + } + hit, miss := 0, 0 + seen := make(map[string]struct{}, len(rows)) + for _, row := range rows { + if _, dup := seen[row.Iccid]; dup { + t.Fatalf("号码重复输出:%s", row.Iccid) + } + seen[row.Iccid] = struct{}{} + switch row.QueryResult { + case queryResultHit: + hit++ + case queryResultMiss: + miss++ + // 不能透露是因为权限查不到 + if row.FailReason != rangeMissReason { + t.Fatalf("失败原因应为 %q,实际 %q", rangeMissReason, row.FailReason) + } + if strings.Contains(row.FailReason, "权限") { + t.Fatalf("失败原因不能提及权限:%s", row.FailReason) + } + } + } + if hit != 3 || miss != 7 { + t.Fatalf("成功/失败应为 3/7,实际 %d/%d", hit, miss) + } + if stats.hit != 3 || stats.miss != 7 { + t.Fatalf("统计应为 3/7,实际 %d/%d", stats.hit, stats.miss) + } + // 首尾号码要对得上,且保持等长(前导零不丢) + if _, ok := seen["89860000000000000001"]; !ok { + t.Fatal("缺少起始号码") + } + if _, ok := seen["89860000000000000010"]; !ok { + t.Fatal("缺少结束号码") + } +} + +func TestRangeSizeAndLimit(t *testing.T) { + in := &model.SimCardQueryExportParam{StartNum: "89860000000000000001", EndNum: "89860000000000000010"} + size, err := rangeSize(in) + if err != nil || size != 10 { + t.Fatalf("号段大小应为 10,实际 %d err=%v", size, err) + } + // 超过单次上限的号段要在提交阶段直接拒绝,否则无法完整展开 + big := &model.SimCardQueryExportParam{ + Dimension: "iccid", + StartNum: "89860000000000000001", + EndNum: "89860000000000099999", + } + if err := parseRangeInputs(big); err == nil { + t.Fatal("超限号段应被拒绝") + } +} + +func TestMonthsAndStats(t *testing.T) { + months := LastCompleteMonths(time.Date(2026, 10, 23, 14, 0, 0, 0, time.FixedZone("CST", 8*3600)), 12) + if len(months) != 12 || months[0] != "2026-09" || months[11] != "2025-10" { + t.Fatalf("unexpected months: %v", months) + } + stats := &resolveStats{} + stats.add(true) + if result, summary := stats.resultSummary(1); result != consts.BatchRunResultSuccess || !strings.Contains(summary, "成功 1 条") { + t.Fatalf("bad success: %d %s", result, summary) + } + stats.add(false) + if result, _ := stats.resultSummary(2); result != consts.BatchRunResultPartialSuccess { + t.Fatal("expected partial success") + } +} diff --git a/server/internal/logic/iot/simcardquery/usage.go b/server/internal/logic/iot/simcardquery/usage.go new file mode 100644 index 0000000..4d50fff --- /dev/null +++ b/server/internal/logic/iot/simcardquery/usage.go @@ -0,0 +1,622 @@ +package simcardquery + +import ( + "context" + "fmt" + "sort" + "strings" + "sync" + "time" + + "git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/dao" + "git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/exportcenter" + "git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/tdengine" + "git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/xgorm/handler" + "git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/model" + + "github.com/gogf/gf/v2/database/gdb" + "github.com/gogf/gf/v2/errors/gerror" + "github.com/gogf/gf/v2/frame/g" + "github.com/gogf/gf/v2/os/gtime" +) + +// usageHistoryMonths 近一年月流水的月份个数(不含当月)。 +// 例:当前 2026-10,导出 2026-09 ~ 2025-10 共 12 个月。 +const usageHistoryMonths = 12 + +// usageHistoryTDEngineChunk 单次向 TDengine 查询的 ICCID 条数。 +// 父表按 iccid 标签过滤会剪枝到对应子表,500 条一批可把单次查询控制在秒级。 +const usageHistoryTDEngineChunk = 500 + +// dept 模式没有输入号码,靠公司范围圈卡,必须设上限,否则顶级账号一次拖全库。 +// deptExportMaxRows 公司模式(含下级)单次导出上限。 +// +// 与号码模式的 20000 分开取值:号码模式受「用户手上有多少号码」约束, +// 公司模式是整批圈卡,按 2 万设限只能靠不断切分下级公司,反而更容易出错。 +// +// 取值依据(实测,见 export_cost_test.go):5 万行写 Excel 约 3.2s、文件 4.5MB, +// 远低于导出中心 10 分钟超时 / 100MB 上限;真正的约束是当前实现会把整批行 +// 先构造成内存切片再分页写出,5 万行内存占用约 50MB 量级,10 万行开始偏高。 +// 若要支持 10 万以上,需要把公司模式改成「按页直接查库」的流式导出。 +const deptExportMaxRows = 50000 + +var usageHistoryFixedFields = []string{"iccid", "msisdn", "imsi", "outStockTime", "expireAt"} + +// 月用量列的取值口径: +// +// 查询失败(服务异常)→ 留空,原因写进「失败原因」列 +// 查到了但该月无记录 → "-" +// 该月有记录且用量为 0 → 0 +// +// 失败格留空而不是写文字:这样整列是「数字 + 空 + 横杠」,Excel 求和/透视不会被文本干扰; +// 同时「失败原因」列会写明该卡流量查询失败,信息不会丢。 +const usageNoDataText = "-" + +// usageQueryFailedNote 流量查询失败时写入「失败原因」列的说明。 +const usageQueryFailedNote = "该卡近12个月流量数据本次查询失败,月份列留空,可稍后重新导出" + +// cardUsageResult 单张卡的历史用量查询结果。 +type cardUsageResult struct { + // Months 键为 "YYYY-MM";缺失表示查询成功但该月无记录。 + Months map[string]float64 + // Failed 表示该卡所属批次的流量查询失败,数据不可信。 + Failed bool +} + +// ExportUsageHistory 月历史用量查询导出。 +func (s *Service) ExportUsageHistory(ctx context.Context, in *model.SimCardUsageHistoryExportParam, input ExcelInput) (*exportcenter.SubmitResult, error) { + months := LastCompleteMonths(time.Now(), usageHistoryMonths) + columns := resolveUsageHistoryColumns(in, months) + + // 公司模式的卡列表必须在后台任务中解析,避免提交请求同步读取最多 2 万张卡。 + // 号码模式仍在提交阶段校验输入格式,实际查库统一延迟到导出 runner。 + var lines []inputLine + var err error + if in.Mode != model.SimCardQueryModeDept { + lines, err = parseInputs(ctx, &in.SimCardQueryExportParam, input) + if err != nil { + return nil, err + } + } + stats := &resolveStats{} + once := &sync.Once{} + var cached []*model.SimCardUsageHistoryRow + var cacheErr error + resolve := func(ctx context.Context) ([]*model.SimCardUsageHistoryRow, error) { + once.Do(func() { + switch in.Mode { + case model.SimCardQueryModeDept: + cached, cacheErr = s.resolveDeptRows(ctx, in, months, stats) + case model.SimCardQueryModeRange: + cached, cacheErr = s.resolveUsageRangeRows(ctx, &in.SimCardQueryExportParam, months, stats) + default: + cached, cacheErr = s.resolveUsageHistoryRows(ctx, lines, months, stats) + } + }) + return cached, cacheErr + } + probeTotal := len(lines) + switch in.Mode { + case model.SimCardQueryModeRange: + // 号段模式:号段包含多少个号码就是预计条数,与是否查到无关。 + if size, sizeErr := rangeSize(&in.SimCardQueryExportParam); sizeErr == nil { + probeTotal = int(size) + } else { + probeTotal = -1 + } + case model.SimCardQueryModeDept: + // 公司卡数只在后台 runner 中查询;提交探针不扫描业务表,条数未知。 + probeTotal = -1 + } + + return exportcenter.Submit(ctx, &exportcenter.Request{ + BizKey: "tSimCardUsageHistory", + BizName: "月历史用量查询", + PermCode: "cpm:iot:tSimCardUsageHistory:view", + SheetName: "月历史用量", + QueryParam: usageHistorySnapshot(in, months, probeTotal), + Columns: columns, + RowFields: append(append([]string{}, usageHistoryFixedFields...), append(months, queryResultField, failReasonField)...), + Tags: func() ([]string, error) { + return usageHistoryTags(months), nil + }, + ToRow: func(v interface{}) []interface{} { + row, ok := v.(*model.SimCardUsageHistoryRow) + if !ok || row == nil { + return nil + } + values := make([]interface{}, 0, len(usageHistoryFixedFields)+len(months)+2) + values = append(values, row.Iccid, row.Msisdn, row.Imsi, formatTime(row.OutStockTime), formatTime(row.ExpireAt)) + values = append(values, formatMonthValues(row, months)...) + return append(values, row.QueryResult, row.FailReason) + }, + Fetch: func(ctx context.Context, page, pageSize int) ([]interface{}, int, error) { + if pageSize <= 1 && page <= 1 { + // probeTotal 为 -1 表示提交阶段无法确定条数(按公司模式),放行由后台实际取数。 + return nil, probeTotal, nil + } + rows, err := resolve(ctx) + if err != nil { + return nil, 0, err + } + total := len(rows) + start := (page - 1) * pageSize + if start >= total { + return nil, total, nil + } + end := start + pageSize + if end > total { + end = total + } + out := make([]interface{}, 0, end-start) + for _, row := range rows[start:end] { + out = append(out, row) + } + return out, total, nil + }, + Result: stats.resultSummary, + }) +} + +// resolveUsageHistoryRows 号码模式:解析输入 → 查卡 → 查近 12 个月用量。 +func (s *Service) resolveUsageHistoryRows(ctx context.Context, lines []inputLine, months []string, stats *resolveStats) ([]*model.SimCardUsageHistoryRow, error) { + values := collectValues(lines) + cards, err := queryCardsByValues(ctx, values) + if err != nil { + return nil, err + } + byIccid := indexCards(cards, func(card *cardBrief) string { return card.Iccid }) + byMsisdn := indexCards(cards, func(card *cardBrief) string { return card.Msisdn }) + byImsi := indexCards(cards, func(card *cardBrief) string { return card.Imsi }) + iccids := make([]string, 0, len(cards)) + for _, card := range cards { + if card.Iccid != "" { + iccids = append(iccids, card.Iccid) + } + } + + usage, err := queryMonthlyUsage(ctx, iccids, months) + if err != nil { + return nil, err + } + + rows := make([]*model.SimCardUsageHistoryRow, 0, len(lines)) + // 同一张卡重复出现只导出一次(同号码重复填写,或 ICCID 与其 MSISDN 各写一行)。 + exported := make(map[int64]struct{}, len(lines)) + for _, line := range lines { + row := &model.SimCardUsageHistoryRow{MonthValues: map[string]float64{}} + card, reason := matchCard(line, byIccid, byMsisdn, byImsi) + if card == nil { + row.Iccid = firstValue(line.Values) + row.QueryResult = queryResultMiss + row.FailReason = inputFailure(line, reason) + stats.add(false) + rows = append(rows, row) + continue + } + if _, ok := exported[card.Id]; ok { + stats.addDup() + continue + } + exported[card.Id] = struct{}{} + rows = append(rows, newUsageRow(card, usage)) + stats.add(true) + } + return rows, nil +} + +// resolveUsageRangeRows 号段模式:按起止号段取卡,再补近 12 个月用量。 +func (s *Service) resolveUsageRangeRows(ctx context.Context, in *model.SimCardQueryExportParam, months []string, stats *resolveStats) ([]*model.SimCardUsageHistoryRow, error) { + cards, err := queryCardsByRange(ctx, in) + if err != nil { + return nil, err + } + iccids := make([]string, 0, len(cards)) + for _, card := range cards { + if card.Iccid != "" { + iccids = append(iccids, card.Iccid) + } + } + usage, err := queryMonthlyUsage(ctx, iccids, months) + if err != nil { + return nil, err + } + // 按号段逐个号码输出:命中出行数据,未命中出「未查询到数据」的失败行, + // 保证导出条数与号段大小一致(号段里可能一部分可见、一部分不可见)。 + dimension := strings.TrimSpace(in.Dimension) + index := rangeCardIndex(cards, dimension) + size, sizeErr := rangeSize(in) + if sizeErr != nil { + size = int64(len(cards)) + } + numbers := rangeNumbers(in, size) + rows := make([]*model.SimCardUsageHistoryRow, 0, len(numbers)) + for _, number := range numbers { + if card, ok := index[number]; ok { + rows = append(rows, newUsageRow(card, usage)) + stats.add(true) + continue + } + row := &model.SimCardUsageHistoryRow{ + MonthValues: map[string]float64{}, + QueryResult: queryResultMiss, + FailReason: rangeMissReason, + } + switch dimension { + case "msisdn": + row.Msisdn = number + case "imsi": + row.Imsi = number + default: + row.Iccid = number + } + stats.add(false) + rows = append(rows, row) + } + return rows, nil +} + +// resolveDeptRows 在后台按提交人的数据权限重新解析公司(含下级)卡列表。 +func (s *Service) resolveDeptRows(ctx context.Context, in *model.SimCardUsageHistoryExportParam, months []string, stats *resolveStats) ([]*model.SimCardUsageHistoryRow, error) { + iccids, err := s.cardsByDept(ctx, in) + if err != nil { + return nil, err + } + cards, err := queryCardsByIccids(ctx, iccids) + if err != nil { + return nil, err + } + usage, err := queryMonthlyUsage(ctx, iccids, months) + if err != nil { + return nil, err + } + rows := make([]*model.SimCardUsageHistoryRow, 0, len(cards)) + for _, card := range cards { + stats.add(true) + rows = append(rows, newUsageRow(card, usage)) + } + return rows, nil +} + +// cardsByDept 取公司(含全部下级公司)名下的 ICCID 列表。 +// +// 选中公司即包含下级,与 DeptOptions 的卡数统计口径一致: +// 顶级公司自身通常不挂卡,只查本级会得到 0 条。 +func (s *Service) cardsByDept(ctx context.Context, in *model.SimCardUsageHistoryExportParam) ([]string, error) { + if in.DeptId <= 0 { + return nil, gerror.New("请选择所属公司") + } + columns := dao.TSimCardInfo.Columns() + base := func() *gdb.Model { + mod := handler.Model(dao.TSimCardInfo.Ctx(ctx), &handler.Option{ + FilterTenant: true, + FilterAuth: true, + }).WhereIn(columns.DeptId, deptSubtreeIDs(ctx, in.DeptId)) + if start := strings.TrimSpace(in.ExpireStartTime); start != "" { + mod = mod.WhereGTE(columns.FlowPkgEndAt, start) + } + if end := strings.TrimSpace(in.ExpireEndTime); end != "" { + mod = mod.WhereLTE(columns.FlowPkgEndAt, end) + } + return mod + } + + count, err := base().Fields(columns.Iccid).Count() + if err != nil { + return nil, gerror.Wrap(err, "统计公司名下卡数量失败") + } + if count <= 0 { + return nil, gerror.New("该公司(含下级)下没有符合条件可导出的卡") + } + if count > deptExportMaxRows { + return nil, gerror.Newf( + "该公司(含下级)共 %d 张卡,超过单次导出上限 %d 张,请改选下级公司或改用号码导入", + count, deptExportMaxRows, + ) + } + values, err := base().Fields(columns.Iccid).OrderAsc(columns.Id).Limit(deptExportMaxRows).Array() + if err != nil { + return nil, gerror.Wrap(err, "查询公司名下卡失败") + } + out := make([]string, 0, len(values)) + for _, value := range values { + if iccid := strings.TrimSpace(value.String()); iccid != "" { + out = append(out, iccid) + } + } + return out, nil +} + +// queryCardsByIccids 按 ICCID 批量取导出所需字段。 +func queryCardsByIccids(ctx context.Context, iccids []string) ([]*cardBrief, error) { + if len(iccids) == 0 { + return nil, nil + } + columns := dao.TSimCardInfo.Columns() + out := make([]*cardBrief, 0, len(iccids)) + for start := 0; start < len(iccids); start += chunkSize { + end := start + chunkSize + if end > len(iccids) { + end = len(iccids) + } + list := make([]*cardBrief, 0, end-start) + err := handler.Model(dao.TSimCardInfo.Ctx(ctx), &handler.Option{ + FilterTenant: true, + FilterAuth: true, + }). + Fields( + columns.Id, columns.Iccid, columns.Msisdn, columns.Imsi, + columns.OutStockTime, columns.FlowPkgEndAt, + ). + WhereIn(columns.Iccid, iccids[start:end]). + Scan(&list) + if err != nil { + return nil, gerror.Wrap(err, "查询卡信息失败,请稍后重试") + } + out = append(out, list...) + } + return out, nil +} + +// queryMonthlyUsage 从 TDengine 取多卡的多月用量。 +// +// 每张卡带上自己的查询状态,三种情况必须在导出里可区分: +// - Failed=true:该批查询异常,月份格写「查询失败」 +// - Months 里缺该月:查询成功但没有该月记录,月份格写 "-" +// - Months 里有该月且为 0:查询成功、零用量,月份格写 0 +// +// 部分批次失败不拖垮整单,只有全部批次失败(服务整体不可用)才返回错误。 +func queryMonthlyUsage(ctx context.Context, iccids []string, months []string) (map[string]*cardUsageResult, error) { + result := make(map[string]*cardUsageResult, len(iccids)) + if len(iccids) == 0 || len(months) == 0 { + return result, nil + } + if !tdengine.Enabled(ctx) { + g.Log().Warning(ctx, "[simcardquery] 流量库未启用") + return nil, gerror.New("流量数据服务暂时不可用,本次导出未能完成。请稍后重试,若持续出现可联系技术支持。") + } + cfg := tdengine.GetConfig(ctx) + if err := validateTDEngineNames(cfg.DbName, cfg.MonthlyStable); err != nil { + g.Log().Errorf(ctx, "[simcardquery] 流量库配置异常: %v", err) + return nil, gerror.New("流量数据服务暂时不可用,本次导出未能完成。请稍后重试,若持续出现可联系技术支持。") + } + + start, end, err := monthRange(months) + if err != nil { + return nil, err + } + table := fmt.Sprintf("%s.%s", cfg.DbName, cfg.MonthlyStable) + // 用秒级时间字面量:月表写入点是当月最后一天 23:59:59,秒级精度已能覆盖。 + startText := start.Format("2006-01-02 15:04:05") + endText := end.Format("2006-01-02 15:04:05") + + batches, failedBatches := 0, 0 + for chunkStart := 0; chunkStart < len(iccids); chunkStart += usageHistoryTDEngineChunk { + chunkEnd := chunkStart + usageHistoryTDEngineChunk + if chunkEnd > len(iccids) { + chunkEnd = len(iccids) + } + chunk := iccids[chunkStart:chunkEnd] + // TDengine 驱动不会给字符串参数补引号,用 ? 绑定会让时间条件变成 + // `ts >= 2025-10-01 00:00:00` 这种非法表达式(报 syntax error)。 + // 因此这里把 ICCID 与时间都拼成带引号的字面量, + // ICCID 必须经过字符白名单校验,杜绝拼接注入。 + quoted := make([]string, 0, len(chunk)) + safe := make([]string, 0, len(chunk)) + for _, iccid := range chunk { + if !isSafeIccid(iccid) { + continue + } + safe = append(safe, iccid) + quoted = append(quoted, "'"+iccid+"'") + } + if len(quoted) == 0 { + continue + } + batches++ + // 先按「查询成功、暂无记录」建条目;一旦本批出错再整体标记为查询失败。 + for _, iccid := range safe { + if result[iccid] == nil { + result[iccid] = &cardUsageResult{Months: make(map[string]float64, len(months))} + } + } + markFailedBatch := func() { + for _, iccid := range safe { + if entry := result[iccid]; entry != nil { + entry.Failed = true + entry.Months = map[string]float64{} + } + } + } + + sql := fmt.Sprintf( + "SELECT iccid, ts, usage_mb FROM %s WHERE iccid IN (%s) AND ts >= '%s' AND ts <= '%s'", + table, strings.Join(quoted, ","), startText, endText, + ) + rows, queryErr := tdengine.Query(ctx, sql) + if queryErr != nil { + // 单批失败不拖垮整个导出:本批卡片标记为「查询失败」,其余批次照常。 + failedBatches++ + markFailedBatch() + g.Log().Errorf(ctx, "[simcardquery] 查询月流量失败 cards=%d range=%s~%s err=%v", + len(chunk), startText, endText, queryErr) + continue + } + latest := make(map[string]map[string]time.Time, len(chunk)) + scanFailed := false + for rows.Next() { + var iccid string + var ts time.Time + var usage float64 + if scanErr := rows.Scan(&iccid, &ts, &usage); scanErr != nil { + scanFailed = true + g.Log().Errorf(ctx, "[simcardquery] 解析月流量结果失败 err=%v", scanErr) + break + } + key := ts.Format("2006-01") + if latest[iccid] == nil { + latest[iccid] = make(map[string]time.Time, len(months)) + } + previous, exists := latest[iccid][key] + if exists && !ts.After(previous) { + continue + } + entry := result[iccid] + if entry == nil { + entry = &cardUsageResult{Months: make(map[string]float64, len(months))} + result[iccid] = entry + } + latest[iccid][key] = ts + entry.Months[key] = usage + } + if !scanFailed { + if rowsErr := rows.Err(); rowsErr != nil { + scanFailed = true + g.Log().Errorf(ctx, "[simcardquery] 遍历月流量结果失败 err=%v", rowsErr) + } + } + rows.Close() + if scanFailed { + failedBatches++ + markFailedBatch() + } + } + // 全部批次都失败才判定服务不可用。 + if batches > 0 && failedBatches == batches { + g.Log().Errorf(ctx, "[simcardquery] 流量服务全部批次查询失败 batches=%d", batches) + return nil, gerror.New("流量数据服务暂时不可用,本次导出未能完成。请稍后重试,若持续出现可联系技术支持。") + } + if failedBatches > 0 { + g.Log().Warningf(ctx, "[simcardquery] 流量服务部分批次失败 batches=%d failed=%d", batches, failedBatches) + } + return result, nil +} + +// formatMonthValues 把某行的月度用量格式化成导出的单元格值,三种状态严格区分: +// +// 查询失败(服务异常)→ 留空(原因见「失败原因」列) +// 查询正常、该月无记录 → "-" +// 查询正常、有记录 → 数值(零用量即 0) +func formatMonthValues(row *model.SimCardUsageHistoryRow, months []string) []interface{} { + values := make([]interface{}, 0, len(months)) + for _, month := range months { + if row.UsageQueryFailed || row.QueryResult == queryResultMiss { + // 整卡流量查询失败,或这张卡本身没查到:月份列留空, + // 原因由「失败原因」列说明,保证月份列始终是「数字 + 半角横杠 + 空」。 + values = append(values, "") + continue + } + usage, exists := row.MonthValues[month] + if !exists { + values = append(values, usageNoDataText) + continue + } + values = append(values, roundMb(usage)) + } + return values +} + +// newUsageRow 组装月历史导出行,并统一处理「流量查询失败」的说明。 +func newUsageRow(card *cardBrief, usage map[string]*cardUsageResult) *model.SimCardUsageHistoryRow { + row := &model.SimCardUsageHistoryRow{ + Iccid: card.Iccid, + Msisdn: card.Msisdn, + Imsi: card.Imsi, + OutStockTime: card.OutStockTime, + ExpireAt: card.ExpireAt, + MonthValues: usageMonths(usage, card.Iccid), + UsageQueryFailed: usageFailed(usage, card.Iccid), + QueryResult: queryResultHit, + } + if row.UsageQueryFailed { + row.FailReason = usageQueryFailedNote + } + return row +} + +// usageMonths 取某卡的月度用量;该卡查询失败或没有条目时返回空 map。 +func usageMonths(usage map[string]*cardUsageResult, iccid string) map[string]float64 { + if entry := usage[iccid]; entry != nil { + return entry.Months + } + return map[string]float64{} +} + +// usageFailed 该卡的流量数据是否查询失败。 +func usageFailed(usage map[string]*cardUsageResult, iccid string) bool { + if entry := usage[iccid]; entry != nil { + return entry.Failed + } + return false +} + +// LastCompleteMonths 返回最近 N 个「已结束的月份」,按时间倒序(最近的在前)。 +// 当月不计入:当月数据仍在累积,导出口径会和下个月对不上。 +func LastCompleteMonths(now time.Time, count int) []string { + months := make([]string, 0, count) + cursor := time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, now.Location()).AddDate(0, -1, 0) + for i := 0; i < count; i++ { + months = append(months, cursor.Format("2006-01")) + cursor = cursor.AddDate(0, -1, 0) + } + return months +} + +// monthRange 把月份清单换算成查询时间范围(含首尾)。 +func monthRange(months []string) (time.Time, time.Time, error) { + if len(months) == 0 { + return time.Time{}, time.Time{}, gerror.New("月份清单为空") + } + parsed := make([]time.Time, 0, len(months)) + for _, month := range months { + value, err := time.ParseInLocation("2006-01", month, time.Local) + if err != nil { + return time.Time{}, time.Time{}, gerror.Wrapf(err, "月份格式错误:%s", month) + } + parsed = append(parsed, value) + } + sort.Slice(parsed, func(i, j int) bool { return parsed[i].Before(parsed[j]) }) + start := parsed[0] + end := parsed[len(parsed)-1].AddDate(0, 1, 0).Add(-time.Second) + return start, end, nil +} + +// isSafeIccid ICCID 拼进 SQL 字面量前的白名单校验。 +// ICCID 正常只含数字,个别批次可能带字母;除此之外一律拒绝,避免拼接注入。 +func isSafeIccid(value string) bool { + if value == "" || len(value) > 32 { + return false + } + for _, r := range value { + switch { + case r >= '0' && r <= '9': + case r >= 'a' && r <= 'z': + case r >= 'A' && r <= 'Z': + default: + return false + } + } + return true +} + +func validateTDEngineNames(database, stable string) error { + + if database == "" || stable == "" { + return gerror.New("流量库配置不完整") + } + for _, value := range []string{database, stable} { + for _, r := range value { + if !(r == '_' || (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9')) { + return gerror.Newf("流量库标识非法:%s", value) + } + } + } + return nil +} + +func formatTime(value *gtime.Time) string { + if value == nil || value.IsZero() { + return "" + } + return value.Format("Y-m-d H:i:s") +} diff --git a/server/internal/logic/iot/stock_business_fields.go b/server/internal/logic/iot/stock_business_fields.go index 34b4e21..cdd2d81 100644 --- a/server/internal/logic/iot/stock_business_fields.go +++ b/server/internal/logic/iot/stock_business_fields.go @@ -51,7 +51,7 @@ func applyStockBusinessFilters(ctx context.Context, mod *gdb.Model, filter stock stockTable, stockColumns.ChanKey, channelColumns.TenantId, - channelColumns.ChanName, + channelDisplayNameColumn(ctx), channelColumns.ChanKey, ), contexts.GetTenantId(ctx), @@ -144,12 +144,16 @@ func enrichStockListBusinessFields(ctx context.Context, list []*model.TSimCardSt batchNos := make([]string, 0, len(list)) chanKeys := make([]string, 0, len(list)) + deptIds := make([]int64, 0, len(list)) for _, item := range list { if item == nil { continue } batchNos = appendUniqueNonBlank(batchNos, item.BatchNo) chanKeys = appendUniqueNonBlank(chanKeys, item.ChanKey) + if item.CreatedDept > 0 { + deptIds = append(deptIds, item.CreatedDept) + } } batchMap, err := loadStockBatchBusinessFields(ctx, batchNos) @@ -167,6 +171,10 @@ func enrichStockListBusinessFields(ctx context.Context, list []*model.TSimCardSt if err != nil { return err } + deptMap, err := loadStockDeptNames(ctx, deptIds) + if err != nil { + return err + } for _, item := range list { if item == nil { @@ -178,6 +186,7 @@ func enrichStockListBusinessFields(ctx context.Context, list []*model.TSimCardSt item.UpPkgCode = fields.UpPkgCode item.UpPkgName = fields.UpPkgName item.AgreementExpiryDate = fields.AgreementExpiryDate + item.CreatedDeptName = deptMap[item.CreatedDept] } return nil } @@ -419,7 +428,7 @@ func loadStockChannelNames(ctx context.Context, chanKeys []string) (map[string]s var rows []*channelRow columns := dao.TSimChannels.Columns() if err := dao.TSimChannels.Ctx(ctx). - Fields(columns.ChanKey, columns.ChanName). + Fields(columns.ChanKey, channelDisplayNameSQL(ctx, dao.TSimChannels.Table())+" AS chan_name"). Where(columns.TenantId, contexts.GetTenantId(ctx)). WhereIn(columns.ChanKey, chanKeys). Scan(&rows); err != nil { @@ -433,6 +442,51 @@ func loadStockChannelNames(ctx context.Context, chanKeys []string) (map[string]s return result, nil } +// loadStockDeptNames 批量补充创建部门名称(sys_dept.dept_id -> dept_name)。 +func loadStockDeptNames(ctx context.Context, deptIds []int64) (map[int64]string, error) { + result := make(map[int64]string) + deptIds = uniqueInt64(deptIds) + if len(deptIds) == 0 { + return result, nil + } + + type deptRow struct { + DeptId int64 `orm:"dept_id"` + DeptName string `orm:"dept_name"` + } + var rows []*deptRow + columns := dao.SysDept.Columns() + mod := dao.SysDept.Ctx(ctx). + Fields(columns.DeptId, columns.DeptName). + WhereIn(columns.DeptId, deptIds) + if tenantId := strings.TrimSpace(contexts.GetTenantId(ctx)); tenantId != "" { + mod = mod.Where(columns.TenantId, tenantId) + } + if err := mod.Scan(&rows); err != nil { + return nil, gerror.Wrap(err, "补充创建部门名称失败") + } + for _, row := range rows { + if row != nil && row.DeptId > 0 { + result[row.DeptId] = row.DeptName + } + } + return result, nil +} + +// uniqueInt64 去重并剔除非正的部门ID。 +func uniqueInt64(values []int64) []int64 { + seen := make(map[int64]bool, len(values)) + result := make([]int64, 0, len(values)) + for _, value := range values { + if value <= 0 || seen[value] { + continue + } + seen[value] = true + result = append(result, value) + } + return result +} + func loadStockUpstreamPackageNames(ctx context.Context, upPkgCodes []string) (map[string]string, error) { result := make(map[string]string) upPkgCodes = uniqueNonBlank(upPkgCodes) diff --git a/server/internal/logic/iot/t_flow_package_config.go b/server/internal/logic/iot/t_flow_package_config.go index 9f32847..f8da4db 100644 --- a/server/internal/logic/iot/t_flow_package_config.go +++ b/server/internal/logic/iot/t_flow_package_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 @@ -11,22 +11,20 @@ import ( "fmt" "git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/dao" "git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/contexts" + "git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/exportcenter" "git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/xgorm/handler" "git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/xgorm/hook" "git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/model" "git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/model/entity" "git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/service" "git.5iot.com/iotlcm-public/iotlcm-admin-svr/utility/convert" - "git.5iot.com/iotlcm-public/iotlcm-admin-svr/utility/excel" "strings" "github.com/gogf/gf/v2/database/gdb" "github.com/gogf/gf/v2/encoding/gjson" "github.com/gogf/gf/v2/errors/gerror" "github.com/gogf/gf/v2/frame/g" - "github.com/gogf/gf/v2/os/gctx" "github.com/gogf/gf/v2/os/gtime" - "github.com/gogf/gf/v2/util/gconv" ) type sIotTFlowPackageConfig struct{} @@ -171,31 +169,32 @@ func (s *sIotTFlowPackageConfig) List(ctx context.Context, in *model.TFlowPackag return } -// Export 导出SIM流量池套餐配置表 +// Export 导出流量池套餐配置(异步:提交任务后到下载中心取文件) func (s *sIotTFlowPackageConfig) Export(ctx context.Context, in *model.TFlowPackageConfigListParam) (err error) { - list, totalCount, err := s.List(ctx, in) - if err != nil { - return - } - - // 字段的排序是依据tags的字段顺序,如果你不想使用默认的排序方式,可以直接定义 tags = []string{"字段名称", "字段名称2", ...} - tags, err := convert.GetEntityDescTags(model.TFlowPackageConfigExportModel{}) - if err != nil { - return - } - - var ( - fileName = "导出SIM流量池套餐配置表-" + gctx.CtxId(ctx) - sheetName = fmt.Sprintf("索引条件共%v行,共%v页,当前导出是第%v页,本页共%v行", totalCount, (totalCount+in.PageSize-1)/in.PageSize, in.Page, len(list)) - exports []model.TFlowPackageConfigExportModel - ) - - if err = gconv.Scan(list, &exports); err != nil { - return - } - - err = excel.ExportByStructs(ctx, tags, exports, fileName, sheetName) - return + _, err = exportcenter.Submit(ctx, &exportcenter.Request{ + BizKey: "tFlowPackageConfig", + BizName: "流量池套餐配置", + PermCode: "cpm:iot:tFlowPackageConfig:export", + QueryParam: in, + Tags: func() ([]string, error) { + return convert.GetEntityDescTags(model.TFlowPackageConfigListModel{}) + }, + Fetch: func(ctx context.Context, page, pageSize int) (rows []interface{}, total int, err error) { + query := *in + query.Page = page + query.PageSize = pageSize + list, count, err := s.List(ctx, &query) + if err != nil { + return nil, 0, err + } + rows = make([]interface{}, 0, len(list)) + for _, item := range list { + rows = append(rows, item) + } + return rows, count, nil + }, + }) + return err } // Edit 修改/新增SIM流量池套餐配置表 diff --git a/server/internal/logic/iot/t_order.go b/server/internal/logic/iot/t_order.go index fd384b0..64cddf9 100644 --- a/server/internal/logic/iot/t_order.go +++ b/server/internal/logic/iot/t_order.go @@ -1,31 +1,28 @@ // 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 import ( "context" - "fmt" "git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/consts" "git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/dao" "git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/contexts" + "git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/exportcenter" "git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/xgorm/handler" "git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/xgorm/hook" "git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/model" "git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/model/entity" "git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/service" "git.5iot.com/iotlcm-public/iotlcm-admin-svr/utility/convert" - "git.5iot.com/iotlcm-public/iotlcm-admin-svr/utility/excel" "strings" "github.com/gogf/gf/v2/database/gdb" "github.com/gogf/gf/v2/encoding/gjson" "github.com/gogf/gf/v2/errors/gerror" - "github.com/gogf/gf/v2/os/gctx" - "github.com/gogf/gf/v2/util/gconv" ) type sIotTOrder struct{} @@ -357,51 +354,38 @@ func orderItemView(item *entity.TOrderItem) *model.TOrderItemViewModel { return view } +// Export 导出订单(异步:提交任务后到下载中心取文件) func (s *sIotTOrder) Export(ctx context.Context, in *model.TOrderListParam) (err error) { - query := *in - query.Page = 1 - query.PageSize = orderExportPageSize - list := make([]*model.TOrderListModel, 0) - totalCount := 0 - for { - var ( - pageList []*model.TOrderListModel - pageTotal int - ) - if query.Perspective == orderPerspectiveSell { - pageList, pageTotal, err = s.SellList(ctx, &query) - } else { - pageList, pageTotal, err = s.List(ctx, &query) - } - if err != nil { - return err - } - totalCount = pageTotal - list = append(list, pageList...) - if len(pageList) == 0 || len(list) >= totalCount { - break - } - query.Page++ - } - - // 字段的排序是依据tags的字段顺序,如果你不想使用默认的排序方式,可以直接定义 tags = []string{"字段名称", "字段名称2", ...} - tags, err := convert.GetEntityDescTags(model.TOrderExportModel{}) - if err != nil { - return - } - - var ( - fileName = "导出订单主表-" + gctx.CtxId(ctx) - sheetName = fmt.Sprintf("筛选结果共%v行", totalCount) - exports []model.TOrderExportModel - ) - - if err = gconv.Scan(list, &exports); err != nil { - return - } - - err = excel.ExportByStructs(ctx, tags, exports, fileName, sheetName) - return + _, err = exportcenter.Submit(ctx, &exportcenter.Request{ + BizKey: "tOrder", + BizName: "订单", + PermCode: "cpm:iot:tOrder:export", + QueryParam: in, + Tags: func() ([]string, error) { + return convert.GetEntityDescTags(model.TOrderListModel{}) + }, + Fetch: func(ctx context.Context, page, pageSize int) (rows []interface{}, total int, err error) { + query := *in + query.Page = page + query.PageSize = pageSize + var list []*model.TOrderListModel + var count int + if query.Perspective == orderPerspectiveSell { + list, count, err = s.SellList(ctx, &query) + } else { + list, count, err = s.List(ctx, &query) + } + if err != nil { + return nil, 0, err + } + rows = make([]interface{}, 0, len(list)) + for _, item := range list { + rows = append(rows, item) + } + return rows, count, nil + }, + }) + return err } // Edit 订单由业务流程生成,不允许后台手工修改。 diff --git a/server/internal/logic/iot/t_order_test.go b/server/internal/logic/iot/t_order_test.go new file mode 100644 index 0000000..29ce2db --- /dev/null +++ b/server/internal/logic/iot/t_order_test.go @@ -0,0 +1,33 @@ +package iot + +import ( + "testing" + + "git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/consts" + "git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/model" +) + +func TestNormalizeOrderItemProduct(t *testing.T) { + tests := []struct { + name string + orderType int + want string + }{ + {name: "renew", orderType: consts.OrderTypeCardRenew, want: "卡续费"}, + {name: "change", orderType: consts.OrderTypeCardChange, want: "卡变更"}, + {name: "subscribe", orderType: consts.OrderTypeCardSubscribe, want: "卡订购"}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + view := &model.TOrderItemViewModel{ + ProductName: "余额充值", + ProductSn: "BALANCE_RECHARGE", + } + normalizeOrderItemProduct(test.orderType, view) + if view.ProductName != test.want || view.ProductSn != "" { + t.Fatalf("got name=%q sn=%q, want name=%q and empty sn", view.ProductName, view.ProductSn, test.want) + } + }) + } +} diff --git a/server/internal/logic/iot/t_sim_card_bind_pkg.go b/server/internal/logic/iot/t_sim_card_bind_pkg.go index 6c29675..0da181f 100644 --- a/server/internal/logic/iot/t_sim_card_bind_pkg.go +++ b/server/internal/logic/iot/t_sim_card_bind_pkg.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/logic/iot/t_sim_card_cancellation.go b/server/internal/logic/iot/t_sim_card_cancellation.go index 33fbd67..f38c8e3 100644 --- a/server/internal/logic/iot/t_sim_card_cancellation.go +++ b/server/internal/logic/iot/t_sim_card_cancellation.go @@ -2,24 +2,22 @@ package iot import ( "context" - "fmt" - "net/http" - "net/url" "strconv" "strings" "git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/consts" "git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/dao" + "git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/exportcenter" "git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/xgorm/handler" "git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/xgorm/hook" "git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/model" "git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/service" + "github.com/gogf/gf/v2/util/gconv" + "github.com/gogf/gf/v2/database/gdb" "github.com/gogf/gf/v2/errors/gerror" - "github.com/gogf/gf/v2/net/ghttp" "github.com/gogf/gf/v2/os/gtime" - excelize "github.com/xuri/excelize/v2" ) const ( @@ -34,6 +32,16 @@ var cancellationExportHeaders = []interface{}{ "入库批次", "出库批次", "划拨批次", "操作人", "备注", } +// cancellationExportFields 与 cancellationExportHeaders 一一对应(json tag), +// 供「按页面显示列导出」在保留 ToRow 格式化的前提下按字段取列。 +var cancellationExportFields = []string{ + "cancelBatchNo", "cancelledAt", "iccid", "msisdn", "imsi", "imei", + "chanKey", "chanName", "deptId", "deptName", + "currentPackageCode", "packageName", "bossCardStatus", + // 字段名必须与列表页列的 field 一致(操作人页面用 createdBy,显示的是 createdBySumma 摘要) + "inboundBatchNo", "outboundBatchNo", "allocationBatchNo", "createdBy", "remark", +} + type sIotTSimCardCancellation struct{} func NewIotTSimCardCancellation() *sIotTSimCardCancellation { @@ -72,6 +80,7 @@ func (s *sIotTSimCardCancellation) List(ctx context.Context, in *model.TSimCardC return list, total, nil } +// Export 导出销户卡信息(异步:提交任务后到下载中心取文件) func (s *sIotTSimCardCancellation) Export(ctx context.Context, filter *model.TSimCardCancellationFilter) error { if filter == nil { filter = &model.TSimCardCancellationFilter{} @@ -80,77 +89,50 @@ func (s *sIotTSimCardCancellation) Export(ctx context.Context, filter *model.TSi return err } - total, err := s.filteredModel(ctx, filter).Count() - if err != nil { - return gerror.Wrap(err, "统计销户卡导出数量失败") - } - if total == 0 { - return gerror.New("暂无可导出数据") - } - if total > cancellationExportMaxRows { - return gerror.Newf("导出数据超过 Excel 上限 %d 条,请缩小筛选范围", cancellationExportMaxRows) - } - - file := excelize.NewFile() - defer file.Close() - const sheetName = "销户卡信息" - file.SetSheetName(file.GetSheetName(0), sheetName) - stream, err := file.NewStreamWriter(sheetName) - if err != nil { - return gerror.Wrap(err, "创建销户卡 Excel 流失败") - } - if err = stream.SetColWidth(1, len(cancellationExportHeaders), 22); err != nil { - return gerror.Wrap(err, "设置销户卡 Excel 列宽失败") - } - if err = stream.SetColWidth(3, 6, 28); err != nil { - return gerror.Wrap(err, "设置销户卡号 Excel 列宽失败") - } - headerStyle, err := file.NewStyle(&excelize.Style{Font: &excelize.Font{Bold: true}}) - if err != nil { - return gerror.Wrap(err, "创建销户卡 Excel 表头样式失败") - } - if err = stream.SetRow("A1", cancellationExportHeaders, excelize.RowOpts{StyleID: headerStyle}); err != nil { - return gerror.Wrap(err, "写入销户卡 Excel 表头失败") - } - - rowNumber := 2 - for offset := 0; offset < total; offset += cancellationExportBatchSize { - var rows []*model.TSimCardCancellationListModel - query := s.selectModel(ctx, filter). - OrderDesc(dao.TSimCardCancellation.Table()+"."+dao.TSimCardCancellation.Columns().CancelledAt). - OrderDesc(dao.TSimCardCancellation.Table()+"."+dao.TSimCardCancellation.Columns().Id). - Limit(offset, cancellationExportBatchSize) - if err = query.Scan(&rows); err != nil { - return gerror.Wrap(err, "分批查询销户卡导出数据失败") - } - for _, row := range rows { - cell, _ := excelize.CoordinatesToCellName(1, rowNumber) - if err = stream.SetRow(cell, cancellationExportRow(row)); err != nil { - return gerror.Wrap(err, "写入销户卡 Excel 数据失败") + counted := false + totalRows := 0 + _, err := exportcenter.Submit(ctx, &exportcenter.Request{ + BizKey: "tSimCardCancellation", + BizName: "销户卡信息", + PermCode: "cpm:iot:tSimCardCancellation:export", + QueryParam: filter, + Tags: func() ([]string, error) { + headers := make([]string, 0, len(cancellationExportHeaders)) + for _, v := range cancellationExportHeaders { + headers = append(headers, gconv.String(v)) } - rowNumber++ - } - } - if err = stream.Flush(); err != nil { - return gerror.Wrap(err, "刷新销户卡 Excel 数据失败") - } - - request := ghttp.RequestFromCtx(ctx) - if request == nil { - return gerror.New("销户卡导出缺少 HTTP 请求上下文") - } - fileName := "销户卡信息-" + gtime.Now().Format("YmdHis") + ".xlsx" - writer := request.Response.Writer - writer.Header().Set("Content-Type", "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet") - writer.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%s", url.QueryEscape(fileName))) - writer.Header().Set("Content-Transfer-Encoding", "binary") - writer.Header().Set("Access-Control-Expose-Headers", "Content-Disposition") - if err = file.Write(writer); err != nil { - return gerror.Wrap(err, "输出销户卡 Excel 失败") - } - request.Response.Status = http.StatusOK - writer.Flush() - return nil + return headers, nil + }, + Fetch: func(ctx context.Context, page, pageSize int) (rows []interface{}, total int, err error) { + if !counted { + totalRows, err = s.filteredModel(ctx, filter).Count() + if err != nil { + return nil, 0, gerror.Wrap(err, "统计销户卡导出数量失败") + } + counted = true + } + var list []*model.TSimCardCancellationListModel + query := s.selectModel(ctx, filter). + OrderDesc(dao.TSimCardCancellation.Table()+"."+dao.TSimCardCancellation.Columns().CancelledAt). + OrderDesc(dao.TSimCardCancellation.Table()+"."+dao.TSimCardCancellation.Columns().Id). + Limit((page-1)*pageSize, pageSize) + if err = query.Scan(&list); err != nil { + return nil, 0, gerror.Wrap(err, "分批查询销户卡导出数据失败") + } + rows = make([]interface{}, 0, len(list)) + for _, row := range list { + rows = append(rows, row) + } + return rows, totalRows, nil + }, + ToRow: func(v interface{}) []interface{} { + row, _ := v.(*model.TSimCardCancellationListModel) + return cancellationExportRow(row) + }, + // ToRow 返回值与字段名的对应关系,供「按页面显示列导出」按字段取列。 + RowFields: cancellationExportFields, + }) + return err } func (s *sIotTSimCardCancellation) filteredModel(ctx context.Context, filter *model.TSimCardCancellationFilter) *gdb.Model { @@ -195,13 +177,13 @@ func (s *sIotTSimCardCancellation) selectModel(ctx context.Context, filter *mode query := s.filteredModel(ctx, filter) query = query.FieldsPrefix(cardTable, model.TSimCardCancellationListModel{}) query = query.Fields( - channelTable+"."+dao.TSimChannels.Columns().ChanName+" AS chanName", + channelDisplayNameSQL(ctx, channelTable)+" AS chanName", deptTable+"."+dao.SysDept.Columns().DeptName+" AS deptName", packageTable+"."+dao.TSimFlowPackageInfo.Columns().PackageName+" AS packageName", ) - query = query.LeftJoin(channelTable, channelTable+"."+dao.TSimChannels.Columns().ChanKey+"="+cardTable+"."+dao.TSimCardCancellation.Columns().ChanKey) + query = query.LeftJoin(channelTable, channelTable+"."+dao.TSimChannels.Columns().ChanKey+"="+cardTable+"."+dao.TSimCardCancellation.Columns().ChanKey+" AND "+channelTable+"."+dao.TSimChannels.Columns().TenantId+"="+cardTable+"."+dao.TSimCardCancellation.Columns().TenantId) query = query.LeftJoin(deptTable, deptTable+"."+dao.SysDept.Columns().DeptId+"="+cardTable+"."+dao.TSimCardCancellation.Columns().DeptId) - query = query.LeftJoin(packageTable, packageTable+"."+dao.TSimFlowPackageInfo.Columns().PackageCode+"="+cardTable+"."+dao.TSimCardCancellation.Columns().CurrentPackageCode) + query = query.LeftJoin(packageTable, packageTable+"."+dao.TSimFlowPackageInfo.Columns().PackageCode+"="+cardTable+"."+dao.TSimCardCancellation.Columns().CurrentPackageCode+" AND "+packageTable+"."+dao.TSimFlowPackageInfo.Columns().TenantId+"="+cardTable+"."+dao.TSimCardCancellation.Columns().TenantId) return query.Hook(hook.MemberSummary) } diff --git a/server/internal/logic/iot/t_sim_card_change_test.go b/server/internal/logic/iot/t_sim_card_change_test.go new file mode 100644 index 0000000..ae8f0ec --- /dev/null +++ b/server/internal/logic/iot/t_sim_card_change_test.go @@ -0,0 +1,213 @@ +package iot + +import ( + "strings" + "testing" + "time" + + "git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/consts" + "git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/model" + "git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/model/entity" +) + +func TestAddRenewDurationByUnitKeepsSameCalendarDate(t *testing.T) { + begin := time.Date(2026, time.September, 1, 0, 0, 0, 0, time.Local) + got := addRenewDurationByUnit(begin, "year", 1) + want := time.Date(2027, time.September, 1, 23, 59, 59, 0, time.Local) + if !got.Equal(want) { + t.Fatalf( + "addRenewDurationByUnit() = %s, want %s", + got.Format(time.RFC3339), + want.Format(time.RFC3339), + ) + } +} + +func TestValidateRenewConfirmContract(t *testing.T) { + snapshot := &model.TSimCardRenewPriceSnapshot{ + RenewableDurationOptions: []int{12, 24, 36}, + } + if err := validateRenewConfirmContract(snapshot, 24); err != nil { + t.Fatalf("valid renew contract rejected: %v", err) + } + if err := validateRenewConfirmContract(snapshot, 6); err == nil { + t.Fatal("renew duration outside prepare contract was accepted") + } + if err := validateRenewConfirmContract(nil, 12); err == nil { + t.Fatal("missing renew contract was accepted") + } +} + +func TestValidateChangeConfirmContract(t *testing.T) { + prepareSnapshot := &model.TSimCardChangePrepareSnapshot{ + OldPackageCode: "PKG-OLD", + } + priceSnapshot := &model.TSimCardChangePriceSnapshot{ + NewPackageCode: "PKG-NEW", + RenewableDurationOptions: []int{12, 24}, + } + valid := &model.TSimCardChangeBatchSubmitParam{ + NewPackageCode: "PKG-NEW", + DurationMonths: 12, + EffectiveMode: cardChangeEffectiveNextMonth, + } + if err := validateChangeConfirmContract(prepareSnapshot, priceSnapshot, cardChangeEffectiveNextMonth, valid); err != nil { + t.Fatalf("valid change contract rejected: %v", err) + } + if err := validateChangeConfirmContract(prepareSnapshot, priceSnapshot, "", valid); err != nil { + t.Fatalf("legacy change contract without effective mode rejected: %v", err) + } + + tests := []struct { + name string + in model.TSimCardChangeBatchSubmitParam + }{ + { + name: "current package selected", + in: model.TSimCardChangeBatchSubmitParam{ + NewPackageCode: "PKG-OLD", + DurationMonths: 12, + EffectiveMode: cardChangeEffectiveNextMonth, + }, + }, + { + name: "target package changed", + in: model.TSimCardChangeBatchSubmitParam{ + NewPackageCode: "PKG-OTHER", + DurationMonths: 12, + EffectiveMode: cardChangeEffectiveNextMonth, + }, + }, + { + name: "duration outside contract", + in: model.TSimCardChangeBatchSubmitParam{ + NewPackageCode: "PKG-NEW", + DurationMonths: 6, + EffectiveMode: cardChangeEffectiveNextMonth, + }, + }, + { + name: "effective mode changed", + in: model.TSimCardChangeBatchSubmitParam{ + NewPackageCode: "PKG-NEW", + DurationMonths: 12, + EffectiveMode: cardChangeEffectiveImmediate, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if err := validateChangeConfirmContract(prepareSnapshot, priceSnapshot, cardChangeEffectiveNextMonth, &tt.in); err == nil { + t.Fatal("invalid change contract was accepted") + } + }) + } + + if err := validateChangeConfirmContract(nil, priceSnapshot, cardChangeEffectiveNextMonth, valid); err == nil { + t.Fatal("missing change contract was accepted") + } +} + +func TestNormalizeAddPackagePaymentIntent(t *testing.T) { + tests := []struct { + name string + mode string + confirmed bool + isTopDept bool + wantMode string + wantConfirm bool + wantErr string + }{ + {name: "child default uses balance", wantMode: "balance"}, + {name: "top default uses balance", isTopDept: true, wantMode: "balance"}, + {name: "balance clears offline confirmation", mode: "balance", confirmed: true, isTopDept: true, wantMode: "balance"}, + {name: "child cannot use offline", mode: "offline", confirmed: true, wantErr: "必须使用余额支付"}, + {name: "top offline requires confirmation", mode: "offline", isTopDept: true, wantErr: "请确认线下合同"}, + {name: "top offline keeps confirmation", mode: "offline", confirmed: true, isTopDept: true, wantMode: "offline", wantConfirm: true}, + {name: "invalid payment mode", mode: "cash", isTopDept: true, wantErr: "付款方式不正确"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + gotMode, gotConfirm, err := normalizeAddPackagePaymentIntent(tt.mode, tt.confirmed, tt.isTopDept) + if tt.wantErr != "" { + if err == nil || !strings.Contains(err.Error(), tt.wantErr) { + t.Fatalf("error = %v, want substring %q", err, tt.wantErr) + } + return + } + if err != nil { + t.Fatalf("normalizeAddPackagePaymentIntent() error = %v", err) + } + if gotMode != tt.wantMode { + t.Fatalf("payment mode = %q, want %q", gotMode, tt.wantMode) + } + if gotConfirm != tt.wantConfirm { + t.Fatalf("offline confirmation = %v, want %v", gotConfirm, tt.wantConfirm) + } + }) + } +} + +func TestNormalizedChangePrepareSourceBatchNo(t *testing.T) { + tests := []struct { + name string + sourceType int + mainSource string + snapshot string + want string + }{ + { + name: "input ignores self reference", + sourceType: consts.BatchSourceTypeBatchInput, + mainSource: "CHG20260803170000INPUT", + snapshot: "CHG20260803170000INPUT", + }, + { + name: "file ignores stale source", + sourceType: consts.BatchSourceTypeFile, + mainSource: "CHG20260803170000FILE", + snapshot: "OUT20260803STALE", + }, + { + name: "batch selection keeps snapshot source", + sourceType: consts.BatchSourceTypeBatchSelect, + mainSource: "OUT20260803MAIN", + snapshot: "OUT20260803CONFIRMED", + want: "OUT20260803CONFIRMED", + }, + { + name: "batch selection repairs worker self reference", + sourceType: consts.BatchSourceTypeBatchSelect, + mainSource: "OUT20260803MAIN", + snapshot: "CHG20260803170000BATCH", + want: "OUT20260803MAIN", + }, + { + name: "legacy self reference without real source becomes empty", + sourceType: consts.BatchSourceTypeBatchSelect, + mainSource: "CHG20260803170000BATCH", + snapshot: "CHG20260803170000BATCH", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + batchNo := "CHG20260803170000BATCH" + if tt.sourceType == consts.BatchSourceTypeBatchInput { + batchNo = "CHG20260803170000INPUT" + } + if tt.sourceType == consts.BatchSourceTypeFile { + batchNo = "CHG20260803170000FILE" + } + batch := &entity.BatchMain{ + BatchNo: batchNo, + SourceType: tt.sourceType, + SourceBatchNo: tt.mainSource, + } + if got := normalizedChangePrepareSourceBatchNo(batch, tt.snapshot); got != tt.want { + t.Fatalf("sourceBatchNo = %q, want %q", got, tt.want) + } + }) + } +} diff --git a/server/internal/logic/iot/t_sim_card_info.go b/server/internal/logic/iot/t_sim_card_info.go index 940841f..4817ec6 100644 --- a/server/internal/logic/iot/t_sim_card_info.go +++ b/server/internal/logic/iot/t_sim_card_info.go @@ -1,17 +1,20 @@ // 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 import ( "context" + "errors" "fmt" "git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/consts" "git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/dao" + "git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/batchinput/text" "git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/contexts" + "git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/exportcenter" "git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/xgorm" "git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/xgorm/handler" "git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/xgorm/hook" @@ -21,15 +24,13 @@ import ( "git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/model/do" "git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/service" "git.5iot.com/iotlcm-public/iotlcm-admin-svr/utility/convert" - "git.5iot.com/iotlcm-public/iotlcm-admin-svr/utility/excel" "strings" "github.com/gogf/gf/v2/database/gdb" + "github.com/gogf/gf/v2/encoding/gjson" "github.com/gogf/gf/v2/errors/gerror" "github.com/gogf/gf/v2/frame/g" - "github.com/gogf/gf/v2/os/gctx" "github.com/gogf/gf/v2/os/gtime" - "github.com/gogf/gf/v2/util/gconv" ) type sIotTSimCardInfo struct{} @@ -66,6 +67,14 @@ func (s *sIotTSimCardInfo) Model(ctx context.Context, option ...*handler.Option) // List 获取卡信息表列表 func (s *sIotTSimCardInfo) List(ctx context.Context, in *model.TSimCardInfoListParam) (list []*model.TSimCardInfoListModel, totalCount int, err error) { + var batchCondition string + var batchArgs []interface{} + if in.BatchInput != nil { + batchCondition, batchArgs, err = simCardBatchCondition(ctx, *in.BatchInput) + if err != nil { + return nil, 0, err + } + } mod := s.Model(ctx) cardInfoTable := dao.TSimCardInfo.Table() simChannels := dao.TSimChannels.Table() @@ -116,6 +125,9 @@ func (s *sIotTSimCardInfo) List(ctx context.Context, in *model.TSimCardInfoListP dao.TSimCardInfo.Columns().Imsi + " = ?)" mod = mod.Where(cardValueCondition, in.CardValue, in.CardValue, in.CardValue) } + if batchCondition != "" { + mod = mod.Where(batchCondition, batchArgs...) + } if in.Remark != "" { mod = mod.Where(dao.TSimCardInfo.Columns().Remark+" LIKE ?", "%"+in.Remark+"%") } @@ -254,6 +266,7 @@ func (s *sIotTSimCardInfo) List(ctx context.Context, in *model.TSimCardInfoListP err = gerror.Wrap(err, "获取卡信息表列表失败,请稍后重试!") return } + applyCardChannelDisplayNames(ctx, list) if resolver, resolverErr := newCardDeptDisplayResolver(ctx); resolverErr != nil { logCardDeptDisplayResolverError(ctx, resolverErr) } else { @@ -266,31 +279,127 @@ func (s *sIotTSimCardInfo) List(ctx context.Context, in *model.TSimCardInfoListP return } -// Export 导出卡信息表 +// exportColumns 取前端传入的导出列。 +// 优先解析 JSON 字符串形式(数组绑定可能丢失内容),失败再退回对象数组。 +func (s *sIotTSimCardInfo) exportColumns(in *model.TSimCardInfoListParam) []model.ExportColumn { + if raw := strings.TrimSpace(in.ExportColumnsJson); raw != "" { + cols := make([]model.ExportColumn, 0) + if err := gjson.New(raw).Scan(&cols); err == nil && len(cols) > 0 { + return cols + } + } + return in.ExportColumns +} + +const simCardBatchMaxRows = 1000 + +// simCardBatchCondition 将批量查询输入编译为查询条件。 +// 每行支持 1 至 3 个号码值,同一行的多个值表示同一张卡的多个字段值。 +// 同一行的值互不相同,必然落在不同字段上,因此“该行每个值都命中这张卡的某个字段” +// 已等价于“这些值都属于这张卡”,无需判断某个值具体是 ICCID、MSISDN 还是 IMSI。 +func simCardBatchCondition(ctx context.Context, input string) (string, []interface{}, error) { + lines, err := text.ParseWhitespaceFields(ctx, input) + if errors.Is(err, text.ErrEmptyInput) { + return "", nil, gerror.New("批量查询卡号不能为空") + } + if err != nil { + return "", nil, err + } + if len(lines) > simCardBatchMaxRows { + return "", nil, gerror.New("批量查询最多支持1000行") + } + + columns := dao.TSimCardInfo.Columns() + // 单值行之间相互独立,合并成 IN 列表后语义不变,可显著降低条件求值成本。 + singleValues := make([]string, 0, len(lines)) + singleIndex := make(map[string]struct{}, len(lines)) + // 多值行需要保留同一行内的配对关系,不能跨行合并。 + multiConditions := make([]string, 0, len(lines)) + multiArgs := make([]interface{}, 0, len(lines)*9) + multiIndex := make(map[string]struct{}, len(lines)) + + for _, line := range lines { + switch len(line.Fields) { + case 1: + value := line.Fields[0] + if _, exists := singleIndex[value]; exists { + continue + } + singleIndex[value] = struct{}{} + singleValues = append(singleValues, value) + case 2, 3: + key := strings.Join(line.Fields, "\x00") + if _, exists := multiIndex[key]; exists { + continue + } + multiIndex[key] = struct{}{} + groups := make([]string, 0, len(line.Fields)) + for _, value := range line.Fields { + groups = append(groups, "("+columns.Iccid+" = ? OR "+ + columns.Msisdn+" = ? OR "+ + columns.Imsi+" = ?)") + multiArgs = append(multiArgs, value, value, value) + } + multiConditions = append(multiConditions, "("+strings.Join(groups, " AND ")+")") + default: + return "", nil, gerror.Newf("第%d行格式错误,每行支持 1 至 3 个号码值", line.RowNo) + } + } + + parts := make([]string, 0, 2) + args := make([]interface{}, 0, len(singleValues)*3+len(multiArgs)) + if len(singleValues) > 0 { + placeholders := "(" + strings.TrimSuffix(strings.Repeat("?,", len(singleValues)), ",") + ")" + parts = append(parts, "("+columns.Iccid+" IN "+placeholders+" OR "+ + columns.Msisdn+" IN "+placeholders+" OR "+ + columns.Imsi+" IN "+placeholders+")") + for repeat := 0; repeat < 3; repeat++ { + for _, value := range singleValues { + args = append(args, value) + } + } + } + if len(multiConditions) > 0 { + parts = append(parts, "("+strings.Join(multiConditions, " OR ")+")") + args = append(args, multiArgs...) + } + + return "(" + strings.Join(parts, " OR ") + ")", args, nil +} + +// Export 导出卡信息(异步:提交任务后到下载中心取文件) +// 导出的列由前端按当前列表显示列传入,未传时按列表模型字段顺序全量导出。 func (s *sIotTSimCardInfo) Export(ctx context.Context, in *model.TSimCardInfoListParam) (err error) { - list, totalCount, err := s.List(ctx, in) - if err != nil { - return + if in.BatchInput != nil { + if _, _, err = simCardBatchCondition(ctx, *in.BatchInput); err != nil { + return err + } } - - // 字段的排序是依据tags的字段顺序,如果你不想使用默认的排序方式,可以直接定义 tags = []string{"字段名称", "字段名称2", ...} - tags, err := convert.GetEntityDescTags(model.TSimCardInfoExportModel{}) - if err != nil { - return - } - - var ( - fileName = "导出卡信息表-" + gctx.CtxId(ctx) - sheetName = fmt.Sprintf("索引条件共%v行,共%v页,当前导出是第%v页,本页共%v行", totalCount, (totalCount+in.PageSize-1)/in.PageSize, in.Page, len(list)) - exports []*model.TSimCardInfoExportModel - ) - - if err = gconv.Scan(list, &exports); err != nil { - return - } - - err = excel.ExportByStructs(ctx, tags, exports, fileName, sheetName) - return + _, err = exportcenter.Submit(ctx, &exportcenter.Request{ + BizKey: "tSimCardInfo", + BizName: "卡信息", + PermCode: "cpm:iot:tSimCardInfo:export", + QueryParam: in, + Columns: s.exportColumns(in), + Tags: func() ([]string, error) { + return convert.GetEntityDescTags(model.TSimCardInfoListModel{}) + }, + Fetch: func(ctx context.Context, page, pageSize int) (rows []interface{}, total int, err error) { + query := *in + query.Page = page + query.PageSize = pageSize + list, count, err := s.List(ctx, &query) + if err != nil { + return nil, 0, err + } + rows = make([]interface{}, 0, len(list)) + for _, item := range list { + rows = append(rows, item) + } + return rows, count, nil + }, + }) + return err } // Edit 修改/新增卡信息表 @@ -428,6 +537,7 @@ func (s *sIotTSimCardInfo) View(ctx context.Context, in *model.TSimCardInfoViewP return } if res != nil { + res.ChanName = channelDisplayName(canViewChannelFullName(ctx), res.ChanName, res.ChanAlias) if resolver, resolverErr := newCardDeptDisplayResolver(ctx); resolverErr != nil { logCardDeptDisplayResolverError(ctx, resolverErr) } else { @@ -940,6 +1050,7 @@ func (s *sIotTSimCardInfo) GetCardsByPackageCodes(ctx context.Context, req *mode err = gerror.Wrap(err, "获取卡列表失败,请稍后重试!") return } + applyCardChannelDisplayNames(ctx, list) if resolver, resolverErr := newCardDeptDisplayResolver(ctx); resolverErr != nil { logCardDeptDisplayResolverError(ctx, resolverErr) } else { diff --git a/server/internal/logic/iot/t_sim_card_info_test.go b/server/internal/logic/iot/t_sim_card_info_test.go index b1b1702..3b98c88 100644 --- a/server/internal/logic/iot/t_sim_card_info_test.go +++ b/server/internal/logic/iot/t_sim_card_info_test.go @@ -1,12 +1,127 @@ package iot import ( + "context" + "encoding/json" + "os" "strings" "testing" "git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/model" ) +func TestSimCardBatchCondition(t *testing.T) { + condition, args, err := simCardBatchCondition(context.Background(), "\n iccid-1 \n\t\niccid-2 13800000000 460001234567890\n") + if err != nil { + t.Fatalf("parse batch input: %v", err) + } + if !strings.HasPrefix(condition, "((") || !strings.HasSuffix(condition, "))") { + t.Fatalf("batch conditions must be grouped: %s", condition) + } + if !strings.Contains(condition, "iccid IN (?) OR msisdn IN (?) OR imsi IN (?)") { + t.Fatalf("single values must be folded into IN lists: %s", condition) + } + if strings.Count(condition, "(iccid = ? OR msisdn = ? OR imsi = ?)") != 3 { + t.Fatalf("three-value row must require every value on the same card: %s", condition) + } + if strings.Contains(condition, " = ? AND msisdn = ? AND imsi = ?") { + t.Fatalf("field positions must not be assumed: %s", condition) + } + wantArgs := []interface{}{ + "iccid-1", "iccid-1", "iccid-1", + "iccid-2", "iccid-2", "iccid-2", + "13800000000", "13800000000", "13800000000", + "460001234567890", "460001234567890", "460001234567890", + } + if len(args) != len(wantArgs) { + t.Fatalf("got %d args, want %d: %#v", len(args), len(wantArgs), args) + } + for i := range wantArgs { + if args[i] != wantArgs[i] { + t.Fatalf("arg %d = %#v, want %#v", i, args[i], wantArgs[i]) + } + } +} + +func TestSimCardBatchConditionAcceptsAnyTwoValuesPerRow(t *testing.T) { + condition, args, err := simCardBatchCondition(context.Background(), + "89860000000000000001 13800000000\n13800000001 460001234567891\n") + if err != nil { + t.Fatalf("two values per row must be accepted: %v", err) + } + if strings.Count(condition, "(iccid = ? OR msisdn = ? OR imsi = ?)") != 4 { + t.Fatalf("each value must match any field, two rows expected four groups: %s", condition) + } + if len(args) != 12 { + t.Fatalf("got %d args, want 12: %#v", len(args), args) + } + if !strings.Contains(condition, " AND ") { + t.Fatalf("values of one row must be paired on the same card: %s", condition) + } +} + +func TestSimCardBatchConditionRejectsEmptyAndInvalidInput(t *testing.T) { + for _, input := range []string{"", " \n\t ", "one two three four", "one two three four five"} { + if _, _, err := simCardBatchCondition(context.Background(), input); err == nil { + t.Errorf("expected input %q to be rejected", input) + } + } +} + +func TestSimCardBatchConditionLimitsNonEmptyRowsBeforeDeduplication(t *testing.T) { + input := strings.Repeat("same-number\n", simCardBatchMaxRows) + condition, args, err := simCardBatchCondition(context.Background(), input) + if err != nil { + t.Fatalf("accept exactly %d rows: %v", simCardBatchMaxRows, err) + } + if len(args) != 3 { + t.Fatalf("duplicate single values must collapse to one IN list, got %d args: %s", len(args), condition) + } + + input += "one-more\n" + if _, _, err := simCardBatchCondition(context.Background(), input); err == nil { + t.Fatal("expected 1001 rows to be rejected even when values repeat") + } +} + +func TestSimCardBatchInputPresenceAndExportValidation(t *testing.T) { + for _, payload := range []string{`{"batchInput":""}`, `{"batchInput":" \n\t"}`} { + var in model.TSimCardInfoListParam + if err := json.Unmarshal([]byte(payload), &in); err != nil { + t.Fatal(err) + } + if in.BatchInput == nil { + t.Fatal("显式空批量输入不能视为省略字段") + } + if _, _, err := NewIotTSimCardInfo().List(context.Background(), &in); err == nil { + t.Fatal("列表应在访问数据库前拒绝空批量输入") + } + if err := NewIotTSimCardInfo().Export(context.Background(), &in); err == nil { + t.Fatal("导出应在提交任务前拒绝空批量输入") + } + } + var omitted model.TSimCardInfoListParam + if err := json.Unmarshal([]byte(`{}`), &omitted); err != nil { + t.Fatal(err) + } + if omitted.BatchInput != nil { + t.Fatal("省略批量字段应保持原列表查询语义") + } +} + +func TestSimCardBatchExportKeepsListReuse(t *testing.T) { + data, err := os.ReadFile("t_sim_card_info.go") + if err != nil { + t.Fatal(err) + } + source := string(data) + for _, fragment := range []string{"QueryParam: in,", "query := *in", "s.List(ctx, &query)", "mod := s.Model(ctx)", "mod = mod.Where(batchCondition, batchArgs...)"} { + if !strings.Contains(source, fragment) { + t.Errorf("批量查询必须保留现有权限及导出复用: %s", fragment) + } + } +} + func TestCardChannelAssocExistsSQLUsesChannelKeyOnly(t *testing.T) { query := cardChannelAssocExistsSQL("") diff --git a/server/internal/logic/iot/t_sim_card_stock.go b/server/internal/logic/iot/t_sim_card_stock.go index c935ace..cd15c71 100644 --- a/server/internal/logic/iot/t_sim_card_stock.go +++ b/server/internal/logic/iot/t_sim_card_stock.go @@ -1,30 +1,27 @@ // 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 import ( "context" - "fmt" "git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/dao" "git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/contexts" + "git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/exportcenter" "git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/xgorm" "git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/xgorm/handler" "git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/xgorm/hook" "git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/model" "git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/service" "git.5iot.com/iotlcm-public/iotlcm-admin-svr/utility/convert" - "git.5iot.com/iotlcm-public/iotlcm-admin-svr/utility/excel" "github.com/gogf/gf/v2/database/gdb" "github.com/gogf/gf/v2/errors/gerror" "github.com/gogf/gf/v2/frame/g" - "github.com/gogf/gf/v2/os/gctx" "github.com/gogf/gf/v2/os/gtime" - "github.com/gogf/gf/v2/util/gconv" ) type sIotTSimCardStock struct{} @@ -130,31 +127,32 @@ func (s *sIotTSimCardStock) List(ctx context.Context, in *model.TSimCardStockLis return } -// Export 导出库存卡信息表 +// Export 导出库存卡(异步:提交任务后到下载中心取文件) func (s *sIotTSimCardStock) Export(ctx context.Context, in *model.TSimCardStockListParam) (err error) { - list, totalCount, err := s.List(ctx, in) - if err != nil { - return - } - - // 字段的排序是依据tags的字段顺序,如果你不想使用默认的排序方式,可以直接定义 tags = []string{"字段名称", "字段名称2", ...} - tags, err := convert.GetEntityDescTags(model.TSimCardStockExportModel{}) - if err != nil { - return - } - - var ( - fileName = "导出库存卡信息表-" + gctx.CtxId(ctx) - sheetName = fmt.Sprintf("索引条件共%v行,共%v页,当前导出是第%v页,本页共%v行", totalCount, (totalCount+in.PageSize-1)/in.PageSize, in.Page, len(list)) - exports []model.TSimCardStockExportModel - ) - - if err = gconv.Scan(list, &exports); err != nil { - return - } - - err = excel.ExportByStructs(ctx, tags, exports, fileName, sheetName) - return + _, err = exportcenter.Submit(ctx, &exportcenter.Request{ + BizKey: "tSimCardStock", + BizName: "库存卡", + PermCode: "cpm:iot:tSimCardStock:export", + QueryParam: in, + Tags: func() ([]string, error) { + return convert.GetEntityDescTags(model.TSimCardStockListModel{}) + }, + Fetch: func(ctx context.Context, page, pageSize int) (rows []interface{}, total int, err error) { + query := *in + query.Page = page + query.PageSize = pageSize + list, count, err := s.List(ctx, &query) + if err != nil { + return nil, 0, err + } + rows = make([]interface{}, 0, len(list)) + for _, item := range list { + rows = append(rows, item) + } + return rows, count, nil + }, + }) + return err } // Edit 修改/新增库存卡信息表 diff --git a/server/internal/logic/iot/t_sim_card_traffic_cutoff.go b/server/internal/logic/iot/t_sim_card_traffic_cutoff.go index c36db53..e20987c 100644 --- a/server/internal/logic/iot/t_sim_card_traffic_cutoff.go +++ b/server/internal/logic/iot/t_sim_card_traffic_cutoff.go @@ -2,26 +2,23 @@ package iot import ( "context" - "fmt" "strings" "git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/consts" "git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/dao" "git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/contexts" + "git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/exportcenter" "git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/logic/iot/simcardbatch/trafficcutoffconfig" "git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/model" "git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/model/do" "git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/service" "git.5iot.com/iotlcm-public/iotlcm-admin-svr/utility/convert" - "git.5iot.com/iotlcm-public/iotlcm-admin-svr/utility/excel" "git.5iot.com/iotlcm-public/iotlcm-admin-svr/utility/iot_excelize" "github.com/gogf/gf/v2/database/gdb" "github.com/gogf/gf/v2/errors/gerror" "github.com/gogf/gf/v2/frame/g" "github.com/gogf/gf/v2/net/ghttp" - "github.com/gogf/gf/v2/os/gctx" - "github.com/gogf/gf/v2/util/gconv" "github.com/xuri/excelize/v2" ) @@ -103,25 +100,32 @@ func (s *sIotTSimCardTrafficCutoff) List(ctx context.Context, in *model.TSimCard return service.IotTSimCardInfo().List(ctx, &cardParam) } -func (s *sIotTSimCardTrafficCutoff) Export(ctx context.Context, in *model.TSimCardTrafficCutoffListParam) error { - if in == nil { - in = &model.TSimCardTrafficCutoffListParam{} - } - list, totalCount, err := s.List(ctx, in) - if err != nil { - return err - } - tags, err := convert.GetEntityDescTags(model.TSimCardTrafficCutoffExportModel{}) - if err != nil { - return err - } - var exports []*model.TSimCardTrafficCutoffExportModel - if err = gconv.Scan(list, &exports); err != nil { - return err - } - fileName := "traffic-cutoff-card-info-" + gctx.CtxId(ctx) - sheetName := fmt.Sprintf("traffic cutoff cards total %d, exported %d", totalCount, len(exports)) - return excel.ExportByStructs(ctx, tags, exports, fileName, sheetName) +// Export 导出达量断网(异步:提交任务后到下载中心取文件) +func (s *sIotTSimCardTrafficCutoff) Export(ctx context.Context, in *model.TSimCardTrafficCutoffListParam) (err error) { + _, err = exportcenter.Submit(ctx, &exportcenter.Request{ + BizKey: "tSimCardTrafficCutoff", + BizName: "达量断网", + PermCode: "cpm:iot:tSimCardTrafficCutoff:export", + QueryParam: in, + Tags: func() ([]string, error) { + return convert.GetEntityDescTags(model.TSimCardInfoListModel{}) + }, + Fetch: func(ctx context.Context, page, pageSize int) (rows []interface{}, total int, err error) { + query := *in + query.Page = page + query.PageSize = pageSize + list, count, err := s.List(ctx, &query) + if err != nil { + return nil, 0, err + } + rows = make([]interface{}, 0, len(list)) + for _, item := range list { + rows = append(rows, item) + } + return rows, count, nil + }, + }) + return err } func (s *sIotTSimCardTrafficCutoff) Edit(ctx context.Context, in *model.TSimCardTrafficCutoffEditParam) error { diff --git a/server/internal/logic/iot/t_sim_channel_template.go b/server/internal/logic/iot/t_sim_channel_template.go index 8b99600..7787ced 100644 --- a/server/internal/logic/iot/t_sim_channel_template.go +++ b/server/internal/logic/iot/t_sim_channel_template.go @@ -1,33 +1,30 @@ // 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 import ( "context" - "fmt" "git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/consts" "git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/dao" "git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/contexts" + "git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/exportcenter" "git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/rabbitmq" "git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/xgorm/handler" "git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/xgorm/hook" "git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/model" "git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/service" "git.5iot.com/iotlcm-public/iotlcm-admin-svr/utility/convert" - "git.5iot.com/iotlcm-public/iotlcm-admin-svr/utility/excel" "strings" "time" "github.com/gogf/gf/v2/database/gdb" "github.com/gogf/gf/v2/errors/gerror" "github.com/gogf/gf/v2/frame/g" - "github.com/gogf/gf/v2/os/gctx" "github.com/gogf/gf/v2/os/gtime" - "github.com/gogf/gf/v2/util/gconv" ) type sIotTSimChannelTemplate struct{} @@ -105,31 +102,32 @@ func (s *sIotTSimChannelTemplate) List(ctx context.Context, in *model.TSimChanne return } -// Export 导出通道模板 +// Export 导出通道模版(异步:提交任务后到下载中心取文件) func (s *sIotTSimChannelTemplate) Export(ctx context.Context, in *model.TSimChannelTemplateListParam) (err error) { - list, totalCount, err := s.List(ctx, in) - if err != nil { - return - } - - // 字段的排序是依据tags的字段顺序,如果你不想使用默认的排序方式,可以直接定义 tags = []string{"字段名称", "字段名称2", ...} - tags, err := convert.GetEntityDescTags(model.TSimChannelTemplateExportModel{}) - if err != nil { - return - } - - var ( - fileName = "导出通道模板-" + gctx.CtxId(ctx) - sheetName = fmt.Sprintf("索引条件共%v行,共%v页,当前导出是第%v页,本页共%v行", totalCount, (totalCount+in.PageSize-1)/in.PageSize, in.Page, len(list)) - exports []model.TSimChannelTemplateExportModel - ) - - if err = gconv.Scan(list, &exports); err != nil { - return - } - - err = excel.ExportByStructs(ctx, tags, exports, fileName, sheetName) - return + _, err = exportcenter.Submit(ctx, &exportcenter.Request{ + BizKey: "tSimChannelTemplate", + BizName: "通道模版", + PermCode: "cpm:iot:tSimChannelTemplate:export", + QueryParam: in, + Tags: func() ([]string, error) { + return convert.GetEntityDescTags(model.TSimChannelTemplateListModel{}) + }, + Fetch: func(ctx context.Context, page, pageSize int) (rows []interface{}, total int, err error) { + query := *in + query.Page = page + query.PageSize = pageSize + list, count, err := s.List(ctx, &query) + if err != nil { + return nil, 0, err + } + rows = make([]interface{}, 0, len(list)) + for _, item := range list { + rows = append(rows, item) + } + return rows, count, nil + }, + }) + return err } // Edit 修改/新增通道模板 diff --git a/server/internal/logic/iot/t_sim_channels.go b/server/internal/logic/iot/t_sim_channels.go index c8bbd0e..f349929 100644 --- a/server/internal/logic/iot/t_sim_channels.go +++ b/server/internal/logic/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 @@ -9,10 +9,11 @@ package iot import ( "context" "crypto/rand" - "fmt" + "io" "git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/consts" "git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/dao" "git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/contexts" + "git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/exportcenter" "git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/rabbitmq" "git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/xgorm/handler" "git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/xgorm/hook" @@ -21,15 +22,12 @@ import ( "git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/model/do" "git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/service" "git.5iot.com/iotlcm-public/iotlcm-admin-svr/utility/convert" - "git.5iot.com/iotlcm-public/iotlcm-admin-svr/utility/excel" - "io" "strings" "time" "github.com/gogf/gf/v2/database/gdb" "github.com/gogf/gf/v2/errors/gerror" "github.com/gogf/gf/v2/frame/g" - "github.com/gogf/gf/v2/os/gctx" "github.com/gogf/gf/v2/os/gtime" "github.com/gogf/gf/v2/util/gconv" ) @@ -90,7 +88,7 @@ func (s *sIotTSimChannels) List(ctx context.Context, in *model.TSimChannelsListP } // 查询通道名称 if in.ChanName != "" { - mod = mod.Where(dao.TSimChannels.Columns().ChanName+" LIKE ?", "%"+in.ChanName+"%") + mod = mod.Where(channelDisplayNameSQL(ctx, simChannels)+" LIKE ?", "%"+in.ChanName+"%") } // 查询通道别名 if in.ChanAlias != "" { @@ -136,6 +134,12 @@ func (s *sIotTSimChannels) List(ctx context.Context, in *model.TSimChannelsListP err = gerror.Wrap(err, "获取通道列表列表失败,请稍后重试!") return } + fullName := canViewChannelFullName(ctx) + for _, item := range list { + if item != nil { + item.ChanName = channelDisplayName(fullName, item.ChanName, item.ChanAlias) + } + } return } @@ -193,31 +197,32 @@ func (s *sIotTSimChannels) RefreshCardCounts(ctx context.Context) error { }) } -// Export 导出通道列表 +// Export 导出通道管理(异步:提交任务后到下载中心取文件) func (s *sIotTSimChannels) Export(ctx context.Context, in *model.TSimChannelsListParam) (err error) { - list, totalCount, err := s.List(ctx, in) - if err != nil { - return - } - - // 字段的排序是依据tags的字段顺序,如果你不想使用默认的排序方式,可以直接定义 tags = []string{"字段名称", "字段名称2", ...} - tags, err := convert.GetEntityDescTags(model.TSimChannelsExportModel{}) - if err != nil { - return - } - - var ( - fileName = "导出通道列表-" + gctx.CtxId(ctx) - sheetName = fmt.Sprintf("索引条件共%v行,共%v页,当前导出是第%v页,本页共%v行", totalCount, (totalCount+in.PageSize-1)/in.PageSize, in.Page, len(list)) - exports []model.TSimChannelsExportModel - ) - - if err = gconv.Scan(list, &exports); err != nil { - return - } - - err = excel.ExportByStructs(ctx, tags, exports, fileName, sheetName) - return + _, err = exportcenter.Submit(ctx, &exportcenter.Request{ + BizKey: "tSimChannels", + BizName: "通道管理", + PermCode: "cpm:iot:tSimChannels:export", + QueryParam: in, + Tags: func() ([]string, error) { + return convert.GetEntityDescTags(model.TSimChannelsListModel{}) + }, + Fetch: func(ctx context.Context, page, pageSize int) (rows []interface{}, total int, err error) { + query := *in + query.Page = page + query.PageSize = pageSize + list, count, err := s.List(ctx, &query) + if err != nil { + return nil, 0, err + } + rows = make([]interface{}, 0, len(list)) + for _, item := range list { + rows = append(rows, item) + } + return rows, count, nil + }, + }) + return err } // shouldGeneratePlatformAppKey reports whether a channel uses the shared-token @@ -569,6 +574,9 @@ func (s *sIotTSimChannels) View(ctx context.Context, in *model.TSimChannelsViewP err = gerror.Wrap(err, "获取通道列表信息,请稍后重试!") return } + if res != nil { + res.ChanName = channelDisplayName(canViewChannelFullName(ctx), res.ChanName, res.ChanAlias) + } return } diff --git a/server/internal/logic/iot/t_sim_channels_shared_token_test.go b/server/internal/logic/iot/t_sim_channels_shared_token_test.go new file mode 100644 index 0000000..1653c50 --- /dev/null +++ b/server/internal/logic/iot/t_sim_channels_shared_token_test.go @@ -0,0 +1,97 @@ +package iot + +import ( + "strings" + "testing" +) + +func TestShouldGeneratePlatformAppKey(t *testing.T) { + tests := []struct { + name string + tempKey string + tempModel string + want bool + }{ + {name: "third party shared token", tempKey: "vendor_alpha", tempModel: "shared_token", want: true}, + {name: "model comparison is case insensitive", tempKey: "vendor_alpha", tempModel: " SHARED_TOKEN ", want: true}, + {name: "official mobile keeps upstream key", tempKey: mobileOfficialTemplateKey, tempModel: sharedTokenTemplateModel, want: false}, + {name: "official mobile key comparison is case insensitive", tempKey: " SIM_YD_V5 ", tempModel: sharedTokenTemplateModel, want: false}, + {name: "other template model", tempKey: "vendor_alpha", tempModel: "oauth", want: false}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + if got := shouldGeneratePlatformAppKey(test.tempKey, test.tempModel); got != test.want { + t.Fatalf("shouldGeneratePlatformAppKey(%q, %q) = %v, want %v", test.tempKey, test.tempModel, got, test.want) + } + }) + } +} + +func TestIsMissingAppKey(t *testing.T) { + tests := []struct { + name string + appKey string + want bool + }{ + {name: "empty", appKey: "", want: true}, + {name: "whitespace", appKey: " ", want: true}, + {name: "legacy slash placeholder", appKey: " / ", want: true}, + {name: "configured key", appKey: "YZT7F3K9M2Q8R6V4N5C2", want: false}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + if got := isMissingAppKey(test.appKey); got != test.want { + t.Fatalf("isMissingAppKey(%q) = %v, want %v", test.appKey, got, test.want) + } + }) + } +} + +func TestGeneratePlatformAppKey(t *testing.T) { + seen := make(map[string]struct{}, 100) + for index := 0; index < 100; index++ { + appKey, err := generatePlatformAppKey() + if err != nil { + t.Fatalf("generatePlatformAppKey() error = %v", err) + } + if len(appKey) != len(platformAppKeyPrefix)+platformAppKeyRandomLength { + t.Fatalf("generated app key length = %d, want %d", len(appKey), len(platformAppKeyPrefix)+platformAppKeyRandomLength) + } + if !strings.HasPrefix(appKey, platformAppKeyPrefix) { + t.Fatalf("generated app key %q does not have prefix %q", appKey, platformAppKeyPrefix) + } + for _, character := range appKey[len(platformAppKeyPrefix):] { + if !strings.ContainsRune(platformAppKeyAlphabet, character) { + t.Fatalf("generated app key %q contains character %q outside alphabet", appKey, character) + } + } + if _, duplicate := seen[appKey]; duplicate { + t.Fatalf("generated duplicate app key %q", appKey) + } + seen[appKey] = struct{}{} + } +} + +func TestResolveSharedTokenChannelKeyChange(t *testing.T) { + requested := " new-key " + oldKey, newKey, changed, err := resolveChannelKeyChange("old-key", 0, &requested) + if err != nil { + t.Fatalf("resolveChannelKeyChange() error = %v", err) + } + if oldKey != "old-key" || newKey != "new-key" || !changed { + t.Fatalf("resolveChannelKeyChange() = (%q, %q, %v), want (%q, %q, true)", oldKey, newKey, changed, "old-key", "new-key") + } + + if _, _, _, err = resolveChannelKeyChange("old-key", 1, &requested); err == nil { + t.Fatal("resolveChannelKeyChange() should reject renaming a shared-token channel") + } + empty := " " + if _, _, _, err = resolveChannelKeyChange("old-key", 0, &empty); err == nil { + t.Fatal("resolveChannelKeyChange() should reject an empty channel key") + } + + oldKey, newKey, changed, err = resolveChannelKeyChange("old-key", 0, nil) + if err != nil || oldKey != "old-key" || newKey != "old-key" || changed { + t.Fatalf("resolveChannelKeyChange() without request = (%q, %q, %v, %v), want unchanged", oldKey, newKey, changed, err) + } +} diff --git a/server/internal/logic/iot/t_sim_diagnose_legacy.go b/server/internal/logic/iot/t_sim_diagnose_legacy.go index 53ae63a..cc0bf08 100644 --- a/server/internal/logic/iot/t_sim_diagnose_legacy.go +++ b/server/internal/logic/iot/t_sim_diagnose_legacy.go @@ -2,11 +2,11 @@ package iot import ( "context" + "github.com/gogf/gf/v2/errors/gerror" + "github.com/gogf/gf/v2/frame/g" "git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/dao" "git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/contexts" "git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/xgorm/handler" - "github.com/gogf/gf/v2/errors/gerror" - "github.com/gogf/gf/v2/frame/g" "strings" ) diff --git a/server/internal/logic/iot/t_sim_diagnose_legacy_test.go b/server/internal/logic/iot/t_sim_diagnose_legacy_test.go new file mode 100644 index 0000000..99c73aa --- /dev/null +++ b/server/internal/logic/iot/t_sim_diagnose_legacy_test.go @@ -0,0 +1,130 @@ +package iot + +import ( + "context" + _ "github.com/gogf/gf/contrib/drivers/mysql/v2" + _ "github.com/gogf/gf/contrib/drivers/sqlite/v2" + "github.com/gogf/gf/v2/database/gdb" + "github.com/gogf/gf/v2/frame/g" + "github.com/gogf/gf/v2/os/gcfg" + "git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/consts" + "git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/contexts" + "git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/model" + "os" + "testing" + "time" +) + +func TestLegacyDiagnoseFallback(t *testing.T) { + ctx := context.Background() + previous := gdb.GetConfig("default") + previousLegacy := gdb.GetConfig("legacyCard") + adapter := g.Cfg().GetAdapter() + cfg, err := gcfg.NewAdapterContent(`{"database":{"legacyCard":{"link":"sqlite::@file(:memory:)"}}}`) + if err != nil { + t.Fatal(err) + } + g.Cfg().SetAdapter(cfg) + for _, group := range []string{"default", "legacyCard"} { + if err := gdb.SetConfigGroup(group, gdb.ConfigGroup{{Type: "sqlite", Link: "sqlite::@file(:memory:)", MaxOpenConnCount: 1}}); err != nil { + t.Fatal(err) + } + } + t.Cleanup(func() { + _ = g.DB().Close(ctx) + _ = g.DB("legacyCard").Close(ctx) + _ = gdb.SetConfigGroup("default", previous) + _ = gdb.SetConfigGroup("legacyCard", previousLegacy) + g.Cfg().SetAdapter(adapter) + }) + for _, sql := range []string{ + "CREATE TABLE yz_card_info(iccid TEXT, imsi TEXT, msisdn TEXT, channel_id TEXT)", + "CREATE TABLE yz_card_route(cd_id TEXT)", + "INSERT INTO yz_card_route VALUES ('77')", + "INSERT INTO yz_card_info VALUES ('898607B91923C0327236','imsi','phone','77')", + } { + if _, err := g.DB("legacyCard").Exec(ctx, sql); err != nil { + t.Fatal(err) + } + } + for _, sql := range []string{ + "CREATE TABLE t_sim_card_info(iccid TEXT, imsi TEXT, msisdn TEXT, tenant_id TEXT)", + "CREATE TABLE t_sim_channels(chan_key TEXT,temp_key TEXT,chan_status INTEGER,tenant_id TEXT,created_by INTEGER,created_dept INTEGER)", + "INSERT INTO t_sim_channels VALUES ('77','sim_yunda_v1',0,'A',7,10)", + } { + if _, err := g.DB().Exec(ctx, sql); err != nil { + t.Fatal(err) + } + } + principal := &model.Identity{BaseClaims: model.BaseClaims{ID: 7, TenantId: "A", DeptId: 10, Roles: []model.Role{{RoleId: 9, DataScope: consts.SysRoleDataScopePersonal}}}} + scopeCtx := contexts.Set(ctx, &model.Context{User: principal}) + for _, code := range []string{"898607B91923C0327236", "imsi", "phone"} { + card, err := resolveLegacyDiagnoseCardInfo(scopeCtx, code) + if err != nil || card == nil || card.ChanKey != "77" || card.HasLocalCard { + t.Fatalf("card=%+v err=%v", card, err) + } + } + if _, err := resolveLegacyDiagnoseCardInfo(ctx, "phone"); err == nil { + t.Fatal("guest allowed") + } + principal.TenantId = "B" + if _, err := resolveLegacyDiagnoseCardInfo(scopeCtx, "phone"); err == nil { + t.Fatal("cross tenant channel allowed") + } + principal.TenantId = "A" + principal.ID = 8 + if _, err := resolveLegacyDiagnoseCardInfo(scopeCtx, "phone"); err == nil { + t.Fatal("foreign channel allowed") + } + principal.ID = 7 + if _, err := g.DB().Exec(ctx, "UPDATE t_sim_channels SET chan_status=1"); err != nil { + t.Fatal(err) + } + if _, err := resolveLegacyDiagnoseCardInfo(scopeCtx, "phone"); err == nil { + t.Fatal("disabled channel allowed") + } + if _, err := g.DB().Exec(ctx, "INSERT INTO t_sim_card_info VALUES ('898607B91923C0327236','imsi','phone','A')"); err != nil { + t.Fatal(err) + } + if _, err := resolveLegacyDiagnoseCardInfo(scopeCtx, "phone"); err == nil { + t.Fatal("local visibility bypassed") + } + if _, err := g.DB("legacyCard").Exec(ctx, "INSERT INTO yz_card_info VALUES ('duplicate','','phone','77')"); err != nil { + t.Fatal(err) + } + if _, err := queryLegacyDiagnoseCard(ctx, "phone"); err == nil { + t.Fatal("ambiguous card accepted") + } + missing, err := queryLegacyDiagnoseCard(ctx, "missing") + if err != nil || missing != nil { + t.Fatalf("missing=%+v err=%v", missing, err) + } +} + +func TestLegacyDiagnoseReadOnlyIntegration(t *testing.T) { + configPath := os.Getenv("IOTLCM_LEGACY_DIAGNOSIS_CONFIG") + if configPath == "" { + t.Skip("未启用真实旧库只读测试") + } + content, err := os.ReadFile(configPath) + if err != nil { + t.Fatal("无法读取测试环境配置") + } + adapter, err := gcfg.NewAdapterContent(string(content)) + if err != nil { + t.Fatal("环境配置格式无效") + } + previous := g.Cfg().GetAdapter() + g.Cfg().SetAdapter(adapter) + t.Cleanup(func() { g.Cfg().SetAdapter(previous) }) + ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second) + defer cancel() + card, err := queryLegacyDiagnoseCard(ctx, "898607B91923C0327236") + if err != nil { + t.Fatal(err) + } + if card == nil { + t.Fatal("真实旧库未查到卡及路由映射") + } + t.Logf("旧库实际命中 ICCID=%s channelKey=%s IMSI存在=%t MSISDN存在=%t", card.ICCID, card.ChannelKey, card.IMSI != "", card.MSISDN != "") +} diff --git a/server/internal/logic/iot/t_sim_diagnose_test.go b/server/internal/logic/iot/t_sim_diagnose_test.go new file mode 100644 index 0000000..c89eb7b --- /dev/null +++ b/server/internal/logic/iot/t_sim_diagnose_test.go @@ -0,0 +1,74 @@ +package iot + +import ( + "testing" + + "git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/model" +) + +func TestDiagnoseDataServiceStatusUsesTargetApn(t *testing.T) { + tests := []struct { + name string + items []model.SimApnInfoItem + want string + }{ + {name: "enabled", items: []model.SimApnInfoItem{{ApnName: "OTHER", Status: "0"}, {ApnName: "CMIOT", Status: "1"}}, want: "1"}, + {name: "disabled", items: []model.SimApnInfoItem{{ApnName: "cmiot", Status: "0"}}, want: "0"}, + {name: "missing target", items: []model.SimApnInfoItem{{ApnName: "OTHER", Status: "1"}}}, + {name: "invalid status", items: []model.SimApnInfoItem{{ApnName: "CMIOT", Status: "unknown"}}}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + got := diagnoseDataServiceStatus(test.items) + if test.want == "" { + if got != nil { + t.Fatalf("status = %q, want no data", *got) + } + return + } + if got == nil || *got != test.want { + t.Fatalf("status = %v, want %q", got, test.want) + } + }) + } +} + +func TestDiagnoseAPNStatusRequiresAgreement(t *testing.T) { + tests := []struct { + name string + items []model.SimApnInfoItem + want string + }{ + {name: "all enabled", items: []model.SimApnInfoItem{{ApnName: "ctnet", Status: "1"}, {ApnName: "ctiot", Status: "1"}}, want: "1"}, + {name: "all stopped", items: []model.SimApnInfoItem{{ApnName: "ctnet", Status: "0"}, {ApnName: "ctiot", Status: "0"}}, want: "0"}, + {name: "mixed", items: []model.SimApnInfoItem{{ApnName: "ctnet", Status: "1"}, {ApnName: "ctiot", Status: "0"}}}, + {name: "mixed reversed", items: []model.SimApnInfoItem{{ApnName: "ctiot", Status: "0"}, {ApnName: "ctnet", Status: "1"}}}, + {name: "missing status", items: []model.SimApnInfoItem{{ApnName: "ctnet", Status: "1"}, {ApnName: "ctiot"}}}, + {name: "unknown status", items: []model.SimApnInfoItem{{ApnName: "ctnet", Status: "1"}, {ApnName: "ctiot", Status: "-1"}}}, + {name: "missing name", items: []model.SimApnInfoItem{{Status: "1"}}}, + {name: "no mobile preference for telecom", items: []model.SimApnInfoItem{{ApnName: "CMIOT", Status: "1"}, {ApnName: "ctnet", Status: "0"}}}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + got := diagnoseDataServiceStatusForChannel(test.items, "sim_dx_v1", "channel_dx") + if test.want == "" { + if got != nil { + t.Fatalf("status = %q, want no data", *got) + } + } else if got == nil || *got != test.want { + t.Fatalf("status = %v, want %q", got, test.want) + } + }) + } +} + +func TestDiagnoseDataServiceStatusForCarrier(t *testing.T) { + telecom := []model.SimApnInfoItem{{ApnName: "CTNET", Status: "1"}} + if got := diagnoseDataServiceStatusForChannel(telecom, "sim_dx_v1", "channel_dx"); got == nil || *got != "1" { + t.Fatalf("telecom status = %v, want enabled", got) + } + mobile := []model.SimApnInfoItem{{ApnName: "CTNET", Status: "1"}} + if got := diagnoseDataServiceStatusForChannel(mobile, "sim_yd_v5", "channel_yd"); got != nil { + t.Fatalf("mobile status = %v, want no CMIOT data", *got) + } +} diff --git a/server/internal/logic/iot/t_sim_flow_package_info.go b/server/internal/logic/iot/t_sim_flow_package_info.go index bddbac6..4d2a97a 100644 --- a/server/internal/logic/iot/t_sim_flow_package_info.go +++ b/server/internal/logic/iot/t_sim_flow_package_info.go @@ -2,12 +2,12 @@ package iot import ( "context" - "fmt" "math" "strings" "git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/dao" "git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/contexts" + "git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/exportcenter" "git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/xgorm/handler" "git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/xgorm/hook" "git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/logic/iot/packageduration" @@ -17,13 +17,11 @@ import ( "git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/model/entity" "git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/service" "git.5iot.com/iotlcm-public/iotlcm-admin-svr/utility/convert" - "git.5iot.com/iotlcm-public/iotlcm-admin-svr/utility/excel" "github.com/gogf/gf/v2/database/gdb" "github.com/gogf/gf/v2/encoding/gjson" "github.com/gogf/gf/v2/errors/gerror" "github.com/gogf/gf/v2/frame/g" - "github.com/gogf/gf/v2/os/gctx" "github.com/gogf/gf/v2/os/gtime" "github.com/gogf/gf/v2/util/gconv" ) @@ -348,29 +346,32 @@ func applyPoolFlowPackageBaseFilter(mod *gdb.Model, mainTable, currentPoolPackag Where(mainTable+"."+columns.PackageType, "102") } +// Export 导出SIM流量套餐(异步:提交任务后到下载中心取文件) func (s *sIotTSimFlowPackageInfo) Export(ctx context.Context, in *model.TSimFlowPackageInfoListParam) (err error) { - list, totalCount, err := s.List(ctx, in) - if err != nil { - return - } - - tags, err := convert.GetEntityDescTags(model.TSimFlowPackageInfoExportModel{}) - if err != nil { - return - } - - var ( - fileName = "导出SIM流量套餐管理-" + gctx.CtxId(ctx) - sheetName = fmt.Sprintf("索引条件共%v行 共%v页 当前导出是第%v页 本页共%v行", totalCount, (totalCount+in.PageSize-1)/in.PageSize, in.Page, len(list)) - exports []model.TSimFlowPackageInfoExportModel - ) - - if err = gconv.Scan(list, &exports); err != nil { - return - } - - err = excel.ExportByStructs(ctx, tags, exports, fileName, sheetName) - return + _, err = exportcenter.Submit(ctx, &exportcenter.Request{ + BizKey: "tSimFlowPackageInfo", + BizName: "SIM流量套餐", + PermCode: "cpm:iot:tSimFlowPackageInfo:export", + QueryParam: in, + Tags: func() ([]string, error) { + return convert.GetEntityDescTags(model.TSimFlowPackageInfoListModel{}) + }, + Fetch: func(ctx context.Context, page, pageSize int) (rows []interface{}, total int, err error) { + query := *in + query.Page = page + query.PageSize = pageSize + list, count, err := s.List(ctx, &query) + if err != nil { + return nil, 0, err + } + rows = make([]interface{}, 0, len(list)) + for _, item := range list { + rows = append(rows, item) + } + return rows, count, nil + }, + }) + return err } func (s *sIotTSimFlowPackageInfo) Edit(ctx context.Context, in *model.TSimFlowPackageInfoEditParam) (err error) { diff --git a/server/internal/logic/iot/t_sim_flow_package_price.go b/server/internal/logic/iot/t_sim_flow_package_price.go index f89f018..1527399 100644 --- a/server/internal/logic/iot/t_sim_flow_package_price.go +++ b/server/internal/logic/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/logic/iot/t_sim_flow_pool.go b/server/internal/logic/iot/t_sim_flow_pool.go index 3631144..4fe22f8 100644 --- a/server/internal/logic/iot/t_sim_flow_pool.go +++ b/server/internal/logic/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/logic/iot/t_sim_package_category.go b/server/internal/logic/iot/t_sim_package_category.go index 4351856..611f695 100644 --- a/server/internal/logic/iot/t_sim_package_category.go +++ b/server/internal/logic/iot/t_sim_package_category.go @@ -1,30 +1,27 @@ // 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 import ( "context" - "fmt" "git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/dao" "git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/contexts" + "git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/exportcenter" "git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/xgorm" "git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/xgorm/handler" "git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/xgorm/hook" "git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/model" "git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/service" "git.5iot.com/iotlcm-public/iotlcm-admin-svr/utility/convert" - "git.5iot.com/iotlcm-public/iotlcm-admin-svr/utility/excel" "github.com/gogf/gf/v2/database/gdb" "github.com/gogf/gf/v2/errors/gerror" "github.com/gogf/gf/v2/frame/g" - "github.com/gogf/gf/v2/os/gctx" "github.com/gogf/gf/v2/os/gtime" - "github.com/gogf/gf/v2/util/gconv" ) type sIotTSimPackageCategory struct{} @@ -95,31 +92,32 @@ func (s *sIotTSimPackageCategory) List(ctx context.Context, in *model.TSimPackag return } -// Export 导出套餐分类表 +// Export 导出套餐分类(异步:提交任务后到下载中心取文件) func (s *sIotTSimPackageCategory) Export(ctx context.Context, in *model.TSimPackageCategoryListParam) (err error) { - list, totalCount, err := s.List(ctx, in) - if err != nil { - return - } - - // 字段的排序是依据tags的字段顺序,如果你不想使用默认的排序方式,可以直接定义 tags = []string{"字段名称", "字段名称2", ...} - tags, err := convert.GetEntityDescTags(model.TSimPackageCategoryExportModel{}) - if err != nil { - return - } - - var ( - fileName = "导出套餐分类表-" + gctx.CtxId(ctx) - sheetName = fmt.Sprintf("索引条件共%v行,共%v页,当前导出是第%v页,本页共%v行", totalCount, (totalCount+in.PageSize-1)/in.PageSize, in.Page, len(list)) - exports []model.TSimPackageCategoryExportModel - ) - - if err = gconv.Scan(list, &exports); err != nil { - return - } - - err = excel.ExportByStructs(ctx, tags, exports, fileName, sheetName) - return + _, err = exportcenter.Submit(ctx, &exportcenter.Request{ + BizKey: "tSimPackageCategory", + BizName: "套餐分类", + PermCode: "cpm:iot:tSimPackageCategory:export", + QueryParam: in, + Tags: func() ([]string, error) { + return convert.GetEntityDescTags(model.TSimPackageCategoryListModel{}) + }, + Fetch: func(ctx context.Context, page, pageSize int) (rows []interface{}, total int, err error) { + query := *in + query.Page = page + query.PageSize = pageSize + list, count, err := s.List(ctx, &query) + if err != nil { + return nil, 0, err + } + rows = make([]interface{}, 0, len(list)) + for _, item := range list { + rows = append(rows, item) + } + return rows, count, nil + }, + }) + return err } // Edit 修改/新增套餐分类表 diff --git a/server/internal/logic/iot/t_sim_pool_bind_package.go b/server/internal/logic/iot/t_sim_pool_bind_package.go index 53faa23..289336d 100644 --- a/server/internal/logic/iot/t_sim_pool_bind_package.go +++ b/server/internal/logic/iot/t_sim_pool_bind_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/logic/iot/t_sim_pool_warn_config.go b/server/internal/logic/iot/t_sim_pool_warn_config.go index 0edb790..7f0b5ee 100644 --- a/server/internal/logic/iot/t_sim_pool_warn_config.go +++ b/server/internal/logic/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/logic/iot/t_sim_upstream_package.go b/server/internal/logic/iot/t_sim_upstream_package.go index d1ae1b9..063fc39 100644 --- a/server/internal/logic/iot/t_sim_upstream_package.go +++ b/server/internal/logic/iot/t_sim_upstream_package.go @@ -1,30 +1,27 @@ // 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 import ( "context" - "fmt" "git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/dao" "git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/contexts" + "git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/exportcenter" "git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/xgorm" "git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/xgorm/handler" "git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/xgorm/hook" "git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/model" "git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/service" "git.5iot.com/iotlcm-public/iotlcm-admin-svr/utility/convert" - "git.5iot.com/iotlcm-public/iotlcm-admin-svr/utility/excel" "github.com/gogf/gf/v2/database/gdb" "github.com/gogf/gf/v2/errors/gerror" "github.com/gogf/gf/v2/frame/g" - "github.com/gogf/gf/v2/os/gctx" "github.com/gogf/gf/v2/os/gtime" - "github.com/gogf/gf/v2/util/gconv" ) type sIotTUpstreamPackage struct{} @@ -95,31 +92,32 @@ func (s *sIotTUpstreamPackage) List(ctx context.Context, in *model.TUpstreamPack return } -// Export 导出上游套餐表 +// Export 导出上游套餐(异步:提交任务后到下载中心取文件) func (s *sIotTUpstreamPackage) Export(ctx context.Context, in *model.TUpstreamPackageListParam) (err error) { - list, totalCount, err := s.List(ctx, in) - if err != nil { - return - } - - // 字段的排序是依据tags的字段顺序,如果你不想使用默认的排序方式,可以直接定义 tags = []string{"字段名称", "字段名称2", ...} - tags, err := convert.GetEntityDescTags(model.TUpstreamPackageExportModel{}) - if err != nil { - return - } - - var ( - fileName = "导出上游套餐表-" + gctx.CtxId(ctx) - sheetName = fmt.Sprintf("索引条件共%v行,共%v页,当前导出是第%v页,本页共%v行", totalCount, (totalCount+in.PageSize-1)/in.PageSize, in.Page, len(list)) - exports []*model.TUpstreamPackageExportModel - ) - - if err = gconv.Scan(list, &exports); err != nil { - return - } - - err = excel.ExportByStructs(ctx, tags, exports, fileName, sheetName) - return + _, err = exportcenter.Submit(ctx, &exportcenter.Request{ + BizKey: "tSimUpstreamPackage", + BizName: "上游套餐", + PermCode: "cpm:iot:tSimUpstreamPackage:export", + QueryParam: in, + Tags: func() ([]string, error) { + return convert.GetEntityDescTags(model.TUpstreamPackageListModel{}) + }, + Fetch: func(ctx context.Context, page, pageSize int) (rows []interface{}, total int, err error) { + query := *in + query.Page = page + query.PageSize = pageSize + list, count, err := s.List(ctx, &query) + if err != nil { + return nil, 0, err + } + rows = make([]interface{}, 0, len(list)) + for _, item := range list { + rows = append(rows, item) + } + return rows, count, nil + }, + }) + return err } // Edit 修改/新增上游套餐表 diff --git a/server/internal/logic/iot/t_sim_warning_config.go b/server/internal/logic/iot/t_sim_warning_config.go index 521aa43..47936cd 100644 --- a/server/internal/logic/iot/t_sim_warning_config.go +++ b/server/internal/logic/iot/t_sim_warning_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/logic/middleware/channel_test_response_test.go b/server/internal/logic/middleware/channel_test_response_test.go new file mode 100644 index 0000000..2129c5d --- /dev/null +++ b/server/internal/logic/middleware/channel_test_response_test.go @@ -0,0 +1,68 @@ +package middleware + +import ( + "encoding/json" + "net/http" + "strconv" + "testing" + + "github.com/gogf/gf/v2/errors/gcode" + "github.com/gogf/gf/v2/errors/gerror" + "github.com/gogf/gf/v2/frame/g" + "github.com/gogf/gf/v2/net/ghttp" + "git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/model" +) + +func TestChannelTestErrorResponsePreservesOriginalError(t *testing.T) { + const raw = `{"code":50,"message":"channel is not accessible","data":null}` + server := g.Server(t.Name()) + server.SetAddr("127.0.0.1:0") + server.SetDumpRouterMap(false) + server.Group("/", func(group *ghttp.RouterGroup) { + group.Middleware(New().ResponseHandler) + group.GET("/plain", func(r *ghttp.Request) { + r.SetError(gerror.New("channel is not accessible")) + }) + group.GET("/diagnostic", func(r *ghttp.Request) { + r.SetError(&model.TSimChannelsTestError{Err: gerror.New("channel is not accessible"), GatewayResponseRaw: raw}) + }) + group.GET("/coded", func(r *ghttp.Request) { + r.SetError(&model.TSimChannelsTestError{Err: gerror.NewCode(gcode.CodeInvalidParameter, "invalid input"), GatewayResponseRaw: raw}) + }) + }) + if err := server.Start(); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = server.Shutdown() }) + for _, tc := range []struct { + path string + code int + message string + raw string + }{ + {"/plain", gcode.CodeInternalError.Code(), "channel is not accessible", ""}, + {"/diagnostic", gcode.CodeInternalError.Code(), "channel is not accessible", raw}, + {"/coded", gcode.CodeInvalidParameter.Code(), "invalid input", raw}, + } { + t.Run(tc.path, func(t *testing.T) { + res, err := http.Get("http://127.0.0.1:" + strconv.Itoa(server.GetListenedPort()) + tc.path) + if err != nil { + t.Fatal(err) + } + defer res.Body.Close() + var body struct { + Code int `json:"code"` + Message string `json:"message"` + Data struct { + GatewayResponseRaw string `json:"gatewayResponseRaw"` + } `json:"data"` + } + if err := json.NewDecoder(res.Body).Decode(&body); err != nil { + t.Fatal(err) + } + if res.StatusCode != http.StatusOK || body.Code != tc.code || body.Message != tc.message || body.Data.GatewayResponseRaw != tc.raw { + t.Fatalf("unexpected response: status=%d body=%+v", res.StatusCode, body) + } + }) + } +} diff --git a/server/internal/logic/middleware/middleware.go b/server/internal/logic/middleware/middleware.go index 3bafe38..fdd5f69 100644 --- a/server/internal/logic/middleware/middleware.go +++ b/server/internal/logic/middleware/middleware.go @@ -198,6 +198,12 @@ func (s *sMiddleware) Auth(r *ghttp.Request) { response.JsonExit(r, gcode.CodeNotAuthorized.Code(), consts.CodeLoginExpired.Message()) return } + // 回填到请求上下文:Identity 由 Ctx 中间件只带 BaseClaims 构造,权限码仅在此处查出。 + // 若不回填,contexts.GetAccessCodeList 在后续业务里恒为空(下载中心按导出权限过滤任务、 + // 下载/删除二次校验都依赖它),表现为非超管看不到自己的导出任务、下载被拒。 + if user := contexts.GetUser(ctx); user != nil { + user.AccessCodeList = userAccessCodeList + } if serveHandler != nil { g.Log().Infof(ctx, "sMiddleware.Auth serveHandler GetMetaTag(x-check-permission): %+v", serveHandler.GetMetaTag("x-check-permission")) accessCode := serveHandler.GetMetaTag("x-check-permission") diff --git a/server/internal/logic/monitor/monitor_server.go b/server/internal/logic/monitor/monitor_server.go index dd933c2..b1410d0 100644 --- a/server/internal/logic/monitor/monitor_server.go +++ b/server/internal/logic/monitor/monitor_server.go @@ -1,7 +1,7 @@ // package monitor // @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 monitor diff --git a/server/internal/logic/simwarning/monthly_expiry.go b/server/internal/logic/simwarning/monthly_expiry.go new file mode 100644 index 0000000..043ca13 --- /dev/null +++ b/server/internal/logic/simwarning/monthly_expiry.go @@ -0,0 +1,319 @@ +package simwarning + +import ( + "context" + "encoding/json" + "fmt" + "strconv" + "strings" + "time" + + "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/logic/smsgateway" + "git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/model" + "git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/service" + + "github.com/gogf/gf/v2/encoding/gjson" + "github.com/gogf/gf/v2/frame/g" +) + +const ( + monthlyExpiryPageSize = 100 + monthlyExpiryCurrentSMSCode = "SMS_512596191" + monthlyExpiryNextSMSCode = "SMS_512646156" + monthlyExpirySMSSignName = "示例企业" + monthlyExpirySentTTL = 93 * 24 * time.Hour +) + +var monthlyExpiryLocation = time.FixedZone("Asia/Shanghai", 8*60*60) + +type monthlyExpiryCompany struct { + DeptID int64 `orm:"dept_id"` + TenantID string `orm:"tenant_id"` + LeaderID int64 `orm:"leader"` +} + +type monthlyExpirySummary struct { + Month string `orm:"expiry_month"` + Count int64 `orm:"card_count"` + FirstDate string `orm:"first_date"` + LastDate string `orm:"last_date"` +} + +type monthlyExpiryLeader struct { + UserID int64 `orm:"user_id"` + Phone string `orm:"phonenumber"` +} + +type monthlyExpiryNotice struct { + Company monthlyExpiryCompany + Summary monthlyExpirySummary + Leader monthlyExpiryLeader + RunTime time.Time +} + +type monthlyExpiryStats struct { + Companies, Groups, Sent, Duplicate, Errors int +} + +// 回调隔离查询和投递,使分页及部分失败可以在不连库、不发送消息的情况下测试。 +type monthlyExpiryRunner struct { + companies func(context.Context, int64) ([]monthlyExpiryCompany, error) + summaries func(context.Context, monthlyExpiryCompany, time.Time, time.Time) ([]monthlyExpirySummary, error) + leader func(context.Context, monthlyExpiryCompany) (*monthlyExpiryLeader, error) + deliver func(context.Context, monthlyExpiryNotice, string) (bool, error) +} + +// NotifyMonthlyExpiry 由独立的 NotifyMonthlyPackageExpiry 任务在每月15日09:00调用。 +// 实时读取 flow_pkg_end_at(包括已办理的未来续费),不依赖 Redis 逐卡候选。 +func (s *sSimWarning) NotifyMonthlyExpiry(ctx context.Context) { + if ctx == nil { + ctx = context.Background() + } + runTime := time.Now().In(monthlyExpiryLocation) + // 企业顺序发送,短信最多每秒5次,避免企业多时集中冲击网关。 + ticker := time.NewTicker(200 * time.Millisecond) + defer ticker.Stop() + runner := monthlyExpiryRunner{ + companies: queryMonthlyExpiryCompanies, + summaries: queryMonthlyExpirySummaries, + leader: queryMonthlyExpiryLeader, + deliver: func(ctx context.Context, notice monthlyExpiryNotice, channel string) (bool, error) { + return deliverMonthlyExpiry(ctx, notice, channel, func() error { + if channel == "platform" { + return writeMonthlyExpiryMessage(ctx, notice) + } + select { + case <-ctx.Done(): + return ctx.Err() + case <-ticker.C: + return sendMonthlyExpirySMS(ctx, notice) + } + }) + }, + } + stats, err := runner.run(ctx, runTime) + if err != nil { + g.Log().Errorf(ctx, "SimWarning.MonthlyExpiry interrupted, err:%+v", err) + } + g.Log().Infof(ctx, "SimWarning.MonthlyExpiry end, runMonth:%s, companies:%d, groups:%d, sent:%d, duplicate:%d, errors:%d", + runTime.Format("2006-01"), stats.Companies, stats.Groups, stats.Sent, stats.Duplicate, stats.Errors) +} + +func (r monthlyExpiryRunner) run(ctx context.Context, runTime time.Time) (monthlyExpiryStats, error) { + stats := monthlyExpiryStats{} + runTime = runTime.In(monthlyExpiryLocation) + begin := calendarMonthBegin(runTime) + end := begin.AddDate(0, 2, 0) + lastID := int64(0) + for { + if err := ctx.Err(); err != nil { + return stats, err + } + companies, err := r.companies(ctx, lastID) + if err != nil { + return stats, err + } + if len(companies) == 0 { + return stats, nil + } + for _, company := range companies { + if err := ctx.Err(); err != nil { + return stats, err + } + lastID = company.DeptID + stats.Companies++ + summaries, err := r.summaries(ctx, company, begin, end) + if err != nil { + stats.Errors++ + g.Log().Errorf(ctx, "SimWarning.MonthlyExpiry count failed, tenant:%s, dept:%d, err:%+v", company.TenantID, company.DeptID, err) + continue + } + if len(summaries) == 0 { + continue + } + leader, err := r.leader(ctx, company) + if err != nil || leader == nil || leader.UserID <= 0 { + stats.Errors++ + g.Log().Warningf(ctx, "SimWarning.MonthlyExpiry leader unavailable, tenant:%s, dept:%d, leader:%d, err:%+v", company.TenantID, company.DeptID, company.LeaderID, err) + continue + } + for _, summary := range summaries { + if summary.Count <= 0 { + continue + } + stats.Groups++ + notice := monthlyExpiryNotice{Company: company, Summary: summary, Leader: *leader, RunTime: runTime} + for _, channel := range []string{"platform", "sms"} { + if err := ctx.Err(); err != nil { + return stats, err + } + sent, err := r.deliver(ctx, notice, channel) + if err != nil { + stats.Errors++ + if sent { + stats.Sent++ + g.Log().Errorf(ctx, "SimWarning.MonthlyExpiry sent but dedupe mark failed, tenant:%s, dept:%d, month:%s, channel:%s, err:%+v", company.TenantID, company.DeptID, summary.Month, channel, err) + } else { + g.Log().Errorf(ctx, "SimWarning.MonthlyExpiry delivery failed, tenant:%s, dept:%d, month:%s, channel:%s, err:%+v", company.TenantID, company.DeptID, summary.Month, channel, err) + } + } else if sent { + stats.Sent++ + g.Log().Infof(ctx, "SimWarning.MonthlyExpiry sent, tenant:%s, dept:%d, month:%s, channel:%s", company.TenantID, company.DeptID, summary.Month, channel) + } else { + stats.Duplicate++ + g.Log().Infof(ctx, "SimWarning.MonthlyExpiry duplicate, tenant:%s, dept:%d, month:%s, channel:%s", company.TenantID, company.DeptID, summary.Month, channel) + } + } + } + } + if len(companies) < monthlyExpiryPageSize { + return stats, nil + } + } +} + +func queryMonthlyExpiryCompanies(ctx context.Context, lastID int64) ([]monthlyExpiryCompany, error) { + var rows []monthlyExpiryCompany + err := dao.SysDept.Ctx(ctx). + Fields("dept_id, tenant_id, leader"). + WhereGT("dept_id", lastID). + Where("dept_type", 1). + Where("status", consts.SysDeptStatusNormal). + WhereNull("deleted_at"). + OrderAsc("dept_id").Limit(monthlyExpiryPageSize).Scan(&rows) + return rows, err +} + +func queryMonthlyExpirySummaries(ctx context.Context, company monthlyExpiryCompany, begin, end time.Time) ([]monthlyExpirySummary, error) { + var rows []monthlyExpirySummary + // 按当前归属企业计数,不加载卡明细,不加旧规则的卡状态/套餐状态/池卡限制。 + // 时间条件保留原列以使用已有 dept_id / flow_pkg_end_at 索引。 + err := dao.TSimCardInfo.Ctx(ctx). + Fields("DATE_FORMAT(flow_pkg_end_at, '%Y-%m') AS expiry_month, COUNT(*) AS card_count, DATE_FORMAT(MIN(flow_pkg_end_at), '%Y-%m-%d') AS first_date, DATE_FORMAT(MAX(flow_pkg_end_at), '%Y-%m-%d') AS last_date"). + Where("tenant_id", company.TenantID).Where("dept_id", company.DeptID). + WhereGTE("flow_pkg_end_at", begin.Format("2006-01-02 15:04:05")). + WhereLT("flow_pkg_end_at", end.Format("2006-01-02 15:04:05")). + Group("expiry_month").OrderAsc("expiry_month").Scan(&rows) + return rows, err +} + +func queryMonthlyExpiryLeader(ctx context.Context, company monthlyExpiryCompany) (*monthlyExpiryLeader, error) { + if company.LeaderID <= 0 { + return nil, nil + } + var row *monthlyExpiryLeader + err := dao.SysUser.Ctx(ctx).Fields("user_id, phonenumber"). + Where("tenant_id", company.TenantID).Where("user_id", company.LeaderID). + Where("user_type", consts.SysUserTypeSys).Where("status", consts.SysDeptStatusNormal). + WhereNull("deleted_at").Scan(&row) + return row, err +} + +func (n monthlyExpiryNotice) nextMonth() bool { + return n.Summary.Month == calendarMonthBegin(n.RunTime.In(monthlyExpiryLocation)).AddDate(0, 1, 0).Format("2006-01") +} + +func (n monthlyExpiryNotice) expiryDateText() string { + if n.Summary.FirstDate == n.Summary.LastDate { + return n.Summary.FirstDate + } + return n.Summary.FirstDate + "至" + n.Summary.LastDate +} + +func (n monthlyExpiryNotice) content() string { + deadline, stopText := "本月", "本月底" + if n.nextMonth() { + deadline, stopText = "次月", "" + } + return fmt.Sprintf("尊敬的客户:\n您好!您名下共有【%d】张物联网卡将于【%s】套餐到期,请及时办理续费,避免业务中断。\n请最晚于%s25日前完成续费,续费后卡片可正常保持通信使用。若套餐到期未续费,%s卡片将执行停机,后续会做销户处理,销户后卡片将无法恢复使用,请您知悉。\n如有疑问,可联系您的销售经理咨询。", n.Summary.Count, n.expiryDateText(), deadline, stopText) +} + +func writeMonthlyExpiryMessage(ctx context.Context, n monthlyExpiryNotice) error { + messageCtx := contexts.Set(ctx, &model.Context{User: &model.Identity{BaseClaims: model.BaseClaims{ + TenantId: n.Company.TenantID, DeptId: n.Company.DeptID, + }}}) + return service.CommonTMessageLog().Edit(messageCtx, monthlyExpiryMessageParam(n)) +} + +func monthlyExpiryMessageParam(n monthlyExpiryNotice) *model.TMessageLogEditParam { + messageType, sendUserType, isRecall := uint(1), uint(1), uint(0) + title, content, link := "到期提醒", n.content(), "" + return &model.TMessageLogEditParam{ + MessageType: &messageType, SendUserType: &sendUserType, + UserIds: gjson.New([]int64{n.Leader.UserID}), + MessageTitle: &title, MessageContent: &content, MessageLink: &link, IsRecall: &isRecall, + } +} + +func sendMonthlyExpirySMS(ctx context.Context, n monthlyExpiryNotice) error { + phone := strings.TrimSpace(n.Leader.Phone) + if phone == "" { + return fmt.Errorf("企业负责人未设置手机号") + } + code, key := monthlyExpiryCurrentSMSCode, "warning.expire.monthly.currentMonthParamsTemplate" + if n.nextMonth() { + code, key = monthlyExpiryNextSMSCode, "warning.expire.monthly.nextMonthParamsTemplate" + } + value, err := g.Cfg().Get(ctx, key) + if err != nil { + return err + } + content, err := renderMonthlyExpirySMSParams(value.String(), n.Summary.Count, n.expiryDateText()) + if err != nil { + return fmt.Errorf("短信模板%s参数未就绪(配置%s): %w", code, key, err) + } + // 模板和签名均已核实,供应商凭证由短信服务持有;不改通用流量预警模板。 + sent, err := smsgateway.Send(ctx, smsgateway.Request{ + TemplateID: code, SignName: monthlyExpirySMSSignName, PhoneNumber: phone, MsgContent: content, + }) + if err != nil { + return err + } + if !sent { + return fmt.Errorf("短信网关未启用或未配置地址") + } + return nil +} + +// 核实供应商变量名后配置 JSON 字符串对象,值中使用 {count} 和 {expireDate}。 +// 未核实前保持配置为空,禁止猜变量名或用验证码模板替代。 +func renderMonthlyExpirySMSParams(template string, count int64, expiryDate string) (string, error) { + if strings.TrimSpace(template) == "" { + return "", fmt.Errorf("请先核实已审核模板变量并配置参数模板") + } + var params map[string]string + if err := json.Unmarshal([]byte(template), ¶ms); err != nil || len(params) == 0 { + return "", fmt.Errorf("参数模板必须为非空 JSON 字符串对象") + } + replacer := strings.NewReplacer("{count}", strconv.FormatInt(count, 10), "{expireDate}", expiryDate) + for key, value := range params { + params[key] = replacer.Replace(value) + } + encoded, err := json.Marshal(params) + return string(encoded), err +} + +func monthlyExpirySentKey(n monthlyExpiryNotice, channel string) string { + return fmt.Sprintf("iotlcm:monthly-expiry:%s:%s:%d:%s:%s", n.RunTime.In(monthlyExpiryLocation).Format("200601"), n.Company.TenantID, n.Company.DeptID, n.Summary.Month, channel) +} + +func deliverMonthlyExpiry(ctx context.Context, n monthlyExpiryNotice, channel string, send func() error) (bool, error) { + key := monthlyExpirySentKey(n, channel) + value, err := g.Redis().Do(ctx, "GET", key) + if err != nil { + return false, err + } + if value.String() == "sent" { + return false, nil + } + if err := send(); err != nil { + return false, err + } + // 外层任务锁串行化扫描;成功才记去重,两渠道互不阻断。 + // 外部网关和 Redis 无分布式事务,失败/未知结果须核对后手动重跑,不自动重试短信。 + _, err = g.Redis().Do(ctx, "SET", key, "sent", "EX", int(monthlyExpirySentTTL.Seconds())) + return true, err // 已投递;若标记失败,上层分别记录成功投递与去重错误。 +} diff --git a/server/internal/logic/simwarning/monthly_expiry_test.go b/server/internal/logic/simwarning/monthly_expiry_test.go new file mode 100644 index 0000000..6d57eca --- /dev/null +++ b/server/internal/logic/simwarning/monthly_expiry_test.go @@ -0,0 +1,220 @@ +package simwarning + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/gogf/gf/v2/frame/g" + "github.com/gogf/gf/v2/os/gcfg" +) + +func TestMonthlyExpiryRunPagesAndPartialFailures(t *testing.T) { + firstPage := make([]monthlyExpiryCompany, monthlyExpiryPageSize) + for i := range firstPage { + firstPage[i] = monthlyExpiryCompany{TenantID: "000000", DeptID: int64(i + 1), LeaderID: int64(i + 1001)} + } + calls := make(map[string]int) + pages := make([]int64, 0, 2) + runner := monthlyExpiryRunner{ + companies: func(_ context.Context, lastID int64) ([]monthlyExpiryCompany, error) { + pages = append(pages, lastID) + if lastID == 0 { + return firstPage, nil + } + if lastID == int64(monthlyExpiryPageSize) { + return []monthlyExpiryCompany{{TenantID: "000000", DeptID: 101, LeaderID: 1101}}, nil + } + return nil, fmt.Errorf("unexpected cursor %d", lastID) + }, + summaries: func(_ context.Context, company monthlyExpiryCompany, begin, end time.Time) ([]monthlyExpirySummary, error) { + if begin.Format("2006-01-02") != "2026-12-01" || end.Format("2006-01-02") != "2027-02-01" { + t.Errorf("wrong query window %s..%s", begin, end) + } + if company.DeptID == 2 { + return nil, errors.New("count failed") + } + return []monthlyExpirySummary{{Month: "2026-12", Count: 3, FirstDate: "2026-12-01", LastDate: "2026-12-31"}, + {Month: "2027-01", Count: 6, FirstDate: "2027-01-31", LastDate: "2027-01-31"}}, nil + }, + leader: func(_ context.Context, company monthlyExpiryCompany) (*monthlyExpiryLeader, error) { + if company.DeptID == 3 { + return nil, nil + } + return &monthlyExpiryLeader{UserID: company.LeaderID, Phone: "13800000000"}, nil + }, + deliver: func(_ context.Context, notice monthlyExpiryNotice, channel string) (bool, error) { + calls[notice.Summary.Month+"/"+channel]++ + if notice.Company.DeptID == 1 && channel == "sms" { + return false, errors.New("sms failed") + } + if notice.Company.DeptID == 1 && notice.Summary.Month == "2027-01" && channel == "platform" { + return false, nil + } + if notice.Company.DeptID == 4 && notice.Summary.Month == "2026-12" && channel == "platform" { + return true, errors.New("dedupe mark failed after delivery") + } + return true, nil + }, + } + stats, err := runner.run(context.Background(), time.Date(2026, 12, 15, 9, 0, 0, 0, monthlyExpiryLocation)) + if err != nil { + t.Fatal(err) + } + if stats.Companies != 101 || stats.Groups != 198 || stats.Sent != 393 || stats.Duplicate != 1 || stats.Errors != 5 { + t.Fatalf("unexpected stats: %+v", stats) + } + if len(pages) != 2 || pages[0] != 0 || pages[1] != 100 { + t.Fatalf("wrong page cursors: %v", pages) + } + if calls["2026-12/platform"] != 99 || calls["2027-01/platform"] != 99 { + t.Fatalf("wrong delivered group counts: %+v", calls) + } +} + +func TestMonthlyExpiryNoticeContentAndRecipient(t *testing.T) { + base := monthlyExpiryNotice{ + Company: monthlyExpiryCompany{TenantID: "000000", DeptID: 117, LeaderID: 12}, + Leader: monthlyExpiryLeader{UserID: 12}, + RunTime: time.Date(2026, 10, 15, 9, 0, 0, 0, monthlyExpiryLocation), + Summary: monthlyExpirySummary{Month: "2026-10", Count: 7, FirstDate: "2026-10-21", LastDate: "2026-10-31"}, + } + current := base.content() + for _, text := range []string{"【7】张", "2026-10-21至2026-10-31", "本月25日前", "本月底卡片将执行停机"} { + if !strings.Contains(current, text) { + t.Errorf("current notice missing %q: %s", text, current) + } + } + param := monthlyExpiryMessageParam(base) + if *param.MessageType != 1 || *param.SendUserType != 1 || *param.MessageTitle != "到期提醒" || !strings.Contains(param.UserIds.String(), "12") { + t.Fatalf("wrong targeted system message: %+v", param) + } + base.Summary = monthlyExpirySummary{Month: "2026-11", Count: 9, FirstDate: "2026-11-30", LastDate: "2026-11-30"} + if got := base.content(); !strings.Contains(got, "次月25日前") || strings.Contains(got, "本月底卡片") { + t.Fatalf("next month notice incorrect: %s", got) + } + if base.expiryDateText() != "2026-11-30" { + t.Errorf("single-day range not collapsed: %s", base.expiryDateText()) + } + if key := monthlyExpirySentKey(base, "sms"); !strings.Contains(key, ":000000:117:2026-11:sms") { + t.Errorf("missing dedupe dimensions: %s", key) + } +} + +func TestMonthlyExpirySMSParamsRequireConfirmedTemplate(t *testing.T) { + for _, invalid := range []string{"", "invalid", "{}", `{"count":7}`} { + if _, err := renderMonthlyExpirySMSParams(invalid, 3, "2026-11-30"); err == nil { + t.Errorf("unconfirmed/invalid template accepted: %q", invalid) + } + } + content, err := renderMonthlyExpirySMSParams(`{"actualCount":"{count}","actualExpire":"{expireDate}"}`, 5, "2026-11-01至2026-11-30") + if err != nil { + t.Fatal(err) + } + var params map[string]string + if err := json.Unmarshal([]byte(content), ¶ms); err != nil || params["actualCount"] != "5" || params["actualExpire"] != "2026-11-01至2026-11-30" { + t.Fatalf("incorrect template params: %s, %v", content, err) + } +} + +func TestMonthlyExpirySMSLocalGateway(t *testing.T) { + // 所有请求只发到本机测试服务;清除环境覆盖,绝不读取真实网关配置。 + for _, name := range []string{"IOTLCM_SMS_GATEWAY_ENABLED", "IOTLCM_SMS_GATEWAY_URL", "IOTLCM_SMS_GATEWAY_GRPC_ADDR", "IOTLCM_SMS_GATEWAY_TIMEOUT", "IOTLCM_SMS_GATEWAY_ROUTER_ID"} { + t.Setenv(name, "") + } + requests := make([]map[string]map[string]string, 0, 3) + fail := false + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var body map[string]map[string]string + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + t.Errorf("decode request: %v", err) + } + requests = append(requests, body) + if fail { + _, _ = w.Write([]byte(`{"code":502,"message":"supplier failure"}`)) + return + } + _, _ = w.Write([]byte(`{"code":0,"message":"OK"}`)) + })) + defer server.Close() + adapter, err := gcfg.NewAdapterContent() + if err != nil { + t.Fatal(err) + } + original := g.Cfg().GetAdapter() + g.Cfg().SetAdapter(adapter) + t.Cleanup(func() { g.Cfg().SetAdapter(original) }) + configure := func(enabled bool, template string) { + t.Helper() + data, err := json.Marshal(g.Map{ + "sms": g.Map{"gateway": g.Map{"enabled": enabled, "url": server.URL, "timeout": "1s"}}, + "warning": g.Map{"expire": g.Map{"monthly": g.Map{ + "currentMonthParamsTemplate": template, "nextMonthParamsTemplate": template, + }}}, + }) + if err != nil { + t.Fatal(err) + } + if err := adapter.SetContent(string(data)); err != nil { + t.Fatal(err) + } + } + configure(true, `{"card_num":"{count}","expire_date":"{expireDate}"}`) + notice := monthlyExpiryNotice{ + RunTime: time.Date(2026, 10, 15, 9, 0, 0, 0, monthlyExpiryLocation), + Leader: monthlyExpiryLeader{UserID: 12, Phone: "13800000000"}, + Summary: monthlyExpirySummary{Month: "2026-10", Count: 7, FirstDate: "2026-10-31", LastDate: "2026-10-31"}, + } + if err := sendMonthlyExpirySMS(context.Background(), notice); err != nil { + t.Fatal(err) + } + notice.Summary = monthlyExpirySummary{Month: "2026-11", Count: 9, FirstDate: "2026-11-01", LastDate: "2026-11-30"} + if err := sendMonthlyExpirySMS(context.Background(), notice); err != nil { + t.Fatal(err) + } + if len(requests) != 2 || requests[0]["config"]["templateId"] != "SMS_512596191" || requests[1]["config"]["templateId"] != "SMS_512646156" { + t.Fatalf("incorrect gateway templates: %+v", requests) + } + for _, request := range requests { + if request["config"]["signName"] != "示例企业" { + t.Fatalf("incorrect gateway sign: %+v", request) + } + } + var params map[string]string + if err := json.Unmarshal([]byte(requests[1]["params"]["msgContent"]), ¶ms); err != nil || params["card_num"] != "9" || params["expire_date"] != "2026-11-01至2026-11-30" { + t.Fatalf("incorrect gateway params: %+v, %v", params, err) + } + fail = true + if err := sendMonthlyExpirySMS(context.Background(), notice); err == nil { + t.Fatal("gateway business failure must propagate") + } + configure(true, "") + if err := sendMonthlyExpirySMS(context.Background(), notice); err == nil || len(requests) != 3 { + t.Fatal("unconfirmed template must block before calling gateway") + } + configure(false, `{"card_num":"{count}","expire_date":"{expireDate}"}`) + if err := sendMonthlyExpirySMS(context.Background(), notice); err == nil || len(requests) != 3 { + t.Fatal("disabled gateway must not count as successful delivery") + } +} + +func TestMonthlyExpiryCancellationStopsBeforeDelivery(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + cancel() + runner := monthlyExpiryRunner{ + companies: func(context.Context, int64) ([]monthlyExpiryCompany, error) { + t.Fatal("canceled run should not query") + return nil, nil + }, + } + _, err := runner.run(ctx, time.Now()) + if !errors.Is(err, context.Canceled) { + t.Fatalf("expected cancellation, got %v", err) + } +} diff --git a/server/internal/logic/simwarning/scanner.go b/server/internal/logic/simwarning/scanner.go index 28646ec..ae6d5d4 100644 --- a/server/internal/logic/simwarning/scanner.go +++ b/server/internal/logic/simwarning/scanner.go @@ -4,6 +4,7 @@ import ( "context" "errors" "git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/dao" + "git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/logic/warningnotify" "git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/model/entity" "git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/service" "strconv" @@ -46,16 +47,17 @@ func (s *sSimWarning) scan(ctx context.Context, scope scanScope) { scope = normalizeScanScope(scope) runTime := time.Now() stats := scanStats{} - pendingWarnings := make([]pendingWarning, 0) + collector := warningnotify.NewCollector() g.Log().Infof(ctx, "SimWarning.Scan begin, runTime:%s, scope:%s, mode:warn_log_only", runTime.Format("2006-01-02 15:04:05"), scope) + var lastID int64 for page := 1; ; page++ { if err := ctx.Err(); err != nil { stats.ErrorCount++ g.Log().Warningf(ctx, "SimWarning.Scan canceled, page:%d, scope:%s, err:%+v", page, scope, err) break } - configs, err := s.queryConfigs(ctx, page, scope) + configs, err := s.queryConfigs(ctx, lastID, scope) if err != nil { stats.ErrorCount++ g.Log().Errorf(ctx, "SimWarning.Scan query configs failed, page:%d, err:%+v", page, err) @@ -64,6 +66,7 @@ func (s *sSimWarning) scan(ctx context.Context, scope scanScope) { if len(configs) == 0 { break } + lastID = configs[len(configs)-1].Id stats.ConfigCount += len(configs) cardMap, err := s.queryCards(ctx, configs) @@ -78,19 +81,30 @@ func (s *sSimWarning) scan(ctx context.Context, scope scanScope) { g.Log().Errorf(ctx, "SimWarning.Scan query flow pools failed, page:%d, err:%+v", page, err) break } + pendingWarnings := make([]pendingWarning, 0, len(configs)) for _, cfg := range configs { if pending := s.scanConfig(ctx, cfg, cardMap[itemKey(cfg.TenantId, cfg.ItemId)], poolMap[itemKey(cfg.TenantId, cfg.ItemId)], runTime, &stats); pending != nil { pendingWarnings = append(pendingWarnings, *pending) } } + stored := s.storePendingWarningsWithoutNotify(ctx, pendingWarnings, runTime, &stats) + events := make([]warningnotify.Event, 0, len(stored)) + for _, item := range stored { + events = append(events, warningNotifyEvent(ctx, item, runTime)) + s.scanAutoAction(ctx, item.Hit, item.Record.WarnLogID, runTime, &stats) + } + if err := collector.Add(ctx, events); err != nil { + stats.ErrorCount++ + g.Log().Errorf(ctx, "warning recipient aggregation failed: %+v", err) + } if len(configs) < scanPageSize { break } } - storedWarnings := s.storePendingWarnings(ctx, pendingWarnings, runTime, &stats) - for _, stored := range storedWarnings { - s.scanAutoAction(ctx, stored.Hit, stored.Record.WarnLogID, runTime, &stats) + if err := collector.Flush(ctx); err != nil { + stats.ErrorCount++ + g.Log().Errorf(ctx, "warning notification failed: %+v", err) } g.Log().Infof( @@ -107,7 +121,7 @@ func (s *sSimWarning) scan(ctx context.Context, scope scanScope) { ) } -func (s *sSimWarning) queryConfigs(ctx context.Context, page int, scope scanScope) ([]warnConfig, error) { +func (s *sSimWarning) queryConfigs(ctx context.Context, lastID int64, scope scanScope) ([]warnConfig, error) { scope = normalizeScanScope(scope) columns := dao.TWarnConfig.Columns() mod := dao.TWarnConfig.Ctx(ctx). @@ -162,8 +176,8 @@ func (s *sSimWarning) queryConfigs(ctx context.Context, page int, scope scanScop } entities := make([]entity.TWarnConfig, 0) - err := mod.OrderAsc(columns.Id). - Page(page, scanPageSize). + err := mod.WhereGT(columns.Id, lastID).OrderAsc(columns.Id). + Limit(scanPageSize). Scan(&entities) if err != nil { return nil, err @@ -279,6 +293,12 @@ func (s *sSimWarning) scanFlowPoolConfig(ctx context.Context, cfg warnConfig, po } func (s *sSimWarning) storePendingWarnings(ctx context.Context, pendingWarnings []pendingWarning, runTime time.Time, stats *scanStats) []storedWarning { + stored := s.storePendingWarningsWithoutNotify(ctx, pendingWarnings, runTime, stats) + notifyStoredWarnings(ctx, stored, runTime) + return stored +} + +func (s *sSimWarning) storePendingWarningsWithoutNotify(ctx context.Context, pendingWarnings []pendingWarning, runTime time.Time, stats *scanStats) []storedWarning { if len(pendingWarnings) == 0 { return nil } @@ -298,7 +318,6 @@ func (s *sSimWarning) storePendingWarnings(ctx context.Context, pendingWarnings for _, item := range storedWarnings { logWarningHit(ctx, item.Hit, item.Record) } - notifyStoredWarnings(ctx, storedWarnings, runTime) return storedWarnings } diff --git a/server/internal/logic/system/sys_auth.go b/server/internal/logic/system/sys_auth.go index a75e644..c3a9746 100644 --- a/server/internal/logic/system/sys_auth.go +++ b/server/internal/logic/system/sys_auth.go @@ -1,7 +1,7 @@ // package system // @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 system @@ -195,7 +195,7 @@ func (s *sSysAuth) GenerateToken(ctx context.Context, user *model.LoginUserOut) ets := g.Cfg().MustGet(ctx, "jwt.expiresTime", "7d").String() bts := g.Cfg().MustGet(ctx, "jwt.bufferTime", "1d").String() iss := g.Cfg().MustGet(ctx, "jwt.issuer", "IotLCM").String() - sk := g.Cfg().MustGet(ctx, "jwt.signingKey", "${JWT_SIGNING_KEY}").String() + sk := g.Cfg().MustGet(ctx, "jwt.signingKey", "39c54195e73304e74a8429b178965865").String() et, _ := utility.ParseDuration(ets) bt, _ := utility.ParseDuration(bts) g.Log().Infof(ctx, "sSysAuth.GenerateToken 生成Token: user:%+v, et:%s, bt:%s, iss:%s, sk:%s", user, et, bt, iss, sk) @@ -265,7 +265,7 @@ func (s *sSysAuth) GenerateToken(ctx context.Context, user *model.LoginUserOut) // 解析Token func (s *sSysAuth) ParseToken(ctx context.Context, token string) (claims *model.CustomClaims, err error) { - sk := g.Cfg().MustGet(ctx, "jwt.signingKey", "${JWT_SIGNING_KEY}").String() + sk := g.Cfg().MustGet(ctx, "jwt.signingKey", "39c54195e73304e74a8429b178965865").String() t, err := jwt.ParseWithClaims(token, &model.CustomClaims{}, func(token *jwt.Token) (interface{}, error) { return []byte(sk), nil }) @@ -287,7 +287,7 @@ func (s *sSysAuth) ParseToken(ctx context.Context, token string) (claims *model. // 删除Token func (s *sSysAuth) DeleteToken(ctx context.Context, token string) (err error) { - sk := g.Cfg().MustGet(ctx, "jwt.signingKey", "${JWT_SIGNING_KEY}").String() + sk := g.Cfg().MustGet(ctx, "jwt.signingKey", "39c54195e73304e74a8429b178965865").String() t, err := jwt.ParseWithClaims(token, &model.CustomClaims{}, func(token *jwt.Token) (interface{}, error) { return []byte(sk), nil }) @@ -341,11 +341,6 @@ func (s *sSysAuth) GetUserAccessCodeList(ctx context.Context, userId int64) (acc accessCodeList = make([]string, 0) menuRoleAccessCodeList = make([]string, 0) roleDataScopeMap := make(map[int64]string) - template, templateErr := rolepolicy.Template(ctx) - if templateErr != nil { - return nil, nil, templateErr - } - managed := template != nil && template.TenantId == contexts.GetTenantId(ctx) && template.DeptId != contexts.GetDeptId(ctx) // 角色权限码 roleIds, err := service.SysUser().GetUserRoleIds(ctx, userId) if err != nil { @@ -353,14 +348,28 @@ func (s *sSysAuth) GetUserAccessCodeList(ctx context.Context, userId int64) (acc } var effectiveRoles []*entity.SysRole if len(roleIds) > 0 { - if err := dao.SysRole.Ctx(ctx).Where("tenant_id", contexts.GetTenantId(ctx)).WhereIn("role_id", roleIds).Where("status", "0").Scan(&effectiveRoles); err != nil { + if err := dao.SysRole.Ctx(ctx).Master().Where("tenant_id", contexts.GetTenantId(ctx)).WhereIn("role_id", roleIds).Where("status", "0").Scan(&effectiveRoles); err != nil { return nil, nil, err } } roleIds = nil + if contexts.GetTenantId(ctx) != consts.DefaultSystemTenantCode { + effectiveRoles = slices.DeleteFunc(effectiveRoles, func(role *entity.SysRole) bool { + return role.RoleId == consts.SuperAdminRoleId + }) + } for _, role := range effectiveRoles { roleIds = append(roleIds, role.RoleId) } + isPlatformSuperAdmin := contexts.GetTenantId(ctx) == consts.DefaultSystemTenantCode && slices.Contains(roleIds, consts.SuperAdminRoleId) + managed := false + if !isPlatformSuperAdmin { + template, templateErr := rolepolicy.Template(ctx) + if templateErr != nil { + return nil, nil, templateErr + } + managed = template != nil && template.TenantId == contexts.GetTenantId(ctx) && template.DeptId != contexts.GetDeptId(ctx) + } for _, role := range effectiveRoles { accessCodeList = append(accessCodeList, consts.SysCheckPermissionRolePrefix+role.RoleKey) @@ -391,7 +400,7 @@ func (s *sSysAuth) GetUserAccessCodeList(ctx context.Context, userId int64) (acc for _, menu := range rmList { menuIds = append(menuIds, menu.MenuId) } - if !contexts.IsSuperAdmin(ctx) { + if !isPlatformSuperAdmin { menuIds, err = rolepolicy.RestrictMenus(ctx, contexts.GetTenantId(ctx), contexts.GetDeptId(ctx), menuIds) if err != nil { return nil, nil, err diff --git a/server/internal/logic/system/sys_captcha.go b/server/internal/logic/system/sys_captcha.go index 8bc4290..a356f1e 100644 --- a/server/internal/logic/system/sys_captcha.go +++ b/server/internal/logic/system/sys_captcha.go @@ -1,7 +1,7 @@ // package system // @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 system diff --git a/server/internal/logic/system/sys_client.go b/server/internal/logic/system/sys_client.go index 72dbbc7..e046412 100644 --- a/server/internal/logic/system/sys_client.go +++ b/server/internal/logic/system/sys_client.go @@ -1,7 +1,7 @@ // package system // @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 system diff --git a/server/internal/logic/system/sys_config.go b/server/internal/logic/system/sys_config.go index 09abdc2..842fac3 100644 --- a/server/internal/logic/system/sys_config.go +++ b/server/internal/logic/system/sys_config.go @@ -1,7 +1,7 @@ // package system // @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 system @@ -131,33 +131,25 @@ func (s *sSysConfig) Add(ctx context.Context, param *model.SysConfigAddParam) (o if insertErr != nil { return insertErr } - if param.ConfigKey == rolepolicy.TemplateConfigKey { - template, resolveErr := rolepolicy.Template(ctx) - if resolveErr != nil { - return resolveErr - } - _ = template - return nil - } return nil }) if err != nil { return nil, err } - + event.EventsInstance().Emit(ctx, consts.EventKeyDBSysConfigCreate, lastInsertId, data.TenantId, data.ConfigKey) if param.ConfigKey == rolepolicy.TemplateConfigKey { template, resolveErr := rolepolicy.Template(ctx) if resolveErr != nil { return nil, resolveErr } - if err = rolesync.EnqueueRole(ctx, template.RoleId, contexts.GetUserId(ctx)); err != nil { + if err := rolesync.EnqueueRole(ctx, template.RoleId, contexts.GetUserId(ctx)); err != nil { return nil, err } } + output = &model.SysConfigAddModel{ ConfigId: lastInsertId, } - event.EventsInstance().Emit(ctx, consts.EventKeyDBSysConfigCreate, lastInsertId, data.TenantId, data.ConfigKey) return } @@ -207,32 +199,25 @@ func (s *sSysConfig) Edit(ctx context.Context, param *model.SysConfigEditParam) if _, updateErr := m.Ctx(ctx).Data(data).Where(dao.SysConfig.Columns().ConfigId, param.ConfigId).Update(); updateErr != nil { return updateErr } - if nextKey == rolepolicy.TemplateConfigKey { - template, resolveErr := rolepolicy.Template(ctx) - if resolveErr != nil { - return resolveErr - } - return rolesync.EnqueueRole(ctx, template.RoleId, contexts.GetUserId(ctx)) - } return nil }) if err != nil { return nil, err } - + event.EventsInstance().Emit(ctx, consts.EventKeyDBSysConfigUpdate, param.ConfigId, contexts.GetTenantId(ctx), nextKey) if nextKey == rolepolicy.TemplateConfigKey { template, resolveErr := rolepolicy.Template(ctx) if resolveErr != nil { return nil, resolveErr } - if err = rolesync.EnqueueRole(ctx, template.RoleId, contexts.GetUserId(ctx)); err != nil { + if err := rolesync.EnqueueRole(ctx, template.RoleId, contexts.GetUserId(ctx)); err != nil { return nil, err } } + output = &model.SysConfigEditModel{ ConfigId: param.ConfigId, } - event.EventsInstance().Emit(ctx, consts.EventKeyDBSysConfigUpdate, param.ConfigId, contexts.GetTenantId(ctx), nextKey) return } diff --git a/server/internal/logic/system/sys_dept.go b/server/internal/logic/system/sys_dept.go index 9ab8ec6..1b4ef76 100644 --- a/server/internal/logic/system/sys_dept.go +++ b/server/internal/logic/system/sys_dept.go @@ -1,7 +1,7 @@ // package system // @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 system @@ -145,6 +145,9 @@ func (l *sSysDept) GetDeptFullTree(ctx context.Context, query model.SysDeptFullT if err != nil { return nil, err } + if err = validateDeptHierarchy(depts); err != nil { + return nil, err + } return buildSysDeptFullTree(depts), nil } @@ -271,6 +274,9 @@ func (l *sSysDept) GetDeptTree(ctx context.Context, query model.SysDeptTreeParam if err != nil { return nil, err } + if err = validateDeptHierarchy(depts); err != nil { + return nil, err + } if len(depts) == 0 { return make([]*model.SysDeptTreeModel, 0), nil } @@ -401,16 +407,46 @@ func (l *sSysDept) deptTreeItem(dept *model.SysDeptListModel, ancestors string) // 递归构建结构 func (l *sSysDept) RecursionDeptIds(ctx context.Context, parentId int64, deptList []*model.SysDeptListModel, data *[]int64) (err error) { - for _, dept := range deptList { - if dept.ParentId == parentId { - *data = append(*data, dept.DeptId) - err := l.RecursionDeptIds(ctx, dept.DeptId, deptList, data) - if err != nil { - return err + if err = validateDeptHierarchy(deptList); err != nil { + return err + } + var walk func(int64) + walk = func(currentId int64) { + for _, dept := range deptList { + if dept.ParentId == currentId { + *data = append(*data, dept.DeptId) + walk(dept.DeptId) } } } - return + walk(parentId) + return nil +} + +func validateDeptHierarchy(depts []*model.SysDeptListModel) error { + parents := make(map[int64]int64, len(depts)) + for _, dept := range depts { + parents[dept.DeptId] = dept.ParentId + } + checked := make(map[int64]bool, len(depts)) + for deptId := range parents { + seen := make(map[int64]bool) + for currentId := deptId; currentId != 0 && !checked[currentId]; { + if seen[currentId] { + return gerror.New("组织层级存在循环引用") + } + seen[currentId] = true + parentId, exists := parents[currentId] + if !exists { + break + } + currentId = parentId + } + for visitedId := range seen { + checked[visitedId] = true + } + } + return nil } // 根据父部门id获取部门列表 @@ -434,7 +470,7 @@ func (l *sSysDept) AddDept(ctx context.Context, dept *model.SysDeptAddModel) (de if user == nil { return 0, gerror.NewCode(consts.CodeLoginExpired, "登录已过期") } - if dept.DeptType == 1 { + if dept.DeptType == 1 && dept.ParentId != 0 { isCompany, err := l.ValidateParentIsCompany(ctx, dept.ParentId) if err != nil { g.Log().Errorf(ctx, "sSysDept.AddDept ValidateParentIsCompany err: %v, parentId:%d, dept:%+v", err, dept.ParentId, dept) @@ -486,8 +522,20 @@ func (l *sSysDept) EditDept(ctx context.Context, dept *model.SysDeptEditModel) ( newParentId := existingDept.ParentId if dept.ParentId != nil { newParentId = *dept.ParentId + var parentDepts []*model.SysDeptViewModel + err = l.Model(ctx, &handler.Option{FilterTenant: true, FilterAuth: false}).Scan(&parentDepts) + if err != nil { + return 0, err + } + if err = validateDeptParent(dept.DeptId, newParentId, parentDepts); err != nil { + return 0, err + } } - if dept.DeptType != nil && *dept.DeptType == 1 && *dept.ParentId != 0 { + newDeptType := existingDept.DeptType + if dept.DeptType != nil { + newDeptType = *dept.DeptType + } + if newDeptType == 1 && newParentId != 0 { isCompany, err := l.ValidateParentIsCompany(ctx, newParentId) if err != nil { return 0, err @@ -576,7 +624,9 @@ func (l *sSysDept) RefreshDeptAncestors(ctx context.Context) (err error) { } for _, dept := range depts { ancestors := "" - l.GetParentIDAncestors(ctx, depts, dept.ParentId, &ancestors) + if err = l.GetParentIDAncestors(ctx, depts, dept.ParentId, &ancestors); err != nil { + return err + } if ancestors != dept.Ancestors { _, err = l.Model(ctx).Data(do.SysDept{ @@ -594,17 +644,50 @@ func (l *sSysDept) RefreshDeptAncestors(ctx context.Context) (err error) { // GetParentIDAncestors 获取部门祖先 func (l *sSysDept) GetParentIDAncestors(ctx context.Context, depts []*model.SysDeptViewModel, printId int64, ancestors *string) (err error) { + parents := make(map[int64]int64, len(depts)) for _, dept := range depts { - if dept.DeptId == printId { - *ancestors = fmt.Sprintf("%d", dept.DeptId) + "," + *ancestors - l.GetParentIDAncestors(ctx, depts, dept.ParentId, ancestors) - return nil + parents[dept.DeptId] = dept.ParentId + } + seen := make(map[int64]bool) + for printId != 0 { + if seen[printId] { + return gerror.New("组织层级存在循环引用") } + seen[printId] = true + parentId, exists := parents[printId] + if !exists { + break + } + *ancestors = fmt.Sprintf("%d,", printId) + *ancestors + printId = parentId } *ancestors = "0," + *ancestors return nil } +func validateDeptParent(deptId, parentId int64, depts []*model.SysDeptViewModel) error { + parents := make(map[int64]int64, len(depts)) + for _, dept := range depts { + parents[dept.DeptId] = dept.ParentId + } + seen := make(map[int64]bool) + for parentId != 0 { + if parentId == deptId { + return gerror.New("上级组织不能是当前组织或其下级组织") + } + if parentId < 0 || seen[parentId] { + return gerror.New("组织层级存在循环引用") + } + seen[parentId] = true + next, exists := parents[parentId] + if !exists { + return gerror.New("上级组织不存在") + } + parentId = next + } + return nil +} + // GetDeptListByIds 根据部门ID列表获取部门列表 func (l *sSysDept) GetDeptListByIds(ctx context.Context, ids []int64) (depts []*model.SysDeptListModel, err error) { err = l.ModelQuery(ctx).WhereIn(dao.SysDept.Columns().DeptId, ids).Scan(&depts) @@ -620,25 +703,36 @@ func (l *sSysDept) ValidateParentIsCompany(ctx context.Context, parentId int64) if err != nil { return false, err } + if dept == nil || dept.DeptId == 0 { + return false, gerror.New("上级组织不存在") + } return dept.DeptType == 1 || dept.ParentId == 0, nil } // 获取上级公司名称 func (l *sSysDept) getParentCompanyInfo(ctx context.Context, depts []*model.SysDeptListModel, deptId int64) *model.SysDeptListModel { var topDept *model.SysDeptListModel + byID := make(map[int64]*model.SysDeptListModel, len(depts)) for _, dept := range depts { + byID[dept.DeptId] = dept if dept.ParentId == 0 { topDept = dept } - if dept.DeptId == deptId { - if dept.ParentId == 0 { - return dept - } - if dept.DeptType == 1 { - return dept - } - return l.getParentCompanyInfo(ctx, depts, dept.ParentId) + } + seen := make(map[int64]bool) + for deptId != 0 { + if seen[deptId] { + return nil } + seen[deptId] = true + dept, exists := byID[deptId] + if !exists { + break + } + if dept.ParentId == 0 || dept.DeptType == 1 { + return dept + } + deptId = dept.ParentId } return topDept } diff --git a/server/internal/logic/system/sys_dept_test.go b/server/internal/logic/system/sys_dept_test.go new file mode 100644 index 0000000..a7be4c9 --- /dev/null +++ b/server/internal/logic/system/sys_dept_test.go @@ -0,0 +1,90 @@ +package system + +import ( + "context" + "testing" + + "git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/model" +) + +func TestDeptHierarchyReadGuards(t *testing.T) { + for _, tc := range []struct { + name string + depts []*model.SysDeptListModel + invalid bool + }{ + {"normal", []*model.SysDeptListModel{{DeptId: 1}, {DeptId: 2, ParentId: 1}}, false}, + {"orphan", []*model.SysDeptListModel{{DeptId: 2, ParentId: 99}}, false}, + {"self", []*model.SysDeptListModel{{DeptId: 1, ParentId: 1}}, true}, + {"cycle", []*model.SysDeptListModel{{DeptId: 1, ParentId: 2}, {DeptId: 2, ParentId: 1}}, true}, + } { + t.Run(tc.name, func(t *testing.T) { + if err := validateDeptHierarchy(tc.depts); (err != nil) != tc.invalid { + t.Fatalf("unexpected hierarchy error: %v", err) + } + var ids []int64 + if err := NewSysDept().RecursionDeptIds(context.Background(), 1, tc.depts, &ids); (err != nil) != tc.invalid { + t.Fatalf("unexpected traversal error: %v", err) + } + company := NewSysDept().getParentCompanyInfo(context.Background(), tc.depts, 1) + if tc.invalid && company != nil { + t.Fatal("cyclic company lookup must return nil") + } + }) + } +} + +func TestValidateDeptParent(t *testing.T) { + depts := []*model.SysDeptViewModel{ + {DeptId: 100, ParentId: 0}, + {DeptId: 117, ParentId: 100}, + {DeptId: 118, ParentId: 117}, + {DeptId: 196, ParentId: 100}, + } + for _, test := range []struct { + name string + parentId int64 + invalid bool + }{ + {"root", 0, false}, + {"sibling", 196, false}, + {"self", 117, true}, + {"descendant", 118, true}, + {"missing", 999, true}, + {"negative", -1, true}, + } { + t.Run(test.name, func(t *testing.T) { + err := validateDeptParent(117, test.parentId, depts) + if (err != nil) != test.invalid { + t.Fatalf("parent %d: unexpected error %v", test.parentId, err) + } + }) + } + cycle := []*model.SysDeptViewModel{{DeptId: 1, ParentId: 2}, {DeptId: 2, ParentId: 1}} + if err := validateDeptParent(3, 1, cycle); err == nil { + t.Fatal("existing cycle was not rejected") + } +} + +func TestGetParentIDAncestorsCycle(t *testing.T) { + dept := NewSysDept() + for _, test := range []struct { + name string + depts []*model.SysDeptViewModel + parentId int64 + expected string + invalid bool + }{ + {"normal", []*model.SysDeptViewModel{{DeptId: 100}, {DeptId: 117, ParentId: 100}}, 117, "0,100,117,", false}, + {"self", []*model.SysDeptViewModel{{DeptId: 117, ParentId: 117}}, 117, "", true}, + {"cycle", []*model.SysDeptViewModel{{DeptId: 1, ParentId: 2}, {DeptId: 2, ParentId: 1}}, 1, "", true}, + } { + t.Run(test.name, func(t *testing.T) { + var ancestors string + err := dept.GetParentIDAncestors(context.Background(), test.depts, test.parentId, &ancestors) + if (err != nil) != test.invalid || !test.invalid && ancestors != test.expected { + t.Fatalf("ancestors=%q error=%v, expected=%q invalid=%v", ancestors, err, test.expected, test.invalid) + } + }) + } +} diff --git a/server/internal/logic/system/sys_dict_data.go b/server/internal/logic/system/sys_dict_data.go index 2ab9d97..a32032d 100644 --- a/server/internal/logic/system/sys_dict_data.go +++ b/server/internal/logic/system/sys_dict_data.go @@ -1,7 +1,7 @@ // package system // @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 system diff --git a/server/internal/logic/system/sys_dict_type.go b/server/internal/logic/system/sys_dict_type.go index 388eccd..95342c5 100644 --- a/server/internal/logic/system/sys_dict_type.go +++ b/server/internal/logic/system/sys_dict_type.go @@ -1,7 +1,7 @@ // package system // @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 system diff --git a/server/internal/logic/system/sys_job.go b/server/internal/logic/system/sys_job.go index 26070f1..a4bc8bf 100644 --- a/server/internal/logic/system/sys_job.go +++ b/server/internal/logic/system/sys_job.go @@ -1,7 +1,7 @@ // package system // @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 system diff --git a/server/internal/logic/system/sys_logininfor.go b/server/internal/logic/system/sys_logininfor.go index 4c68785..faa0599 100644 --- a/server/internal/logic/system/sys_logininfor.go +++ b/server/internal/logic/system/sys_logininfor.go @@ -1,7 +1,7 @@ // package system // @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 system diff --git a/server/internal/logic/system/sys_menu.go b/server/internal/logic/system/sys_menu.go index f3832c4..4b48cb7 100644 --- a/server/internal/logic/system/sys_menu.go +++ b/server/internal/logic/system/sys_menu.go @@ -1,7 +1,7 @@ // package system // @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 system diff --git a/server/internal/logic/system/sys_menu_document_center_test.go b/server/internal/logic/system/sys_menu_document_center_test.go index 1dae3d5..51944b5 100644 --- a/server/internal/logic/system/sys_menu_document_center_test.go +++ b/server/internal/logic/system/sys_menu_document_center_test.go @@ -13,8 +13,8 @@ func TestDocumentCenterAppearsAfterExecutionLog(t *testing.T) { {MenuId: 1, MenuName: "系统管理", Path: "system", MenuType: "M", OrderNum: 900}, {MenuId: 1836, MenuName: "执行日志", Path: "batchExecutionLog", MenuType: "C", OrderNum: 32}, {MenuId: 2153, MenuName: "文档中心", ParentId: 1, Path: "docs", MenuType: "M", OrderNum: 13}, - {MenuId: 2155, MenuName: "Web 使用手册", ParentId: 2153, Path: "https://admin.iotlcm.example.com/docs/manual/web/", MenuType: "C", IsFrame: 0, OrderNum: 1}, - {MenuId: 2154, MenuName: "OpenAPI 文档", ParentId: 2153, Path: "https://admin.iotlcm.example.com/docs/openapi/v3/", MenuType: "C", IsFrame: 0, OrderNum: 2}, + {MenuId: 2155, MenuName: "Web 使用手册", ParentId: 2153, Path: "https://admin.example.com/docs/manual/web/", MenuType: "C", IsFrame: 0, OrderNum: 1}, + {MenuId: 2154, MenuName: "OpenAPI 文档", ParentId: 2153, Path: "https://admin.example.com/docs/openapi/v3/", MenuType: "C", IsFrame: 0, OrderNum: 2}, } menus, err := NewSysMenu().BuildUserMenuTree(context.Background(), nil, placeDocumentCenterAfterExecutionLog(menuList), "") if err != nil { @@ -35,8 +35,8 @@ func TestDocumentCenterMenuListHierarchy(t *testing.T) { menus := []*model.SysMenuListModel{ {MenuId: 1, MenuName: "system", ParentId: 0, Path: "system", OrderNum: 900}, {MenuId: 2153, MenuName: "docs", ParentId: 1, Path: "docs", MenuType: "M", OrderNum: 13, Perms: "docs:menu"}, - {MenuId: 2155, ParentId: 2153, Path: "https://admin.iotlcm.example.com/docs/manual/web/", OrderNum: 1}, - {MenuId: 2154, ParentId: 2153, Path: "https://admin.iotlcm.example.com/docs/openapi/v3/", OrderNum: 2}, + {MenuId: 2155, ParentId: 2153, Path: "https://admin.example.com/docs/manual/web/", OrderNum: 1}, + {MenuId: 2154, ParentId: 2153, Path: "https://admin.example.com/docs/openapi/v3/", OrderNum: 2}, {MenuId: 1836, ParentId: 0, Path: "batchExecutionLog", OrderNum: 32}, } placeDocumentCenterInMenuList(menus) diff --git a/server/internal/logic/system/sys_notice.go b/server/internal/logic/system/sys_notice.go index 691e1e4..2c65fa3 100644 --- a/server/internal/logic/system/sys_notice.go +++ b/server/internal/logic/system/sys_notice.go @@ -1,7 +1,7 @@ // package system // @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 system diff --git a/server/internal/logic/system/sys_notice_user.go b/server/internal/logic/system/sys_notice_user.go index c4a27f8..04da2bb 100644 --- a/server/internal/logic/system/sys_notice_user.go +++ b/server/internal/logic/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/logic/system/sys_oper_log.go b/server/internal/logic/system/sys_oper_log.go index 330502c..8593fc0 100644 --- a/server/internal/logic/system/sys_oper_log.go +++ b/server/internal/logic/system/sys_oper_log.go @@ -1,7 +1,7 @@ // package system // @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 system diff --git a/server/internal/logic/system/sys_oss.go b/server/internal/logic/system/sys_oss.go index 1378447..0c1f18f 100644 --- a/server/internal/logic/system/sys_oss.go +++ b/server/internal/logic/system/sys_oss.go @@ -1,7 +1,7 @@ // package system // @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 system @@ -9,6 +9,7 @@ import ( "context" "errors" "fmt" + "io" "git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/consts" "git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/dao" "git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/contexts" @@ -19,7 +20,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/uuid32" - "io" "net/http" "os" "path/filepath" @@ -56,7 +56,10 @@ func (s *sSysOss) Model(ctx context.Context, option ...*handler.Option) *gdb.Mod FilterAuth: true, }) } - return handler.Model(dao.SysOss.Ctx(ctx), option...) + return handler.Model(dao.SysOss.Ctx(ctx), option...). + WhereNotIn(dao.SysOss.Columns().OssId, dao.TExportTask.Ctx(ctx).Fields(dao.TExportTask.Columns().OssId).WhereGT(dao.TExportTask.Columns().OssId, 0)). + Where("LOWER(REPLACE(COALESCE(path, ''), CHAR(92), '/')) NOT REGEXP ?", `(^|/)private-export(/|$)|(^|/)user/file/([^/]+/)?export(/|$)`). + Where("LOWER(REPLACE(COALESCE(url, ''), CHAR(92), '/')) NOT REGEXP ?", `(^|/)private-export(/|$)|(^|/)user/file/([^/]+/)?export(/|$)`) } func (s *sSysOss) List(ctx context.Context, param *model.SysOssListParam, pageInfo *request.PageInfo) (items []*model.SysOssListModel, total int, err error) { @@ -683,7 +686,7 @@ func joinUploadURL(base string, suffix string) string { return base + "/" + suffix } -func (s *sSysOss) DownloadLocal(ctx context.Context, file *entity.SysOss) (err error) { +func (s *sSysOss) DownloadLocal(ctx context.Context, file *entity.SysOss, beforeSend ...func() error) (err error) { if file == nil { err = errors.New("文件必须") return @@ -697,6 +700,23 @@ func (s *sSysOss) DownloadLocal(ctx context.Context, file *entity.SysOss) (err e err = gerror.New("ctx not http request") return } + localFile, err := os.Open(finalPath) + if err != nil { + return gerror.Wrap(err, "本地文件不可读") + } + defer localFile.Close() + info, err := localFile.Stat() + if err != nil { + return gerror.Wrap(err, "获取本地文件信息失败") + } + if !info.Mode().IsRegular() { + return gerror.New("本地文件不是普通文件") + } + for _, callback := range beforeSend { + if err = callback(); err != nil { + return err + } + } r.Response.ServeFileDownload(finalPath) r.Response.Status = http.StatusOK r.Response.Flush() diff --git a/server/internal/logic/system/sys_oss_config.go b/server/internal/logic/system/sys_oss_config.go index 1adc926..9d71668 100644 --- a/server/internal/logic/system/sys_oss_config.go +++ b/server/internal/logic/system/sys_oss_config.go @@ -1,7 +1,7 @@ // package system // @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 system diff --git a/server/internal/logic/system/sys_post.go b/server/internal/logic/system/sys_post.go index f3b71ca..b77f909 100644 --- a/server/internal/logic/system/sys_post.go +++ b/server/internal/logic/system/sys_post.go @@ -1,28 +1,25 @@ // package system // @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 system import ( "context" - "fmt" "git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/consts" "git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/dao" "git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/contexts" "git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/event" + "git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/exportcenter" "git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/xgorm/handler" "git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/model" "git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/model/do" "git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/service" "git.5iot.com/iotlcm-public/iotlcm-admin-svr/utility/convert" - "git.5iot.com/iotlcm-public/iotlcm-admin-svr/utility/excel" "github.com/gogf/gf/v2/database/gdb" "github.com/gogf/gf/v2/errors/gerror" - "github.com/gogf/gf/v2/frame/g" - "github.com/gogf/gf/v2/os/gctx" "github.com/gogf/gf/v2/os/gtime" "github.com/gogf/gf/v2/util/gconv" ) @@ -180,35 +177,31 @@ func (l *sSysPost) Delete(ctx context.Context, param *model.SysPostDeleteParam) return } +// Export 导出岗位管理(异步:提交任务后到下载中心取文件) func (l *sSysPost) Export(ctx context.Context, param *model.SysPostExportParam) (post *model.SysPostExportModel, err error) { - if param.Page == 0 { - param.Page = 1 - } - if param.PageSize == 0 { - param.PageSize = 1000 - } - items, total, err := l.List(ctx, param.SysPostListParam) - if err != nil { - return - } - // 字段的排序是依据tags的字段顺序,如果你不想使用默认的排序方式,可以直接定义 tags = []string{"字段名称", "字段名称2", ...} - tags, err := convert.GetEntityDescTags(model.SysPostExportModel{}) - if err != nil { - return - } - - var ( - fileName = "导出CURD列表-" + gctx.CtxId(ctx) - sheetName = fmt.Sprintf("索引条件共%v行,共%v页,当前导出是第%v页,本页共%v行", total, total+param.PageSize-1/param.PageSize, param.Page, len(items)) - exports []model.SysPostExportModel - ) - g.Log().Info(ctx, " sSysPost.Export total", total) - if err = gconv.Scan(items, &exports); err != nil { - return - } - g.Log().Info(ctx, " sSysPost.Export exports", len(exports)) - err = excel.ExportByStructs(ctx, tags, exports, fileName, sheetName) - g.Log().Info(ctx, " sSysPost.Export err", err) + _, err = exportcenter.Submit(ctx, &exportcenter.Request{ + BizKey: "post", + BizName: "岗位管理", + PermCode: "cpm:system:post:export", + QueryParam: param, + Tags: func() ([]string, error) { + return convert.GetEntityDescTags(model.SysPostListModel{}) + }, + Fetch: func(ctx context.Context, page, pageSize int) (rows []interface{}, total int, err error) { + query := param.SysPostListParam + query.Page = page + query.PageSize = pageSize + items, count, err := l.List(ctx, query) + if err != nil { + return nil, 0, err + } + rows = make([]interface{}, 0, len(items)) + for _, item := range items { + rows = append(rows, item) + } + return rows, count, nil + }, + }) post = &model.SysPostExportModel{} return } diff --git a/server/internal/logic/system/sys_qrcode.go b/server/internal/logic/system/sys_qrcode.go index a9d3a51..4337fd4 100644 --- a/server/internal/logic/system/sys_qrcode.go +++ b/server/internal/logic/system/sys_qrcode.go @@ -8,6 +8,7 @@ import ( "encoding/json" "errors" "fmt" + "io" "git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/consts" "git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/dao" "git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/contexts" @@ -16,7 +17,6 @@ import ( "git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/model/request" "git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/service" "git.5iot.com/iotlcm-public/iotlcm-admin-svr/utility" - "io" "strconv" "strings" "time" diff --git a/server/internal/logic/system/sys_qrcode_timeout_test.go b/server/internal/logic/system/sys_qrcode_timeout_test.go index 7d420f6..93c427c 100644 --- a/server/internal/logic/system/sys_qrcode_timeout_test.go +++ b/server/internal/logic/system/sys_qrcode_timeout_test.go @@ -2,8 +2,8 @@ package system import ( "context" - "git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/model" "io" + "git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/model" "net/http" "net/http/httptest" "testing" @@ -74,7 +74,7 @@ func TestUserCodeHTTPTimeoutAndStatus(t *testing.T) { started := time.Now() err := callUserCodeHTTP(context.Background(), model.UserCodeHttpSenderConfig{ Url: server.URL, Timeout: "70ms", SuccessStatus: http.StatusAccepted, - }, "user@example.com", "123456") + }, "xxx@example.com", "123456") if (err != nil) != (scenario != "success") || time.Since(started) > time.Second { t.Fatalf("unexpected sender result: %v", err) } diff --git a/server/internal/logic/system/sys_role.go b/server/internal/logic/system/sys_role.go index 33ebfd1..dad7a72 100644 --- a/server/internal/logic/system/sys_role.go +++ b/server/internal/logic/system/sys_role.go @@ -1,7 +1,7 @@ // package system // @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 system @@ -47,7 +47,7 @@ func (l *sSysRole) Model(ctx context.Context, option ...*handler.Option) *gdb.Mo FilterAuth: false, // 角色表不验证数据权限 }) } - return handler.Model(dao.SysRole.Ctx(ctx), option...) + return handler.Model(dao.SysRole.Ctx(ctx).Master(), option...) } func (l *sSysRole) ModelQuery(ctx context.Context, option ...*handler.Option) *gdb.Model { @@ -57,13 +57,12 @@ func (l *sSysRole) ModelQuery(ctx context.Context, option ...*handler.Option) *g FilterAuth: false, // 角色表不验证数据权限 }) } - return handler.Model(service.MemoryDB().DB(ctx).Ctx(ctx).Model(dao.SysRole.Table()), option...) + return handler.Model(dao.SysRole.Ctx(ctx).Master(), option...) } // 获取租户下角色列表 func (s *sSysRole) List(ctx context.Context, param *model.SysRoleListParam) (res []*model.SysRoleListModel, total int, err error) { db := s.ModelQuery(ctx) - // 注意:ModelQuery使用内存数据库,删除的记录会被物理删除,所以不需要过滤deleted_at if param.RoleName != "" { db = db.WhereLike(dao.SysRole.Columns().RoleName, "%"+param.RoleName+"%") } @@ -109,6 +108,19 @@ func (s *sSysRole) View(ctx context.Context, param *model.SysRoleViewParam) (res if err != nil { return nil, err } + if res == nil { + return nil, gerror.New("角色不存在或已删除") + } + template, err := rolepolicy.TemplateForRoles(ctx, res.RoleId) + if err != nil { + return nil, err + } + res.IsTemplateRole = template != nil + defaultRoleCount, err := dao.SysDept.Ctx(ctx).Master().Where("tenant_id", res.TenantId).Where("dept_id", res.DeptId).Where("dept_role", res.RoleId).Count() + if err != nil { + return nil, err + } + res.IsDeptDefaultRole = defaultRoleCount > 0 // 获取角色菜单 roleMenus, err := s.getRoleMenuRelations(ctx, param.RoleId) if err != nil { @@ -136,7 +148,7 @@ func (s *sSysRole) View(ctx context.Context, param *model.SysRoleViewParam) (res } func (s *sSysRole) getRoleMenuRelations(ctx context.Context, id int64) (res []*entity.SysRoleMenu, err error) { - err = dao.SysRoleMenu.Ctx(ctx). + err = dao.SysRoleMenu.Ctx(ctx).Master(). Where(dao.SysRoleMenu.Columns().TenantId, contexts.GetTenantId(ctx)). Where(dao.SysRoleMenu.Columns().RoleId, id).Scan(&res) return @@ -144,7 +156,7 @@ func (s *sSysRole) getRoleMenuRelations(ctx context.Context, id int64) (res []*e // 获取角色菜单 func (s *sSysRole) GetRoleMenu(ctx context.Context, id int64) (res []*entity.SysMenu, err error) { - err = dao.SysRoleMenu.Ctx(ctx). + err = dao.SysRoleMenu.Ctx(ctx).Master(). Where(dao.SysRoleMenu.Columns().TenantId, contexts.GetTenantId(ctx)). Where(dao.SysRoleMenu.Columns().RoleId, id).Scan(&res) return @@ -152,7 +164,7 @@ func (s *sSysRole) GetRoleMenu(ctx context.Context, id int64) (res []*entity.Sys // 获取角色部门 func (s *sSysRole) GetRoleDept(ctx context.Context, id int64) (res []*entity.SysDept, err error) { - err = dao.SysRoleDept.Ctx(ctx). + err = dao.SysRoleDept.Ctx(ctx).Master(). Where(dao.SysRoleDept.Columns().TenantId, contexts.GetTenantId(ctx)). Where(dao.SysRoleDept.Columns().RoleId, id).Scan(&res) return @@ -177,7 +189,7 @@ func (s *sSysRole) GetRoleListMenu(ctx context.Context, ids []int64) (res []*ent } return res, nil } - err = dao.SysRoleMenu.Ctx(ctx). + err = dao.SysRoleMenu.Ctx(ctx).Master(). Where(dao.SysRoleMenu.Columns().TenantId, contexts.GetTenantId(ctx)). WhereIn(dao.SysRoleMenu.Columns().RoleId, ids).Scan(&res) return @@ -194,8 +206,8 @@ func (s *sSysRole) Add(ctx context.Context, param *model.SysRoleAddParam) (role return nil }) if err == nil && role != nil { - err = rolesync.EnqueueRole(ctx, role.RoleId, contexts.GetUserId(ctx)) event.EventsInstance().Emit(ctx, consts.EventKeyDBSysRoleCreate, role.RoleId) + err = rolesync.EnqueueRole(ctx, role.RoleId, contexts.GetUserId(ctx)) } return } @@ -247,8 +259,8 @@ func (s *sSysRole) Edit(ctx context.Context, param *model.SysRoleEditParam) (rol return nil }) if err == nil { - err = rolesync.EnqueueRole(ctx, param.RoleId, contexts.GetUserId(ctx)) event.EventsInstance().Emit(ctx, consts.EventKeyDBSysRoleUpdate, param.RoleId) + err = rolesync.EnqueueRole(ctx, param.RoleId, contexts.GetUserId(ctx)) } return } @@ -264,7 +276,7 @@ func (s *sSysRole) editRoleAtomic(ctx context.Context, param *model.SysRoleEditP if user == nil { return role, gerror.New("登录已过期") } - template, templateErr := rolepolicy.Template(ctx) + template, templateErr := rolepolicy.TemplateForRoles(ctx, param.RoleId) if templateErr != nil { return role, templateErr } @@ -299,7 +311,11 @@ func (s *sSysRole) editRoleAtomic(ctx context.Context, param *model.SysRoleEditP // 删除角色 func (s *sSysRole) Delete(ctx context.Context, param *model.SysRoleDeleteParam) (role *model.SysRoleDeleteModel, err error) { - template, templateErr := rolepolicy.Template(ctx) + targetIDs := []int64{param.RoleId} + if param.RoleId == 0 { + targetIDs = param.RoleIds + } + template, templateErr := rolepolicy.TemplateForRoles(ctx, targetIDs...) if templateErr != nil { return nil, templateErr } @@ -331,19 +347,32 @@ func (s *sSysRole) Delete(ctx context.Context, param *model.SysRoleDeleteParam) } data := do.SysRole{} data.DeletedBy = user.ID - data.DeletedAt = gtime.Now() - _, err = s.Model(ctx).WhereIn(dao.SysRole.Columns().RoleId, roleIds).Data(data).OmitNil().Update() + err = g.DB().Transaction(ctx, func(ctx context.Context, _ gdb.TX) error { + if _, deleteErr := s.Model(ctx).WhereIn(dao.SysRole.Columns().RoleId, roleIds).Data(data).OmitNil().Update(); deleteErr != nil { + return deleteErr + } + if _, deleteErr := s.Model(ctx).WhereIn(dao.SysRole.Columns().RoleId, roleIds).Delete(); deleteErr != nil { + return deleteErr + } + return nil + }) + if err != nil { + return role, err + } event.EventsInstance().Emit(ctx, consts.EventKeyDBSysRoleDelete, roleIds) clearRoleUsersAccessCache(ctx, roleIds...) return } func (s *sSysRole) RoleMenu(ctx context.Context, roleId int64, menuIds []int64, dataScopes map[int64]string) (err error) { - template, templateErr := rolepolicy.Template(ctx) + template, templateErr := rolepolicy.TemplateForRoles(ctx, roleId) if templateErr != nil { return templateErr } if template != nil && template.RoleId == roleId { + if !contexts.IsSuperAdmin(ctx) { + return gerror.New("仅平台超级管理员可修改代理模板") + } for _, scope := range dataScopes { if scope == consts.SysRoleDataScopeAll || scope == consts.SysRoleDataScopeCustom { return gerror.New("模板按钮不允许全部或自定义数据范围") @@ -352,15 +381,11 @@ func (s *sSysRole) RoleMenu(ctx context.Context, roleId int64, menuIds []int64, } tenantId := contexts.GetTenantId(ctx) if !contexts.IsSuperAdmin(ctx) { - allowed, err := rolepolicy.RestrictMenus(ctx, tenantId, contexts.GetDeptId(ctx), menuIds) + allowed, err := rolepolicy.NormalizeAssignableMenus(ctx, tenantId, contexts.GetDeptId(ctx), contexts.GetUserId(ctx), menuIds) if err != nil { return err } - for _, id := range menuIds { - if !slices.Contains(allowed, id) { - return gerror.New("角色授权超出机构或模板范围") - } - } + menuIds = allowed for _, scope := range dataScopes { if scope == consts.SysRoleDataScopeAll || scope == consts.SysRoleDataScopeCustom { return gerror.New("不允许授予全部或自定义数据范围") @@ -369,7 +394,7 @@ func (s *sSysRole) RoleMenu(ctx context.Context, roleId int64, menuIds []int64, } // 查询角色菜单 roleMenu := make([]*entity.SysRoleMenu, 0) - err = dao.SysRoleMenu.Ctx(ctx).Where(dao.SysRoleMenu.Columns().TenantId, tenantId).Where(dao.SysRoleMenu.Columns().RoleId, roleId).Scan(&roleMenu) + err = dao.SysRoleMenu.Ctx(ctx).Master().Where(dao.SysRoleMenu.Columns().TenantId, tenantId).Where(dao.SysRoleMenu.Columns().RoleId, roleId).Scan(&roleMenu) if err != nil { return err } @@ -407,7 +432,7 @@ func (s *sSysRole) RoleMenu(ctx context.Context, roleId int64, menuIds []int64, } } for _, menuId := range updateMenuIds { - _, err = dao.SysRoleMenu.Ctx(ctx). + _, err = dao.SysRoleMenu.Ctx(ctx).Master(). Where(dao.SysRoleMenu.Columns().TenantId, tenantId). Where(dao.SysRoleMenu.Columns().RoleId, roleId). Where(dao.SysRoleMenu.Columns().MenuId, menuId). @@ -443,7 +468,7 @@ func clearRoleUsersAccessCache(ctx context.Context, roleIds ...int64) { return } relations := make([]*entity.SysUserRole, 0) - if err := dao.SysUserRole.Ctx(ctx). + if err := dao.SysUserRole.Ctx(ctx).Master(). Fields(dao.SysUserRole.Columns().UserId). Where(dao.SysUserRole.Columns().TenantId, contexts.GetTenantId(ctx)). WhereIn(dao.SysUserRole.Columns().RoleId, roleIds). @@ -474,7 +499,7 @@ func (s *sSysRole) RoleDept(ctx context.Context, roleId int64, deptIds []int64) tenantId := contexts.GetTenantId(ctx) // 查询角色部门 roleDept := make([]*entity.SysRoleDept, 0) - err = dao.SysRoleDept.Ctx(ctx).Where(dao.SysRoleDept.Columns().TenantId, tenantId).Where(dao.SysRoleDept.Columns().RoleId, roleId).Scan(&roleDept) + err = dao.SysRoleDept.Ctx(ctx).Master().Where(dao.SysRoleDept.Columns().TenantId, tenantId).Where(dao.SysRoleDept.Columns().RoleId, roleId).Scan(&roleDept) if err != nil { return err } @@ -511,10 +536,13 @@ func (s *sSysRole) RoleDept(ctx context.Context, roleId int64, deptIds []int64) // 编辑角色数据权限 func (s *sSysRole) EditRoleDataScope(ctx context.Context, model *model.SysRoleDataScopeEditParam) (err error) { - template, templateErr := rolepolicy.Template(ctx) + template, templateErr := rolepolicy.TemplateForRoles(ctx, model.RoleId) if templateErr != nil { return templateErr } + if template != nil && !contexts.IsSuperAdmin(ctx) { + return gerror.New("仅平台超级管理员可修改代理模板") + } if !contexts.IsSuperAdmin(ctx) || (template != nil && template.RoleId == model.RoleId) { if model.DataScope == consts.SysRoleDataScopeAll || model.DataScope == consts.SysRoleDataScopeCustom || model.DataScope == "" { return gerror.New("不允许全部、自定义或空数据权限") @@ -544,10 +572,8 @@ func (s *sSysRole) EditRoleDataScope(ctx context.Context, model *model.SysRoleDa if err != nil { return err } - if err = rolesync.EnqueueRole(ctx, model.RoleId, contexts.GetUserId(ctx)); err != nil { - return err - } event.EventsInstance().Emit(ctx, consts.EventKeyDBSysRoleUpdate, model.RoleId) + err = rolesync.EnqueueRole(ctx, model.RoleId, contexts.GetUserId(ctx)) return } @@ -558,7 +584,7 @@ func (s *sSysRole) ensureRolesNotInherited(ctx context.Context, roleIds []int64) if contexts.IsSuperAdmin(ctx) { return nil } - count, err := g.Model(dao.SysRole.Table()+" r"). + count, err := g.Model(dao.SysRole.Table()+" r").Ctx(ctx). LeftJoin(dao.SysDept.Table()+" d", "d.dept_id = r.dept_id AND d.tenant_id = r.tenant_id"). WhereIn("r.role_id", roleIds). Where("r.deleted_at IS NULL"). @@ -581,7 +607,14 @@ func (s *sSysRole) ensureRolesHaveNoInheritedChildren(ctx context.Context, roleI if len(roleIds) == 0 { return nil } - count, err := g.Model(dao.SysRole.Table()+" r"). + configured, err := dao.SysConfig.Ctx(ctx).Master().Where("tenant_id", consts.DefaultSystemTenantCode).Where("config_key", rolepolicy.TemplateConfigKey).Count() + if err != nil { + return err + } + if configured > 0 { + return nil + } + count, err := g.Model(dao.SysRole.Table()+" r").Ctx(ctx). LeftJoin(dao.SysDept.Table()+" child_dept", "child_dept.dept_id = r.dept_id AND child_dept.tenant_id = r.tenant_id"). Where("r.created_dept IN (SELECT dept_id FROM sys_role WHERE role_id IN (?) AND is_pass = 1 AND deleted_at IS NULL)", roleIds). Where("r.deleted_at IS NULL"). diff --git a/server/internal/logic/system/sys_role_template_test.go b/server/internal/logic/system/sys_role_template_test.go new file mode 100644 index 0000000..7c6e7ba --- /dev/null +++ b/server/internal/logic/system/sys_role_template_test.go @@ -0,0 +1,437 @@ +package system + +import ( + "context" + "os" + "os/exec" + "path/filepath" + "slices" + "strings" + "testing" + + _ "github.com/gogf/gf/contrib/drivers/sqlite/v2" + "github.com/gogf/gf/v2/database/gdb" + "github.com/gogf/gf/v2/frame/g" + "github.com/gogf/gf/v2/os/gcfg" + "git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/consts" + "git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/contexts" + "git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/rolepolicy" + "git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/rolesync" + "git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/logic/agent" + _ "git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/logic/memorydb" + "git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/model" +) + +func TestRoleMenuTemplateValidationIsolation(t *testing.T) { + if os.Getenv("ROLE_TEMPLATE_TEST_CHILD") != "1" { + configPath := filepath.Join(t.TempDir(), "config.yaml") + if err := os.WriteFile(configPath, []byte("database:\n default:\n type: sqlite\n link: sqlite::@file(:memory:)\n memorydb:\n link: \"\"\n"), 0600); err != nil { + t.Fatal(err) + } + executable, err := os.Executable() + if err != nil { + t.Fatal(err) + } + command := exec.Command(executable, "-test.run=^TestRoleMenuTemplateValidationIsolation$", "-test.v") + command.Env = append(os.Environ(), "ROLE_TEMPLATE_TEST_CHILD=1", "GF_GCFG_FILE="+configPath) + output, err := command.CombinedOutput() + if err != nil { + t.Fatalf("isolated role tests failed: %v\n%s", err, output) + } + t.Log(string(output)) + return + } + originalAdapter := g.Cfg().GetAdapter() + adapter, err := gcfg.NewAdapterContent("database:\n default:\n type: sqlite\n link: sqlite::@file(:memory:)\n memorydb:\n link: \"\"\n", "yaml") + if err != nil { + t.Fatal(err) + } + g.Cfg().SetAdapter(adapter) + t.Cleanup(func() { g.Cfg().SetAdapter(originalAdapter) }) + if err := gdb.SetConfig(gdb.Config{"default": {{Type: "sqlite", Link: "sqlite::@file(:memory:)"}}}); err != nil { + t.Fatal(err) + } + ctx := contexts.Set(context.Background(), &model.Context{User: &model.Identity{BaseClaims: model.BaseClaims{ID: 1, TenantId: "000000", DeptId: 100, Roles: []model.Role{{RoleId: consts.SuperAdminRoleId}}}}}) + 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, is_pass INTEGER, data_scope TEXT, role_name TEXT, role_sort INTEGER, menu_check_strictly INTEGER, dept_check_strictly INTEGER, created_by INTEGER, created_at DATETIME, updated_by INTEGER, updated_at DATETIME, deleted_by INTEGER, remark 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_role_dept (tenant_id TEXT, role_id INTEGER, dept_id INTEGER)", + "INSERT INTO sys_config VALUES ('000000','agent.role.template.role_id','82')", + "INSERT INTO sys_role (role_id,tenant_id,dept_id,created_dept,status,is_pass,data_scope,deleted_at) VALUES (82,'000000',0,100,'0',0,'4',NULL)", + } { + if _, err := g.DB().Exec(ctx, statement); err != nil { + t.Fatal(err) + } + } + service := NewSysRole() + t.Run("普通角色新增菜单不受无效模板影响", func(t *testing.T) { + if err := service.RoleMenu(ctx, 200, []int64{10, 11}, nil); err != nil { + t.Fatal(err) + } + count, err := g.DB().Model("sys_role_menu").Where("role_id", 200).Count() + if err != nil || count != 2 { + t.Fatalf("menu count %d: %v", count, err) + } + }) + t.Run("普通角色修改菜单不受无效模板影响", func(t *testing.T) { + if err := service.RoleMenu(ctx, 200, []int64{11, 12}, nil); err != nil { + t.Fatal(err) + } + count, err := g.DB().Model("sys_role_menu").Where("role_id", 200).Where("menu_id", 10).Count() + if err != nil || count != 0 { + t.Fatalf("removed menu count %d: %v", count, err) + } + }) + t.Run("模板仍禁止全部按钮数据权限", func(t *testing.T) { + err := service.RoleMenu(ctx, 82, []int64{10}, map[int64]string{10: consts.SysRoleDataScopeAll}) + if err == nil || !strings.Contains(err.Error(), "模板按钮") { + t.Fatalf("expected template restriction: %v", err) + } + }) + t.Run("无效模板仍禁止删除", func(t *testing.T) { + _, err := service.Delete(ctx, &model.SysRoleDeleteParam{RoleId: 82}) + if err == nil || !strings.Contains(err.Error(), "模板不能删除") { + t.Fatalf("expected template deletion rejection: %v", err) + } + }) + t.Run("批量删除不能包含模板", func(t *testing.T) { + _, err := service.Delete(ctx, &model.SysRoleDeleteParam{RoleIds: []int64{200, 82}}) + if err == nil || !strings.Contains(err.Error(), "模板不能删除") { + t.Fatalf("expected batch deletion rejection: %v", err) + } + }) + t.Run("非管理员不能绕过无效模板授权", func(t *testing.T) { + limited := contexts.Set(context.Background(), &model.Context{User: &model.Identity{BaseClaims: model.BaseClaims{ID: 2, TenantId: "000000", DeptId: 175}}}) + if err := service.RoleMenu(limited, 201, []int64{10}, nil); err == nil { + t.Fatal("invalid template must fail closed") + } + count, err := g.DB().Model("sys_role_menu").Where("role_id", 201).Count() + if err != nil || count != 0 { + t.Fatalf("unauthorized menu count %d: %v", count, err) + } + }) + t.Run("运行时模板校验仍然严格", func(t *testing.T) { + if _, err := rolepolicy.Template(ctx); err == nil { + t.Fatal("invalid template was accepted") + } + }) + t.Run("无关角色不读取模板角色实体", func(t *testing.T) { + template, err := rolepolicy.TemplateForRoles(ctx, 200) + if err != nil || template != nil { + t.Fatalf("unexpected template: %+v %v", template, err) + } + }) + t.Run("已删除模板仍保留身份保护", func(t *testing.T) { + if _, err := g.DB().Exec(ctx, "DELETE FROM sys_role WHERE role_id=82"); err != nil { + t.Fatal(err) + } + template, err := rolepolicy.TemplateForRoles(ctx, 82) + if err != nil || template == nil || template.RoleId != 82 { + t.Fatalf("template identity lost: %+v %v", template, err) + } + }) + t.Run("错误参数不阻断无关角色但不能用于授权", func(t *testing.T) { + if _, err := g.DB().Exec(ctx, "UPDATE sys_config SET config_value='invalid'"); err != nil { + t.Fatal(err) + } + if err := service.RoleMenu(ctx, 202, []int64{10}, nil); err != nil { + t.Fatal(err) + } + if _, err := rolepolicy.Template(ctx); err == nil { + t.Fatal("invalid configuration must fail strict validation") + } + }) + + t.Run("平台管理员权限加载不依赖无效模板", func(t *testing.T) { + for _, statement := range []string{ + "UPDATE sys_config SET config_value='82'", + "ALTER TABLE sys_role ADD COLUMN role_key TEXT", + "INSERT INTO sys_role (role_id,tenant_id,dept_id,status,role_key,data_scope) VALUES (1,'000000',100,'0','admin','1'),(102,'000000',175,'0','agent','1')", + "CREATE TABLE sys_dept (dept_id INTEGER PRIMARY KEY, tenant_id TEXT, parent_id INTEGER, dept_role INTEGER, status TEXT, ancestors TEXT, dept_type INTEGER, order_num INTEGER DEFAULT 0, deleted_at DATETIME)", + "INSERT INTO sys_dept (dept_id,tenant_id,parent_id,dept_role,status,deleted_at) VALUES (100,'000000',0,1,'0',NULL),(175,'000000',100,102,'0',NULL)", + "CREATE TABLE sys_menu (menu_id INTEGER PRIMARY KEY, menu_name TEXT, parent_id INTEGER, path TEXT, menu_type TEXT, order_num INTEGER, perms TEXT, status TEXT, deleted_at DATETIME)", + "INSERT INTO sys_menu VALUES (10,'授权功能',0,'allowed','C',1,'system:test:allowed','0',NULL),(11,'受限功能',0,'restricted','C',2,'system:test:restricted','0',NULL)", + "CREATE TABLE sys_user (user_id INTEGER PRIMARY KEY, tenant_id TEXT, dept_id INTEGER, user_name TEXT, status TEXT, deleted_at DATETIME)", + "INSERT INTO sys_user VALUES (1,'000000',100,'admin','0',NULL),(2,'000000',175,'agent','0',NULL)", + "CREATE TABLE sys_user_post (tenant_id TEXT, user_id INTEGER, post_id INTEGER)", + "INSERT INTO sys_user_role VALUES ('000000',1,1),('000000',2,102)", + } { + if _, err := g.DB().Exec(ctx, statement); err != nil { + t.Fatal(err) + } + } + accessCodes, _, err := NewSysAuth().GetUserAccessCodeList(ctx, 1) + if err != nil { + t.Fatal(err) + } + if !slices.Contains(accessCodes, "cpm:system:test:allowed") { + t.Fatalf("administrator permissions missing: %v", accessCodes) + } + menus, err := NewSysMenu().GetUserMenu(ctx) + if err != nil || len(menus) != 2 { + t.Fatalf("administrator menu loading failed: %v %v", menus, err) + } + }) + t.Run("不能仅凭旧令牌声称是平台管理员", func(t *testing.T) { + stale := contexts.Set(context.Background(), &model.Context{User: &model.Identity{BaseClaims: model.BaseClaims{ID: 2, TenantId: "000000", DeptId: 175, Roles: []model.Role{{RoleId: consts.SuperAdminRoleId}}}}}) + if _, _, err := NewSysAuth().GetUserAccessCodeList(stale, 2); err == nil { + t.Fatal("stale administrator claim bypassed template validation") + } + }) + t.Run("停用平台管理员角色不能跳过模板校验", func(t *testing.T) { + if _, err := g.DB().Exec(ctx, "UPDATE sys_role SET status='1' WHERE role_id=1"); err != nil { + t.Fatal(err) + } + defer func() { + if _, err := g.DB().Exec(ctx, "UPDATE sys_role SET status='0' WHERE role_id=1"); err != nil { + t.Error(err) + } + }() + if _, _, err := NewSysAuth().GetUserAccessCodeList(ctx, 1); err == nil { + t.Fatal("disabled administrator role bypassed validation") + } + }) + t.Run("跨租户管理员标记不能跳过模板校验", func(t *testing.T) { + other := contexts.Set(context.Background(), &model.Context{User: &model.Identity{BaseClaims: model.BaseClaims{ID: 1, TenantId: "other", DeptId: 100, Roles: []model.Role{{RoleId: consts.SuperAdminRoleId}}}}}) + if _, _, err := NewSysAuth().GetUserAccessCodeList(other, 1); err == nil { + t.Fatal("cross-tenant administrator claim bypassed validation") + } + }) + t.Run("模板参数格式异常不阻断平台管理员", func(t *testing.T) { + if _, err := g.DB().Exec(ctx, "UPDATE sys_config SET config_value='invalid'"); err != nil { + t.Fatal(err) + } + if _, _, err := NewSysAuth().GetUserAccessCodeList(ctx, 1); err != nil { + t.Fatal(err) + } + }) + t.Run("平台单独扩大默认角色权限后菜单及鉴权均生效且不扩大数据范围", func(t *testing.T) { + for _, statement := range []string{ + "UPDATE sys_config SET config_value='82'", + "INSERT INTO sys_role (role_id,tenant_id,dept_id,created_dept,status,is_pass,data_scope) VALUES (82,'000000',100,100,'0',1,'4')", + "INSERT INTO sys_role_menu VALUES ('000000',82,10,'0'),('000000',102,10,'0'),('000000',102,11,'0')", + } { + if _, err := g.DB().Exec(ctx, statement); err != nil { + t.Fatal(err) + } + } + agent := contexts.Set(context.Background(), &model.Context{User: &model.Identity{BaseClaims: model.BaseClaims{ID: 2, TenantId: "000000", DeptId: 175}}}) + accessCodes, roleCodes, err := NewSysAuth().GetUserAccessCodeList(agent, 2) + if err != nil { + t.Fatal(err) + } + if !slices.Contains(accessCodes, "cpm:system:test:allowed") || !slices.Contains(accessCodes, "cpm:system:test:restricted") { + t.Fatalf("agent menu boundary violated: %v", accessCodes) + } + menus, err := NewSysMenu().GetUserMenu(agent) + if err != nil || len(menus) != 2 { + t.Fatalf("individual menus missing: %v %v", menus, err) + } + if len(roleCodes) != 2 || !strings.HasSuffix(roleCodes[0], "|4") || !strings.HasSuffix(roleCodes[1], "|4") { + t.Fatalf("agent data scope boundary violated: %v", roleCodes) + } + }) + + t.Run("平台从角色管理扩大普通角色权限不受默认角色限制", func(t *testing.T) { + for _, statement := range []string{ + "DELETE FROM sys_role_menu WHERE role_id=102 AND menu_id=11", + "INSERT INTO sys_role(role_id,tenant_id,dept_id,created_dept,status,data_scope,role_key) VALUES(103,'000000',175,175,'0','4','special')", + "INSERT INTO sys_user VALUES(3,'000000',175,'special','0',NULL)", + "INSERT INTO sys_user_role VALUES('000000',3,103)", + } { + if _, err := g.DB().Exec(ctx, statement); err != nil { + t.Fatal(err) + } + } + if _, err := service.Edit(rolesync.DeferRecording(ctx), &model.SysRoleEditParam{RoleId: 103, MenuIds: []int64{11}}); err != nil { + t.Fatal(err) + } + special := contexts.Set(context.Background(), &model.Context{User: &model.Identity{BaseClaims: model.BaseClaims{ID: 3, TenantId: "000000", DeptId: 175}}}) + codes, _, err := NewSysAuth().GetUserAccessCodeList(special, 3) + if err != nil || !slices.Contains(codes, "cpm:system:test:restricted") { + t.Fatalf("saved permission did not reach API authorization: %v %v", codes, err) + } + menus, err := NewSysMenu().GetUserMenu(special) + if err != nil || len(menus) != 1 || menus[0].MenuId != 11 { + t.Fatalf("saved permission did not reach navigation: %v %v", menus, err) + } + if _, err := g.DB().Exec(ctx, "DELETE FROM sys_role_menu WHERE role_id=103"); err != nil { + t.Fatal(err) + } + codes, _, err = NewSysAuth().GetUserAccessCodeList(special, 3) + if err != nil || slices.Contains(codes, "cpm:system:test:restricted") { + t.Fatalf("revoked role permission remained usable: %v %v", codes, err) + } + }) + t.Run("角色详情说明模板和默认角色身份", func(t *testing.T) { + for _, test := range []struct { + id int64 + template, defaultRole bool + }{{82, true, false}, {102, false, true}, {103, false, false}} { + role, err := service.View(ctx, &model.SysRoleViewParam{RoleId: test.id}) + if err != nil { + t.Fatal(err) + } + if role.IsTemplateRole != test.template || role.IsDeptDefaultRole != test.defaultRole { + t.Fatalf("wrong role metadata: %+v", role) + } + } + }) + + t.Run("非平台管理员不能单独修改模板菜单或数据范围", func(t *testing.T) { + operator := contexts.Set(context.Background(), &model.Context{User: &model.Identity{BaseClaims: model.BaseClaims{ID: 2, TenantId: "000000", DeptId: 175}}}) + if err := service.RoleMenu(operator, 82, []int64{10}, nil); err == nil || !strings.Contains(err.Error(), "仅平台超级管理员") { + t.Fatalf("template menu change was not rejected: %v", err) + } + if err := service.EditRoleDataScope(operator, &model.SysRoleDataScopeEditParam{RoleId: 82, DataScope: consts.SysRoleDataScopeDeptAndBelow}); err == nil || !strings.Contains(err.Error(), "仅平台超级管理员") { + t.Fatalf("template data scope change was not rejected: %v", err) + } + }) + t.Run("企业入口允许已有下发角色时新增多个普通角色", func(t *testing.T) { + for _, roleName := range []string{"财务", "客服"} { + roleID, err := agent.NewAgentRole().RoleSave(ctx, &model.AgentRoleSaveParam{RoleName: roleName, RoleKey: "staff", MenuIds: []int64{10}, DataScope: consts.SysRoleDataScopePersonal}) + if err != nil { + t.Fatal(err) + } + record, err := g.DB().Model("sys_role").Where("role_id", roleID).One() + if err != nil || record["is_pass"].Int() != 0 || record["data_scope"].String() != consts.SysRoleDataScopePersonal { + t.Fatalf("ordinary role was changed: %v %v", record, err) + } + } + }) + t.Run("配置平台模板后历史下发标记不限制普通角色维护", func(t *testing.T) { + _, err := agent.NewAgentRole().RoleSave(ctx, &model.AgentRoleSaveParam{RoleName: "历史标记角色", RoleKey: "legacy", IsPass: 1}) + if err != nil { + t.Fatal(err) + } + }) + t.Run("平台模板由参数识别不依赖隐藏下发标记", func(t *testing.T) { + _, err := agent.NewAgentRole().RoleSave(rolesync.DeferRecording(ctx), &model.AgentRoleSaveParam{RoleId: 82, RoleName: "模板", RoleKey: "template", MenuIds: []int64{10}, IsPass: 0}) + if err != nil { + t.Fatal(err) + } + template, err := rolepolicy.Template(ctx) + if err != nil || template == nil || template.RoleId != 82 || template.IsPass != 0 { + t.Fatalf("configured template not recognized: %+v %v", template, err) + } + }) + t.Run("企业入口拒绝错误历史传递标记", func(t *testing.T) { + _, err := agent.NewAgentRole().RoleSave(ctx, &model.AgentRoleSaveParam{RoleName: "无效标记", RoleKey: "invalid", IsPass: 2}) + if err == nil || !strings.Contains(err.Error(), "传递标记无效") { + t.Fatalf("invalid legacy marker was accepted: %v", err) + } + }) + + t.Run("配置模板后历史下发标记不阻断普通角色删除", func(t *testing.T) { + for _, statement := range []string{ + "INSERT INTO sys_role(role_id,tenant_id,dept_id,created_dept,status,is_pass,data_scope) VALUES(600,'000000',100,100,'0',1,'4')", + "UPDATE sys_role SET created_dept=100 WHERE role_id=102", + } { + if _, err := g.DB().Exec(ctx, statement); err != nil { + t.Fatal(err) + } + } + if _, err := service.Delete(ctx, &model.SysRoleDeleteParam{RoleId: 600}); err != nil { + t.Fatal(err) + } + }) + t.Run("授权范围不能超出当前操作员有效角色", func(t *testing.T) { + if _, err := g.DB().Exec(ctx, "INSERT INTO sys_role(role_id,tenant_id,dept_id,created_dept,status,is_pass,data_scope,role_key) VALUES(501,'000000',175,175,'0',0,'4','staff')"); err != nil { + t.Fatal(err) + } + if _, err := g.DB().Exec(ctx, "INSERT INTO sys_user_role VALUES('000000',3,501)"); err != nil { + t.Fatal(err) + } + operator := contexts.Set(context.Background(), &model.Context{User: &model.Identity{BaseClaims: model.BaseClaims{ID: 3, TenantId: "000000", DeptId: 175}}}) + if err := service.RoleMenu(operator, 501, []int64{10}, nil); err == nil || !strings.Contains(err.Error(), "超出") { + t.Fatalf("operator without permission granted it: %v", err) + } + if _, err := g.DB().Exec(ctx, "INSERT INTO sys_role_menu VALUES('000000',501,10,'0')"); err != nil { + t.Fatal(err) + } + if err := service.RoleMenu(operator, 501, []int64{10}, nil); err != nil { + t.Fatal(err) + } + if _, err := g.DB().Exec(ctx, "UPDATE sys_role SET status='1' WHERE role_id=501"); err != nil { + t.Fatal(err) + } + if err := service.RoleMenu(operator, 501, []int64{10}, nil); err == nil { + t.Fatal("disabled operator role granted permission") + } + }) + t.Run("展示祖先兼容旧请求但不写入实际授权", func(t *testing.T) { + for _, statement := range []string{ + "INSERT INTO sys_menu VALUES (900,'展示目录',0,'display','M',0,'','0',NULL),(901,'未授权父菜单',900,'parent','C',0,'system:parent:list','0',NULL)", + "UPDATE sys_menu SET parent_id=901 WHERE menu_id=10", + } { + if _, err := g.DB().Exec(ctx, statement); err != nil { + t.Fatal(err) + } + } + operator := contexts.Set(context.Background(), &model.Context{User: &model.Identity{BaseClaims: model.BaseClaims{ID: 2, TenantId: "000000", DeptId: 175}}}) + tree, assignable, err := agent.NewAgentMenu().AllowedMenuTree(operator, 175) + if err != nil { + t.Fatal(err) + } + if !slices.Contains(assignable, 10) || slices.Contains(assignable, 900) || slices.Contains(assignable, 901) { + t.Fatalf("display ancestors leaked into assignable IDs: %v", assignable) + } + if len(tree) != 3 || !slices.ContainsFunc(tree, func(menu *model.SysMenuTreeModel) bool { return menu.MenuId == 901 && menu.ParentId == 900 }) || !slices.ContainsFunc(tree, func(menu *model.SysMenuTreeModel) bool { return menu.MenuId == 10 && menu.ParentId == 901 }) { + t.Fatalf("display hierarchy is incomplete: %+v", tree) + } + if err := service.RoleMenu(operator, 502, []int64{900, 901, 10}, nil); err != nil { + t.Fatal(err) + } + var stored []int64 + values, err := g.DB().Model("sys_role_menu").Where("role_id", 502).Array("menu_id") + if err != nil { + t.Fatal(err) + } + for _, value := range values { + stored = append(stored, value.Int64()) + } + if len(stored) != 1 || stored[0] != 10 { + t.Fatalf("display-only permissions were persisted: %v", stored) + } + newRole, err := agent.NewAgentRole().RoleSave(rolesync.DeferRecording(operator), &model.AgentRoleSaveParam{ + RoleName: "下级测试角色", RoleKey: "scoped-agent", MenuIds: []int64{900, 901, 10}, + }) + if err != nil { + t.Fatal(err) + } + storedMenus, err := g.DB().Model("sys_role_menu").Where("role_id", newRole).Array("menu_id") + if err != nil || len(storedMenus) != 1 || storedMenus[0].Int64() != 10 { + t.Fatalf("agent save did not preserve the same authorization boundary: %v %v", storedMenus, err) + } + if err := service.RoleMenu(operator, 502, []int64{10, 11}, nil); err == nil { + t.Fatal("unrelated permission was accepted") + } + count, err := g.DB().Model("sys_role_menu").Where("role_id", 502).Count() + if err != nil || count != 1 { + t.Fatalf("failed validation changed permissions: %d %v", count, err) + } + }) + t.Run("默认角色缺失明确报错而不是静默返回404", func(t *testing.T) { + if _, err := g.DB().Exec(ctx, "UPDATE sys_dept SET dept_role=0 WHERE dept_id=175"); err != nil { + t.Fatal(err) + } + defer func() { _, _ = g.DB().Exec(ctx, "UPDATE sys_dept SET dept_role=102 WHERE dept_id=175") }() + if _, err := rolepolicy.RestrictMenus(ctx, "000000", 175, []int64{10}); err == nil || !strings.Contains(err.Error(), "未关联默认角色") { + t.Fatalf("missing role must be actionable: %v", err) + } + operator := contexts.Set(context.Background(), &model.Context{User: &model.Identity{BaseClaims: model.BaseClaims{ID: 2, TenantId: "000000", DeptId: 175}}}) + if _, err := g.DB().Exec(ctx, "UPDATE sys_dept SET dept_type=1 WHERE dept_id=175"); err != nil { + t.Fatal(err) + } + if err := agent.NewAgentDept().EnsureCurrentDeptDefaultRole(operator); err == nil { + t.Fatal("missing default role was not reported") + } + count, err := g.DB().Model("sys_user_role").Where("user_id", 2).Count() + if err != nil || count != 1 { + t.Fatalf("read request changed user roles: %d %v", count, err) + } + }) +} diff --git a/server/internal/logic/system/sys_social.go b/server/internal/logic/system/sys_social.go index 299b3c0..62af333 100644 --- a/server/internal/logic/system/sys_social.go +++ b/server/internal/logic/system/sys_social.go @@ -1,7 +1,7 @@ // package system // @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 system diff --git a/server/internal/logic/system/sys_tenant.go b/server/internal/logic/system/sys_tenant.go index 8cbe3b5..a07b575 100644 --- a/server/internal/logic/system/sys_tenant.go +++ b/server/internal/logic/system/sys_tenant.go @@ -1,7 +1,7 @@ // package system // @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 system diff --git a/server/internal/logic/system/sys_tenant_package.go b/server/internal/logic/system/sys_tenant_package.go index 08213af..64261df 100644 --- a/server/internal/logic/system/sys_tenant_package.go +++ b/server/internal/logic/system/sys_tenant_package.go @@ -1,7 +1,7 @@ // package system // @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 system diff --git a/server/internal/logic/system/sys_user.go b/server/internal/logic/system/sys_user.go index 03db41c..050b26c 100644 --- a/server/internal/logic/system/sys_user.go +++ b/server/internal/logic/system/sys_user.go @@ -1,7 +1,7 @@ // package system // @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 system @@ -16,6 +16,7 @@ import ( "git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/bcache" "git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/contexts" "git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/event" + "git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/rolepolicy" "git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/xgorm/handler" "git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/logic/smsgateway" "git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/model" @@ -317,6 +318,9 @@ func (l *sSysUser) AddUser(ctx context.Context, req *model.SysUserAddModel) (dat return nil, gerror.NewCode(consts.CodeDeptNotFound, "部门不存在") } + if err := rolepolicy.ValidateUserRoleAssignment(ctx, req.TenantId, req.DeptId, req.RoleIds, nil); err != nil { + return nil, err + } // 判断用户名是否存在(在当前租户下) var user *entity.SysUser err = dao.SysUser.Ctx(ctx).Where(dao.SysUser.Columns().UserName, req.UserName).Where(dao.SysUser.Columns().TenantId, req.TenantId).Scan(&user) @@ -600,6 +604,18 @@ func (l *sSysUser) UpdateUser(ctx context.Context, req *model.SysUserUpdateModel return gerror.NewCode(consts.CodeUserNotFound, "用户不存在") } tenantId := existsUser.TenantId + existingRoles, err := l.GetUserRoleIds(ctx, req.UserId) + if err != nil { + return err + } + deptID := existsUser.DeptId + if req.DeptId != nil && *req.DeptId != deptID { + deptID = *req.DeptId + existingRoles = nil + } + if err := rolepolicy.ValidateUserRoleAssignment(ctx, tenantId, deptID, req.RoleIds, existingRoles); err != nil { + return err + } req.UpdatedAt = gtime.Now() req.UpdatedBy = &currUserId _, err = l.Model(ctx).Where(dao.SysUser.Columns().UserId, req.UserId).Data(req).OmitNil().Update() @@ -801,7 +817,7 @@ func (l *sSysUser) UpdateLoginInfo(ctx context.Context, userId int64, loginIp st func (l *sSysUser) GetUserRoleIds(ctx context.Context, userId int64) (roleIds []int64, err error) { urList := make([]*entity.SysUserRole, 0) tenantId := contexts.GetTenantId(ctx) - db := dao.SysUserRole.Ctx(ctx).Where(dao.SysUserRole.Columns().UserId, userId) + db := dao.SysUserRole.Ctx(ctx).Master().Where(dao.SysUserRole.Columns().UserId, userId) if tenantId != "" { db = db.Where(dao.SysUserRole.Columns().TenantId, tenantId) } diff --git a/server/internal/logic/system/sys_user_online.go b/server/internal/logic/system/sys_user_online.go index 9ff5e83..8758f06 100644 --- a/server/internal/logic/system/sys_user_online.go +++ b/server/internal/logic/system/sys_user_online.go @@ -1,7 +1,7 @@ // package system // @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 system diff --git a/server/internal/logic/system/sys_user_test.go b/server/internal/logic/system/sys_user_test.go new file mode 100644 index 0000000..31435e8 --- /dev/null +++ b/server/internal/logic/system/sys_user_test.go @@ -0,0 +1,51 @@ +package system_test + +import ( + "context" + "testing" + + _ "github.com/gogf/gf/contrib/drivers/sqlite/v2" + "github.com/gogf/gf/v2/database/gdb" + "git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/dao" + "git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/model/do" + "git.5iot.com/iotlcm-public/iotlcm-admin-svr/utility" +) + +func TestSysUser_UpdateUserPassword(t *testing.T) { + database, err := gdb.New(gdb.ConfigNode{Type: "sqlite", Link: "sqlite::@file(:memory:)"}) + if err != nil { + t.Fatal(err) + } + ctx := context.Background() + t.Cleanup(func() { _ = database.Close(ctx) }) + if _, err := database.Exec(ctx, "CREATE TABLE sys_user (user_id INTEGER PRIMARY KEY, tenant_id TEXT, password TEXT, salt TEXT, updated_by INTEGER, created_at DATETIME, updated_at DATETIME, deleted_at DATETIME)"); err != nil { + t.Fatal(err) + } + for _, userID := range []int64{900001, 900002} { + if _, err := database.Model(dao.SysUser.Table()).Ctx(ctx).Data(do.SysUser{ + UserId: userID, TenantId: "test-only", Password: "original", Salt: "original", + }).Insert(); err != nil { + t.Fatal(err) + } + } + password := utility.PasswordEncrypt(utility.Md5("isolated-test-password"), "isolated-test-salt") + if _, err := database.Model(dao.SysUser.Table()).Ctx(ctx). + Where("tenant_id", "test-only").Where("user_id", 900001). + Data(do.SysUser{Password: password, Salt: "isolated-test-salt", UpdatedBy: 900001}).Update(); err != nil { + t.Fatal(err) + } + var updated struct { + Password string + Salt string + } + if err := database.Model(dao.SysUser.Table()).Ctx(ctx).Where("user_id", 900001).Scan(&updated); err != nil { + t.Fatal(err) + } + if updated.Password != password || updated.Salt != "isolated-test-salt" { + t.Fatal("password and salt were not updated in the isolated database") + } + untouched, err := database.Model(dao.SysUser.Table()).Ctx(ctx).Where("user_id", 900002).Value("password") + if err != nil || untouched.String() != "original" { + t.Fatalf("another test user was modified: %v", err) + } +} diff --git a/server/internal/logic/system/t_tplatform_setting.go b/server/internal/logic/system/t_tplatform_setting.go index bccbe7d..30710ba 100644 --- a/server/internal/logic/system/t_tplatform_setting.go +++ b/server/internal/logic/system/t_tplatform_setting.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 system diff --git a/server/internal/logic/system/task_test.go b/server/internal/logic/system/task_test.go new file mode 100644 index 0000000..14f2976 --- /dev/null +++ b/server/internal/logic/system/task_test.go @@ -0,0 +1,169 @@ +// // package system_test +// // @Link https://github.com/xiujiecn/xiu-admin +// // @Copyright Copyright (c) 2025 LiXiujie +// // @Author Lxj +// // @License https://github.com/xiujiecn/xiu-admin/blob/master/LICENSE +package system_test + +// import ( +// "context" +// "encoding/json" +// "fmt" +// "testing" +// "time" +// "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/worker" +// "git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/model" +// "git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/tasks" + +// _ "github.com/gogf/gf/contrib/drivers/mysql/v2" +// "github.com/gogf/gf/v2/frame/g" +// "github.com/gogf/gf/v2/os/gctx" +// "github.com/gogf/gf/v2/util/gconv" +// ) + +// func init() { +// // 数据库只读,不执行 +// // g.DB().SetDryRun(true) +// g.DB().SetDebug(true) +// testing.Init() +// } + +// func TestTask_runOnce(t *testing.T) { +// // 设置上下文用户身份为用户 +// ctx := context.WithValue(gctx.New(), consts.ContextKey, &model.Context{ +// // 为了测试只设置了hook中需要用到的数据 +// User: &model.Identity{ +// BaseClaims: model.BaseClaims{ +// ID: 1, +// TenantId: "000000", +// DeptId: 1, +// }, +// }, +// }) + +// exists := &model.SysJobViewModel{} +// dao.SysJob.Ctx(ctx).Where(dao.SysJob.Columns().JobId, 4).Scan(&exists) +// paramArr, _ := tasks.TasksInstance().ParseParameters(exists.JobParams) +// taskData := tasks.Task{ +// ID: fmt.Sprintf("%s-job-%d", exists.InvokeTarget, exists.JobId), +// TaskType: "Type-" + gconv.String(exists.MisfirePolicy), +// MethodName: exists.InvokeTarget, +// Params: paramArr, +// Explain: exists.JobName, +// } +// runPayload, _ := json.Marshal(taskData) + +// tasks.TasksInstance().Once( +// worker.WithTaskCtx(context.Background()), +// worker.WithTaskUid(taskData.ID), // 任务ID +// worker.WithTaskGroup(taskData.MethodName), // 任务组 +// worker.WithTaskTimeout(10), +// worker.WithTaskNow(true), +// worker.WithTaskReplace(true), +// worker.WithTaskPayload(runPayload), +// ) +// } + +// func TestTask_schedule(t *testing.T) { +// // 设置上下文用户身份为用户 +// ctx := context.WithValue(gctx.New(), consts.ContextKey, &model.Context{ +// // 为了测试只设置了hook中需要用到的数据 +// User: &model.Identity{ +// BaseClaims: model.BaseClaims{ +// ID: 1, +// TenantId: "000000", +// DeptId: 1, +// }, +// }, +// }) + +// exists := &model.SysJobViewModel{} +// dao.SysJob.Ctx(ctx).Where(dao.SysJob.Columns().JobId, 4).Scan(&exists) +// paramArr, _ := tasks.TasksInstance().ParseParameters(exists.JobParams) +// //获取task目录下是否绑定对应的方法 +// actualName, exist := tasks.TasksInstance().CheckFuncName(exists.InvokeTarget) +// if !exist { +// errInfo := fmt.Sprintf("没有绑定对应的方法:%s", exists.InvokeTarget) +// g.Log().Error(ctx, errInfo) +// } + +// //传参解析 +// paramArr, err := tasks.TasksInstance().ParseParameters(exists.JobParams) +// if err != nil { +// g.Log().Error(ctx, err) +// } + +// taskData := tasks.Task{ +// ID: fmt.Sprintf("%s-job-%d", exists.InvokeTarget, exists.JobId), +// TaskType: "Type-" + gconv.String(exists.MisfirePolicy), +// MethodName: actualName, +// Params: paramArr, +// Explain: exists.JobName, +// } +// runPayload, _ := json.Marshal(taskData) +// _, err2 := tasks.TasksInstance().Cron( +// 1, +// worker.WithTaskCtx(context.Background()), +// worker.WithTaskUid(taskData.ID), // 任务ID +// worker.WithTaskGroup(taskData.MethodName), // 任务组 +// worker.WithTaskExpr("0/2 * * * * *"), +// //worker.WithTaskExpr("@every 2s"), +// worker.WithTaskTimeout(10), +// worker.WithTaskReplace(true), +// worker.WithTaskPayload(runPayload), +// ) +// if err2 != nil { +// g.Log().Debug(ctx, taskData.MethodName, taskData.Explain, "启动任务失败") +// } + +// time.Sleep(10 * time.Minute) +// } + +// func TestTask_remove(t *testing.T) { +// // 设置上下文用户身份为用户 +// ctx := context.WithValue(gctx.New(), consts.ContextKey, &model.Context{ +// // 为了测试只设置了hook中需要用到的数据 +// User: &model.Identity{ +// BaseClaims: model.BaseClaims{ +// ID: 1, +// TenantId: "000000", +// DeptId: 1, +// }, +// }, +// }) + +// exists := &model.SysJobViewModel{} +// dao.SysJob.Ctx(ctx).Where(dao.SysJob.Columns().JobId, 4).Scan(&exists) +// taskJobId := fmt.Sprintf("%s-job-%d", exists.InvokeTarget, exists.JobId) +// g.Log().Debug(ctx, taskJobId) +// err := tasks.TasksInstance().Remove(taskJobId) +// if err != nil { +// g.Log().Debug(ctx, exists.JobName, "启动停止失败") +// g.Log().Error(ctx, err) +// } +// } + +// func TestTask_getTask(t *testing.T) { +// ctx := context.WithValue(gctx.New(), consts.ContextKey, &model.Context{ +// // 为了测试只设置了hook中需要用到的数据 +// User: &model.Identity{ +// BaseClaims: model.BaseClaims{ +// ID: 1, +// TenantId: "000000", +// DeptId: 1, +// }, +// }, +// }) + +// exists := &model.SysJobViewModel{} +// dao.SysJob.Ctx(ctx).Where(dao.SysJob.Columns().JobId, 4).Scan(&exists) +// taskJobId := fmt.Sprintf("%s-job-%d", exists.InvokeTarget, exists.JobId) +// task, err := tasks.TasksInstance().GetTask(taskJobId) +// if err != nil { +// g.Log().Debug(ctx, exists.JobName, "获取任务失败") +// g.Log().Error(ctx, err) +// } +// fmt.Println("task", task) +// } diff --git a/server/internal/logic/trafficcutoff/notify.go b/server/internal/logic/trafficcutoff/notify.go index c0f65cf..a0d3d5c 100644 --- a/server/internal/logic/trafficcutoff/notify.go +++ b/server/internal/logic/trafficcutoff/notify.go @@ -16,14 +16,23 @@ func notifyCutoffBatch(ctx context.Context, key cutoffBatchGroupKey, items []loc return } - deptName := cutoffDeptName(ctx, key.CreatedDept) + deptNames := make(map[int64]string) events := make([]warningnotify.Event, 0, len(items)) for _, item := range items { card := item.Card + deptID := cutoffNotificationDeptID(card, key.CreatedDept) + if card.DeptId <= 0 { + g.Log().Warningf(ctx, "TrafficCutoff notify fallback to created dept because card dept is empty, cardId:%d, iccid:%s, createdDept:%d", card.Id, card.Iccid, key.CreatedDept) + } + deptName, ok := deptNames[deptID] + if !ok { + deptName = cutoffDeptName(ctx, deptID) + deptNames[deptID] = deptName + } events = append(events, warningnotify.Event{ Business: warningnotify.BusinessTrafficCutoff, TenantID: key.TenantId, - DeptID: key.CreatedDept, + DeptID: deptID, DeptName: deptName, CreatedBy: key.CreatedBy, Category: "达量断网", @@ -51,6 +60,13 @@ func notifyCutoffBatch(ctx context.Context, key cutoffBatchGroupKey, items []loc } } +func cutoffNotificationDeptID(card cutoffCard, fallbackDeptID int64) int64 { + if card.DeptId > 0 { + return card.DeptId + } + return fallbackDeptID +} + func cutoffDeptName(ctx context.Context, deptID int64) string { if deptID <= 0 { return "" diff --git a/server/internal/logic/trafficcutoff/scanner.go b/server/internal/logic/trafficcutoff/scanner.go index 05b6ced..fcf4697 100644 --- a/server/internal/logic/trafficcutoff/scanner.go +++ b/server/internal/logic/trafficcutoff/scanner.go @@ -103,6 +103,7 @@ func (s *sTrafficCutoff) queryCandidates(ctx context.Context, lastID int64, runT triggeredAtField, field(columns.ChanKey), field(columns.TenantId), + field(columns.DeptId), field(columns.CreatedDept), field(columns.CreatedBy), cardflow.TotalFlowExpr(cardTable, packageAlias)+" AS total_flow_mb", diff --git a/server/internal/logic/trafficcutoff/types_test.go b/server/internal/logic/trafficcutoff/types_test.go index 385eb3d..932ff19 100644 --- a/server/internal/logic/trafficcutoff/types_test.go +++ b/server/internal/logic/trafficcutoff/types_test.go @@ -2,6 +2,7 @@ package trafficcutoff import ( "git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/consts" + "git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/logic/warningnotify" "git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/model/entity" "testing" "time" @@ -93,3 +94,58 @@ func TestMonthStart(t *testing.T) { t.Fatalf("monthStart() = %v, want %v", got, want) } } + +func TestCutoffNotificationDeptIDUsesCardCurrentDept(t *testing.T) { + card := cutoffCard{TSimCardInfo: entity.TSimCardInfo{DeptId: 172, CreatedDept: 1, CreatedBy: 1}} + if got := cutoffNotificationDeptID(card, card.CreatedDept); got != 172 { + t.Fatalf("cutoffNotificationDeptID() = %d, want current card dept 172", got) + } +} + +func TestCutoffNotificationDeptIDFallsBackAndDoesNotSilentlyDrop(t *testing.T) { + card := cutoffCard{TSimCardInfo: entity.TSimCardInfo{CreatedDept: 1, CreatedBy: 1}} + if got := cutoffNotificationDeptID(card, card.CreatedDept); got != 1 { + t.Fatalf("cutoffNotificationDeptID() = %d, want fallback created dept 1", got) + } +} + +func TestCutoffNotificationsSplitByCurrentCardDept(t *testing.T) { + events := []warningnotify.Event{ + { + Business: warningnotify.BusinessTrafficCutoff, + TenantID: "000000", + DeptID: 172, + CreatedBy: 1, + Category: "达量断网", + ItemType: 1, + ItemCode: "card-for-172", + }, + { + Business: warningnotify.BusinessTrafficCutoff, + TenantID: "000000", + DeptID: 206, + CreatedBy: 1, + Category: "达量断网", + ItemType: 1, + ItemCode: "card-for-206", + }, + } + + batches := warningnotify.BuildBatches(events) + if len(batches) != 2 { + t.Fatalf("BuildBatches() returned %d batches, want 2", len(batches)) + } + + want := map[int64]string{172: "card-for-172", 206: "card-for-206"} + for _, batch := range batches { + if batch.Method != warningnotify.MethodPlatform { + t.Fatalf("batch method = %s, want platform", batch.Method) + } + if len(batch.Items) != 1 { + t.Fatalf("dept %d has %d items, want 1", batch.DeptID, len(batch.Items)) + } + if wantCode, ok := want[batch.DeptID]; !ok || batch.Items[0].ItemCode != wantCode { + t.Fatalf("batch dept=%d item=%s, want one matching current-dept card", batch.DeptID, batch.Items[0].ItemCode) + } + } +} diff --git a/server/internal/logic/trafficusage/routing_test.go b/server/internal/logic/trafficusage/routing_test.go new file mode 100644 index 0000000..93556de --- /dev/null +++ b/server/internal/logic/trafficusage/routing_test.go @@ -0,0 +1,126 @@ +package trafficusage + +import ( + "context" + "database/sql" + "database/sql/driver" + "errors" + "io" + "strings" + "testing" + "time" + + "github.com/gogf/gf/v2/frame/g" + "github.com/gogf/gf/v2/os/gcfg" + + "git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/tdengine" + "git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/model" +) + +type routingDriver struct{ statements []string } +type routingConnection struct{ owner *routingDriver } +type routingRows struct { + count bool + emitted bool +} + +func (recorder *routingDriver) Open(string) (driver.Conn, error) { + return &routingConnection{owner: recorder}, nil +} +func (connection *routingConnection) Prepare(string) (driver.Stmt, error) { + return nil, errors.New("unexpected prepare") +} +func (connection *routingConnection) Close() error { return nil } +func (connection *routingConnection) Begin() (driver.Tx, error) { + return nil, errors.New("unexpected transaction") +} +func (connection *routingConnection) Ping(context.Context) error { return nil } +func (connection *routingConnection) ExecContext(_ context.Context, statement string, _ []driver.NamedValue) (driver.Result, error) { + connection.owner.statements = append(connection.owner.statements, statement) + return driver.RowsAffected(1), nil +} +func (connection *routingConnection) QueryContext(_ context.Context, statement string, _ []driver.NamedValue) (driver.Rows, error) { + connection.owner.statements = append(connection.owner.statements, statement) + return &routingRows{count: strings.Contains(statement, "COUNT(*)")}, nil +} +func (rows *routingRows) Columns() []string { + if rows.count { + return []string{"count"} + } + return []string{"ts", "usage_mb", "package_code", "iccid"} +} +func (rows *routingRows) Close() error { return nil } +func (rows *routingRows) Next(values []driver.Value) error { + if !rows.count || rows.emitted { + return io.EOF + } + rows.emitted = true + values[0] = int64(0) + return nil +} + +var routingRecorder = &routingDriver{} + +func init() { + sql.Register("admin_traffic_split_routing", routingRecorder) +} + +func TestTrafficUsageReadWriteDeleteSplitRouting(t *testing.T) { + recorder := routingRecorder + recorder.statements = nil + previous := g.Cfg().GetAdapter() + t.Cleanup(func() { _ = tdengine.Close(); g.Cfg().SetAdapter(previous) }) + adapter, err := gcfg.NewAdapterContent("tsdb:\n database: TdEngine\n tdengine:\n type: admin_traffic_split_routing\n dsn: test\n dbName: legacy\n dailyDbName: iotlcm_daily\n monthlyDbName: iotlcm_monthly\n") + if err != nil { + t.Fatal(err) + } + g.Cfg().SetAdapter(adapter) + _ = tdengine.Close() + usage := NewTrafficUsage() + ctx := context.Background() + period := time.Date(2026, 10, 21, 0, 0, 0, 0, time.FixedZone("CST", 8*60*60)) + base := model.TrafficUsageBase{Iccid: "8986112520609988658", UsageMb: 12.5, PackageCode: "test"} + for _, test := range []struct { + name string + database string + stable string + operation func() error + }{ + {"write_daily", "iotlcm_daily", "sim_card_usage_d_", func() error { + return usage.WriteDaily(ctx, []model.TrafficUsageDailyItem{{TrafficUsageBase: base, Date: period}}) + }}, + {"write_monthly", "iotlcm_monthly", "sim_card_usage_m_", func() error { + return usage.WriteMonthly(ctx, []model.TrafficUsageMonthlyItem{{TrafficUsageBase: base, Month: period}}) + }}, + {"read_daily", "iotlcm_daily", "sim_card_usage_d_", func() error { + _, _, err := usage.QueryDaily(ctx, model.TrafficUsageQueryInput{Iccid: base.Iccid}) + return err + }}, + {"read_monthly", "iotlcm_monthly", "sim_card_usage_m_", func() error { + _, _, err := usage.QueryMonthly(ctx, model.TrafficUsageQueryInput{Iccid: base.Iccid}) + return err + }}, + {"delete_daily", "iotlcm_daily", "sim_card_usage_d_", func() error { + return usage.DeleteDaily(ctx, model.TrafficUsageDeleteInput{Iccid: base.Iccid, StartTime: period, EndTime: period.AddDate(0, 0, 1)}) + }}, + {"delete_monthly", "iotlcm_monthly", "sim_card_usage_m_", func() error { + return usage.DeleteMonthly(ctx, model.TrafficUsageDeleteInput{Iccid: base.Iccid, StartTime: period, EndTime: period.AddDate(0, 1, 0)}) + }}, + } { + t.Run(test.name, func(t *testing.T) { + recorder.statements = nil + if err := test.operation(); err != nil { + t.Fatal(err) + } + if len(recorder.statements) == 0 { + t.Fatal("no SQL issued") + } + for _, statement := range recorder.statements { + normalized := strings.ReplaceAll(statement, "\x60", "") + if !strings.Contains(normalized, test.database+"."+test.stable+base.Iccid) || strings.Contains(normalized, "legacy.") { + t.Errorf("wrong target database: %s", statement) + } + } + }) + } +} diff --git a/server/internal/logic/trafficusage/traffic_usage.go b/server/internal/logic/trafficusage/traffic_usage.go index 00c4683..3de226f 100644 --- a/server/internal/logic/trafficusage/traffic_usage.go +++ b/server/internal/logic/trafficusage/traffic_usage.go @@ -4,11 +4,11 @@ import ( "context" "database/sql" "fmt" + "hash/fnv" "git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/cmd/inithttp" "git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/tdengine" "git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/model" "git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/service" - "hash/fnv" "math" "regexp" "strconv" @@ -97,7 +97,7 @@ func (s *sTrafficUsage) WriteDaily(ctx context.Context, items []model.TrafficUsa } // WriteMonthly 批量写入月流量数据。 -// 月流量和日流量共用同一个 TDengine database,但使用不同超级表。 +// 月流量按配置写入月库,未配置分库时回退默认库。 func (s *sTrafficUsage) WriteMonthly(ctx context.Context, items []model.TrafficUsageMonthlyItem) error { if len(items) == 0 { return nil diff --git a/server/internal/logic/trafficusage/traffic_usage_daily.go b/server/internal/logic/trafficusage/traffic_usage_daily.go index 2ea4c89..f55ce04 100644 --- a/server/internal/logic/trafficusage/traffic_usage_daily.go +++ b/server/internal/logic/trafficusage/traffic_usage_daily.go @@ -11,8 +11,8 @@ import ( "git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/service" "time" - "git.5iot.com/iotlcm-public/iotlcm-admin-svr/utility/uuid32" "github.com/gogf/gf/v2/frame/g" + "git.5iot.com/iotlcm-public/iotlcm-admin-svr/utility/uuid32" ) const ( diff --git a/server/internal/logic/trafficusage/traffic_usage_daily_start_test.go b/server/internal/logic/trafficusage/traffic_usage_daily_start_test.go new file mode 100644 index 0000000..5d390fb --- /dev/null +++ b/server/internal/logic/trafficusage/traffic_usage_daily_start_test.go @@ -0,0 +1,309 @@ +package trafficusage + +import ( + "context" + "errors" + "testing" + "time" + + "git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/consts" + "git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/model" +) + +func stubDailyTrigger(t *testing.T) { + t.Helper() + originalNow := dailyUsageNow + originalRunID := dailyUsageRunID + originalEnabled := dailyUsageRabbitEnabled + originalPublish := dailyUsagePublish + t.Cleanup(func() { + dailyUsageNow = originalNow + dailyUsageRunID = originalRunID + dailyUsageRabbitEnabled = originalEnabled + dailyUsagePublish = originalPublish + }) +} + +// TestStartDailyPublishesPreviousShanghaiDay pins the period: the run must cover +// the previous calendar day in Asia/Shanghai, matching what the legacy admin +// scan wrote, so the migration cannot shift the day by one. +func TestStartDailyPublishesPreviousShanghaiDay(t *testing.T) { + stubDailyTrigger(t) + + dailyUsageNow = func() time.Time { + // 2026-10-10 06:00 +08:00 -> the previous day is 20261009. + return time.Date(2026, time.October, 10, 6, 0, 0, 0, time.FixedZone("UTC+8", 8*60*60)) + } + dailyUsageRunID = func() string { return "test-run" } + dailyUsageRabbitEnabled = func(context.Context) bool { return true } + + var publishedQueue string + var published model.TrafficUsageDailyStartMessage + dailyUsagePublish = func(_ context.Context, queue string, data any) error { + publishedQueue = queue + published = data.(model.TrafficUsageDailyStartMessage) + return nil + } + + if err := NewTrafficUsageDailySync().StartDaily(context.Background()); err != nil { + t.Fatalf("StartDaily() error = %v", err) + } + if publishedQueue != consts.QueueTrafficUsageDailyStart { + t.Fatalf("queue = %q, want %q", publishedQueue, consts.QueueTrafficUsageDailyStart) + } + if published.QueryDate != "20261009" { + t.Fatalf("queryDate = %q, want 20261009", published.QueryDate) + } + if published.RunID != "daily-usage-20261009-test-run" { + t.Fatalf("runId = %q", published.RunID) + } + if published.Operation != model.OperationPreviousDayUsageFullSync { + t.Fatalf("operation = %q", published.Operation) + } +} + +// TestStartDailyUsesShanghaiDayAcrossUTCMidnight guards the timezone bug that +// the legacy code carried: a UTC instant just after midnight is still the +// previous day in Shanghai, so the period must not be computed in UTC. +func TestStartDailyUsesShanghaiDayAcrossUTCMidnight(t *testing.T) { + stubDailyTrigger(t) + + // 2026-10-09 17:30 UTC == 2026-10-10 01:30 +08:00 -> previous day 20261009. + dailyUsageNow = func() time.Time { + return time.Date(2026, time.October, 9, 17, 30, 0, 0, time.UTC) + } + dailyUsageRunID = func() string { return "tz-run" } + dailyUsageRabbitEnabled = func(context.Context) bool { return true } + + var published model.TrafficUsageDailyStartMessage + dailyUsagePublish = func(_ context.Context, _ string, data any) error { + published = data.(model.TrafficUsageDailyStartMessage) + return nil + } + + if err := NewTrafficUsageDailySync().StartDaily(context.Background()); err != nil { + t.Fatalf("StartDaily() error = %v", err) + } + if published.QueryDate != "20261009" { + t.Fatalf("queryDate = %q, want 20261009 (Shanghai, not UTC)", published.QueryDate) + } + if published.TriggeredAt != "2026-10-10T01:30:00+08:00" { + t.Fatalf("triggeredAt = %q, want the Shanghai instant", published.TriggeredAt) + } +} + +func TestStartDailyRunScopedForwardsScope(t *testing.T) { + stubDailyTrigger(t) + + dailyUsageNow = func() time.Time { + return time.Date(2026, time.October, 10, 6, 0, 0, 0, time.FixedZone("UTC+8", 8*60*60)) + } + dailyUsageRunID = func() string { return "scoped-run" } + dailyUsageRabbitEnabled = func(context.Context) bool { return true } + + var published model.TrafficUsageDailyStartMessage + dailyUsagePublish = func(_ context.Context, _ string, data any) error { + published = data.(model.TrafficUsageDailyStartMessage) + return nil + } + + runID, err := NewTrafficUsageDailySync().StartDailyRunScoped(context.Background(), &model.SyncScope{Channels: []string{"217"}}) + if err != nil { + t.Fatalf("StartDailyRunScoped() error = %v", err) + } + if runID != "daily-usage-20261009-scoped-run" { + t.Fatalf("runId = %q", runID) + } + if published.Scope == nil || published.Scope.ChannelCount() != 1 || !published.Scope.Allows("217") { + t.Fatalf("scope = %+v, want channel 217 only", published.Scope) + } +} + +func TestStartDailyRejectsInvalidScope(t *testing.T) { + stubDailyTrigger(t) + + dailyUsageRabbitEnabled = func(context.Context) bool { return true } + called := false + dailyUsagePublish = func(context.Context, string, any) error { + called = true + return nil + } + + if _, err := NewTrafficUsageDailySync().StartDailyRunScoped(context.Background(), &model.SyncScope{Channels: []string{"invalid/chan"}}); err == nil { + t.Fatal("expected an invalid chanKey to be rejected") + } + if called { + t.Fatal("publisher called for a rejected scope") + } +} + +func TestStartDailyReturnsPublishError(t *testing.T) { + stubDailyTrigger(t) + + dailyUsageNow = func() time.Time { return time.Date(2026, time.October, 10, 6, 0, 0, 0, time.UTC) } + dailyUsageRunID = func() string { return "err-run" } + dailyUsageRabbitEnabled = func(context.Context) bool { return true } + dailyUsagePublish = func(context.Context, string, any) error { + return errors.New("broker unavailable") + } + + if err := NewTrafficUsageDailySync().StartDaily(context.Background()); err == nil { + t.Fatal("StartDaily() error = nil, want publish error") + } +} + +func TestStartDailySkipsWhenRabbitMQDisabled(t *testing.T) { + stubDailyTrigger(t) + + dailyUsageRabbitEnabled = func(context.Context) bool { return false } + called := false + dailyUsagePublish = func(context.Context, string, any) error { + called = true + return nil + } + + if err := NewTrafficUsageDailySync().StartDaily(context.Background()); err == nil { + t.Fatal("StartDaily() error = nil, want disabled error") + } + if called { + t.Fatal("publisher called while RabbitMQ is disabled") + } +} + +// dailyBackfillClock pins "now" for the period-aware daily tests: 2026-10-20 +// 06:00 +08:00, so the default day is 20261019 and nothing later is valid. +func dailyBackfillClock(t *testing.T) { + t.Helper() + stubDailyTrigger(t) + dailyUsageNow = func() time.Time { + return time.Date(2026, time.October, 20, 6, 0, 0, 0, time.FixedZone("UTC+8", 8*60*60)) + } + dailyUsageRunID = func() string { return "backfill-run" } + dailyUsageRabbitEnabled = func(context.Context) bool { return true } +} + +func captureDailyStart(t *testing.T) *model.TrafficUsageDailyStartMessage { + t.Helper() + published := &model.TrafficUsageDailyStartMessage{} + dailyUsagePublish = func(_ context.Context, _ string, data any) error { + *published = data.(model.TrafficUsageDailyStartMessage) + return nil + } + return published +} + +func TestStartDailyRunScopedAtUsesGivenDate(t *testing.T) { + dailyBackfillClock(t) + published := captureDailyStart(t) + + runID, err := NewTrafficUsageDailySync().StartDailyRunScopedAt(context.Background(), nil, "20261014") + if err != nil { + t.Fatalf("StartDailyRunScopedAt() error = %v", err) + } + if published.QueryDate != "20261014" { + t.Fatalf("queryDate = %q, want 20261014", published.QueryDate) + } + if runID != "daily-usage-20261014-backfill-run" { + t.Fatalf("runId = %q", runID) + } + if published.Operation != model.OperationPreviousDayUsageFullSync { + t.Fatalf("operation = %q", published.Operation) + } +} + +func TestStartDailyRunScopedAtRejectsFutureDate(t *testing.T) { + dailyBackfillClock(t) + called := false + dailyUsagePublish = func(context.Context, string, any) error { + called = true + return nil + } + + for _, queryDate := range []string{"20261020", "20261021", "20270101"} { + if _, err := NewTrafficUsageDailySync().StartDailyRunScopedAt(context.Background(), nil, queryDate); err == nil { + t.Fatalf("queryDate %q accepted, want a future-date error", queryDate) + } + } + if called { + t.Fatal("publisher called for a rejected date") + } +} + +func TestStartDailyRunScopedAtRejectsMalformedDate(t *testing.T) { + dailyBackfillClock(t) + called := false + dailyUsagePublish = func(context.Context, string, any) error { + called = true + return nil + } + + for _, queryDate := range []string{"2026-10-14", "abc", "2026101", "202610140", "20261314"} { + if _, err := NewTrafficUsageDailySync().StartDailyRunScopedAt(context.Background(), nil, queryDate); err == nil { + t.Fatalf("queryDate %q accepted, want a format error", queryDate) + } + } + if called { + t.Fatal("publisher called for a malformed date") + } +} + +func TestStartDailyRunScopedAtRejectsDateBeforeLowerBound(t *testing.T) { + dailyBackfillClock(t) + called := false + dailyUsagePublish = func(context.Context, string, any) error { + called = true + return nil + } + + for _, queryDate := range []string{"20191231", "19990101"} { + if _, err := NewTrafficUsageDailySync().StartDailyRunScopedAt(context.Background(), nil, queryDate); err == nil { + t.Fatalf("queryDate %q accepted, want a lower-bound error", queryDate) + } + } + if called { + t.Fatal("publisher called for a date below the lower bound") + } +} + +func TestStartDailyRunScopedAtEmptyFallsBackToYesterday(t *testing.T) { + dailyBackfillClock(t) + published := captureDailyStart(t) + + runID, err := NewTrafficUsageDailySync().StartDailyRunScopedAt(context.Background(), nil, "") + if err != nil { + t.Fatalf("StartDailyRunScopedAt() error = %v", err) + } + if published.QueryDate != "20261019" { + t.Fatalf("queryDate = %q, want 20261019 (yesterday)", published.QueryDate) + } + if runID != "daily-usage-20261019-backfill-run" { + t.Fatalf("runId = %q", runID) + } +} + +// TestStartDailyRunScopedAtEmptyMatchesLegacyScoped keeps the backward +// compatibility promise concrete: the old entry point and an empty period must +// publish byte-identical messages. +func TestStartDailyRunScopedAtEmptyMatchesLegacyScoped(t *testing.T) { + dailyBackfillClock(t) + var messages []model.TrafficUsageDailyStartMessage + dailyUsagePublish = func(_ context.Context, _ string, data any) error { + messages = append(messages, data.(model.TrafficUsageDailyStartMessage)) + return nil + } + + legacyID, err := NewTrafficUsageDailySync().StartDailyRunScoped(context.Background(), nil) + if err != nil { + t.Fatal(err) + } + atID, err := NewTrafficUsageDailySync().StartDailyRunScopedAt(context.Background(), nil, "") + if err != nil { + t.Fatal(err) + } + if len(messages) != 2 { + t.Fatalf("published %d messages, want 2", len(messages)) + } + if legacyID != atID || messages[0] != messages[1] { + t.Fatalf("legacy = %+v/%q, at = %+v/%q", messages[0], legacyID, messages[1], atID) + } +} diff --git a/server/internal/logic/trafficusage/traffic_usage_monthly.go b/server/internal/logic/trafficusage/traffic_usage_monthly.go index 47f5c4f..8300aa0 100644 --- a/server/internal/logic/trafficusage/traffic_usage_monthly.go +++ b/server/internal/logic/trafficusage/traffic_usage_monthly.go @@ -11,8 +11,8 @@ import ( "git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/service" "time" - "git.5iot.com/iotlcm-public/iotlcm-admin-svr/utility/uuid32" "github.com/gogf/gf/v2/frame/g" + "git.5iot.com/iotlcm-public/iotlcm-admin-svr/utility/uuid32" ) const ( diff --git a/server/internal/logic/trafficusage/traffic_usage_monthly_history_start_test.go b/server/internal/logic/trafficusage/traffic_usage_monthly_history_start_test.go new file mode 100644 index 0000000..bdc4671 --- /dev/null +++ b/server/internal/logic/trafficusage/traffic_usage_monthly_history_start_test.go @@ -0,0 +1,392 @@ +package trafficusage + +import ( + "context" + "errors" + "testing" + "time" + + "git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/consts" + "git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/model" +) + +func stubMonthlyHistoryTrigger(t *testing.T) { + t.Helper() + originalNow := monthlyHistoryNow + originalRunID := monthlyHistoryRunID + originalEnabled := monthlyHistoryRabbitEnabled + originalPublish := monthlyHistoryPublish + t.Cleanup(func() { + monthlyHistoryNow = originalNow + monthlyHistoryRunID = originalRunID + monthlyHistoryRabbitEnabled = originalEnabled + monthlyHistoryPublish = originalPublish + }) +} + +// TestStartMonthlyHistoryPublishesPreviousMonth pins the period: the run must +// backfill the previous calendar month in Asia/Shanghai, matching what the +// legacy SyncMonthly scan wrote. +func TestStartMonthlyHistoryPublishesPreviousMonth(t *testing.T) { + stubMonthlyHistoryTrigger(t) + + monthlyHistoryNow = func() time.Time { + return time.Date(2026, time.October, 10, 6, 0, 0, 0, time.FixedZone("UTC+8", 8*60*60)) + } + monthlyHistoryRunID = func() string { return "test-run" } + monthlyHistoryRabbitEnabled = func(context.Context) bool { return true } + + var publishedQueue string + var published model.TrafficUsageMonthlyHistoryStartMessage + monthlyHistoryPublish = func(_ context.Context, queue string, data any) error { + publishedQueue = queue + published = data.(model.TrafficUsageMonthlyHistoryStartMessage) + return nil + } + + if err := NewTrafficUsageMonthlySync().StartMonthlyHistory(context.Background()); err != nil { + t.Fatalf("StartMonthlyHistory() error = %v", err) + } + if publishedQueue != consts.QueueTrafficUsageMonthlyHistoryStart { + t.Fatalf("queue = %q, want %q", publishedQueue, consts.QueueTrafficUsageMonthlyHistoryStart) + } + if published.QueryMonth != "202609" { + t.Fatalf("queryMonth = %q, want 202609", published.QueryMonth) + } + if published.RunID != "monthly-history-202609-test-run" { + t.Fatalf("runId = %q", published.RunID) + } + if published.Operation != model.OperationPreviousMonthUsageFullSync { + t.Fatalf("operation = %q", published.Operation) + } +} + +// TestStartMonthlyHistoryHandlesJanuaryRollover guards the year boundary: the +// previous month of January is December of the prior year. +func TestStartMonthlyHistoryHandlesJanuaryRollover(t *testing.T) { + stubMonthlyHistoryTrigger(t) + + monthlyHistoryNow = func() time.Time { + return time.Date(2026, time.January, 5, 6, 0, 0, 0, time.FixedZone("UTC+8", 8*60*60)) + } + monthlyHistoryRunID = func() string { return "jan-run" } + monthlyHistoryRabbitEnabled = func(context.Context) bool { return true } + + var published model.TrafficUsageMonthlyHistoryStartMessage + monthlyHistoryPublish = func(_ context.Context, _ string, data any) error { + published = data.(model.TrafficUsageMonthlyHistoryStartMessage) + return nil + } + + if err := NewTrafficUsageMonthlySync().StartMonthlyHistory(context.Background()); err != nil { + t.Fatalf("StartMonthlyHistory() error = %v", err) + } + if published.QueryMonth != "202512" { + t.Fatalf("queryMonth = %q, want 202512 (January rolls back to the previous December)", published.QueryMonth) + } +} + +// TestStartMonthlyHistoryUsesShanghaiMonthAcrossUTCMidnight guards the timezone +// bug the legacy code carried: a UTC instant on the 1st is still the last day of +// the previous month in Shanghai. +func TestStartMonthlyHistoryUsesShanghaiMonthAcrossUTCMidnight(t *testing.T) { + stubMonthlyHistoryTrigger(t) + + // 2026-09-30 17:30 UTC == 2026-10-01 01:30 +08:00 -> previous month 202609. + monthlyHistoryNow = func() time.Time { + return time.Date(2026, time.September, 30, 17, 30, 0, 0, time.UTC) + } + monthlyHistoryRunID = func() string { return "tz-run" } + monthlyHistoryRabbitEnabled = func(context.Context) bool { return true } + + var published model.TrafficUsageMonthlyHistoryStartMessage + monthlyHistoryPublish = func(_ context.Context, _ string, data any) error { + published = data.(model.TrafficUsageMonthlyHistoryStartMessage) + return nil + } + + if err := NewTrafficUsageMonthlySync().StartMonthlyHistory(context.Background()); err != nil { + t.Fatalf("StartMonthlyHistory() error = %v", err) + } + if published.QueryMonth != "202609" { + t.Fatalf("queryMonth = %q, want 202609 (Shanghai, not UTC)", published.QueryMonth) + } +} + +func TestStartMonthlyHistoryRunScopedForwardsScope(t *testing.T) { + stubMonthlyHistoryTrigger(t) + + monthlyHistoryNow = func() time.Time { + return time.Date(2026, time.October, 10, 6, 0, 0, 0, time.FixedZone("UTC+8", 8*60*60)) + } + monthlyHistoryRunID = func() string { return "scoped-run" } + monthlyHistoryRabbitEnabled = func(context.Context) bool { return true } + + var published model.TrafficUsageMonthlyHistoryStartMessage + monthlyHistoryPublish = func(_ context.Context, _ string, data any) error { + published = data.(model.TrafficUsageMonthlyHistoryStartMessage) + return nil + } + + runID, err := NewTrafficUsageMonthlySync().StartMonthlyHistoryRunScoped(context.Background(), &model.SyncScope{Channels: []string{"217"}}) + if err != nil { + t.Fatalf("StartMonthlyHistoryRunScoped() error = %v", err) + } + if runID != "monthly-history-202609-scoped-run" { + t.Fatalf("runId = %q", runID) + } + if published.Scope == nil || published.Scope.ChannelCount() != 1 || !published.Scope.Allows("217") { + t.Fatalf("scope = %+v, want channel 217 only", published.Scope) + } +} + +func TestStartMonthlyHistoryRejectsInvalidScope(t *testing.T) { + stubMonthlyHistoryTrigger(t) + + monthlyHistoryRabbitEnabled = func(context.Context) bool { return true } + called := false + monthlyHistoryPublish = func(context.Context, string, any) error { + called = true + return nil + } + + if _, err := NewTrafficUsageMonthlySync().StartMonthlyHistoryRunScoped(context.Background(), &model.SyncScope{Channels: []string{"invalid/chan"}}); err == nil { + t.Fatal("expected an invalid chanKey to be rejected") + } + if called { + t.Fatal("publisher called for a rejected scope") + } +} + +func TestStartMonthlyHistoryReturnsPublishError(t *testing.T) { + stubMonthlyHistoryTrigger(t) + + monthlyHistoryNow = func() time.Time { return time.Date(2026, time.October, 10, 6, 0, 0, 0, time.UTC) } + monthlyHistoryRunID = func() string { return "err-run" } + monthlyHistoryRabbitEnabled = func(context.Context) bool { return true } + monthlyHistoryPublish = func(context.Context, string, any) error { + return errors.New("broker unavailable") + } + + if err := NewTrafficUsageMonthlySync().StartMonthlyHistory(context.Background()); err == nil { + t.Fatal("StartMonthlyHistory() error = nil, want publish error") + } +} + +func TestStartMonthlyHistorySkipsWhenRabbitMQDisabled(t *testing.T) { + stubMonthlyHistoryTrigger(t) + + monthlyHistoryRabbitEnabled = func(context.Context) bool { return false } + called := false + monthlyHistoryPublish = func(context.Context, string, any) error { + called = true + return nil + } + + if err := NewTrafficUsageMonthlySync().StartMonthlyHistory(context.Background()); err == nil { + t.Fatal("StartMonthlyHistory() error = nil, want disabled error") + } + if called { + t.Fatal("publisher called while RabbitMQ is disabled") + } +} + +// TestMonthlyHistoryAndCurrentMonthTriggersUseDistinctQueues keeps the two +// monthly chains from crossing: one fills the MySQL snapshot, the other the +// TDengine monthly table. +func TestMonthlyHistoryAndCurrentMonthTriggersUseDistinctQueues(t *testing.T) { + stubMonthlyHistoryTrigger(t) + originalNow := monthlyUsageNow + originalRunID := monthlyUsageRunID + originalEnabled := monthlyUsageRabbitEnabled + originalPublish := monthlyUsagePublish + t.Cleanup(func() { + monthlyUsageNow = originalNow + monthlyUsageRunID = originalRunID + monthlyUsageRabbitEnabled = originalEnabled + monthlyUsagePublish = originalPublish + }) + + fixed := time.Date(2026, time.October, 10, 6, 0, 0, 0, time.FixedZone("UTC+8", 8*60*60)) + monthlyHistoryNow = func() time.Time { return fixed } + monthlyHistoryRunID = func() string { return "r" } + monthlyHistoryRabbitEnabled = func(context.Context) bool { return true } + monthlyUsageNow = func() time.Time { return fixed } + monthlyUsageRunID = func() string { return "r" } + monthlyUsageRabbitEnabled = func(context.Context) bool { return true } + + var historyQueue, currentQueue string + monthlyHistoryPublish = func(_ context.Context, queue string, _ any) error { + historyQueue = queue + return nil + } + monthlyUsagePublish = func(_ context.Context, queue string, _ any) error { + currentQueue = queue + return nil + } + + sync := NewTrafficUsageMonthlySync() + if err := sync.StartMonthlyHistory(context.Background()); err != nil { + t.Fatal(err) + } + if err := sync.StartMonthly(context.Background()); err != nil { + t.Fatal(err) + } + if historyQueue == currentQueue { + t.Fatalf("both monthly chains publish to %q; they must stay separate", historyQueue) + } +} + +// monthlyHistoryBackfillClock pins "now" for the period-aware monthly tests: +// 2026-10-20 06:00 +08:00, so the current month is 202610, the default month is +// 202609, and nothing from 202610 onwards is a valid history target. +func monthlyHistoryBackfillClock(t *testing.T) { + t.Helper() + stubMonthlyHistoryTrigger(t) + monthlyHistoryNow = func() time.Time { + return time.Date(2026, time.October, 20, 6, 0, 0, 0, time.FixedZone("UTC+8", 8*60*60)) + } + monthlyHistoryRunID = func() string { return "backfill-run" } + monthlyHistoryRabbitEnabled = func(context.Context) bool { return true } +} + +func captureMonthlyHistoryStart(t *testing.T) *model.TrafficUsageMonthlyHistoryStartMessage { + t.Helper() + published := &model.TrafficUsageMonthlyHistoryStartMessage{} + monthlyHistoryPublish = func(_ context.Context, _ string, data any) error { + *published = data.(model.TrafficUsageMonthlyHistoryStartMessage) + return nil + } + return published +} + +func TestStartMonthlyHistoryRunScopedAtUsesGivenMonth(t *testing.T) { + monthlyHistoryBackfillClock(t) + published := captureMonthlyHistoryStart(t) + + runID, err := NewTrafficUsageMonthlySync().StartMonthlyHistoryRunScopedAt(context.Background(), nil, "202604") + if err != nil { + t.Fatalf("StartMonthlyHistoryRunScopedAt() error = %v", err) + } + if published.QueryMonth != "202604" { + t.Fatalf("queryMonth = %q, want 202604", published.QueryMonth) + } + if runID != "monthly-history-202604-backfill-run" { + t.Fatalf("runId = %q", runID) + } + if published.Operation != model.OperationPreviousMonthUsageFullSync { + t.Fatalf("operation = %q", published.Operation) + } +} + +func TestStartMonthlyHistoryRunScopedAtRejectsCurrentMonth(t *testing.T) { + monthlyHistoryBackfillClock(t) + called := false + monthlyHistoryPublish = func(context.Context, string, any) error { + called = true + return nil + } + + // The current month belongs to the MySQL snapshot chain, not this one. + if _, err := NewTrafficUsageMonthlySync().StartMonthlyHistoryRunScopedAt(context.Background(), nil, "202610"); err == nil { + t.Fatal("current month accepted, want an error") + } + if called { + t.Fatal("publisher called for the current month") + } +} + +func TestStartMonthlyHistoryRunScopedAtRejectsFutureMonth(t *testing.T) { + monthlyHistoryBackfillClock(t) + called := false + monthlyHistoryPublish = func(context.Context, string, any) error { + called = true + return nil + } + + for _, queryMonth := range []string{"202612", "202701"} { + if _, err := NewTrafficUsageMonthlySync().StartMonthlyHistoryRunScopedAt(context.Background(), nil, queryMonth); err == nil { + t.Fatalf("queryMonth %q accepted, want a future-month error", queryMonth) + } + } + if called { + t.Fatal("publisher called for a rejected month") + } +} + +func TestStartMonthlyHistoryRunScopedAtRejectsMalformed(t *testing.T) { + monthlyHistoryBackfillClock(t) + called := false + monthlyHistoryPublish = func(context.Context, string, any) error { + called = true + return nil + } + + for _, queryMonth := range []string{"2026-04", "20264", "abc", "20260401", "202613"} { + if _, err := NewTrafficUsageMonthlySync().StartMonthlyHistoryRunScopedAt(context.Background(), nil, queryMonth); err == nil { + t.Fatalf("queryMonth %q accepted, want a format error", queryMonth) + } + } + if called { + t.Fatal("publisher called for a malformed month") + } +} + +func TestStartMonthlyHistoryRunScopedAtRejectsMonthBeforeLowerBound(t *testing.T) { + monthlyHistoryBackfillClock(t) + called := false + monthlyHistoryPublish = func(context.Context, string, any) error { + called = true + return nil + } + + if _, err := NewTrafficUsageMonthlySync().StartMonthlyHistoryRunScopedAt(context.Background(), nil, "201912"); err == nil { + t.Fatal("month below the lower bound accepted, want an error") + } + if called { + t.Fatal("publisher called for a month below the lower bound") + } +} + +func TestStartMonthlyHistoryRunScopedAtEmptyFallsBackToPreviousMonth(t *testing.T) { + monthlyHistoryBackfillClock(t) + published := captureMonthlyHistoryStart(t) + + runID, err := NewTrafficUsageMonthlySync().StartMonthlyHistoryRunScopedAt(context.Background(), nil, "") + if err != nil { + t.Fatalf("StartMonthlyHistoryRunScopedAt() error = %v", err) + } + if published.QueryMonth != "202609" { + t.Fatalf("queryMonth = %q, want 202609 (previous month)", published.QueryMonth) + } + if runID != "monthly-history-202609-backfill-run" { + t.Fatalf("runId = %q", runID) + } +} + +// TestStartMonthlyHistoryRunScopedAtEmptyMatchesLegacyScoped keeps the backward +// compatibility promise concrete: the old entry point and an empty period must +// publish identical messages. +func TestStartMonthlyHistoryRunScopedAtEmptyMatchesLegacyScoped(t *testing.T) { + monthlyHistoryBackfillClock(t) + var messages []model.TrafficUsageMonthlyHistoryStartMessage + monthlyHistoryPublish = func(_ context.Context, _ string, data any) error { + messages = append(messages, data.(model.TrafficUsageMonthlyHistoryStartMessage)) + return nil + } + + legacyID, err := NewTrafficUsageMonthlySync().StartMonthlyHistoryRunScoped(context.Background(), nil) + if err != nil { + t.Fatal(err) + } + atID, err := NewTrafficUsageMonthlySync().StartMonthlyHistoryRunScopedAt(context.Background(), nil, "") + if err != nil { + t.Fatal(err) + } + if len(messages) != 2 { + t.Fatalf("published %d messages, want 2", len(messages)) + } + if legacyID != atID || messages[0] != messages[1] { + t.Fatalf("legacy = %+v/%q, at = %+v/%q", messages[0], legacyID, messages[1], atID) + } +} diff --git a/server/internal/logic/trafficusage/traffic_usage_monthly_start_test.go b/server/internal/logic/trafficusage/traffic_usage_monthly_start_test.go new file mode 100644 index 0000000..c289461 --- /dev/null +++ b/server/internal/logic/trafficusage/traffic_usage_monthly_start_test.go @@ -0,0 +1,98 @@ +package trafficusage + +import ( + "context" + "errors" + "testing" + "time" + + "git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/consts" + "git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/model" +) + +func TestStartMonthlyPublishesShanghaiMonth(t *testing.T) { + originalNow := monthlyUsageNow + originalRunID := monthlyUsageRunID + originalEnabled := monthlyUsageRabbitEnabled + originalPublish := monthlyUsagePublish + t.Cleanup(func() { + monthlyUsageNow = originalNow + monthlyUsageRunID = originalRunID + monthlyUsageRabbitEnabled = originalEnabled + monthlyUsagePublish = originalPublish + }) + + monthlyUsageNow = func() time.Time { + return time.Date(2026, time.August, 31, 23, 30, 0, 0, time.UTC) + } + monthlyUsageRunID = func() string { return "test-run" } + monthlyUsageRabbitEnabled = func(context.Context) bool { return true } + + var publishedQueue string + var published model.TrafficUsageMonthlyStartMessage + monthlyUsagePublish = func(_ context.Context, queue string, data any) error { + publishedQueue = queue + published = data.(model.TrafficUsageMonthlyStartMessage) + return nil + } + + if err := NewTrafficUsageMonthlySync().StartMonthly(context.Background()); err != nil { + t.Fatalf("StartMonthly() error = %v", err) + } + if publishedQueue != consts.QueueTrafficUsageMonthlyStart { + t.Fatalf("queue = %q, want %q", publishedQueue, consts.QueueTrafficUsageMonthlyStart) + } + if published.RunID != "current-month-usage-20260901-test-run" { + t.Fatalf("runId = %q", published.RunID) + } + if published.Operation != monthlyUsageFullSyncOperation { + t.Fatalf("operation = %q", published.Operation) + } + if published.QueryMonth != "202609" { + t.Fatalf("queryMonth = %q, want 202609", published.QueryMonth) + } + if published.TriggeredAt != "2026-09-01T07:30:00+08:00" { + t.Fatalf("triggeredAt = %q", published.TriggeredAt) + } +} + +func TestStartMonthlyReturnsPublishError(t *testing.T) { + originalEnabled := monthlyUsageRabbitEnabled + originalPublish := monthlyUsagePublish + t.Cleanup(func() { + monthlyUsageRabbitEnabled = originalEnabled + monthlyUsagePublish = originalPublish + }) + + monthlyUsageRabbitEnabled = func(context.Context) bool { return true } + monthlyUsagePublish = func(context.Context, string, any) error { + return errors.New("broker unavailable") + } + + if err := NewTrafficUsageMonthlySync().StartMonthly(context.Background()); err == nil { + t.Fatal("StartMonthly() error = nil, want publish error") + } +} + +func TestStartMonthlySkipsWhenRabbitMQDisabled(t *testing.T) { + originalEnabled := monthlyUsageRabbitEnabled + originalPublish := monthlyUsagePublish + t.Cleanup(func() { + monthlyUsageRabbitEnabled = originalEnabled + monthlyUsagePublish = originalPublish + }) + + monthlyUsageRabbitEnabled = func(context.Context) bool { return false } + called := false + monthlyUsagePublish = func(context.Context, string, any) error { + called = true + return nil + } + + if err := NewTrafficUsageMonthlySync().StartMonthly(context.Background()); err == nil { + t.Fatal("StartMonthly() error = nil, want disabled error") + } + if called { + t.Fatal("publisher called while RabbitMQ is disabled") + } +} diff --git a/server/internal/logic/upstream/upstream_chan_test.go b/server/internal/logic/upstream/upstream_chan_test.go new file mode 100644 index 0000000..f67a47c --- /dev/null +++ b/server/internal/logic/upstream/upstream_chan_test.go @@ -0,0 +1,139 @@ +package upstream + +import ( + "context" + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/gogf/gf/v2/errors/gcode" + "github.com/gogf/gf/v2/errors/gerror" + "github.com/gogf/gf/v2/frame/g" + "github.com/gogf/gf/v2/os/gcfg" + "git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/model" +) + +func TestTestChannelPostsOneTenantBoundRequestToGateway(t *testing.T) { + const gatewayBody = `{"code":0,"message":"success","data":{"items":[{"key":"simStatus","name":"卡状态","status":"success"}]}}` + var requestBody model.UpstreamChannelTestRequest + requestCount := 0 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/internal/channel/test" || r.Method != http.MethodPost { + t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path) + } + requestCount++ + if r.Header.Get("X-Internal-Token") != "" { + t.Errorf("unexpected internal token header") + } + if err := json.NewDecoder(r.Body).Decode(&requestBody); err != nil { + t.Fatal(err) + } + _, _ = w.Write([]byte(gatewayBody)) + })) + defer server.Close() + + oldAdapter := g.Cfg().GetAdapter() + adapter, err := gcfg.NewAdapterContent(`upstream: + gateway: + address: "` + strings.TrimPrefix(server.URL, "http://") + `" + timeout: 1s +`) + if err != nil { + t.Fatal(err) + } + g.Cfg().SetAdapter(adapter) + t.Cleanup(func() { g.Cfg().SetAdapter(oldAdapter) }) + + output, err := NewUpstreamChan().TestChannel(context.Background(), &model.UpstreamChannelTestRequest{ + ChannelID: 42, TenantID: "tenant-a", InputMode: "complete", + ICCID: "iccid", IMSI: "imsi", MSISDN: "msisdn", Items: []string{"simStatus"}, + }) + if err != nil { + t.Fatal(err) + } + if requestCount != 1 { + t.Fatalf("request count=%d, want one", requestCount) + } + if requestBody.ChannelID != 42 || requestBody.TenantID != "tenant-a" || len(requestBody.Items) != 1 { + t.Fatalf("unexpected request body: %+v", requestBody) + } + if output == nil || len(output.Items) != 1 || output.Items[0].Status != "success" { + t.Fatalf("unexpected output: %+v", output) + } + if output.GatewayResponseRaw != gatewayBody { + t.Fatalf("raw response=%q, want %q", output.GatewayResponseRaw, gatewayBody) + } +} + +func TestTestChannelPreservesGatewayErrorResponses(t *testing.T) { + responseBody := "" + responseStatus := http.StatusOK + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(responseStatus) + _, _ = w.Write([]byte(responseBody)) + })) + defer server.Close() + + oldAdapter := g.Cfg().GetAdapter() + adapter, err := gcfg.NewAdapterContent(`upstream: + gateway: + address: "` + strings.TrimPrefix(server.URL, "http://") + `" + timeout: 1s +`) + if err != nil { + t.Fatal(err) + } + g.Cfg().SetAdapter(adapter) + t.Cleanup(func() { g.Cfg().SetAdapter(oldAdapter) }) + + for _, tc := range []struct { + name string + body string + status int + wantMessage string + }{ + {"business error", `{"code":13000,"message":"系统忙","data":{"items":[]}}`, http.StatusOK, "系统忙"}, + {"completion error", `{"code":50,"message":"carrier did not return complete card identifiers","data":null}`, http.StatusOK, "carrier did not return complete card identifiers"}, + {"empty message", `{"code":50,"message":"","data":null}`, http.StatusOK, "上游通道测试失败"}, + {"HTTP error", `{"code":13000,"message":"网关故障"}`, http.StatusBadGateway, "网关故障"}, + {"invalid JSON", `gateway unavailable`, http.StatusOK, "解析上游通道测试响应失败"}, + {"schema mismatch", `{"code":0,"data":{"items":"invalid"}}`, http.StatusOK, "解析上游通道测试响应失败"}, + } { + t.Run(tc.name, func(t *testing.T) { + responseBody = tc.body + responseStatus = tc.status + output, err := NewUpstreamChan().TestChannel(context.Background(), &model.UpstreamChannelTestRequest{ + ChannelID: 42, TenantID: "tenant-a", InputMode: "complete", + ICCID: "iccid", IMSI: "imsi", MSISDN: "msisdn", + }) + if err == nil || output != nil { + t.Fatalf("failure must remain an error: output=%+v err=%v", output, err) + } + var diagnostic *model.TSimChannelsTestError + if !errors.As(err, &diagnostic) || diagnostic.GatewayResponseRaw != tc.body { + t.Fatalf("missing raw error response: %+v", err) + } + if !strings.Contains(err.Error(), tc.wantMessage) || gerror.Code(err) != gcode.CodeNil { + t.Fatalf("original error changed: %v code=%v", err, gerror.Code(err)) + } + if err.Error() != diagnostic.Err.Error() || !errors.Is(err, diagnostic.Err) { + t.Fatalf("original error chain changed: %v", err) + } + }) + } + + t.Run("individual failures remain results", func(t *testing.T) { + responseStatus = http.StatusOK + responseBody = `{"code":0,"message":"success","data":{"items":[{"key":"simStatus","status":"failed","errorCode":9999,"message":"carrier rejected"},{"key":"simSession","status":"timeout","message":"channel test item timed out"}]}}` + output, err := NewUpstreamChan().TestChannel(context.Background(), &model.UpstreamChannelTestRequest{ChannelID: 42, TenantID: "tenant-a"}) + if err != nil || output == nil { + t.Fatalf("single-item failures must not fail the whole request: %v", err) + } + if output.GatewayResponseRaw != responseBody || len(output.Items) != 2 || output.Items[0].ErrorCode != 9999 || output.Items[0].Message != "carrier rejected" || output.Items[1].Status != "timeout" { + t.Fatalf("item results changed: %+v", output) + } + }) +} diff --git a/server/internal/logic/warningnotify/aggregate.go b/server/internal/logic/warningnotify/aggregate.go index ab8f4c2..5d63b1e 100644 --- a/server/internal/logic/warningnotify/aggregate.go +++ b/server/internal/logic/warningnotify/aggregate.go @@ -166,9 +166,6 @@ func methodsForEvent(event Event) []string { seen[value] = struct{}{} result = append(result, value) } - if event.AppNotifyEnabled { - result = append(result, MethodWechat) - } sort.SliceStable(result, func(i, j int) bool { return methodOrder(result[i]) < methodOrder(result[j]) }) diff --git a/server/internal/logic/warningnotify/content.go b/server/internal/logic/warningnotify/content.go index a579b52..9e7835f 100644 --- a/server/internal/logic/warningnotify/content.go +++ b/server/internal/logic/warningnotify/content.go @@ -13,15 +13,20 @@ func applyContent(batch *Batch) { if batch == nil { return } - batch.SMSContent = buildSMSContent(*batch) - batch.EmailSubject = buildEmailSubject(*batch) - batch.EmailBody = buildEmailBody(*batch) - batch.PlatformTitle = buildPlatformTitle(*batch) - batch.PlatformContent = buildPlatformContent(*batch) + switch batch.Method { + case MethodSMS: + batch.SMSContent = buildSMSContent(*batch) + case MethodEmail: + batch.EmailSubject = buildEmailSubject(*batch) + batch.EmailBody = buildEmailBody(*batch) + case MethodPlatform: + batch.PlatformTitle = buildPlatformTitle(*batch) + batch.PlatformContent = buildPlatformContent(*batch) + } } func buildSMSContent(batch Batch) string { - count := len(batch.Items) + count := batchItemCount(batch) if count == 0 { return "" } @@ -29,7 +34,7 @@ func buildSMSContent(batch Batch) string { summary := batchObjectSummary(batch) title := warningTitle(batch) action := "" - if hasAffectOperation(batch.Items) { + if batchHasAffectOperation(batch) { action = ",涉及停机或断网操作" } @@ -42,7 +47,7 @@ func buildSMSContent(batch Batch) string { } func buildEmailSubject(batch Batch) string { - count := len(batch.Items) + count := batchItemCount(batch) if count == 0 { return "物联网预警提醒" } @@ -50,15 +55,15 @@ func buildEmailSubject(batch Batch) string { } func buildEmailBody(batch Batch) string { - count := len(batch.Items) + count := batchItemCount(batch) if count == 0 { return "" } var builder strings.Builder builder.WriteString(`
`) - fmt.Fprintf(&builder, `

%s,共 %d 条,最高级别:%s。

`, htmlEscape(warningTitle(batch)), count, htmlEscape(levelText(highestLevel(batch.Items)))) - if hasAffectOperation(batch.Items) { + fmt.Fprintf(&builder, `

%s,共 %d 条,最高级别:%s。

`, htmlEscape(warningTitle(batch)), count, htmlEscape(levelText(batchHighestLevel(batch)))) + if batchHasAffectOperation(batch) { builder.WriteString(`

本次预警包含停机或断网类操作,请重点确认。

`) } builder.WriteString(``) @@ -92,13 +97,13 @@ func buildPlatformTitle(batch Batch) string { } func buildPlatformContent(batch Batch) string { - count := len(batch.Items) + count := batchItemCount(batch) if count == 0 { return "" } content := buildPlatformSummaryContent(batch) - if hasAffectOperation(batch.Items) { + if batchHasAffectOperation(batch) { content += "本次提醒包含停机或断网类操作,请及时确认。" } @@ -110,15 +115,15 @@ func buildPlatformSummaryContent(batch Batch) string { return fmt.Sprintf( "%s有%d张卡临近到期,最高档位:%s,请登录控制台查看。", firstObjectCode(batch), - len(batch.Items), + batchItemCount(batch), buildPlatformTitle(batch), ) } return fmt.Sprintf( "%s触发%s,最高级别:%s,请登录控制台查看。", - batchObjectSummary(batch), + platformObjectSummary(batch), buildPlatformTitle(batch), - levelText(highestLevel(batch.Items)), + levelText(batchHighestLevel(batch)), ) } @@ -201,7 +206,7 @@ func itemDetailSummary(item Event) string { } func batchObjectSummary(batch Batch) string { - count := len(batch.Items) + count := batchItemCount(batch) if count == 0 { return "" } @@ -427,3 +432,17 @@ func htmlEscape(value string) string { } return html.EscapeString(value) } + +func platformObjectSummary(batch Batch) string { + if len(batch.Items) == 0 { + return "" + } + if batchItemCount(batch) > 5 { + return fmt.Sprintf("\u5171%d%s", batchItemCount(batch), objectCountUnit(batch.Items[0])) + } + codes := make([]string, 0, len(batch.Items)) + for _, item := range batch.Items { + codes = append(codes, displayObjectCode(item)) + } + return strings.Join(codes, "\u3001") +} diff --git a/server/internal/logic/warningnotify/delivery_test.go b/server/internal/logic/warningnotify/delivery_test.go new file mode 100644 index 0000000..71dd33c --- /dev/null +++ b/server/internal/logic/warningnotify/delivery_test.go @@ -0,0 +1,150 @@ +package warningnotify + +import ( + "context" + "errors" + "testing" + + "git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/consts" +) + +type failureRecordingSender struct{ calls int } + +func (sender *failureRecordingSender) Send(ctx context.Context, batch Batch) error { + sender.calls++ + return errors.New("send failed") +} + +func TestDispatchContinuesAfterDeliveryFailure(t *testing.T) { + sender := &failureRecordingSender{} + restore := SetSender(sender) + defer restore() + events := []Event{{TenantID: "tenant", ItemType: 1, ItemCode: "card", Category: "traffic", WarnMethods: MethodEmail, WarnEmails: "xxx@example.com", AppNotifyEnabled: true}} + expected := len(BuildBatches(events)) + if expected < 2 { + t.Fatal("expected multiple channels") + } + if err := Dispatch(context.Background(), events); err == nil { + t.Fatal("failure must propagate") + } + if sender.calls != expected { + t.Fatalf("calls=%d expected=%d", sender.calls, expected) + } +} + +func TestBuildBatchesOnlyCreatesPlatformAndConfiguredChannels(t *testing.T) { + batches := BuildBatches([]Event{{ + Business: BusinessSimWarning, + TenantID: "tenant", + ItemCode: "card-1", + WarnMethods: MethodEmail, + WarnEmails: "xxx@example.com", + AppNotifyEnabled: false, + }}) + + methods := make(map[string]bool, len(batches)) + for _, batch := range batches { + methods[batch.Method] = true + } + + for _, method := range []string{MethodPlatform, MethodEmail} { + if !methods[method] { + t.Errorf("expected %q batch to be generated", method) + } + } + if methods[MethodWechat] { + t.Error("wechat subscription batch must not be generated") + } +} + +func TestBuildBatchesIgnoresLegacyWechatFlag(t *testing.T) { + for _, enabled := range []bool{false, true} { + for _, business := range []string{BusinessSimWarning, BusinessTrafficCutoff} { + batches := BuildBatches([]Event{{ + Business: business, + TenantID: "tenant", + ItemCode: "card-1", + AppNotifyEnabled: enabled, + }}) + if len(batches) != 1 || batches[0].Method != MethodPlatform { + t.Errorf("business=%q enabled=%t: expected platform only, got %+v", business, enabled, batches) + } + } + } +} + +func TestEnterpriseWarningRecipientEligibility(t *testing.T) { + if !enterpriseWarningRecipientEligible(consts.SysUserTypeSys, 175, 175, "") { + t.Fatal("same-organization enterprise creator should be eligible") + } + for _, test := range []struct { + name string + userType string + userDeptID int64 + cardDeptID int64 + }{ + {name: "app user", userType: consts.SysUserTypeAPP, userDeptID: 175, cardDeptID: 175}, + {name: "different organization", userType: consts.SysUserTypeSys, userDeptID: 176, cardDeptID: 175}, + {name: "missing organization", userType: consts.SysUserTypeSys, userDeptID: 0, cardDeptID: 175}, + } { + t.Run(test.name, func(t *testing.T) { + if enterpriseWarningRecipientEligible(test.userType, test.userDeptID, test.cardDeptID, "") { + t.Fatal("recipient should not be eligible") + } + }) + } +} +func TestEnterpriseWarningAncestorScope(t *testing.T) { + for _, test := range []struct { + name string + ancestors string + expected bool + }{ + {"direct descendant", "0,100,175,", true}, + {"deep descendant", "0,100,175,185", true}, + {"whitespace", "0, 175 ,185", true}, + {"partial identifier", "0,100,1175,1750", false}, + {"unrelated branch", "0,100,176", false}, + {"missing path", "", false}, + } { + t.Run(test.name, func(t *testing.T) { + if actual := enterpriseWarningRecipientEligible(consts.SysUserTypeSys, 175, 195, test.ancestors); actual != test.expected { + t.Fatalf("eligible = %v, want %v", actual, test.expected) + } + }) + } +} + +func TestWechatTokenInvalidCodes(t *testing.T) { + for _, code := range []int{40001, 40014, 42001} { + if !warningWechatTokenInvalid(code) { + t.Fatalf("code %d must refresh token", code) + } + } + for _, code := range []int{0, 43101, 47003, 40003} { + if warningWechatTokenInvalid(code) { + t.Fatalf("code %d must not refresh token", code) + } + } +} + +func TestWechatDefaultTemplateMatchesMiniProgram(t *testing.T) { + const expected = "DMDP2PKktdtG1ES_3Ic1JcR0LGELQmWE6LunYyerHCY" + if actual := wechatTemplateID(nil, consts.WarningWechatTemplateIDDefault); actual != expected { + t.Fatalf("default template = %q, expected %q", actual, expected) + } +} + +func TestTrafficCutoffPlatformRecipientsExcludeCreator(t *testing.T) { + got := platformAgentRecipientIDs(BusinessTrafficCutoff, []int64{130}, 1) + if len(got) != 1 || got[0] != 130 { + t.Fatalf("traffic cutoff recipients = %v, want [130] without admin creator 1", got) + } +} + +func TestOtherPlatformRecipientsRetainCreator(t *testing.T) { + got := platformAgentRecipientIDs(BusinessSimWarning, []int64{130}, 1) + if len(got) != 2 || got[0] != 130 || got[1] != 1 { + t.Fatalf("regular warning recipients = %v, want [130 1]", got) + } +} diff --git a/server/internal/logic/warningnotify/message_center_sender.go b/server/internal/logic/warningnotify/message_center_sender.go index 54b4d81..3a3f726 100644 --- a/server/internal/logic/warningnotify/message_center_sender.go +++ b/server/internal/logic/warningnotify/message_center_sender.go @@ -91,7 +91,7 @@ func (s MessageCenterSender) sendPlatform(ctx context.Context, batch Batch) erro len(batch.Items), err, ) - return nil + return err } return nil } @@ -205,9 +205,12 @@ func (s MessageCenterSender) writePlatform(ctx context.Context, batch Batch) err } func resolvePlatformDeliveries(ctx context.Context, batch Batch) ([]platformDelivery, error) { + if batch.prepared != nil { + return []platformDelivery{{Group: *batch.prepared, Batch: batch}}, nil + } deliveries := make([]platformDelivery, 0, 2) - agentUserIDs, err := queryAgentUserIDs(ctx, batch.TenantID, batch.DeptID, batch.CreatedBy) + agentUserIDs, err := queryAgentUserIDs(ctx, batch.TenantID, batch.DeptID, batch.CreatedBy, batch.Business) if err != nil { return nil, err } @@ -219,6 +222,8 @@ func resolvePlatformDeliveries(ctx context.Context, batch Batch) ([]platformDeli }, Batch: batch, }) + } else if batch.Business == BusinessTrafficCutoff { + g.Log().Warningf(ctx, "WarningNotify traffic cutoff skipped department recipients, tenantId:%s, deptId:%d, category:%s, stage:%d, count:%d", batch.TenantID, batch.DeptID, batch.Category, batch.WarnStage, len(batch.Items)) } if hasCardWarningItems(batch.Items) { @@ -228,11 +233,11 @@ func resolvePlatformDeliveries(ctx context.Context, batch Batch) ([]platformDeli return deliveries, nil } -func queryAgentUserIDs(ctx context.Context, tenantID string, deptID int64, createdBy int64) ([]int64, error) { - userIDs := make([]int64, 0) - seen := make(map[int64]struct{}) +func queryAgentUserIDs(ctx context.Context, tenantID string, deptID int64, createdBy int64, business string) ([]int64, error) { + deptUserIDs := make([]int64, 0) + creatorUserID := int64(0) if strings.TrimSpace(tenantID) == "" { - return userIDs, nil + return deptUserIDs, nil } columns := dao.SysUser.Columns() @@ -248,11 +253,11 @@ func queryAgentUserIDs(ctx context.Context, tenantID string, deptID int64, creat return nil, err } for _, user := range users { - userIDs = appendUniqueInt64(userIDs, seen, user.UserId) + deptUserIDs = append(deptUserIDs, user.UserId) } } - if createdBy > 0 { + if business != BusinessTrafficCutoff && createdBy > 0 { var creator *entity.SysUser if err := dao.SysUser.Ctx(ctx). Fields(columns.UserId). @@ -264,11 +269,23 @@ func queryAgentUserIDs(ctx context.Context, tenantID string, deptID int64, creat return nil, err } if creator != nil { - userIDs = appendUniqueInt64(userIDs, seen, creator.UserId) + creatorUserID = creator.UserId } } - return userIDs, nil + return platformAgentRecipientIDs(business, deptUserIDs, creatorUserID), nil +} + +func platformAgentRecipientIDs(business string, deptUserIDs []int64, createdBy int64) []int64 { + userIDs := make([]int64, 0, len(deptUserIDs)+1) + seen := make(map[int64]struct{}) + for _, userID := range deptUserIDs { + userIDs = appendUniqueInt64(userIDs, seen, userID) + } + if business != BusinessTrafficCutoff { + userIDs = appendUniqueInt64(userIDs, seen, createdBy) + } + return userIDs } func queryCreatedTerminalUserIDs(ctx context.Context, tenantID string, createdBy int64) ([]int64, error) { @@ -295,6 +312,9 @@ func queryCreatedTerminalUserIDs(ctx context.Context, tenantID string, createdBy } func resolveTerminalPlatformDeliveries(ctx context.Context, batch Batch) []platformDelivery { + if batch.prepared != nil { + return []platformDelivery{{Group: *batch.prepared, Batch: batch}} + } itemsByUser := make(map[int64][]Event) seenByUser := make(map[int64]map[int]struct{}) @@ -408,16 +428,26 @@ func queryTerminalUserCardRowsByColumn(ctx context.Context, tenantID string, col if strings.TrimSpace(tenantID) == "" || len(values) == 0 { return rows, nil } - err := g.DB().Model(simTerminalTable+" st").Ctx(ctx).Safe(). - LeftJoin(terminalTable+" t", "t.terminal_id = st.terminal_id AND t.tenant_id = st.tenant_id"). - LeftJoin(dao.SysUser.Table()+" u", "(u.user_id = t.leader OR u.user_id = st.terminal_id) AND u.tenant_id = st.tenant_id"). - Fields("DISTINCT u.user_id, st.iccid, st.msisdn"). - Where("st.tenant_id", tenantID). - Where("u.user_type", consts.SysUserTypeAPP). - Where("u.status", consts.SysDeptStatusNormal). - WhereIn(column, values). - Scan(&rows) - return rows, err + for offset := 0; offset < len(values); offset += 500 { + if err := ctx.Err(); err != nil { + return nil, err + } + end := min(offset+500, len(values)) + var page []terminalUserCardRow + if err := g.DB().Model(simTerminalTable+" st").Ctx(ctx).Safe(). + LeftJoin(terminalTable+" t", "t.terminal_id = st.terminal_id AND t.tenant_id = st.tenant_id"). + LeftJoin(dao.SysUser.Table()+" u", "(u.user_id = t.leader OR u.user_id = st.terminal_id) AND u.tenant_id = st.tenant_id"). + Fields("DISTINCT u.user_id, st.iccid, st.msisdn"). + Where("st.tenant_id", tenantID). + Where("u.user_type", consts.SysUserTypeAPP). + Where("u.status", consts.SysDeptStatusNormal). + WhereIn(column, values[offset:end]). + Scan(&page); err != nil { + return nil, err + } + rows = append(rows, page...) + } + return rows, nil } func hasCardWarningItems(items []Event) bool { diff --git a/server/internal/logic/warningnotify/notification_content_test.go b/server/internal/logic/warningnotify/notification_content_test.go new file mode 100644 index 0000000..8e3ceab --- /dev/null +++ b/server/internal/logic/warningnotify/notification_content_test.go @@ -0,0 +1,83 @@ +package warningnotify + +import ( + "fmt" + "strings" + "testing" +) + +func TestSmallAndLargePlatformSummary(t *testing.T) { + b := Batch{Method: MethodPlatform, WarnStage: 2} + for i := 0; i < 2; i++ { + b.Items = append(b.Items, Event{ItemType: 1, ItemCode: fmt.Sprintf("898600000000000000%02d", i), WarnLevel: 3}) + } + applyContent(&b) + for _, item := range b.Items { + if !strings.Contains(b.PlatformContent, item.ItemCode) { + t.Fatal("missing card") + } + } + if b.EmailBody != "" || b.SMSContent != "" { + t.Fatal("unrelated content generated") + } + for i := 2; i < 10000; i++ { + b.Items = append(b.Items, Event{ItemType: 1, ItemCode: "89860000000000000000", WarnLevel: 3}) + } + applyContent(&b) + if !strings.Contains(b.PlatformContent, "10000") || len([]rune(b.PlatformContent)) > 150 { + t.Fatal("unbounded summary") + } + b.Method = MethodWechat + b.EmailBody = "" + applyContent(&b) + if b.EmailBody != "" { + t.Fatal("wechat generated email") + } + payload := buildWechatPayload(b, "openid", "template", "pages/message/index") + if payload.Data["character_string6"].Value != "TOTAL-10000" { + t.Fatal("missing total") + } + if !strings.Contains(payload.Data["thing9"].Value, "10000") || !strings.Contains(payload.Data["thing9"].Value, levelText(3)) { + t.Fatal("missing count or severity") + } +} + +func TestCollectorKeepsFullCountWithBoundedRecipientPreview(t *testing.T) { + collector := NewCollector() + recipient := &platformRecipientGroup{SendUserType: 2, UserIDs: []int64{11}} + other := &platformRecipientGroup{SendUserType: 2, UserIDs: []int64{12}} + for page := 0; page < 50; page++ { + batch := Batch{Method: MethodPlatform, Business: BusinessSimWarning, TenantID: "tenant-a", Category: "traffic", WarnStage: 2} + for index := 0; index < 200; index++ { + batch.Items = append(batch.Items, Event{ItemCode: fmt.Sprintf("8986%016d", page*200+index), WarnLevel: 3, ItemType: 1}) + } + collector.merge(batch, recipient) + batch.Method = MethodWechat + collector.merge(batch, recipient) + } + collector.merge(Batch{Method: MethodPlatform, TenantID: "tenant-a", Business: BusinessSimWarning, Category: "traffic", Items: []Event{{ItemCode: "private", ItemType: 1}}}, other) + if len(collector.batches) != 3 { + t.Fatalf("channels or recipients merged: %d", len(collector.batches)) + } + for _, batch := range collector.batches { + if len(batch.Items) > 5 { + t.Fatal("preview grows with cards") + } + if batch.prepared.UserIDs[0] == 11 { + if batchItemCount(*batch) != 10000 { + t.Fatal("count lost across pages") + } + for _, item := range batch.Items { + if item.ItemCode == "private" { + t.Fatal("recipient data leak") + } + } + if batch.Method == MethodWechat { + payload := buildWechatPayload(*batch, "openid", "template", "pages/message/index") + if payload.Data["character_string6"].Value != "TOTAL-10000" { + t.Fatal("preview count used as total") + } + } + } + } +} diff --git a/server/internal/logic/warningnotify/preference.go b/server/internal/logic/warningnotify/preference.go new file mode 100644 index 0000000..ee6ef1a --- /dev/null +++ b/server/internal/logic/warningnotify/preference.go @@ -0,0 +1,19 @@ +package warningnotify + +import ( + "context" + "fmt" + "git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/dao" +) + +func warningWechatEnabled(ctx context.Context, tenantID string, userID int64) (bool, error) { + var preference *struct { + Value string `orm:"config_value"` + } + err := dao.SysConfig.Ctx(ctx).Fields("config_value").Where("tenant_id", tenantID). + Where("config_key", fmt.Sprintf("app.wechat.warning.enabled.%d", userID)).OrderDesc("config_id").Scan(&preference) + if err != nil { + return false, err + } + return preference == nil || preference.Value == "1", nil +} diff --git a/server/internal/logic/warningnotify/preference_test.go b/server/internal/logic/warningnotify/preference_test.go new file mode 100644 index 0000000..8078a23 --- /dev/null +++ b/server/internal/logic/warningnotify/preference_test.go @@ -0,0 +1,63 @@ +package warningnotify + +import ( + "context" + _ "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/do" + "testing" +) + +func TestWarningWechatPreferenceIsolation(t *testing.T) { + ctx := context.Background() + previous := gdb.GetConfig("default") + if err := gdb.SetConfigGroup("default", gdb.ConfigGroup{{Type: "sqlite", Link: "sqlite::@file(:memory:)", MaxOpenConnCount: 1}}); err != nil { + t.Fatal(err) + } + db := g.DB() + t.Cleanup(func() { + _ = db.GetCore().ClearCacheAll(ctx) + _ = db.Close(ctx) + _ = gdb.SetConfigGroup("default", previous) + }) + if _, err := db.Exec(ctx, "CREATE TABLE sys_config (config_id INTEGER PRIMARY KEY AUTOINCREMENT, tenant_id TEXT, config_key TEXT, config_value TEXT)"); err != nil { + t.Fatal(err) + } + enabled, err := warningWechatEnabled(ctx, "tenant", 1) + if err != nil || !enabled { + t.Fatalf("legacy default: %v %v", enabled, err) + } + for _, item := range []struct{ tenant, value string }{{"tenant", "0"}, {"other", "1"}} { + if _, err := db.Model("sys_config").Data(do.SysConfig{TenantId: item.tenant, ConfigKey: "app.wechat.warning.enabled.1", ConfigValue: item.value}).Insert(); err != nil { + t.Fatal(err) + } + } + enabled, err = warningWechatEnabled(ctx, "tenant", 1) + if err != nil || enabled { + t.Fatalf("disabled preference ignored: %v %v", enabled, err) + } + enabled, err = warningWechatEnabled(ctx, "other", 1) + if err != nil || !enabled { + t.Fatalf("other tenant affected: %v %v", enabled, err) + } + enabled, err = warningWechatEnabled(ctx, "tenant", 2) + if err != nil || !enabled { + t.Fatalf("other user affected: %v %v", enabled, err) + } + if _, err := db.Model("sys_config").Where("tenant_id", "tenant").Data(do.SysConfig{ConfigValue: "1"}).Update(); err != nil { + t.Fatal(err) + } + enabled, err = warningWechatEnabled(ctx, "tenant", 1) + if err != nil || !enabled { + t.Fatalf("stale preference after enable: %v %v", enabled, err) + } + if _, err := db.Exec(ctx, "DROP TABLE sys_config"); err != nil { + t.Fatal(err) + } + _ = db.GetCore().ClearCacheAll(ctx) + enabled, err = warningWechatEnabled(ctx, "tenant", 1) + if err == nil || enabled { + t.Fatal("read errors must fail closed") + } +} diff --git a/server/internal/logic/warningnotify/stream.go b/server/internal/logic/warningnotify/stream.go new file mode 100644 index 0000000..7c6db16 --- /dev/null +++ b/server/internal/logic/warningnotify/stream.go @@ -0,0 +1,112 @@ +package warningnotify + +import ( + "context" + "encoding/json" + "github.com/gogf/gf/v2/errors/gerror" + "sort" + "strings" +) + +type Collector struct{ batches map[string]*Batch } + +func NewCollector() *Collector { return &Collector{batches: make(map[string]*Batch)} } +func (c *Collector) Add(ctx context.Context, events []Event) error { + for _, batch := range BuildBatches(events) { + switch batch.Method { + case MethodPlatform: + deliveries, err := resolvePlatformDeliveries(ctx, batch) + if err != nil { + return err + } + for _, delivery := range deliveries { + c.merge(delivery.Batch, &delivery.Group) + } + case MethodWechat: + for _, delivery := range resolveWechatDeliveries(ctx, batch) { + c.merge(delivery.Batch, &delivery.Group) + } + default: + c.merge(batch, nil) + } + } + return nil +} +func (c *Collector) merge(batch Batch, recipient *platformRecipientGroup) { + keyBytes, _ := json.Marshal(struct { + Group groupKey + Recipient *platformRecipientGroup + }{groupKeyForEvent(batch.Method, batch.Recipients, Event{Business: batch.Business, TenantID: batch.TenantID, DeptID: batch.DeptID, CreatedBy: batch.CreatedBy, Category: batch.Category}), recipient}) + key := string(keyBytes) + current := c.batches[key] + if current == nil { + copy := batch + copy.Items = nil + copy.TotalCount = 0 + copy.prepared = recipient + current = © + c.batches[key] = current + } + current.TotalCount += len(batch.Items) + current.WarnStage = max(current.WarnStage, batch.WarnStage) + current.HighestLevel = max(current.HighestLevel, highestLevel(batch.Items)) + current.AffectOperation = current.AffectOperation || hasAffectOperation(batch.Items) + latest := latestEvent(batch.Items).WarnTime + if latest.After(current.LatestTime) { + current.LatestTime = latest + } + current.Items = append(current.Items, batch.Items...) + if recipient != nil { + sort.SliceStable(current.Items, func(i, j int) bool { + if current.Items[i].WarnLevel != current.Items[j].WarnLevel { + return current.Items[i].WarnLevel > current.Items[j].WarnLevel + } + if current.Items[i].WarnStage != current.Items[j].WarnStage { + return current.Items[i].WarnStage > current.Items[j].WarnStage + } + return current.Items[i].ItemCode < current.Items[j].ItemCode + }) + if len(current.Items) > 5 { + preview := append([]Event(nil), current.Items[:5]...) + current.Items = preview + } + } +} +func (c *Collector) Flush(ctx context.Context) error { + senderMu.RLock() + sender := currentSender + senderMu.RUnlock() + if sender == nil { + sender = LogSender{} + } + keys := make([]string, 0, len(c.batches)) + for key := range c.batches { + keys = append(keys, key) + } + sort.Strings(keys) + var failures []string + for _, key := range keys { + if err := ctx.Err(); err != nil { + return err + } + batch := c.batches[key] + applyContent(batch) + if err := sender.Send(ctx, *batch); err != nil { + failures = append(failures, err.Error()) + } + } + if len(failures) > 0 { + return gerror.New(strings.Join(failures, "; ")) + } + return nil +} +func batchItemCount(batch Batch) int { + if batch.TotalCount > 0 { + return batch.TotalCount + } + return len(batch.Items) +} +func batchHighestLevel(batch Batch) int { return max(batch.HighestLevel, highestLevel(batch.Items)) } +func batchHasAffectOperation(batch Batch) bool { + return batch.AffectOperation || hasAffectOperation(batch.Items) +} diff --git a/server/internal/logic/warningnotify/stream_wechat_test.go b/server/internal/logic/warningnotify/stream_wechat_test.go new file mode 100644 index 0000000..ddabaa6 --- /dev/null +++ b/server/internal/logic/warningnotify/stream_wechat_test.go @@ -0,0 +1,151 @@ +package warningnotify + +import ( + "context" + "testing" + + "git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/model/do" + + "github.com/gogf/gf/v2/database/gdb" + "github.com/gogf/gf/v2/frame/g" +) + +type batchRecordingSender struct{ batches []Batch } + +func (sender *batchRecordingSender) Send(ctx context.Context, batch Batch) error { + sender.batches = append(sender.batches, batch) + return nil +} + +func TestCollectorSkipsWechatEvenForEligibleEnterpriseCreators(t *testing.T) { + ctx := context.Background() + previous := gdb.GetConfig("default") + if err := gdb.SetConfigGroup("default", gdb.ConfigGroup{{Type: "sqlite", Link: "sqlite::@file(:memory:)", MaxOpenConnCount: 1}}); err != nil { + t.Fatal(err) + } + db := g.DB() + t.Cleanup(func() { + _ = db.GetCore().ClearCacheAll(ctx) + _ = db.Close(ctx) + _ = gdb.SetConfigGroup("default", previous) + }) + for _, statement := range []string{ + "CREATE TABLE sys_dept (dept_id INTEGER PRIMARY KEY, tenant_id TEXT, ancestors TEXT, deleted_at DATETIME)", + "CREATE TABLE sys_user (user_id INTEGER PRIMARY KEY, tenant_id TEXT, user_type TEXT, dept_id INTEGER, status TEXT, deleted_at DATETIME)", + "CREATE TABLE t_sim_card_info (id INTEGER PRIMARY KEY, tenant_id TEXT, dept_id INTEGER)", + "CREATE TABLE t_sim_terminal (terminal_id INTEGER, tenant_id TEXT, iccid TEXT, msisdn TEXT)", + "CREATE TABLE t_terminal (terminal_id INTEGER, tenant_id TEXT, leader INTEGER, deleted_at DATETIME)", + } { + if _, err := db.Exec(ctx, statement); err != nil { + t.Fatal(err) + } + } + for _, dept := range []do.SysDept{ + {DeptId: 175, TenantId: "tenant", Ancestors: "0,100,"}, + {DeptId: 176, TenantId: "tenant", Ancestors: "0,100,"}, + {DeptId: 185, TenantId: "tenant", Ancestors: "0,100,175,"}, + {DeptId: 195, TenantId: "tenant", Ancestors: "0,100,175,185"}, + {DeptId: 100, TenantId: "tenant", Ancestors: "0"}, + } { + if _, err := db.Model("sys_dept").Data(dept).Insert(); err != nil { + t.Fatal(err) + } + } + for _, user := range []do.SysUser{ + {UserId: 133, TenantId: "tenant", UserType: "sys_user", DeptId: 175, Status: "0"}, + {UserId: 134, TenantId: "tenant", UserType: "sys_user", DeptId: 175, Status: "0"}, + {UserId: 135, TenantId: "other", UserType: "sys_user", DeptId: 175, Status: "0"}, + {UserId: 136, TenantId: "tenant", UserType: "sys_user", DeptId: 175, Status: "1"}, + {UserId: 137, TenantId: "tenant", UserType: "sys_user", DeptId: 175, Status: "0"}, + } { + if _, err := db.Model("sys_user").Data(user).Insert(); err != nil { + t.Fatal(err) + } + } + if _, err := db.Model("sys_user").Where("user_id", 137).Delete(); err != nil { + t.Fatal(err) + } + for _, card := range []do.TSimCardInfo{ + {Id: 84189, TenantId: "tenant", DeptId: 175}, + {Id: 84190, TenantId: "tenant", DeptId: 176}, + {Id: 84191, TenantId: "other", DeptId: 175}, + {Id: 84199, TenantId: "tenant", DeptId: 185}, + {Id: 84200, TenantId: "tenant", DeptId: 195}, + {Id: 84201, TenantId: "tenant", DeptId: 100}, + {Id: 84202, TenantId: "tenant", DeptId: 999}, + } { + if _, err := db.Model("t_sim_card_info").Data(card).Insert(); err != nil { + t.Fatal(err) + } + } + + base := Event{Business: BusinessSimWarning, TenantID: "tenant", DeptID: 175, CreatedBy: 133, ItemType: 1, ItemID: 84189, ItemCode: "89860401791508277319", WarnMethods: MethodPlatform, WarnStage: 2} + for _, test := range []struct { + name string + creator int64 + card int64 + expected int + }{ + {"enterprise creator", 133, 84189, 1}, + {"another creator in same department", 134, 84189, 1}, + {"sibling organization", 133, 84190, 0}, + {"direct descendant card", 133, 84199, 1}, + {"deep descendant card", 133, 84200, 1}, + {"ancestor organization", 133, 84201, 0}, + {"missing card organization", 133, 84202, 0}, + {"different card tenant", 133, 84191, 0}, + {"different user tenant", 135, 84189, 0}, + {"disabled creator", 136, 84189, 0}, + {"deleted creator", 137, 84189, 0}, + {"missing creator", 999, 84189, 0}, + } { + t.Run(test.name, func(t *testing.T) { + event := base + event.CreatedBy = test.creator + event.ItemID = test.card + collector := NewCollector() + for page := 0; page < 2; page++ { + event.EventID = int64(page + 1) + if err := collector.Add(ctx, []Event{event}); err != nil { + t.Fatal(err) + } + } + sender := &batchRecordingSender{} + restore := SetSender(sender) + defer restore() + if err := collector.Flush(ctx); err != nil { + t.Fatal(err) + } + platformCount := 0 + for _, batch := range sender.batches { + if batch.Method == MethodWechat { + t.Fatal("wechat subscription batch must not be sent") + } + if batch.Method == MethodPlatform { + platformCount++ + } + } + if test.expected > 0 && platformCount == 0 { + t.Fatal("eligible creator lost in-app notification") + } + legacy := Batch{Method: MethodWechat, Business: BusinessSimWarning, TenantID: "tenant", Items: []Event{event}} + eligible := 0 + for _, delivery := range resolveWechatDeliveries(ctx, legacy) { + if delivery.Group.SendUserType == sendUserTypeAgent && len(delivery.Group.UserIDs) == 1 && delivery.Group.UserIDs[0] == test.creator { + eligible++ + } + } + if eligible != test.expected { + t.Fatalf("legacy eligibility = %d, want %d", eligible, test.expected) + } + }) + } +} + +func TestWechatPreparedDeliveryDoesNotResolveAgain(t *testing.T) { + batch := Batch{Business: BusinessSimWarning, Method: MethodWechat, prepared: &platformRecipientGroup{SendUserType: sendUserTypeAgent, UserIDs: []int64{133}}, Items: []Event{{CreatedBy: 133, DeptID: 175, ItemType: 1, ItemID: 84189}}} + deliveries := resolveWechatDeliveries(context.Background(), batch) + if len(deliveries) != 1 || len(deliveries[0].Group.UserIDs) != 1 || deliveries[0].Group.UserIDs[0] != 133 { + t.Fatalf("unexpected prepared deliveries: %+v", deliveries) + } +} diff --git a/server/internal/logic/warningnotify/types.go b/server/internal/logic/warningnotify/types.go index e90dcbf..6c26c9f 100644 --- a/server/internal/logic/warningnotify/types.go +++ b/server/internal/logic/warningnotify/types.go @@ -48,6 +48,11 @@ type Event struct { } type Batch struct { + TotalCount int + HighestLevel int + LatestTime time.Time + AffectOperation bool + prepared *platformRecipientGroup Business string Method string TenantID string diff --git a/server/internal/logic/warningnotify/wechat_sender.go b/server/internal/logic/warningnotify/wechat_sender.go index a63f60b..752e95c 100644 --- a/server/internal/logic/warningnotify/wechat_sender.go +++ b/server/internal/logic/warningnotify/wechat_sender.go @@ -10,6 +10,7 @@ import ( "net/http" "net/url" "os" + "sort" "strconv" "strings" "sync" @@ -83,7 +84,7 @@ func sendWarningWechat(ctx context.Context, batch Batch) error { batch.DeptID, batch.Category, batch.WarnStage, - len(batch.Items), + batchItemCount(batch), ) return nil } @@ -92,40 +93,61 @@ func sendWarningWechat(ctx context.Context, batch Batch) error { return nil } - deliveries := resolveTerminalPlatformDeliveries(ctx, batch) + deliveries := resolveWechatDeliveries(ctx, batch) if len(deliveries) == 0 { - g.Log().Infof(ctx, "WarningNotify wechat subscribe skipped, reason:no terminal recipients, tenantId:%s, count:%d", batch.TenantID, len(batch.Items)) return nil } var sendErrs []string for _, delivery := range deliveries { - if len(delivery.Group.UserIDs) == 0 || len(delivery.Batch.Items) == 0 { + if len(delivery.Group.UserIDs) == 0 || batchItemCount(delivery.Batch) == 0 { continue } userID := delivery.Group.UserIDs[0] + if batch.Business == BusinessSimWarning { + enabled, err := warningWechatEnabled(ctx, batch.TenantID, userID) + if err != nil { + sendErrs = append(sendErrs, fmt.Sprintf("userId=%d query preference: %v", userID, err)) + continue + } + if !enabled { + g.Log().Infof(ctx, "WarningNotify wechat subscribe skipped, reason:user preference disabled, tenantId:%s, userId:%d, count:%d", batch.TenantID, userID, batchItemCount(delivery.Batch)) + continue + } + } bindings, err := queryWechatBindings(ctx, batch.TenantID, userID) if err != nil { sendErrs = append(sendErrs, fmt.Sprintf("userId=%d query binding: %v", userID, err)) continue } if len(bindings) == 0 { - g.Log().Infof(ctx, "WarningNotify wechat subscribe skipped, reason:no openid, tenantId:%s, userId:%d, count:%d", batch.TenantID, userID, len(delivery.Batch.Items)) + g.Log().Infof(ctx, "WarningNotify wechat subscribe skipped, reason:no openid, tenantId:%s, userId:%d, count:%d", batch.TenantID, userID, batchItemCount(delivery.Batch)) continue } acquired, sentKey, err := acquireWechatSendDedupe(ctx, delivery.Batch, userID) if err != nil { g.Log().Warningf(ctx, "WarningNotify wechat subscribe dedupe check failed, tenantId:%s, userId:%d, category:%s, stage:%d, count:%d, err:%+v", - batch.TenantID, userID, delivery.Batch.Category, delivery.Batch.WarnStage, len(delivery.Batch.Items), err) + batch.TenantID, userID, delivery.Batch.Category, delivery.Batch.WarnStage, batchItemCount(delivery.Batch), err) } else if !acquired { g.Log().Infof(ctx, "WarningNotify wechat subscribe skipped, reason:duplicated, tenantId:%s, userId:%d, key:%s, category:%s, stage:%d, count:%d", - batch.TenantID, userID, sentKey, delivery.Batch.Category, delivery.Batch.WarnStage, len(delivery.Batch.Items)) + batch.TenantID, userID, sentKey, delivery.Batch.Category, delivery.Batch.WarnStage, batchItemCount(delivery.Batch)) continue } successCount := 0 for _, binding := range bindings { + if batch.Business == BusinessSimWarning { + enabled, err := warningWechatEnabled(ctx, batch.TenantID, userID) + if err != nil { + sendErrs = append(sendErrs, fmt.Sprintf("userId=%d query preference: %v", userID, err)) + continue + } + if !enabled { + g.Log().Infof(ctx, "WarningNotify wechat subscribe skipped, reason:user preference disabled before send, tenantId:%s, userId:%d", batch.TenantID, userID) + continue + } + } templateID := wechatTemplateID(delivery.Batch.Items, cfg.TemplateID) if templateID == "" { sendErrs = append(sendErrs, fmt.Sprintf("userId=%d template id empty", userID)) @@ -135,11 +157,11 @@ func sendWarningWechat(ctx context.Context, batch Batch) error { if err := sendWechatSubscription(ctx, cfg, payload); err != nil { sendErrs = append(sendErrs, fmt.Sprintf("userId=%d openid=%s: %v", userID, maskOpenID(binding.OpenID), err)) g.Log().Errorf(ctx, "WarningNotify wechat subscribe failed, tenantId:%s, userId:%d, openid:%s, category:%s, stage:%d, count:%d, err:%+v", - batch.TenantID, userID, maskOpenID(binding.OpenID), delivery.Batch.Category, delivery.Batch.WarnStage, len(delivery.Batch.Items), err) + batch.TenantID, userID, maskOpenID(binding.OpenID), delivery.Batch.Category, delivery.Batch.WarnStage, batchItemCount(delivery.Batch), err) continue } g.Log().Infof(ctx, "WarningNotify wechat subscribe sent, tenantId:%s, userId:%d, openid:%s, category:%s, stage:%d, count:%d", - batch.TenantID, userID, maskOpenID(binding.OpenID), delivery.Batch.Category, delivery.Batch.WarnStage, len(delivery.Batch.Items)) + batch.TenantID, userID, maskOpenID(binding.OpenID), delivery.Batch.Category, delivery.Batch.WarnStage, batchItemCount(delivery.Batch)) successCount++ } if acquired && successCount == 0 { @@ -153,6 +175,107 @@ func sendWarningWechat(ctx context.Context, batch Batch) error { return nil } +func resolveWechatDeliveries(ctx context.Context, batch Batch) []platformDelivery { + if batch.prepared != nil { + return []platformDelivery{{Group: *batch.prepared, Batch: batch}} + } + deliveries := resolveTerminalPlatformDeliveries(ctx, batch) + appendEnterpriseWechatDeliveries(ctx, batch, &deliveries) + if len(deliveries) == 0 { + g.Log().Infof(ctx, "WarningNotify wechat subscribe skipped, reason:no eligible recipients, tenantId:%s, count:%d", batch.TenantID, batchItemCount(batch)) + } + return deliveries +} + +func appendEnterpriseWechatDeliveries(ctx context.Context, batch Batch, deliveries *[]platformDelivery) { + if batch.Business != BusinessSimWarning { + return + } + itemsByUser := make(map[int64][]Event) + seenByUser := make(map[int64]map[int]struct{}) + for index, item := range batch.Items { + if item.ItemType != 1 { + continue + } + createdBy := item.CreatedBy + if createdBy <= 0 { + createdBy = batch.CreatedBy + } + if createdBy <= 0 || item.ItemID <= 0 { + continue + } + userID, ok, err := queryEnterpriseWarningUser(ctx, batch.TenantID, createdBy, item.ItemID) + if err != nil { + g.Log().Warningf(ctx, "WarningNotify query enterprise user failed, tenantId:%s, userId:%d, cardId:%d, err:%+v", batch.TenantID, createdBy, item.ItemID, err) + continue + } + if ok { + appendTerminalEvent(itemsByUser, seenByUser, userID, index, item) + } + } + userIDs := make([]int64, 0, len(itemsByUser)) + for userID := range itemsByUser { + userIDs = append(userIDs, userID) + } + sort.Slice(userIDs, func(i, j int) bool { return userIDs[i] < userIDs[j] }) + for _, userID := range userIDs { + items := itemsByUser[userID] + enterpriseBatch := batch + enterpriseBatch.Items = append([]Event(nil), items...) + enterpriseBatch.WarnStage = highestStage(enterpriseBatch.Items) + *deliveries = append(*deliveries, platformDelivery{ + Group: platformRecipientGroup{SendUserType: sendUserTypeAgent, UserIDs: []int64{userID}}, + Batch: enterpriseBatch, + }) + } +} + +func enterpriseWarningRecipientEligible(userType string, userDeptID, cardDeptID int64, ancestors string) bool { + if userType != consts.SysUserTypeSys || userDeptID <= 0 || cardDeptID <= 0 { + return false + } + if userDeptID == cardDeptID { + return true + } + target := strconv.FormatInt(userDeptID, 10) + for _, ancestor := range strings.Split(ancestors, ",") { + if strings.TrimSpace(ancestor) == target { + return true + } + } + return false +} +func queryEnterpriseWarningUser(ctx context.Context, tenantID string, userID, cardID int64) (int64, bool, error) { + if strings.TrimSpace(tenantID) == "" || userID <= 0 || cardID <= 0 { + return 0, false, nil + } + var result struct { + UserID int64 `orm:"user_id"` + UserType string `orm:"user_type"` + DeptID int64 `orm:"dept_id"` + CardDeptID int64 `orm:"card_dept_id"` + Ancestors string `orm:"ancestors"` + } + err := g.DB().Model(dao.TSimCardInfo.Table()+" c").Ctx(ctx).Safe(). + LeftJoin(dao.SysUser.Table()+" u", "u.user_id = "+strconv.FormatInt(userID, 10)). + InnerJoin(dao.SysDept.Table()+" d", "d.dept_id = c.dept_id AND d.tenant_id = c.tenant_id"). + InnerJoin(dao.SysDept.Table()+" ud", "ud.dept_id = u.dept_id AND ud.tenant_id = u.tenant_id"). + Fields("u.user_id, u.user_type, u.dept_id, c.dept_id AS card_dept_id, d.ancestors"). + Where("c.tenant_id", tenantID). + Where("c.id", cardID). + Where("u.tenant_id", tenantID). + Where("u.user_type", consts.SysUserTypeSys). + Where("u.status", consts.SysDeptStatusNormal). + Where("u.deleted_at IS NULL"). + Where("d.deleted_at IS NULL"). + Where("ud.deleted_at IS NULL"). + Scan(&result) + if err != nil { + return 0, false, err + } + return result.UserID, result.UserID > 0 && enterpriseWarningRecipientEligible(result.UserType, result.DeptID, result.CardDeptID, result.Ancestors), nil +} + func loadWarningWechatConfig(ctx context.Context) (warningWechatConfig, bool) { var cfg warningWechatConfig loaded := false @@ -358,6 +481,9 @@ func wechatBatchHasExpireKind(batch Batch) bool { func wechatBatchTime(batch Batch) time.Time { latest := latestEvent(batch.Items) + if !batch.LatestTime.IsZero() { + latest.WarnTime = batch.LatestTime + } if latest.WarnTime.IsZero() { return time.Now() } @@ -392,6 +518,9 @@ func wechatTemplateID(items []Event, fallback string) string { func buildWechatPayload(batch Batch, openID string, templateID string, page string) warningWechatPayload { latest := latestEvent(batch.Items) + if !batch.LatestTime.IsZero() { + latest.WarnTime = batch.LatestTime + } return warningWechatPayload{ ToUser: strings.TrimSpace(openID), TemplateID: strings.TrimSpace(templateID), @@ -417,25 +546,25 @@ func latestEvent(items []Event) Event { } func wechatDeviceText(batch Batch) string { - if len(batch.Items) == 0 { + if batchItemCount(batch) == 0 { return "--" } first := displayObjectCode(batch.Items[0]) - if len(batch.Items) == 1 { + if batchItemCount(batch) == 1 { return first } - return fmt.Sprintf("%s等%d张卡", first, len(batch.Items)) + return fmt.Sprintf("%s等%d张卡", first, batchItemCount(batch)) } func wechatDeviceCharacterText(batch Batch) string { - if len(batch.Items) == 0 { + if batchItemCount(batch) == 0 { return "NA" } first := displayObjectCode(batch.Items[0]) - if len(batch.Items) == 1 { + if batchItemCount(batch) == 1 { return first } - return fmt.Sprintf("%sx%d", first, len(batch.Items)) + return fmt.Sprintf("TOTAL-%d", batchItemCount(batch)) } func wechatTypeText(batch Batch) string { @@ -443,25 +572,28 @@ func wechatTypeText(batch Batch) string { if title == "" { title = "设备预警" } - if len(batch.Items) > 1 { - return fmt.Sprintf("%s,共%d张卡", title, len(batch.Items)) + if batchItemCount(batch) > 1 { + return fmt.Sprintf("%s,共%d张卡", title, batchItemCount(batch)) } return title } func wechatReasonText(batch Batch) string { - if len(batch.Items) == 1 { + if batchItemCount(batch) == 1 { return emailWarningReason(batch.Items[0]) } - reason := fmt.Sprintf("共%d张卡触发预警,最高级别:%s", len(batch.Items), levelText(highestLevel(batch.Items))) - if hasAffectOperation(batch.Items) { + reason := fmt.Sprintf("%s\uff0c\u5171%d\u5f20\u5361", levelText(batchHighestLevel(batch)), batchItemCount(batch)) + if batchHasAffectOperation(batch) { reason += ",涉及停机或断网" } return reason } func wechatValueText(batch Batch) string { - if len(batch.Items) == 0 { + if batchItemCount(batch) > 1 { + return fmt.Sprintf("TOTAL-%d", batchItemCount(batch)) + } + if batchItemCount(batch) == 0 { return "NA" } item := batch.Items[0] diff --git a/server/internal/model/agent_dept.go b/server/internal/model/agent_dept.go index 4a20daa..4504987 100644 --- a/server/internal/model/agent_dept.go +++ b/server/internal/model/agent_dept.go @@ -3,9 +3,24 @@ package model import "github.com/gogf/gf/v2/os/gtime" type AgentDeptListParam struct { - DeptName string `json:"deptName"` - Status string `json:"status"` - DeptType *int `json:"deptType"` + ExportColumnsJson string `json:"exportColumnsJson"` + Page int `json:"page" d:"1" v:"min:1|max:100000000" ` + PageSize int `json:"pageSize" d:"20" v:"min:1|max:2000" ` + DeptName string `json:"deptName"` + Status string `json:"status"` + DeptType *int `json:"deptType"` +} + +func (in *AgentDeptListParam) NormalizePagination() { + if in.Page <= 0 { + in.Page = 1 + } + if in.PageSize <= 0 { + in.PageSize = 20 + } + if in.PageSize > 2000 { + in.PageSize = 2000 + } } type AgentDeptModel struct { diff --git a/server/internal/model/agent_dept_test.go b/server/internal/model/agent_dept_test.go new file mode 100644 index 0000000..d5ab6af --- /dev/null +++ b/server/internal/model/agent_dept_test.go @@ -0,0 +1,15 @@ +package model + +import "testing" + +func TestAgentDeptPaginationDefaultsAndBounds(t *testing.T) { + for _, test := range []struct{ page, size, wantPage, wantSize int }{ + {0, 0, 1, 20}, {-1, -10, 1, 20}, {3, 50, 3, 50}, {1, 5000, 1, 2000}, + } { + params := AgentDeptListParam{Page: test.page, PageSize: test.size} + params.NormalizePagination() + if params.Page != test.wantPage || params.PageSize != test.wantSize { + t.Fatalf("pagination = %d/%d, want %d/%d", params.Page, params.PageSize, test.wantPage, test.wantSize) + } + } +} diff --git a/server/internal/model/agent_role.go b/server/internal/model/agent_role.go index e525fe6..8ce03d3 100644 --- a/server/internal/model/agent_role.go +++ b/server/internal/model/agent_role.go @@ -3,11 +3,18 @@ package model import "github.com/gogf/gf/v2/os/gtime" type AgentRoleSyncMessage struct { - TenantId string `json:"tenantId"` - SourceRoleId int64 `json:"sourceRoleId"` - SourceDeptId int64 `json:"sourceDeptId"` - ActorId int64 `json:"actorId"` - RetryCount int `json:"retryCount"` + TemplateRole bool `json:"templateRole,omitempty"` + Superseded bool `json:"superseded,omitempty"` + TaskId string `json:"taskId,omitempty"` + TotalCount int `json:"totalCount,omitempty"` + SuccessCount int `json:"successCount,omitempty"` + FailedCount int `json:"failedCount,omitempty"` + TenantId string `json:"tenantId"` + SourceRoleId int64 `json:"sourceRoleId"` + SourceDeptId int64 `json:"sourceDeptId"` + ActorId int64 `json:"actorId"` + RetryCount int `json:"retryCount"` + ChildDeptIds []int64 `json:"childDeptIds,omitempty"` } type AgentRoleSyncDeadLetterMessage struct { diff --git a/server/internal/model/agent_user.go b/server/internal/model/agent_user.go index ba77dae..4289998 100644 --- a/server/internal/model/agent_user.go +++ b/server/internal/model/agent_user.go @@ -8,13 +8,14 @@ import ( type AgentUserListParam struct { request.PageInfo - DeptId int64 `json:"deptId"` - UserName string `json:"userName"` - NickName string `json:"nickName"` - Email string `json:"email"` - Phonenumber string `json:"phonenumber"` - Status string `json:"status"` - CreatedAt []string `json:"createdAt"` + ExportColumnsJson string `json:"exportColumnsJson"` + DeptId int64 `json:"deptId"` + UserName string `json:"userName"` + NickName string `json:"nickName"` + Email string `json:"email"` + Phonenumber string `json:"phonenumber"` + Status string `json:"status"` + CreatedAt []string `json:"createdAt"` } type AgentUserListModel struct { diff --git a/server/internal/model/batch_detail_list.go b/server/internal/model/batch_detail_list.go index 23c3a76..1d70f8b 100644 --- a/server/internal/model/batch_detail_list.go +++ b/server/internal/model/batch_detail_list.go @@ -7,6 +7,7 @@ import ( ) type BatchCardListParam struct { + DetailStatus *int `json:"detailStatus" v:"in:0,1,2,3,4,5#明细状态不合法" dc:"明细状态"` request.PageInfo BatchNo string `json:"batchNo" v:"required#批次号不能为空" dc:"批次编号"` BatchType *int `json:"batchType" dc:"批次类型"` @@ -20,6 +21,7 @@ func (in *BatchCardListParam) Filter(ctx context.Context) (err error) { } type BatchCardListModel struct { + ErrorReason string `json:"errorReason" dc:"失败或待核查原因"` allocationunits.Fields RowNo int `json:"rowNo" dc:"行号"` Iccid string `json:"iccid" dc:"ICCID"` diff --git a/server/internal/model/batch_execution_log.go b/server/internal/model/batch_execution_log.go index 16bc8fd..6b2f3c4 100644 --- a/server/internal/model/batch_execution_log.go +++ b/server/internal/model/batch_execution_log.go @@ -2,10 +2,10 @@ package model import ( "context" + "github.com/gogf/gf/v2/encoding/gjson" "git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/allocationunits" "git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/xgorm/hook" "git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/model/request" - "github.com/gogf/gf/v2/encoding/gjson" "github.com/gogf/gf/v2/os/gtime" ) @@ -37,7 +37,10 @@ type BatchExecutionLogListModel struct { BatchType int `json:"batchType" dc:"批次类型"` SourceType *int `json:"sourceType" dc:"来源方式 枚举"` BatchAsyncStatus int `json:"batchAsyncStatus" dc:"异步处理状态"` + RestartPhase string `json:"restartPhase" orm:"-" dc:"重启阶段 waiting_resume=等待复机"` + RestartResumeAt string `json:"restartResumeAt" orm:"-" dc:"计划复机时间"` TotalCount int `json:"totalCount" dc:"总数量"` + UnknownCount int `json:"unknownCount" orm:"-" dc:"待核查数量"` RunResult int `json:"runResult" dc:"运行结果"` FailReason string `json:"failReason" dc:"失败原因"` StartTime *gtime.Time `json:"startTime" dc:"开始时间"` @@ -56,15 +59,36 @@ func (in *BatchExecutionLogDetailParam) Filter(ctx context.Context) (err error) return } +// BatchExecutionLogSyncReviewParam 手动同步待核查批次的上游执行结果。 +// 只按既有受理号(jobId)重查一次,不重发办理。 +type BatchExecutionLogSyncReviewParam struct { + BatchNo string `json:"batchNo" v:"required#批次号不能为空" dc:"批次编号"` + Reason string `json:"reason" dc:"同步原因"` +} + +func (in *BatchExecutionLogSyncReviewParam) Filter(ctx context.Context) (err error) { + return +} + +type BatchExecutionLogSyncReviewModel struct { + BatchNo string `json:"batchNo" dc:"批次编号"` + BatchType int `json:"batchType" dc:"批次类型"` + BatchAsyncStatus int `json:"batchAsyncStatus" dc:"异步处理状态"` + UnknownCount int `json:"unknownCount" dc:"待核查数量"` + Queued bool `json:"queued" dc:"是否已提交同步"` +} + type BatchExecutionLogFileModel struct { OssId int64 `json:"ossId" dc:"OSS文件ID"` Url string `json:"url" dc:"文件URL"` } type BatchExecutionLogResultFileModel struct { + ExportTaskId int64 `json:"exportTaskId,omitempty" dc:"导出任务ID"` DownloadId int64 `json:"downloadId" dc:"下载任务ID"` OssId int64 `json:"ossId" dc:"OSS文件ID"` Url string `json:"url" dc:"文件URL"` + OriginalName string `json:"originalName" dc:"原始下载文件名"` DownloadStatus int `json:"downloadStatus" dc:"下载状态"` } @@ -77,9 +101,12 @@ type BatchExecutionLogDetailModel struct { BatchType int `json:"batchType" dc:"批次类型"` SourceType *int `json:"sourceType" dc:"来源方式 枚举"` BatchAsyncStatus int `json:"batchAsyncStatus" dc:"异步处理状态 0=已创建 1=待处理 2=处理中 3=成功 4=失败 5=部分成功"` + RestartPhase string `json:"restartPhase" orm:"-" dc:"重启阶段 waiting_resume=等待复机"` + RestartResumeAt string `json:"restartResumeAt" orm:"-" dc:"计划复机时间"` RunResult int `json:"runResult" dc:"运行结果"` FailReason string `json:"failReason" dc:"失败原因"` TotalCount int `json:"totalCount" dc:"总数量"` + UnknownCount int `json:"unknownCount" dc:"待核查数量"` StartTime *gtime.Time `json:"startTime" dc:"开始时间"` EndTime *gtime.Time `json:"endTime" dc:"结束时间"` SourceFile *BatchExecutionLogFileModel `json:"sourceFile" dc:"源文件"` diff --git a/server/internal/model/batch_execution_log_test.go b/server/internal/model/batch_execution_log_test.go new file mode 100644 index 0000000..8bb1300 --- /dev/null +++ b/server/internal/model/batch_execution_log_test.go @@ -0,0 +1,48 @@ +package model + +import ( + "context" + "encoding/json" + "testing" + + "git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/model/request" + + "github.com/gogf/gf/v2/frame/g" +) + +func TestBatchExecutionLogSourceTypeValidation(t *testing.T) { + page := request.PageInfo{Page: 1, PageSize: 20} + if err := g.Validator().Data(BatchExecutionLogListParam{PageInfo: page}).Run(context.Background()); err != nil { + t.Fatalf("omitted sourceType: %v", err) + } + for _, value := range []int{-1, 0, 1, 2, 3, 4, 5} { + err := g.Validator().Data(BatchExecutionLogListParam{PageInfo: page, SourceType: &value}).Run(context.Background()) + valid := value >= 0 && value <= 4 + if (err == nil) != valid { + t.Fatalf("sourceType=%d: valid=%t err=%v", value, valid, err) + } + } +} + +func TestBatchExecutionLogDetailModelOmitsItemCounts(t *testing.T) { + payload, err := json.Marshal(BatchExecutionLogDetailModel{ + TotalCount: 7, + }) + if err != nil { + t.Fatalf("marshal summary model: %v", err) + } + + var fields map[string]any + if err := json.Unmarshal(payload, &fields); err != nil { + t.Fatalf("decode summary model: %v", err) + } + + if got := fields["totalCount"]; got != float64(7) { + t.Fatalf("totalCount = %v, want 7", got) + } + for _, field := range []string{"validCount", "successCount", "failCount"} { + if _, ok := fields[field]; ok { + t.Fatalf("summary response still contains %q", field) + } + } +} diff --git a/server/internal/model/batch_main.go b/server/internal/model/batch_main.go index 65fc3a8..5e92b9e 100644 --- a/server/internal/model/batch_main.go +++ b/server/internal/model/batch_main.go @@ -1,7 +1,7 @@ // Package genin // @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 model @@ -208,6 +208,7 @@ type BatchMainListModel struct { // BatchMainSuccessListParam 获取指定类型成功批次列表 type BatchMainSuccessListParam struct { request.PageInfo + ChanKey string `json:"chanKey" dc:"上游渠道编码"` BatchType int `json:"batchType" dc:"批次类型(不传则查询所有成功批次)"` BatchNo string `json:"batchNo" dc:"批次编号"` Time []*gtime.Time `json:"time" dc:"批次执行时间"` diff --git a/server/internal/model/context.go b/server/internal/model/context.go index 45e4a5e..b1254e3 100644 --- a/server/internal/model/context.go +++ b/server/internal/model/context.go @@ -1,7 +1,7 @@ // package model // @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 model @@ -9,9 +9,9 @@ import "github.com/gogf/gf/v2/frame/g" // 上下文结构 type Context struct { - User *Identity // 上下文用户信息 - IsWebSocket bool // 是否是WebSocket - Data g.Map // 自定KV变量,业务模块根据需要设置,不固定 + User *Identity // 上下文用户信息 + IsWebSocket bool // 是否是WebSocket + Data g.Map // 自定KV变量,业务模块根据需要设置,不固定 } type Role struct { diff --git a/server/internal/model/do/sys_permission_revision.go b/server/internal/model/do/sys_permission_revision.go new file mode 100644 index 0000000..fc9e2bc --- /dev/null +++ b/server/internal/model/do/sys_permission_revision.go @@ -0,0 +1,18 @@ +// ================================================================================= +// Code generated and maintained by GoFrame CLI tool. DO NOT EDIT. +// ================================================================================= + +package do + +import ( + "github.com/gogf/gf/v2/frame/g" + "github.com/gogf/gf/v2/os/gtime" +) + +// SysPermissionRevision is the golang structure of table sys_permission_revision for DAO operations like Where/Data. +type SysPermissionRevision struct { + g.Meta `orm:"table:sys_permission_revision, do:true"` + Id any // + Revision any // + UpdatedAt *gtime.Time // +} diff --git a/server/internal/model/do/sys_role_sync_task.go b/server/internal/model/do/sys_role_sync_task.go new file mode 100644 index 0000000..28d4f58 --- /dev/null +++ b/server/internal/model/do/sys_role_sync_task.go @@ -0,0 +1,32 @@ +// ================================================================================= +// Code generated and maintained by GoFrame CLI tool. DO NOT EDIT. +// ================================================================================= + +package do + +import ( + "github.com/gogf/gf/v2/frame/g" + "github.com/gogf/gf/v2/os/gtime" +) + +// SysRoleSyncTask is the golang structure of table sys_role_sync_task for DAO operations like Where/Data. +type SysRoleSyncTask struct { + g.Meta `orm:"table:sys_role_sync_task, do:true"` + TaskId any // + TenantId any // + SourceRoleId any // + SourceDeptId any // + ActorId any // + Status any // + Attempts any // + NextAttemptAt any // + LeaseUntil any // + LeaseToken any // + Payload any // + LastError any // + TotalCount any // + SuccessCount any // + FailedCount any // + CreatedAt *gtime.Time // + UpdatedAt *gtime.Time // +} diff --git a/server/internal/model/do/sys_user.go b/server/internal/model/do/sys_user.go index cbbe5ec..fcc594b 100644 --- a/server/internal/model/do/sys_user.go +++ b/server/internal/model/do/sys_user.go @@ -11,30 +11,30 @@ import ( // SysUser is the golang structure of table sys_user for DAO operations like Where/Data. type SysUser struct { - g.Meta `orm:"table:sys_user, do:true"` - UserId any // 用户ID - TenantId any // 租户编号 - DeptId any // 部门ID - UserName any // 用户账号 - NickName any // 用户昵称 - UserType any // 用户类型(sys_user系统用户) - Email any // 用户邮箱 - Phonenumber any // 手机号码 - Sex any // 用户性别(0男 1女 2未知) - Avatar any // 头像地址 - Salt any // 加密盐 - Password any // 密码 - IsInitPassword any // 当前密码是否未由用户本人设置(1=初始或管理员临时密码 0=用户已自设) - Status any // 帐号状态(0正常 1停用) - LoginIp any // 最后登录IP - LoginDate *gtime.Time // 最后登录时间 - MfaEnabled any // 是否开启邮箱MFA 0否 1是 - CreatedDept any // 创建部门 - CreatedBy any // 创建者 - CreatedAt *gtime.Time // 创建时间 - UpdatedBy any // 更新者 - UpdatedAt *gtime.Time // 更新时间 - DeletedBy any // 删除人 - DeletedAt *gtime.Time // 删除时间 - Remark any // 备注 + g.Meta `orm:"table:sys_user, do:true"` + UserId any // 用户ID + TenantId any // 租户编号 + DeptId any // 部门ID + UserName any // 用户账号 + NickName any // 用户昵称 + UserType any // 用户类型(sys_user系统用户) + Email any // 用户邮箱 + Phonenumber any // 手机号码 + Sex any // 用户性别(0男 1女 2未知) + Avatar any // 头像地址 + Salt any // 加密盐 + Password any // 密码 + IsInitPassword any // 当前密码是否未由用户本人设置(1=初始或管理员临时密码 0=用户已自设) + Status any // 帐号状态(0正常 1停用) + LoginIp any // 最后登录IP + LoginDate *gtime.Time // 最后登录时间 + MfaEnabled any // 是否开启邮箱MFA 0否 1是 + CreatedDept any // 创建部门 + CreatedBy any // 创建者 + CreatedAt *gtime.Time // 创建时间 + UpdatedBy any // 更新者 + UpdatedAt *gtime.Time // 更新时间 + DeletedBy any // 删除人 + DeletedAt *gtime.Time // 删除时间 + Remark any // 备注 } diff --git a/server/internal/model/download_center.go b/server/internal/model/download_center.go new file mode 100644 index 0000000..6a265f8 --- /dev/null +++ b/server/internal/model/download_center.go @@ -0,0 +1,69 @@ +package model + +import ( + "github.com/gogf/gf/v2/os/gtime" + + "git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/model/request" +) + +// ExportIdentitySnapshot 提交导出时的用户身份与角色范围快照。 +// 异步执行时用它还原 ctx,保证 FilterAuth 仍按提交人的数据范围过滤。 +type ExportIdentitySnapshot struct { + UserId int64 `json:"userId"` + DeptId int64 `json:"deptId"` + TenantId string `json:"tenantId"` + Roles []Role `json:"roles"` + AccessCodeList []string `json:"accessCodeList"` + CurrentRoleId string `json:"currentRoleId"` + CurrentRoleDataScope string `json:"currentRoleDataScope"` +} + +// DownloadCenterListParam 下载中心列表查询 +type DownloadCenterListParam struct { + request.PageInfo + BizKey string `json:"bizKey" dc:"业务标识"` + Status string `json:"status" dc:"状态,多个用逗号分隔 0排队 2生成中 1已完成 3失败"` + CreatedAt []*gtime.Time `json:"createdAt" dc:"创建时间区间"` +} + +// DownloadCenterListModel 下载中心列表项 +type DownloadCenterListModel struct { + Id int64 `json:"id" dc:"主键ID"` + TaskNo string `json:"taskNo" dc:"任务编号"` + BizKey string `json:"bizKey" dc:"业务标识"` + BizName string `json:"bizName" dc:"业务名称"` + Status int `json:"status" dc:"状态"` + TotalCount int `json:"totalCount" dc:"导出总行数"` + FileName string `json:"fileName" dc:"文件名"` + FileSize int64 `json:"fileSize" dc:"文件大小"` + ErrorMessage string `json:"errorMessage" dc:"失败原因"` + CreatedAt *gtime.Time `json:"createdAt" dc:"创建时间"` + FinishTime *gtime.Time `json:"finishTime" dc:"完成时间"` + ExpireTime *gtime.Time `json:"expireTime" dc:"过期时间"` + DeletedTime *gtime.Time `json:"deletedTime" dc:"文件清理时间"` + DeleteReason string `json:"deleteReason" dc:"清理原因 user/expired"` +} + +// DownloadCenterTaskParam 按任务ID操作 +type DownloadCenterTaskParam struct { + TaskId int64 `json:"taskId" v:"required|min:1#任务ID不能为空|任务ID不正确" dc:"任务ID"` +} + +// DownloadCenterDownloadModel 下载结果 +type DownloadCenterDownloadModel struct { + FileName string `json:"fileName" dc:"文件名"` + OssId int64 `json:"ossId" dc:"OSS文件ID"` + TaskNo string `json:"taskNo" dc:"任务编号"` +} + +// DownloadCenterRecentParam 右上角全局入口:最近任务 +type DownloadCenterRecentParam struct { + Limit int `json:"limit" d:"5" dc:"返回条数"` +} + +// DownloadCenterRecentModel 全局入口返回 +type DownloadCenterRecentModel struct { + Items []*DownloadCenterListModel `json:"items" dc:"最近任务"` + RunningCount int `json:"runningCount" dc:"进行中数量"` + FinishedCount int `json:"finishedCount" dc:"已完成数量"` +} diff --git a/server/internal/model/entity/sys_permission_revision.go b/server/internal/model/entity/sys_permission_revision.go new file mode 100644 index 0000000..d57faf0 --- /dev/null +++ b/server/internal/model/entity/sys_permission_revision.go @@ -0,0 +1,16 @@ +// ================================================================================= +// Code generated and maintained by GoFrame CLI tool. DO NOT EDIT. +// ================================================================================= + +package entity + +import ( + "github.com/gogf/gf/v2/os/gtime" +) + +// SysPermissionRevision is the golang structure for table sys_permission_revision. +type SysPermissionRevision struct { + Id int `json:"id" orm:"id" description:""` + Revision int64 `json:"revision" orm:"revision" description:""` + UpdatedAt *gtime.Time `json:"updatedAt" orm:"updated_at" description:""` +} diff --git a/server/internal/model/entity/sys_role_sync_task.go b/server/internal/model/entity/sys_role_sync_task.go new file mode 100644 index 0000000..5f421e5 --- /dev/null +++ b/server/internal/model/entity/sys_role_sync_task.go @@ -0,0 +1,30 @@ +// ================================================================================= +// Code generated and maintained by GoFrame CLI tool. DO NOT EDIT. +// ================================================================================= + +package entity + +import ( + "github.com/gogf/gf/v2/os/gtime" +) + +// SysRoleSyncTask is the golang structure for table sys_role_sync_task. +type SysRoleSyncTask struct { + TaskId string `json:"taskId" orm:"task_id" description:""` + TenantId string `json:"tenantId" orm:"tenant_id" description:""` + SourceRoleId int64 `json:"sourceRoleId" orm:"source_role_id" description:""` + SourceDeptId int64 `json:"sourceDeptId" orm:"source_dept_id" description:""` + ActorId int64 `json:"actorId" orm:"actor_id" description:""` + Status string `json:"status" orm:"status" description:""` + Attempts int `json:"attempts" orm:"attempts" description:""` + NextAttemptAt int64 `json:"nextAttemptAt" orm:"next_attempt_at" description:""` + LeaseUntil int64 `json:"leaseUntil" orm:"lease_until" description:""` + LeaseToken string `json:"leaseToken" orm:"lease_token" description:""` + Payload string `json:"payload" orm:"payload" description:""` + LastError string `json:"lastError" orm:"last_error" description:""` + TotalCount int `json:"totalCount" orm:"total_count" description:""` + SuccessCount int `json:"successCount" orm:"success_count" description:""` + FailedCount int `json:"failedCount" orm:"failed_count" description:""` + CreatedAt *gtime.Time `json:"createdAt" orm:"created_at" description:""` + UpdatedAt *gtime.Time `json:"updatedAt" orm:"updated_at" description:""` +} diff --git a/server/internal/model/entity/sys_user.go b/server/internal/model/entity/sys_user.go index 6b95f31..b0da9dd 100644 --- a/server/internal/model/entity/sys_user.go +++ b/server/internal/model/entity/sys_user.go @@ -10,29 +10,29 @@ import ( // SysUser is the golang structure for table sys_user. type SysUser struct { - UserId int64 `json:"userId" orm:"user_id" description:"用户ID"` - TenantId string `json:"tenantId" orm:"tenant_id" description:"租户编号"` - DeptId int64 `json:"deptId" orm:"dept_id" description:"部门ID"` - UserName string `json:"userName" orm:"user_name" description:"用户账号"` - NickName string `json:"nickName" orm:"nick_name" description:"用户昵称"` - UserType string `json:"userType" orm:"user_type" description:"用户类型(sys_user系统用户)"` - Email string `json:"email" orm:"email" description:"用户邮箱"` - Phonenumber string `json:"phonenumber" orm:"phonenumber" description:"手机号码"` - Sex string `json:"sex" orm:"sex" description:"用户性别(0男 1女 2未知)"` - Avatar string `json:"avatar" orm:"avatar" description:"头像地址"` - Salt string `json:"salt" orm:"salt" description:"加密盐"` - Password string `json:"password" orm:"password" description:"密码"` - IsInitPassword int `json:"isInitPassword" orm:"is_init_password" description:"当前密码是否未由用户本人设置(1=初始或管理员临时密码 0=用户已自设)"` - Status string `json:"status" orm:"status" description:"帐号状态(0正常 1停用)"` - LoginIp string `json:"loginIp" orm:"login_ip" description:"最后登录IP"` - LoginDate *gtime.Time `json:"loginDate" orm:"login_date" description:"最后登录时间"` - MfaEnabled int `json:"mfaEnabled" orm:"mfa_enabled" description:"是否开启邮箱MFA 0否 1是"` - CreatedDept int64 `json:"createdDept" orm:"created_dept" description:"创建部门"` - CreatedBy int64 `json:"createdBy" orm:"created_by" description:"创建者"` - CreatedAt *gtime.Time `json:"createdAt" orm:"created_at" description:"创建时间"` - UpdatedBy int64 `json:"updatedBy" orm:"updated_by" description:"更新者"` - UpdatedAt *gtime.Time `json:"updatedAt" orm:"updated_at" description:"更新时间"` - DeletedBy int64 `json:"deletedBy" orm:"deleted_by" description:"删除人"` - DeletedAt *gtime.Time `json:"deletedAt" orm:"deleted_at" description:"删除时间"` - Remark string `json:"remark" orm:"remark" description:"备注"` + UserId int64 `json:"userId" orm:"user_id" description:"用户ID"` + TenantId string `json:"tenantId" orm:"tenant_id" description:"租户编号"` + DeptId int64 `json:"deptId" orm:"dept_id" description:"部门ID"` + UserName string `json:"userName" orm:"user_name" description:"用户账号"` + NickName string `json:"nickName" orm:"nick_name" description:"用户昵称"` + UserType string `json:"userType" orm:"user_type" description:"用户类型(sys_user系统用户)"` + Email string `json:"email" orm:"email" description:"用户邮箱"` + Phonenumber string `json:"phonenumber" orm:"phonenumber" description:"手机号码"` + Sex string `json:"sex" orm:"sex" description:"用户性别(0男 1女 2未知)"` + Avatar string `json:"avatar" orm:"avatar" description:"头像地址"` + Salt string `json:"salt" orm:"salt" description:"加密盐"` + Password string `json:"password" orm:"password" description:"密码"` + IsInitPassword int `json:"isInitPassword" orm:"is_init_password" description:"当前密码是否未由用户本人设置(1=初始或管理员临时密码 0=用户已自设)"` + Status string `json:"status" orm:"status" description:"帐号状态(0正常 1停用)"` + LoginIp string `json:"loginIp" orm:"login_ip" description:"最后登录IP"` + LoginDate *gtime.Time `json:"loginDate" orm:"login_date" description:"最后登录时间"` + MfaEnabled int `json:"mfaEnabled" orm:"mfa_enabled" description:"是否开启邮箱MFA 0否 1是"` + CreatedDept int64 `json:"createdDept" orm:"created_dept" description:"创建部门"` + CreatedBy int64 `json:"createdBy" orm:"created_by" description:"创建者"` + CreatedAt *gtime.Time `json:"createdAt" orm:"created_at" description:"创建时间"` + UpdatedBy int64 `json:"updatedBy" orm:"updated_by" description:"更新者"` + UpdatedAt *gtime.Time `json:"updatedAt" orm:"updated_at" description:"更新时间"` + DeletedBy int64 `json:"deletedBy" orm:"deleted_by" description:"删除人"` + DeletedAt *gtime.Time `json:"deletedAt" orm:"deleted_at" description:"删除时间"` + Remark string `json:"remark" orm:"remark" description:"备注"` } diff --git a/server/internal/model/entity/t_export_task.go b/server/internal/model/entity/t_export_task.go new file mode 100644 index 0000000..1e07a2d --- /dev/null +++ b/server/internal/model/entity/t_export_task.go @@ -0,0 +1,42 @@ +// ================================================================================= +// Code generated and maintained by GoFrame CLI tool. DO NOT EDIT. +// ================================================================================= + +package entity + +import ( + "github.com/gogf/gf/v2/encoding/gjson" + "github.com/gogf/gf/v2/os/gtime" +) + +// TExportTask is the golang structure for table t_export_task. +type TExportTask struct { + Id int64 `json:"id" orm:"id" description:"主键ID"` + TaskNo string `json:"taskNo" orm:"task_no" description:"任务编号(=执行日志批次编号=文件名主体)"` + BizKey string `json:"bizKey" orm:"biz_key" description:"业务标识"` + BizName string `json:"bizName" orm:"biz_name" description:"业务名称"` + PermCode string `json:"permCode" orm:"perm_code" description:"导出权限码快照,K1用"` + QueryParams *gjson.Json `json:"queryParams" orm:"query_params" description:"筛选条件快照"` + IdentitySnapshot *gjson.Json `json:"identitySnapshot" orm:"identity_snapshot" description:"提交时用户身份与角色范围快照"` + Status int `json:"status" orm:"status" description:"状态 0排队 2生成中 1已完成 3失败"` + TotalCount int `json:"totalCount" orm:"total_count" description:"导出总行数"` + OssId int64 `json:"ossId" orm:"oss_id" description:"OSS文件ID"` + FileName string `json:"fileName" orm:"file_name" description:"文件名"` + FileSize int64 `json:"fileSize" orm:"file_size" description:"文件大小(字节)"` + FileMd5 string `json:"fileMd5" orm:"file_md5" description:"文件MD5"` + DownloadCount int `json:"downloadCount" orm:"download_count" description:"下载次数"` + LastDownloadTime *gtime.Time `json:"lastDownloadTime" orm:"last_download_time" description:"最后下载时间"` + ErrorMessage string `json:"errorMessage" orm:"error_message" description:"失败原因"` + OwnerUserId int64 `json:"ownerUserId" orm:"owner_user_id" description:"发起用户ID,K2用"` + DeptId int64 `json:"deptId" orm:"dept_id" description:"机构ID"` + TenantId string `json:"tenantId" orm:"tenant_id" description:"租户ID"` + CreatedDept int64 `json:"createdDept" orm:"created_dept" description:"创建部门ID"` + CreatedBy int64 `json:"createdBy" orm:"created_by" description:"创建者ID"` + CreatedAt *gtime.Time `json:"createdAt" orm:"created_at" description:"创建时间"` + StartTime *gtime.Time `json:"startTime" orm:"start_time" description:"开始生成时间"` + FinishTime *gtime.Time `json:"finishTime" orm:"finish_time" description:"完成/失败时间"` + ExpireTime *gtime.Time `json:"expireTime" orm:"expire_time" description:"文件过期时间"` + DeletedTime *gtime.Time `json:"deletedTime" orm:"deleted_time" description:"文件清理时间"` + DeleteReason string `json:"deleteReason" orm:"delete_reason" description:"文件清理原因 user/expired"` + UpdatedAt *gtime.Time `json:"updatedAt" orm:"updated_at" description:"更新时间"` +} diff --git a/server/internal/model/export_column.go b/server/internal/model/export_column.go new file mode 100644 index 0000000..0f94e2f --- /dev/null +++ b/server/internal/model/export_column.go @@ -0,0 +1,18 @@ +package model + +// ExportColumn 导出列。 +// 由前端按「当前列表显示列」传入,顺序即导出列顺序。 +type ExportColumn struct { + // Field 列表模型字段名(对应 JSON tag),导出时按它取值 + Field string `json:"field" dc:"字段名"` + // Label 表头文字,取列表页列标题 + Label string `json:"label" dc:"列标题"` + DictType string `json:"dictType,omitempty" dc:"列表列使用的字典类型"` + // DictValueMap 字典取值前的值归一化(页面渲染前改过值时用) + DictValueMap map[string]string `json:"dictValueMap,omitempty" dc:"列表渲染前的值归一化"` + // ValueMap 固定值映射:页面用三元表达式等非字典方式渲染的列(如 1→池卡、0→单卡) + ValueMap map[string]string `json:"valueMap,omitempty" dc:"固定值映射,键为原始值"` + // ValueDefault 固定值映射未命中时的兜底文案;为空则保留原值 + ValueDefault string `json:"valueDefault,omitempty" dc:"固定值映射兜底文案"` + FallbackField string `json:"fallbackField,omitempty" dc:"旧数据备用字段"` +} diff --git a/server/internal/model/genin/test_demo.go b/server/internal/model/genin/test_demo.go index 9372476..568ca2b 100644 --- a/server/internal/model/genin/test_demo.go +++ b/server/internal/model/genin/test_demo.go @@ -1,7 +1,7 @@ // Package genin // @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 genin diff --git a/server/internal/model/monitor_server.go b/server/internal/model/monitor_server.go index 2be2376..21f03b2 100644 --- a/server/internal/model/monitor_server.go +++ b/server/internal/model/monitor_server.go @@ -1,7 +1,7 @@ // package model // @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 model diff --git a/server/internal/model/request/common.go b/server/internal/model/request/common.go index e816e9d..467192f 100644 --- a/server/internal/model/request/common.go +++ b/server/internal/model/request/common.go @@ -1,7 +1,7 @@ // package request // @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 request diff --git a/server/internal/model/response/common.go b/server/internal/model/response/common.go index 9f9000a..472fd7a 100644 --- a/server/internal/model/response/common.go +++ b/server/internal/model/response/common.go @@ -1,7 +1,7 @@ // package response // @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 response diff --git a/server/internal/model/sim_card_query.go b/server/internal/model/sim_card_query.go new file mode 100644 index 0000000..d5a3c5b --- /dev/null +++ b/server/internal/model/sim_card_query.go @@ -0,0 +1,115 @@ +package model + +import ( + "github.com/gogf/gf/v2/os/gtime" +) + +// 卡基础信息查询 / 月历史用量查询 共用的输入模式。 +const ( + SimCardQueryModeExcel = "excel" // Excel 模板导入 + SimCardQueryModeInput = "input" // 多行文本批量输入 + SimCardQueryModeRange = "range" // 连号(起止号段) + SimCardQueryModeDept = "dept" // 按所属公司查询(仅月历史用量查询支持) +) + +// 单次查询的号码条数上限。 +// +// 取值依据: +// - 与现有批量卡操作上限 20000 保持一致,避免用户对「批量」能力的容量认知分裂; +// - t_export_task.query_params 是 JSON 列,20000 条号码快照约 1MB,仍在可控范围; +// - 20000 行 xlsx 约 3~5MB,导出中心单任务 10 分钟超时内可以写完。 +const SimCardQueryMaxRows = 20000 + +// SimCardQueryExportParam 卡基础信息查询导出参数。 +// Excel 模式用 File,批量输入用 BatchInput,连号模式用 Dimension/StartNum/EndNum。 +type SimCardQueryExportParam struct { + Mode string `json:"mode" dc:"输入方式 excel/input/range"` + BatchInput string `json:"batchInput" dc:"批量输入内容,每行 ICCID 或 ICCID MSISDN IMSI"` + Dimension string `json:"dimension" dc:"连号维度 iccid/msisdn/imsi"` + StartNum string `json:"startNum" dc:"起始号段"` + EndNum string `json:"endNum" dc:"结束号段"` + Iccid string `json:"iccid" dc:"仅按 ICCID 精确查询"` + ExportColumnsJson string `json:"exportColumnsJson" dc:"导出列(JSON字符串)"` + ExportColumns []ExportColumn `json:"exportColumns" dc:"导出列"` +} + +// SimCardUsageHistoryExportParam 月历史用量查询导出参数。 +// 在卡基础信息查询的基础上增加「按所属公司」模式。 +type SimCardUsageHistoryExportParam struct { + SimCardQueryExportParam + DeptId int64 `json:"deptId" dc:"所属公司部门ID,mode=dept 时必填"` + // 到期日期区间,仅 dept 模式生效;为空表示不限。 + ExpireStartTime string `json:"expireStartTime" dc:"到期日期起(含)"` + ExpireEndTime string `json:"expireEndTime" dc:"到期日期止(含)"` +} + +// SimCardQuerySubmitRes 提交导出后的回执。 +// 文件不在本次响应里返回,用户到「下载中心」下载;执行日志里看汇总结果。 +type SimCardQuerySubmitRes struct { + TaskId int64 `json:"taskId" dc:"任务ID"` + TaskNo string `json:"taskNo" dc:"任务编号"` + Total int `json:"total" dc:"预计导出行数"` +} + +// SimCardQueryResolveResult 输入解析结果。 +// Rows 为按输入顺序展开的待导出行,未命中的行同样保留,用于在文件里写明失败原因。 +type SimCardQueryResolveResult struct { + Rows []*SimCardBaseQueryRow + HitCount int + MissCount int + // Summaries 输入侧的错误(如某行格式错误),按行记录,最终汇总进执行日志。 + Summaries []string +} + +// SimCardBaseQueryRow 卡基础信息查询的导出行。 +// 字段顺序即无列清单时的默认导出顺序;Field 名与前端列配置的 field 一一对应。 +type SimCardBaseQueryRow struct { + Iccid string `json:"iccid" dc:"ICCID"` + Msisdn string `json:"msisdn" dc:"MSISDN"` + Imsi string `json:"imsi" dc:"IMSI"` + Imei string `json:"imei" dc:"IMEI"` + UsedFlowMb float64 `json:"usedFlowMb" dc:"当月用量(MB)"` + RemainFlowMb float64 `json:"remainFlowMb" dc:"当月剩余用量(MB)"` + PackageTotalMb float64 `json:"packageTotalMb" dc:"套餐总流量(MB)"` + CardStatus int `json:"cardStatus" dc:"卡状态"` + NetModel int `json:"netModel" dc:"网络类型"` + Operators string `json:"operators" dc:"运营商"` + ActivateAt *gtime.Time `json:"activateAt" dc:"激活时间"` + OutStockTime *gtime.Time `json:"outStockTime" dc:"出库时间"` + ExpireAt *gtime.Time `json:"expireAt" dc:"到期时间"` + OutboundBatch string `json:"outboundBatch" dc:"出库批次"` + InboundBatch string `json:"inboundBatch" dc:"入库批次"` + PackageName string `json:"packageName" dc:"当前套餐名称"` + DeptName string `json:"deptName" dc:"所属企业"` + Remark string `json:"remark" dc:"备注"` + ExtraRemark string `json:"extraRemark" dc:"其他备注"` + // QueryResult / FailReason 固定在文件末尾两列,保证「部分成功」时失败原因可追溯到具体输入。 + QueryResult string `json:"queryResult" dc:"查询结果"` + FailReason string `json:"failReason" dc:"失败原因"` +} + +// SimCardUsageHistoryRow 月历史用量查询的导出行。 +// 近 12 个月的用量按月份动态列展开,MonthValues 与请求里的月份顺序一致。 +type SimCardUsageHistoryRow struct { + Iccid string `json:"iccid" dc:"ICCID"` + Msisdn string `json:"msisdn" dc:"MSISDN"` + Imsi string `json:"imsi" dc:"IMSI"` + OutStockTime *gtime.Time `json:"outStockTime" dc:"出库时间"` + ExpireAt *gtime.Time `json:"expireAt" dc:"到期时间"` + // MonthValues 由调用方按月份顺序拼成 "2026-09" 这样的键,导出时逐列取值。 + // 键不存在表示「查询成功但该月没有流量记录」,导出为 "-"。 + MonthValues map[string]float64 `json:"monthValues" dc:"近12个月用量(MB)"` + // UsageQueryFailed 该卡的流量数据整体查询失败(服务抖动),导出时月份列写「查询失败」, + // 与「无记录(-)」「零用量(0)」区分开,避免客户误解。 + UsageQueryFailed bool `json:"usageQueryFailed" dc:"流量数据是否查询失败"` + QueryResult string `json:"queryResult" dc:"查询结果"` + FailReason string `json:"failReason" dc:"失败原因"` +} + +// SimCardQueryDeptOption 所属公司下拉项。 +type SimCardQueryDeptOption struct { + DeptId int64 `json:"deptId" dc:"部门ID"` + DeptName string `json:"deptName" dc:"公司名称"` + CardCount int64 `json:"cardCount" dc:"名下卡数量"` + ParentId int64 `json:"parentId" dc:"上级部门ID"` +} diff --git a/server/internal/model/sys_auth.go b/server/internal/model/sys_auth.go index 1b12fce..bc76585 100644 --- a/server/internal/model/sys_auth.go +++ b/server/internal/model/sys_auth.go @@ -1,7 +1,7 @@ // package model // @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 model diff --git a/server/internal/model/sys_client.go b/server/internal/model/sys_client.go index bc6a6ab..e898f17 100644 --- a/server/internal/model/sys_client.go +++ b/server/internal/model/sys_client.go @@ -1,7 +1,7 @@ // package model // @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 model diff --git a/server/internal/model/sys_config.go b/server/internal/model/sys_config.go index f20c7b1..0099760 100644 --- a/server/internal/model/sys_config.go +++ b/server/internal/model/sys_config.go @@ -1,7 +1,7 @@ // package model // @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 model diff --git a/server/internal/model/sys_dept.go b/server/internal/model/sys_dept.go index 7746785..cb0cbac 100644 --- a/server/internal/model/sys_dept.go +++ b/server/internal/model/sys_dept.go @@ -1,7 +1,7 @@ // package model // @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 model @@ -24,17 +24,17 @@ type SysDeptListParam struct { } type SysDeptListModel struct { - DeptId int64 `json:"deptId" orm:"dept_id" description:"部门id"` - TenantId string `json:"tenantId" orm:"tenant_id" description:"租户编号"` - DeptType int `json:"deptType" orm:"dept_type" description:"部门类型(0:部门 1:公司)"` - ParentId int64 `json:"parentId" orm:"parent_id" description:"父部门id"` - Ancestors string `json:"ancestors" orm:"ancestors" description:"祖级列表"` - DeptName string `json:"deptName" orm:"dept_name" description:"部门名称"` - DeptCategory string `json:"deptCategory" orm:"dept_category" description:"部门类别编码"` - OrderNum int `json:"orderNum" orm:"order_num" description:"显示顺序"` - Leader int64 `json:"leader" orm:"leader" description:"负责人"` - Phone string `json:"phone" orm:"phone" description:"联系电话"` - Email string `json:"email" orm:"email" description:"邮箱"` + DeptId int64 `json:"deptId" orm:"dept_id" description:"部门id"` + TenantId string `json:"tenantId" orm:"tenant_id" description:"租户编号"` + DeptType int `json:"deptType" orm:"dept_type" description:"部门类型(0:部门 1:公司)"` + ParentId int64 `json:"parentId" orm:"parent_id" description:"父部门id"` + Ancestors string `json:"ancestors" orm:"ancestors" description:"祖级列表"` + DeptName string `json:"deptName" orm:"dept_name" description:"部门名称"` + DeptCategory string `json:"deptCategory" orm:"dept_category" description:"部门类别编码"` + OrderNum int `json:"orderNum" orm:"order_num" description:"显示顺序"` + Leader int64 `json:"leader" orm:"leader" description:"负责人"` + Phone string `json:"phone" orm:"phone" description:"联系电话"` + Email string `json:"email" orm:"email" description:"邮箱"` Status string `json:"status" orm:"status" description:"部门状态(0正常 1停用)"` CreatedAt *gtime.Time `json:"createdAt" orm:"created_at" description:"创建时间"` } diff --git a/server/internal/model/sys_dict.go b/server/internal/model/sys_dict.go index b3b7ae0..1b3e7e3 100644 --- a/server/internal/model/sys_dict.go +++ b/server/internal/model/sys_dict.go @@ -1,7 +1,7 @@ // package model // @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 model diff --git a/server/internal/model/sys_gen_table.go b/server/internal/model/sys_gen_table.go index 0a87805..13cb090 100644 --- a/server/internal/model/sys_gen_table.go +++ b/server/internal/model/sys_gen_table.go @@ -1,7 +1,7 @@ // package model // @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 model diff --git a/server/internal/model/sys_job.go b/server/internal/model/sys_job.go index 8ec52be..2da6a1a 100644 --- a/server/internal/model/sys_job.go +++ b/server/internal/model/sys_job.go @@ -1,7 +1,7 @@ // package model // @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 model diff --git a/server/internal/model/sys_logininfor.go b/server/internal/model/sys_logininfor.go index 5f7faa9..b8ca4c0 100644 --- a/server/internal/model/sys_logininfor.go +++ b/server/internal/model/sys_logininfor.go @@ -1,7 +1,7 @@ // package model // @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 model diff --git a/server/internal/model/sys_menu.go b/server/internal/model/sys_menu.go index 71b755e..18c3803 100644 --- a/server/internal/model/sys_menu.go +++ b/server/internal/model/sys_menu.go @@ -1,7 +1,7 @@ // package model // @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 model diff --git a/server/internal/model/sys_notice.go b/server/internal/model/sys_notice.go index afe1631..665abd7 100644 --- a/server/internal/model/sys_notice.go +++ b/server/internal/model/sys_notice.go @@ -1,7 +1,7 @@ // package model // @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 model diff --git a/server/internal/model/sys_notice_user.go b/server/internal/model/sys_notice_user.go index 5f020d0..48e8cdc 100644 --- a/server/internal/model/sys_notice_user.go +++ b/server/internal/model/sys_notice_user.go @@ -1,7 +1,7 @@ // Package genin // @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 model diff --git a/server/internal/model/sys_oper_log.go b/server/internal/model/sys_oper_log.go index e30b903..c11663b 100644 --- a/server/internal/model/sys_oper_log.go +++ b/server/internal/model/sys_oper_log.go @@ -1,7 +1,7 @@ // package model // @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 model diff --git a/server/internal/model/sys_oss.go b/server/internal/model/sys_oss.go index da5c49c..18aad72 100644 --- a/server/internal/model/sys_oss.go +++ b/server/internal/model/sys_oss.go @@ -1,7 +1,7 @@ // package model // @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 model diff --git a/server/internal/model/sys_oss_config.go b/server/internal/model/sys_oss_config.go index f6d2bfa..367c8f6 100644 --- a/server/internal/model/sys_oss_config.go +++ b/server/internal/model/sys_oss_config.go @@ -1,7 +1,7 @@ // package model // @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 model diff --git a/server/internal/model/sys_post.go b/server/internal/model/sys_post.go index eb8323f..8b9bbd5 100644 --- a/server/internal/model/sys_post.go +++ b/server/internal/model/sys_post.go @@ -1,7 +1,7 @@ // package model // @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 model diff --git a/server/internal/model/sys_role.go b/server/internal/model/sys_role.go index e72cb59..21149d1 100644 --- a/server/internal/model/sys_role.go +++ b/server/internal/model/sys_role.go @@ -1,7 +1,7 @@ // package model // @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 model @@ -48,6 +48,8 @@ type SysRoleViewParam struct { } type SysRoleViewModel struct { + IsTemplateRole bool `json:"isTemplateRole" orm:"-" description:"是否为平台代理模板"` + IsDeptDefaultRole bool `json:"isDeptDefaultRole" orm:"-" description:"是否为所属机构默认角色"` RoleId int64 `json:"roleId" orm:"role_id" description:"角色ID"` TenantId string `json:"tenantId" orm:"tenant_id" description:"租户编号"` DeptId int64 `json:"deptId" orm:"dept_id" description:"部门组织id"` diff --git a/server/internal/model/sys_social.go b/server/internal/model/sys_social.go index 1fd12e0..e39b5e6 100644 --- a/server/internal/model/sys_social.go +++ b/server/internal/model/sys_social.go @@ -1,7 +1,7 @@ // package model // @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 model diff --git a/server/internal/model/sys_tenant.go b/server/internal/model/sys_tenant.go index 6460a22..4d99347 100644 --- a/server/internal/model/sys_tenant.go +++ b/server/internal/model/sys_tenant.go @@ -1,7 +1,7 @@ // package model // @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 model diff --git a/server/internal/model/sys_tenant_package.go b/server/internal/model/sys_tenant_package.go index 52e51e8..80078c6 100644 --- a/server/internal/model/sys_tenant_package.go +++ b/server/internal/model/sys_tenant_package.go @@ -1,7 +1,7 @@ // package model // @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 model diff --git a/server/internal/model/sys_user.go b/server/internal/model/sys_user.go index c41e41e..c03a387 100644 --- a/server/internal/model/sys_user.go +++ b/server/internal/model/sys_user.go @@ -1,7 +1,7 @@ // package model // @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 model @@ -68,21 +68,21 @@ type SysUserListModel struct { } type SysUserViewModel struct { - UserId int64 `json:"userId" orm:"user_id" description:"用户ID"` - TenantId string `json:"tenantId" orm:"tenant_id" description:"租户编号"` - DeptId int64 `json:"deptId" orm:"dept_id" description:"部门ID"` - UserName string `json:"userName" orm:"user_name" description:"用户账号"` - NickName string `json:"nickName" orm:"nick_name" description:"用户昵称"` - UserType string `json:"userType" orm:"user_type" description:"用户类型(sys_user系统用户)"` - Email string `json:"email" orm:"email" description:"用户邮箱"` - Phonenumber string `json:"phonenumber" orm:"phonenumber" description:"手机号码"` - Sex string `json:"sex" orm:"sex" description:"用户性别(0男 1女 2未知)"` - Avatar string `json:"avatar" orm:"avatar" description:"头像地址"` - Status string `json:"status" orm:"status" description:"帐号状态(0正常 1停用)"` - LoginIp string `json:"loginIp" orm:"login_ip" description:"最后登录IP"` - LoginDate *gtime.Time `json:"loginDate" orm:"login_date" description:"最后登录时间"` - MfaEnabled int64 `json:"mfaEnabled" orm:"mfa_enabled" description:"是否开启mfa验证"` - IsInitPassword int64 `json:"isInitPassword" orm:"is_init_password" description:"当前密码是否未由用户本人设置(1=初始或管理员临时密码)"` + UserId int64 `json:"userId" orm:"user_id" description:"用户ID"` + TenantId string `json:"tenantId" orm:"tenant_id" description:"租户编号"` + DeptId int64 `json:"deptId" orm:"dept_id" description:"部门ID"` + UserName string `json:"userName" orm:"user_name" description:"用户账号"` + NickName string `json:"nickName" orm:"nick_name" description:"用户昵称"` + UserType string `json:"userType" orm:"user_type" description:"用户类型(sys_user系统用户)"` + Email string `json:"email" orm:"email" description:"用户邮箱"` + Phonenumber string `json:"phonenumber" orm:"phonenumber" description:"手机号码"` + Sex string `json:"sex" orm:"sex" description:"用户性别(0男 1女 2未知)"` + Avatar string `json:"avatar" orm:"avatar" description:"头像地址"` + Status string `json:"status" orm:"status" description:"帐号状态(0正常 1停用)"` + LoginIp string `json:"loginIp" orm:"login_ip" description:"最后登录IP"` + LoginDate *gtime.Time `json:"loginDate" orm:"login_date" description:"最后登录时间"` + MfaEnabled int64 `json:"mfaEnabled" orm:"mfa_enabled" description:"是否开启mfa验证"` + IsInitPassword int64 `json:"isInitPassword" orm:"is_init_password" description:"当前密码是否未由用户本人设置(1=初始或管理员临时密码)"` CreatedDept int64 `json:"createdDept" orm:"created_dept" description:"创建部门"` CreatedBy int64 `json:"createdBy" orm:"created_by" description:"创建者"` diff --git a/server/internal/model/sys_user_online.go b/server/internal/model/sys_user_online.go index 0e70fc7..d8b0a05 100644 --- a/server/internal/model/sys_user_online.go +++ b/server/internal/model/sys_user_online.go @@ -1,7 +1,7 @@ // package model // @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 model diff --git a/server/internal/model/t_agent_balance.go b/server/internal/model/t_agent_balance.go index cf30835..94699de 100644 --- a/server/internal/model/t_agent_balance.go +++ b/server/internal/model/t_agent_balance.go @@ -1,7 +1,7 @@ // Package genin // @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 model diff --git a/server/internal/model/t_agent_enterprise.go b/server/internal/model/t_agent_enterprise.go index 48f8440..e4fb4cd 100644 --- a/server/internal/model/t_agent_enterprise.go +++ b/server/internal/model/t_agent_enterprise.go @@ -1,7 +1,7 @@ // Package model // @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 model diff --git a/server/internal/model/t_api.go b/server/internal/model/t_api.go index c8bd01d..a853dea 100644 --- a/server/internal/model/t_api.go +++ b/server/internal/model/t_api.go @@ -1,7 +1,7 @@ // Package iotin // @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 model diff --git a/server/internal/model/t_api_group.go b/server/internal/model/t_api_group.go index d3d0925..36f73f2 100644 --- a/server/internal/model/t_api_group.go +++ b/server/internal/model/t_api_group.go @@ -1,7 +1,7 @@ // Package genin // @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 model diff --git a/server/internal/model/t_api_resource.go b/server/internal/model/t_api_resource.go index 7f74c66..9c4c590 100644 --- a/server/internal/model/t_api_resource.go +++ b/server/internal/model/t_api_resource.go @@ -1,7 +1,7 @@ // Package genin // @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 model diff --git a/server/internal/model/t_balance_detail.go b/server/internal/model/t_balance_detail.go index 51014d0..88dd445 100644 --- a/server/internal/model/t_balance_detail.go +++ b/server/internal/model/t_balance_detail.go @@ -1,7 +1,7 @@ // Package genin // @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 model diff --git a/server/internal/model/t_consume_account_month_bill.go b/server/internal/model/t_consume_account_month_bill.go index 9309603..d4dce00 100644 --- a/server/internal/model/t_consume_account_month_bill.go +++ b/server/internal/model/t_consume_account_month_bill.go @@ -1,7 +1,7 @@ // Package genin // @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 model diff --git a/server/internal/model/t_credit_detail.go b/server/internal/model/t_credit_detail.go index 2725d90..8e2e81d 100644 --- a/server/internal/model/t_credit_detail.go +++ b/server/internal/model/t_credit_detail.go @@ -1,7 +1,7 @@ // Package genin // @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 model diff --git a/server/internal/model/t_cust_withdraw_account.go b/server/internal/model/t_cust_withdraw_account.go index ad43b98..ccc0953 100644 --- a/server/internal/model/t_cust_withdraw_account.go +++ b/server/internal/model/t_cust_withdraw_account.go @@ -1,7 +1,7 @@ // Package iotin // @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 model diff --git a/server/internal/model/t_division_code.go b/server/internal/model/t_division_code.go index 81fa3d5..2f87a2f 100644 --- a/server/internal/model/t_division_code.go +++ b/server/internal/model/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 model diff --git a/server/internal/model/t_fin_commission_detail.go b/server/internal/model/t_fin_commission_detail.go index 7bc19ad..a125fb4 100644 --- a/server/internal/model/t_fin_commission_detail.go +++ b/server/internal/model/t_fin_commission_detail.go @@ -1,7 +1,7 @@ // Package genin // @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 model diff --git a/server/internal/model/t_fin_withdrawable_change.go b/server/internal/model/t_fin_withdrawable_change.go index 97d23c4..a13d45b 100644 --- a/server/internal/model/t_fin_withdrawable_change.go +++ b/server/internal/model/t_fin_withdrawable_change.go @@ -1,7 +1,7 @@ // Package genin // @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 model diff --git a/server/internal/model/t_finance_apply.go b/server/internal/model/t_finance_apply.go index e186baa..9c5e439 100644 --- a/server/internal/model/t_finance_apply.go +++ b/server/internal/model/t_finance_apply.go @@ -1,7 +1,7 @@ // Package genin // @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 model diff --git a/server/internal/model/t_finance_audit_record.go b/server/internal/model/t_finance_audit_record.go index 8ae4e8f..bec0f5e 100644 --- a/server/internal/model/t_finance_audit_record.go +++ b/server/internal/model/t_finance_audit_record.go @@ -1,7 +1,7 @@ // Package genin // @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 model diff --git a/server/internal/model/t_flow_package_config.go b/server/internal/model/t_flow_package_config.go index 79a96f1..fc39213 100644 --- a/server/internal/model/t_flow_package_config.go +++ b/server/internal/model/t_flow_package_config.go @@ -1,7 +1,7 @@ // Package genin // @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 model diff --git a/server/internal/model/t_message_log.go b/server/internal/model/t_message_log.go index b9e5eb6..82404f1 100644 --- a/server/internal/model/t_message_log.go +++ b/server/internal/model/t_message_log.go @@ -1,7 +1,7 @@ // Package genin // @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 model @@ -103,6 +103,43 @@ type TMessageLogViewModel struct { UpdatedBySumma *hook.MemberSumma `json:"updatedBySumma" dc:"更新者摘要信息"` } +// TMessageLogUnreadListParam 获取当前登录用户未读消息通知 +type TMessageLogUnreadListParam struct { + Limit int `json:"limit" d:"50" dc:"返回条数上限,默认50,最大200"` +} + +func (in *TMessageLogUnreadListParam) Filter(ctx context.Context) (err error) { + return +} + +// TMessageLogUnreadListModel 未读消息通知(右上角消息提醒数据源) +type TMessageLogUnreadListModel struct { + MessageLogId int64 `json:"messageLogId" orm:"message_log_id" dc:"主键ID"` + MessageType int `json:"messageType" orm:"message_type" dc:"消息类型 1系统通知 2活动通知 3订单通知 4账户通知"` + SendUserType int `json:"sendUserType" orm:"send_user_type" dc:"发送用户类型 1代理商 2终端用户"` + MessageTitle string `json:"messageTitle" orm:"message_title" dc:"消息标题"` + MessageContent string `json:"messageContent" orm:"message_content" dc:"消息内容"` + MessageLink string `json:"messageLink" orm:"message_link" dc:"跳转链接地址"` + SendTime *gtime.Time `json:"sendTime" orm:"send_time" dc:"发送时间"` + IsRead int `json:"isRead" orm:"is_read" dc:"是否已读 0未读 1已读"` +} + +// TMessageLogReadParam 标记当前登录用户指定消息已读 +type TMessageLogReadParam struct { + MessageLogId int64 `json:"messageLogId" v:"required#主键ID不能为空" dc:"主键ID"` +} + +func (in *TMessageLogReadParam) Filter(ctx context.Context) (err error) { + return +} + +// TMessageLogReadAllParam 标记当前登录用户全部消息已读 +type TMessageLogReadAllParam struct{} + +func (in *TMessageLogReadAllParam) Filter(ctx context.Context) (err error) { + return +} + // TMessageLogListParam 获取消息日志列表 type TMessageLogListParam struct { request.PageInfo diff --git a/server/internal/model/t_notify_log.go b/server/internal/model/t_notify_log.go index 2055efd..93a87cc 100644 --- a/server/internal/model/t_notify_log.go +++ b/server/internal/model/t_notify_log.go @@ -1,7 +1,7 @@ // Package genin // @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 model diff --git a/server/internal/model/t_order.go b/server/internal/model/t_order.go index 58e8dcc..fb829bb 100644 --- a/server/internal/model/t_order.go +++ b/server/internal/model/t_order.go @@ -1,7 +1,7 @@ // Package genin // @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 model diff --git a/server/internal/model/t_profit_account_month_bill.go b/server/internal/model/t_profit_account_month_bill.go index a25d7ed..653f59b 100644 --- a/server/internal/model/t_profit_account_month_bill.go +++ b/server/internal/model/t_profit_account_month_bill.go @@ -1,7 +1,7 @@ // Package genin // @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 model diff --git a/server/internal/model/t_sim_card_allocate.go b/server/internal/model/t_sim_card_allocate.go index 04b11a9..e44503b 100644 --- a/server/internal/model/t_sim_card_allocate.go +++ b/server/internal/model/t_sim_card_allocate.go @@ -1,5 +1,29 @@ package model +import ( + "strings" + + "github.com/gogf/gf/v2/errors/gerror" +) + +const SimCardAllocateMaxInputRows = 20000 + +func (in *TSimCardAllocatePrepareParam) ValidateInput() error { + count := 0 + for _, line := range strings.Split(in.Input, "\n") { + if strings.TrimSpace(line) != "" { + count++ + if count > SimCardAllocateMaxInputRows { + return gerror.Newf("单次批量输入最多 %d 条,超出请使用 Excel 导入或分批提交", SimCardAllocateMaxInputRows) + } + } + } + if count == 0 { + return gerror.New("批量输入内容不能为空") + } + return nil +} + type TSimCardAllocateTargetListParam struct { Keyword string `json:"keyword" dc:"目标客户名称、编码或ID"` } diff --git a/server/internal/model/t_sim_card_allocate_input_test.go b/server/internal/model/t_sim_card_allocate_input_test.go new file mode 100644 index 0000000..f444418 --- /dev/null +++ b/server/internal/model/t_sim_card_allocate_input_test.go @@ -0,0 +1,26 @@ +package model + +import ( + "strings" + "testing" +) + +func TestAllocateInputLimit(t *testing.T) { + for _, test := range []struct { + name string + input string + wantError bool + }{ + {"empty", " \r\n\t", true}, + {"ignore blanks", "\r\n123\r\n\n456 789 012\n", false}, + {"at limit", strings.Repeat("123\n", SimCardAllocateMaxInputRows), false}, + {"above limit", strings.Repeat("123\n", SimCardAllocateMaxInputRows+1), true}, + } { + t.Run(test.name, func(t *testing.T) { + param := &TSimCardAllocatePrepareParam{Input: test.input} + if err := param.ValidateInput(); (err != nil) != test.wantError { + t.Fatalf("ValidateInput() = %v, wantError = %v", err, test.wantError) + } + }) + } +} diff --git a/server/internal/model/t_sim_card_bind_pkg.go b/server/internal/model/t_sim_card_bind_pkg.go index 24d6ca6..65b3b08 100644 --- a/server/internal/model/t_sim_card_bind_pkg.go +++ b/server/internal/model/t_sim_card_bind_pkg.go @@ -1,7 +1,7 @@ // Package genin // @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 model diff --git a/server/internal/model/t_sim_card_info.go b/server/internal/model/t_sim_card_info.go index 1b790aa..1a0d99d 100644 --- a/server/internal/model/t_sim_card_info.go +++ b/server/internal/model/t_sim_card_info.go @@ -1,7 +1,7 @@ // Package genin // @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 model @@ -184,6 +184,11 @@ type TSimCardInfoViewModel struct { // TSimCardInfoListParam 获取卡信息表列表 type TSimCardInfoListParam struct { request.PageInfo + // ExportColumnsJson 导出列(仅导出接口使用,JSON 字符串形式):前端传当前列表显示列 + ExportColumnsJson string `json:"exportColumnsJson" dc:"导出列(JSON字符串)"` + // ExportColumns 导出列对象形式,兼容直接传数组的调用 + ExportColumns []ExportColumn `json:"exportColumns" dc:"导出列"` + BossCardStatus *int `json:"bossCardStatus" dc:"运营商卡状态"` TimeType *int `json:"timeType" dc:"时间类型"` Time []*gtime.Time `json:"time" dc:"时间段"` @@ -198,6 +203,7 @@ type TSimCardInfoListParam struct { ChanKey *string `json:"chanKey" dc:"通道编号"` CurrentPackageCode *string `json:"currentPackageCode" dc:"当前套餐编码"` CardValue string `json:"cardValue" dc:"卡号"` + BatchInput *string `json:"batchInput" dc:"批量查询卡号(每行一个号码或 ICCID MSISDN IMSI)"` Remark string `json:"remark" dc:"备注"` Imei string `json:"imei" dc:"IMEI"` DeptId int64 `json:"deptId" dc:"渠道客户ID"` @@ -220,70 +226,70 @@ func (in *TSimCardInfoListParam) Filter(ctx context.Context) (err error) { } type TSimCardInfoListModel struct { - TrafficCutoffCrossMonthHold int `json:"trafficCutoffCrossMonthHold" dc:"cross-month traffic cutoff hold: 0 disabled, 1 enabled"` - Id int64 `json:"id" dc:"主键ID"` - Iccid string `json:"iccid" dc:"ICCID"` - Msisdn string `json:"msisdn" dc:"MSISDN/卡号"` - Imsi string `json:"imsi" dc:"IMSI"` - Imei string `json:"imei" dc:"IMEI"` - RegionCode string `json:"regionCode" dc:"归属地"` - DeptId int64 `json:"deptId" dc:"渠道客户ID"` - DeptName string `json:"deptName" dc:"渠道客户名称"` - CurrentPackageCode string `json:"currentPackageCode" dc:"当前套餐编号"` - PackageName string `json:"packageName" dc:"当前套餐名称"` - PackageStatus int `json:"packageStatus" dc:"套餐状态:0 无套餐,1待生效,2生效中,3已过期"` - AccessType int `json:"accessType" dc:"接入方式:ipv4、ipv6、ipv4_ipv6"` - BossCardStatus int `json:"bossCardStatus" dc:"运营商卡状态 枚举"` - CardType int `json:"cardType" dc:"卡类型 枚举"` - BossOpenAt *gtime.Time `json:"bossOpenAt" dc:"运营商开户日期"` - BossActivateAt *gtime.Time `json:"bossActivateAt" dc:"运营商激活日期"` - IsBossActivateEnabled int `json:"isBossActivateEnabled" dc:"是否按运营商激活减去赠送时长方:1是,0否"` - InboundBatchNo string `json:"inboundBatchNo" dc:"入库批次"` - OutboundBatchNo string `json:"outboundBatchNo" dc:"出库批次"` - AllocationBatchNo string `json:"allocationBatchNo" dc:"划拨批次"` - IsFlowPool int `json:"isFlowPool" dc:"是否为池卡:1是,0否"` - FlowPoolCode string `json:"flowPoolCode" dc:"流量池编号"` - OutStockTime *gtime.Time `json:"outStockTime" dc:"出库时间"` - FlowPkgBeginAt *gtime.Time `json:"flowPkgBeginAt" dc:"当前套餐生效时间"` - FlowPkgPresentAt *gtime.Time `json:"flowPkgPresentAt" dc:"套餐赠送结束时间"` - FlowPkgRealPresentAt *gtime.Time `json:"flowPkgRealPresentAt" dc:"实际赠送结束时间"` - FlowPkgEndAt *gtime.Time `json:"flowPkgEndAt" dc:"当前套餐预计到期时间"` - DataServiceStatus int `json:"dataServiceStatus" dc:"数据服务状态:1启用、0禁用"` - PackageTotalFlowMb float64 `json:"packageTotalFlowMb" dc:"套餐总流量MB"` - UsedFlowMb float64 `json:"usedFlowMb" dc:"已用流量MB"` - ReminFlowMb float64 `json:"reminFlowMb" dc:"剩余流量MB"` - TrafficCutoffStatus int `json:"trafficCutoffStatus" dc:"达量断网配置状态:0未配置,1已开启,2已关闭"` - TrafficCutoffThreshold float64 `json:"trafficCutoffThreshold" dc:"达量断网阈值"` - SmsServiceStatus int `json:"smsServiceStatus" dc:"短信服务状态:1启用、0禁用"` - PackageTotalSms int `json:"packageTotalSms" dc:"套餐短信总条数"` - UsedSms int `json:"usedSms" dc:"已用短信条数"` - VoiceServiceStatus int `json:"voiceServiceStatus" dc:"语音服务状态:1启用、0禁用"` - PackageTotalVoice int `json:"packageTotalVoice" dc:"套餐语音总分钟数"` - UsedVoice int `json:"usedVoice" dc:"已用语音分钟数"` - RoamServiceStatus int `json:"roamServiceStatus" dc:"国漫服务状态:1启用、0禁用"` - PackageTotalRoamFlowMb float64 `json:"packageTotalRoamFlowMb" dc:"套餐国际流量MB"` - UsedRoamFlowMb float64 `json:"usedRoamFlowMb" dc:"已用国际流量MB"` - StatusSyncTime *gtime.Time `json:"statusSyncTime" dc:"状态最后一次同步时间"` - FlowSyncTime *gtime.Time `json:"flowSyncTime" dc:"流量最后一次同步时间"` - SmsSyncTime *gtime.Time `json:"smsSyncTime" dc:"短信最后一次同步时间"` - VoiceSyncTime *gtime.Time `json:"voiceSyncTime" dc:"语音最后一次同步时间"` - RoamSyncTime *gtime.Time `json:"roamSyncTime" dc:"国漫最后一次同步时间"` - ExtraFeeEnabled int `json:"extraFeeEnabled" dc:"是否收取附加费:1是,0否"` - ExtraFeeMultiple float64 `json:"extraFeeMultiple" dc:"附加费用倍数"` - ExtraFee float64 `json:"extraFee" dc:"附加金额"` - ChanKey string `json:"chanKey" dc:"通道编号"` - ChanName string `json:"chanName" dc:"通道名称"` - ChanAlias string `json:"chanAlias" dc:"通道别名"` - Remark string `json:"remark" dc:"备注"` - NetModel int `json:"netModel" dc:"网络类型:4G、5G、NB-IoT"` - TenantId string `json:"tenantId" dc:"租户ID"` - CreatedDept int64 `json:"createdDept" dc:"创建部门"` - CreatedBy int64 `json:"createdBy" dc:"创建者"` - CreatedBySumma *hook.MemberSumma `json:"createdBySumma" dc:"创建者摘要信息"` - CreatedAt *gtime.Time `json:"createdAt" dc:"创建时间"` - UpdatedBy int64 `json:"updatedBy" dc:"更新者"` - UpdatedBySumma *hook.MemberSumma `json:"updatedBySumma" dc:"更新者摘要信息"` - UpdatedAt *gtime.Time `json:"updatedAt" dc:"更新时间"` + TrafficCutoffCrossMonthHold int `json:"trafficCutoffCrossMonthHold" dc:"cross-month traffic cutoff hold: 0 disabled, 1 enabled"` + Id int64 `json:"id" dc:"主键ID"` + Iccid string `json:"iccid" dc:"ICCID"` + Msisdn string `json:"msisdn" dc:"MSISDN/卡号"` + Imsi string `json:"imsi" dc:"IMSI"` + Imei string `json:"imei" dc:"IMEI"` + RegionCode string `json:"regionCode" dc:"归属地"` + DeptId int64 `json:"deptId" dc:"渠道客户ID"` + DeptName string `json:"deptName" dc:"渠道客户名称"` + CurrentPackageCode string `json:"currentPackageCode" dc:"当前套餐编号"` + PackageName string `json:"packageName" dc:"当前套餐名称"` + PackageStatus int `json:"packageStatus" dc:"套餐状态:0 无套餐,1待生效,2生效中,3已过期"` + AccessType int `json:"accessType" dc:"接入方式:ipv4、ipv6、ipv4_ipv6"` + BossCardStatus int `json:"bossCardStatus" dc:"运营商卡状态 枚举"` + CardType int `json:"cardType" dc:"卡类型 枚举"` + BossOpenAt *gtime.Time `json:"bossOpenAt" dc:"运营商开户日期"` + BossActivateAt *gtime.Time `json:"bossActivateAt" dc:"运营商激活日期"` + IsBossActivateEnabled int `json:"isBossActivateEnabled" dc:"是否按运营商激活减去赠送时长方:1是,0否"` + InboundBatchNo string `json:"inboundBatchNo" dc:"入库批次"` + OutboundBatchNo string `json:"outboundBatchNo" dc:"出库批次"` + AllocationBatchNo string `json:"allocationBatchNo" dc:"划拨批次"` + IsFlowPool int `json:"isFlowPool" dc:"是否为池卡:1是,0否"` + FlowPoolCode string `json:"flowPoolCode" dc:"流量池编号"` + OutStockTime *gtime.Time `json:"outStockTime" dc:"出库时间"` + FlowPkgBeginAt *gtime.Time `json:"flowPkgBeginAt" dc:"当前套餐生效时间"` + FlowPkgPresentAt *gtime.Time `json:"flowPkgPresentAt" dc:"套餐赠送结束时间"` + FlowPkgRealPresentAt *gtime.Time `json:"flowPkgRealPresentAt" dc:"实际赠送结束时间"` + FlowPkgEndAt *gtime.Time `json:"flowPkgEndAt" dc:"当前套餐预计到期时间"` + DataServiceStatus int `json:"dataServiceStatus" dc:"数据服务状态:1启用、0禁用"` + PackageTotalFlowMb float64 `json:"packageTotalFlowMb" dc:"套餐总流量MB"` + UsedFlowMb float64 `json:"usedFlowMb" dc:"已用流量MB"` + ReminFlowMb float64 `json:"reminFlowMb" dc:"剩余流量MB"` + TrafficCutoffStatus int `json:"trafficCutoffStatus" dc:"达量断网配置状态:0未配置,1已开启,2已关闭"` + TrafficCutoffThreshold float64 `json:"trafficCutoffThreshold" dc:"达量断网阈值"` + SmsServiceStatus int `json:"smsServiceStatus" dc:"短信服务状态:1启用、0禁用"` + PackageTotalSms int `json:"packageTotalSms" dc:"套餐短信总条数"` + UsedSms int `json:"usedSms" dc:"已用短信条数"` + VoiceServiceStatus int `json:"voiceServiceStatus" dc:"语音服务状态:1启用、0禁用"` + PackageTotalVoice int `json:"packageTotalVoice" dc:"套餐语音总分钟数"` + UsedVoice int `json:"usedVoice" dc:"已用语音分钟数"` + RoamServiceStatus int `json:"roamServiceStatus" dc:"国漫服务状态:1启用、0禁用"` + PackageTotalRoamFlowMb float64 `json:"packageTotalRoamFlowMb" dc:"套餐国际流量MB"` + UsedRoamFlowMb float64 `json:"usedRoamFlowMb" dc:"已用国际流量MB"` + StatusSyncTime *gtime.Time `json:"statusSyncTime" dc:"状态最后一次同步时间"` + FlowSyncTime *gtime.Time `json:"flowSyncTime" dc:"流量最后一次同步时间"` + SmsSyncTime *gtime.Time `json:"smsSyncTime" dc:"短信最后一次同步时间"` + VoiceSyncTime *gtime.Time `json:"voiceSyncTime" dc:"语音最后一次同步时间"` + RoamSyncTime *gtime.Time `json:"roamSyncTime" dc:"国漫最后一次同步时间"` + ExtraFeeEnabled int `json:"extraFeeEnabled" dc:"是否收取附加费:1是,0否"` + ExtraFeeMultiple float64 `json:"extraFeeMultiple" dc:"附加费用倍数"` + ExtraFee float64 `json:"extraFee" dc:"附加金额"` + ChanKey string `json:"chanKey" dc:"通道编号"` + ChanName string `json:"chanName" dc:"通道名称"` + ChanAlias string `json:"chanAlias" dc:"通道别名"` + Remark string `json:"remark" dc:"备注"` + NetModel int `json:"netModel" dc:"网络类型:4G、5G、NB-IoT"` + TenantId string `json:"tenantId" dc:"租户ID"` + CreatedDept int64 `json:"createdDept" dc:"创建部门"` + CreatedBy int64 `json:"createdBy" dc:"创建者"` + CreatedBySumma *hook.MemberSumma `json:"createdBySumma" dc:"创建者摘要信息"` + CreatedAt *gtime.Time `json:"createdAt" dc:"创建时间"` + UpdatedBy int64 `json:"updatedBy" dc:"更新者"` + UpdatedBySumma *hook.MemberSumma `json:"updatedBySumma" dc:"更新者摘要信息"` + UpdatedAt *gtime.Time `json:"updatedAt" dc:"更新时间"` } // TSimCardInfoExportModel 导出卡信息表 diff --git a/server/internal/model/t_sim_card_stock.go b/server/internal/model/t_sim_card_stock.go index ab11128..94c1a0c 100644 --- a/server/internal/model/t_sim_card_stock.go +++ b/server/internal/model/t_sim_card_stock.go @@ -1,7 +1,7 @@ // Package genin // @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 model @@ -124,6 +124,7 @@ type TSimCardStockListModel struct { AgreementExpiryDate string `json:"agreementExpiryDate" dc:"协议到期时间"` TenantId string `json:"tenantId" dc:"租户ID"` CreatedDept int64 `json:"createdDept" dc:"创建部门"` + CreatedDeptName string `json:"createdDeptName" dc:"创建部门名称"` CreatedBy int64 `json:"createdBy" dc:"创建者"` CreatedBySumma *hook.MemberSumma `json:"createdBySumma" dc:"创建者摘要信息"` CreatedAt *gtime.Time `json:"createdAt" dc:"创建时间"` diff --git a/server/internal/model/t_sim_card_traffic_cutoff.go b/server/internal/model/t_sim_card_traffic_cutoff.go index fa61f06..f1b7114 100644 --- a/server/internal/model/t_sim_card_traffic_cutoff.go +++ b/server/internal/model/t_sim_card_traffic_cutoff.go @@ -1,9 +1,9 @@ package model type TSimCardTrafficCutoffOptions struct { - TrafficCutoffCrossMonthHold *int `json:"trafficCutoffCrossMonthHold" v:"in:0,1" dc:"cross-month traffic cutoff hold: 0 disabled, 1 enabled"` - TrafficCutoffStatus *int `json:"trafficCutoffStatus" v:"required#达量断网配置状态不能为空" dc:"达量断网配置状态:0未配置,1已开启,2已关闭"` - TrafficCutoffThreshold float64 `json:"trafficCutoffThreshold" v:"min:0#达量断网阈值不能小于0" dc:"达量断网阈值MB"` + TrafficCutoffCrossMonthHold *int `json:"trafficCutoffCrossMonthHold" v:"in:0,1" dc:"cross-month traffic cutoff hold: 0 disabled, 1 enabled"` + TrafficCutoffStatus *int `json:"trafficCutoffStatus" v:"required#达量断网配置状态不能为空" dc:"达量断网配置状态:0未配置,1已开启,2已关闭"` + TrafficCutoffThreshold float64 `json:"trafficCutoffThreshold" v:"min:0#达量断网阈值不能小于0" dc:"达量断网阈值MB"` } type TSimCardTrafficCutoffListParam struct { @@ -12,24 +12,24 @@ type TSimCardTrafficCutoffListParam struct { } type TSimCardTrafficCutoffExportModel struct { - TrafficCutoffCrossMonthHold int `json:"trafficCutoffCrossMonthHold" dc:"cross-month traffic cutoff hold: 0 disabled, 1 enabled"` - Msisdn string `json:"msisdn" dc:"MSISDN/卡号"` - Iccid string `json:"iccid" dc:"ICCID"` - Imsi string `json:"imsi" dc:"IMSI"` - Imei string `json:"imei" dc:"IMEI"` - ChanName string `json:"chanName" dc:"通道"` - DeptName string `json:"deptName" dc:"所属公司"` - PackageName string `json:"packageName" dc:"当前套餐"` - TrafficCutoffStatus int `json:"trafficCutoffStatus" dc:"达量断网状态:1已开启,2已关闭"` - TrafficCutoffThreshold float64 `json:"trafficCutoffThreshold" dc:"达量断网阈值(MB)"` + TrafficCutoffCrossMonthHold int `json:"trafficCutoffCrossMonthHold" dc:"cross-month traffic cutoff hold: 0 disabled, 1 enabled"` + Msisdn string `json:"msisdn" dc:"MSISDN/卡号"` + Iccid string `json:"iccid" dc:"ICCID"` + Imsi string `json:"imsi" dc:"IMSI"` + Imei string `json:"imei" dc:"IMEI"` + ChanName string `json:"chanName" dc:"通道"` + DeptName string `json:"deptName" dc:"所属公司"` + PackageName string `json:"packageName" dc:"当前套餐"` + TrafficCutoffStatus int `json:"trafficCutoffStatus" dc:"达量断网状态:1已开启,2已关闭"` + TrafficCutoffThreshold float64 `json:"trafficCutoffThreshold" dc:"达量断网阈值(MB)"` } type TSimCardTrafficCutoffEditParam struct { - TrafficCutoffCrossMonthHold *int `json:"trafficCutoffCrossMonthHold" v:"in:0,1"` - Id int64 `json:"id" v:"required|min:1#卡片ID不能为空"` - Iccid string `json:"iccid" v:"required#ICCID不能为空"` - TrafficCutoffStatus *int `json:"trafficCutoffStatus" v:"required|in:1,2#达量断网状态无效"` - TrafficCutoffThreshold *float64 `json:"trafficCutoffThreshold" v:"required|min:0#达量断网阈值不能小于0"` + TrafficCutoffCrossMonthHold *int `json:"trafficCutoffCrossMonthHold" v:"in:0,1"` + Id int64 `json:"id" v:"required|min:1#卡片ID不能为空"` + Iccid string `json:"iccid" v:"required#ICCID不能为空"` + TrafficCutoffStatus *int `json:"trafficCutoffStatus" v:"required|in:1,2#达量断网状态无效"` + TrafficCutoffThreshold *float64 `json:"trafficCutoffThreshold" v:"required|min:0#达量断网阈值不能小于0"` } type TSimCardTrafficCutoffDeleteParam struct { diff --git a/server/internal/model/t_sim_channel_template.go b/server/internal/model/t_sim_channel_template.go index 3cb6dd9..c85e28e 100644 --- a/server/internal/model/t_sim_channel_template.go +++ b/server/internal/model/t_sim_channel_template.go @@ -1,7 +1,7 @@ // Package genin // @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 model diff --git a/server/internal/model/t_sim_channel_testing_input_test.go b/server/internal/model/t_sim_channel_testing_input_test.go new file mode 100644 index 0000000..bc8e55a --- /dev/null +++ b/server/internal/model/t_sim_channel_testing_input_test.go @@ -0,0 +1,67 @@ +package model + +import ( + "context" + "testing" +) + +func TestTSimChannelsTestParamFilterCompleteMode(t *testing.T) { + in := &TSimChannelsTestParam{ + InputMode: TSimChannelsTestInputModeComplete, + Iccid: " 8986 ", + Imsi: " 4600 ", + Msisdn: " 1064 ", + } + if err := in.Filter(context.Background()); err != nil { + t.Fatalf("Filter returned error: %v", err) + } + if in.Iccid != "8986" || in.Imsi != "4600" || in.Msisdn != "1064" { + t.Fatalf("unexpected trimmed values: %#v", in) + } +} + +func TestTSimChannelsTestParamFilterSingleMode(t *testing.T) { + in := &TSimChannelsTestParam{ + InputMode: TSimChannelsTestInputModeSingle, + CardValueType: " IMSI ", + CardValue: " 4600 ", + } + if err := in.Filter(context.Background()); err != nil { + t.Fatalf("Filter returned error: %v", err) + } + if in.CardValueType != "imsi" || in.CardValue != "4600" { + t.Fatalf("unexpected normalized values: %#v", in) + } +} + +func TestTSimChannelsTestParamFilterRejectsInvalidSingleMode(t *testing.T) { + in := &TSimChannelsTestParam{ + InputMode: TSimChannelsTestInputModeSingle, + CardValueType: "imei", + CardValue: "123", + } + if err := in.Filter(context.Background()); err == nil { + t.Fatal("expected error for invalid single mode card value type") + } +} + +func TestTSimChannelsTestParamFilterDirectSingleMode(t *testing.T) { + in := &TSimChannelsTestParam{ + InputMode: TSimChannelsTestInputModeDirectSingle, + CardValueType: " MSISDN ", + CardValue: " 1064 ", + } + if err := in.Filter(context.Background()); err != nil { + t.Fatalf("Filter returned error: %v", err) + } + if in.CardValueType != "msisdn" || in.CardValue != "1064" { + t.Fatalf("unexpected normalized values: %#v", in) + } +} + +func TestTSimChannelsTestParamFilterRejectsIncompleteDirectSingleMode(t *testing.T) { + in := &TSimChannelsTestParam{InputMode: TSimChannelsTestInputModeDirectSingle} + if err := in.Filter(context.Background()); err == nil { + t.Fatal("expected error for incomplete direct single mode") + } +} diff --git a/server/internal/model/t_sim_channel_testing_test.go b/server/internal/model/t_sim_channel_testing_test.go new file mode 100644 index 0000000..fe42784 --- /dev/null +++ b/server/internal/model/t_sim_channel_testing_test.go @@ -0,0 +1,43 @@ +package model + +import ( + "context" + "testing" +) + +func TestCardValidationRemainsEnabled(t *testing.T) { + for _, items := range [][]string{nil, {"simStatus"}} { + in := &TSimChannelsTestParam{ChannelId: 42, InputMode: TSimChannelsTestInputModeDirectSingle, Items: items} + if err := in.Filter(context.Background()); err == nil { + t.Fatalf("card validation must remain enabled for %v", items) + } + } +} + +func TestNormalizeTSimChannelTestItems(t *testing.T) { + t.Run("empty selects all items", func(t *testing.T) { + items, err := NormalizeTSimChannelTestItems(nil) + if err != nil { + t.Fatalf("NormalizeTSimChannelTestItems returned error: %v", err) + } + if items != nil { + t.Fatalf("expected nil items, got %#v", items) + } + }) + + t.Run("trims and deduplicates requested items", func(t *testing.T) { + items, err := NormalizeTSimChannelTestItems([]string{" simBasicInfo ", "simSession", "simBasicInfo"}) + if err != nil { + t.Fatalf("NormalizeTSimChannelTestItems returned error: %v", err) + } + if len(items) != 2 || items[0] != "simBasicInfo" || items[1] != "simSession" { + t.Fatalf("unexpected normalized items: %#v", items) + } + }) + + t.Run("rejects unknown items", func(t *testing.T) { + if _, err := NormalizeTSimChannelTestItems([]string{"unknown"}); err == nil { + t.Fatal("expected an error for an unknown item") + } + }) +} diff --git a/server/internal/model/t_sim_channels.go b/server/internal/model/t_sim_channels.go index a263ca4..8764a78 100644 --- a/server/internal/model/t_sim_channels.go +++ b/server/internal/model/t_sim_channels.go @@ -1,7 +1,7 @@ // Package genin // @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 model @@ -186,73 +186,73 @@ func (in *TSimChannelsListParam) Filter(ctx context.Context) (err error) { } type TSimChannelsListModel struct { - StockCardCount int64 `json:"stockCardCount" dc:"库存卡数量"` - CardInfoCount int64 `json:"cardInfoCount" dc:"卡信息数量"` - CancellationCardCount int64 `json:"cancellationCardCount" dc:"销户卡数量"` - Id int64 `json:"id" dc:"ID"` - Operator int `json:"operator" dc:"运营商:1移动,2联通,3电信"` - TempKey string `json:"tempKey" dc:"模板标识"` - TempName string `json:"tempName" dc:"模板名称"` - ChanKey string `json:"chanKey" dc:"通道编号"` - ChanName string `json:"chanName" dc:"通道名称"` - ChanConfig string `json:"chanConfig" dc:"通道配置"` - ChanStatus int `json:"chanStatus" dc:"通道状态(0未启用,1启用)"` - ShareEnabled int `json:"shareEnabled" dc:"是否共享Token(0否,1是)"` - SyncEnabled int `json:"syncEnabled" dc:"是否轮询:0是(参与) 1否(不参与)"` - ChanAlias string `json:"chanAlias" dc:"通道别名"` - IsNormal *int `json:"isNormal" dc:"是否正常(0是,1否)"` - DockingStatus *int `json:"dockingStatus" dc:"对接信息"` - RegionCode string `json:"regionCode" dc:"通道行政区域代码(省市)"` - AppKey string `json:"appKey" dc:"应用标识"` - AppSecret string `json:"appSecret" dc:"应用密钥"` - AccessToken string `json:"accessToken" dc:"接口调用凭证"` - AccessTokenCreated *gtime.Time `json:"accessTokenCreated" dc:"凭证申请时间"` - AccessTokenExpire *gtime.Time `json:"accessTokenExpire" dc:"凭证过期时间"` - RefreshToken string `json:"refreshToken" dc:"刷新凭证"` - RefreshTokenCreated *gtime.Time `json:"refreshTokenCreated" dc:"刷新凭证申请时间"` - RefreshTokenExpire *gtime.Time `json:"refreshTokenExpire" dc:"刷新凭证过期时间"` - Remark string `json:"remark" dc:"备注"` - LoginUser string `json:"loginUser" dc:"登录账号"` - LoginPassword string `json:"loginPassword" dc:"登录密码"` - LoginUrl string `json:"loginUrl" dc:"登录地址"` - LoginState int `json:"loginState" dc:"登录状态(0未启用,1启用)"` - TenantId string `json:"tenantId" dc:"租户ID"` - CreatedDept int64 `json:"createdDept" dc:"创建部门"` - CreatedBy int64 `json:"createdBy" dc:"创建者"` - CreatedBySumma *hook.MemberSumma `json:"createdBySumma" dc:"创建者摘要信息"` - CreatedAt *gtime.Time `json:"createdAt" dc:"创建时间"` - UpdatedBy int64 `json:"updatedBy" dc:"更新者"` - UpdatedBySumma *hook.MemberSumma `json:"updatedBySumma" dc:"更新者摘要信息"` - UpdatedAt *gtime.Time `json:"updatedAt" dc:"更新时间"` + StockCardCount int64 `json:"stockCardCount" dc:"库存卡数量"` + CardInfoCount int64 `json:"cardInfoCount" dc:"卡信息数量"` + CancellationCardCount int64 `json:"cancellationCardCount" dc:"销户卡数量"` + Id int64 `json:"id" dc:"ID"` + Operator int `json:"operator" dc:"运营商:1移动,2联通,3电信"` + TempKey string `json:"tempKey" dc:"模板标识"` + TempName string `json:"tempName" dc:"模板名称"` + ChanKey string `json:"chanKey" dc:"通道编号"` + ChanName string `json:"chanName" dc:"通道名称"` + ChanConfig string `json:"chanConfig" dc:"通道配置"` + ChanStatus int `json:"chanStatus" dc:"通道状态(0未启用,1启用)"` + ShareEnabled int `json:"shareEnabled" dc:"是否共享Token(0否,1是)"` + SyncEnabled int `json:"syncEnabled" dc:"是否轮询:0是(参与) 1否(不参与)"` + ChanAlias string `json:"chanAlias" dc:"通道别名"` + IsNormal *int `json:"isNormal" dc:"是否正常(0是,1否)"` + DockingStatus *int `json:"dockingStatus" dc:"对接信息"` + RegionCode string `json:"regionCode" dc:"通道行政区域代码(省市)"` + AppKey string `json:"appKey" dc:"应用标识"` + AppSecret string `json:"appSecret" dc:"应用密钥"` + AccessToken string `json:"accessToken" dc:"接口调用凭证"` + AccessTokenCreated *gtime.Time `json:"accessTokenCreated" dc:"凭证申请时间"` + AccessTokenExpire *gtime.Time `json:"accessTokenExpire" dc:"凭证过期时间"` + RefreshToken string `json:"refreshToken" dc:"刷新凭证"` + RefreshTokenCreated *gtime.Time `json:"refreshTokenCreated" dc:"刷新凭证申请时间"` + RefreshTokenExpire *gtime.Time `json:"refreshTokenExpire" dc:"刷新凭证过期时间"` + Remark string `json:"remark" dc:"备注"` + LoginUser string `json:"loginUser" dc:"登录账号"` + LoginPassword string `json:"loginPassword" dc:"登录密码"` + LoginUrl string `json:"loginUrl" dc:"登录地址"` + LoginState int `json:"loginState" dc:"登录状态(0未启用,1启用)"` + TenantId string `json:"tenantId" dc:"租户ID"` + CreatedDept int64 `json:"createdDept" dc:"创建部门"` + CreatedBy int64 `json:"createdBy" dc:"创建者"` + CreatedBySumma *hook.MemberSumma `json:"createdBySumma" dc:"创建者摘要信息"` + CreatedAt *gtime.Time `json:"createdAt" dc:"创建时间"` + UpdatedBy int64 `json:"updatedBy" dc:"更新者"` + UpdatedBySumma *hook.MemberSumma `json:"updatedBySumma" dc:"更新者摘要信息"` + UpdatedAt *gtime.Time `json:"updatedAt" dc:"更新时间"` } // TSimChannelsExportModel 导出通道列表 type TSimChannelsExportModel struct { - StockCardCount int64 `json:"stockCardCount" dc:"库存卡数量"` - CardInfoCount int64 `json:"cardInfoCount" dc:"卡信息数量"` - CancellationCardCount int64 `json:"cancellationCardCount" dc:"销户卡数量"` - Id int64 `json:"id" dc:"ID"` - Operator int `json:"operator" dc:"运营商:1移动,2联通,3电信"` - TempKey string `json:"tempKey" dc:"模板标识"` - ChanKey string `json:"chanKey" dc:"通道编号"` - ChanName string `json:"chanName" dc:"通道名称"` - ChanStatus int `json:"chanStatus" dc:"通道状态(0未启用,1启用)"` - ShareEnabled int `json:"shareEnabled" dc:"是否共享Token(0否,1是)"` - ChanAlias string `json:"chanAlias" dc:"通道别名"` - RegionCode string `json:"regionCode" dc:"通道行政区域代码(省市)"` - AppKey string `json:"appKey" dc:"应用标识"` - AppSecret string `json:"appSecret" dc:"应用密钥"` - AccessTokenCreated *gtime.Time `json:"accessTokenCreated" dc:"凭证申请时间"` - AccessTokenExpire *gtime.Time `json:"accessTokenExpire" dc:"凭证过期时间"` - RefreshTokenCreated *gtime.Time `json:"refreshTokenCreated" dc:"刷新凭证申请时间"` - RefreshTokenExpire *gtime.Time `json:"refreshTokenExpire" dc:"刷新凭证过期时间"` - LoginUser string `json:"loginUser" dc:"登录账号"` - LoginPassword string `json:"loginPassword" dc:"登录密码"` - LoginState int `json:"loginState" dc:"登录状态(0未启用,1启用)"` - TenantId string `json:"tenantId" dc:"租户ID"` - CreatedDept int64 `json:"createdDept" dc:"创建部门"` - CreatedBy int64 `json:"createdBy" dc:"创建者"` - CreatedAt *gtime.Time `json:"createdAt" dc:"创建时间"` - UpdatedBy int64 `json:"updatedBy" dc:"更新者"` - UpdatedAt *gtime.Time `json:"updatedAt" dc:"更新时间"` + StockCardCount int64 `json:"stockCardCount" dc:"库存卡数量"` + CardInfoCount int64 `json:"cardInfoCount" dc:"卡信息数量"` + CancellationCardCount int64 `json:"cancellationCardCount" dc:"销户卡数量"` + Id int64 `json:"id" dc:"ID"` + Operator int `json:"operator" dc:"运营商:1移动,2联通,3电信"` + TempKey string `json:"tempKey" dc:"模板标识"` + ChanKey string `json:"chanKey" dc:"通道编号"` + ChanName string `json:"chanName" dc:"通道名称"` + ChanStatus int `json:"chanStatus" dc:"通道状态(0未启用,1启用)"` + ShareEnabled int `json:"shareEnabled" dc:"是否共享Token(0否,1是)"` + ChanAlias string `json:"chanAlias" dc:"通道别名"` + RegionCode string `json:"regionCode" dc:"通道行政区域代码(省市)"` + AppKey string `json:"appKey" dc:"应用标识"` + AppSecret string `json:"appSecret" dc:"应用密钥"` + AccessTokenCreated *gtime.Time `json:"accessTokenCreated" dc:"凭证申请时间"` + AccessTokenExpire *gtime.Time `json:"accessTokenExpire" dc:"凭证过期时间"` + RefreshTokenCreated *gtime.Time `json:"refreshTokenCreated" dc:"刷新凭证申请时间"` + RefreshTokenExpire *gtime.Time `json:"refreshTokenExpire" dc:"刷新凭证过期时间"` + LoginUser string `json:"loginUser" dc:"登录账号"` + LoginPassword string `json:"loginPassword" dc:"登录密码"` + LoginState int `json:"loginState" dc:"登录状态(0未启用,1启用)"` + TenantId string `json:"tenantId" dc:"租户ID"` + CreatedDept int64 `json:"createdDept" dc:"创建部门"` + CreatedBy int64 `json:"createdBy" dc:"创建者"` + CreatedAt *gtime.Time `json:"createdAt" dc:"创建时间"` + UpdatedBy int64 `json:"updatedBy" dc:"更新者"` + UpdatedAt *gtime.Time `json:"updatedAt" dc:"更新时间"` } diff --git a/server/internal/model/t_sim_flow_package_info.go b/server/internal/model/t_sim_flow_package_info.go index 8486bce..71ceb2e 100644 --- a/server/internal/model/t_sim_flow_package_info.go +++ b/server/internal/model/t_sim_flow_package_info.go @@ -1,7 +1,7 @@ // Package genin // @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 model diff --git a/server/internal/model/t_sim_flow_pool.go b/server/internal/model/t_sim_flow_pool.go index c2ee8ba..1d20de4 100644 --- a/server/internal/model/t_sim_flow_pool.go +++ b/server/internal/model/t_sim_flow_pool.go @@ -1,7 +1,7 @@ // Package genin // @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 model diff --git a/server/internal/model/t_sim_package_category.go b/server/internal/model/t_sim_package_category.go index 66e58d4..b99af99 100644 --- a/server/internal/model/t_sim_package_category.go +++ b/server/internal/model/t_sim_package_category.go @@ -1,7 +1,7 @@ // Package genin // @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 model diff --git a/server/internal/model/t_sim_pool_bind_package.go b/server/internal/model/t_sim_pool_bind_package.go index 2a8ea42..7b0aff0 100644 --- a/server/internal/model/t_sim_pool_bind_package.go +++ b/server/internal/model/t_sim_pool_bind_package.go @@ -1,7 +1,7 @@ // Package genin // @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 model diff --git a/server/internal/model/t_sim_pool_warn_config.go b/server/internal/model/t_sim_pool_warn_config.go index 9924f83..31cc012 100644 --- a/server/internal/model/t_sim_pool_warn_config.go +++ b/server/internal/model/t_sim_pool_warn_config.go @@ -1,7 +1,7 @@ // Package genin // @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 model diff --git a/server/internal/model/t_tplatform_setting.go b/server/internal/model/t_tplatform_setting.go index 84fbc97..68d06b2 100644 --- a/server/internal/model/t_tplatform_setting.go +++ b/server/internal/model/t_tplatform_setting.go @@ -1,7 +1,7 @@ // Package genin // @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 model diff --git a/server/internal/model/t_upstream_package.go b/server/internal/model/t_upstream_package.go index 8fde74c..1fcfa9f 100644 --- a/server/internal/model/t_upstream_package.go +++ b/server/internal/model/t_upstream_package.go @@ -1,7 +1,7 @@ // Package genin // @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 model diff --git a/server/internal/model/t_wo_communication.go b/server/internal/model/t_wo_communication.go index d78a8a2..6498c19 100644 --- a/server/internal/model/t_wo_communication.go +++ b/server/internal/model/t_wo_communication.go @@ -1,7 +1,7 @@ // Package genin // @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 model diff --git a/server/internal/model/t_wo_main.go b/server/internal/model/t_wo_main.go index 88f278d..de58d0a 100644 --- a/server/internal/model/t_wo_main.go +++ b/server/internal/model/t_wo_main.go @@ -1,7 +1,7 @@ // Package genin // @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 model diff --git a/server/internal/model/t_wo_record.go b/server/internal/model/t_wo_record.go index 1fb0660..faf8a9b 100644 --- a/server/internal/model/t_wo_record.go +++ b/server/internal/model/t_wo_record.go @@ -1,7 +1,7 @@ // Package genin // @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 model diff --git a/server/internal/packed/packed.go b/server/internal/packed/packed.go index 25355af..cedc529 100644 --- a/server/internal/packed/packed.go +++ b/server/internal/packed/packed.go @@ -1,6 +1,6 @@ // package packed // @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 packed diff --git a/server/internal/packed/response/response.go b/server/internal/packed/response/response.go index 39d78ed..0e6e1af 100644 --- a/server/internal/packed/response/response.go +++ b/server/internal/packed/response/response.go @@ -1,7 +1,7 @@ // package response // @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 response diff --git a/server/internal/queues/base.go b/server/internal/queues/base.go index 62676b3..4a6478a 100644 --- a/server/internal/queues/base.go +++ b/server/internal/queues/base.go @@ -1,7 +1,7 @@ // package queues // @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 queues diff --git a/server/internal/queues/httpqueues/broadcast_db_chg_queue.go b/server/internal/queues/httpqueues/broadcast_db_chg_queue.go index ef86f82..3f6f92c 100644 --- a/server/internal/queues/httpqueues/broadcast_db_chg_queue.go +++ b/server/internal/queues/httpqueues/broadcast_db_chg_queue.go @@ -1,7 +1,7 @@ // package httpqueues // @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 httpqueues @@ -48,4 +48,4 @@ func (q *qBroadcastDbChg) Handle(ctx context.Context, p queue.Payload) error { return err } return service.MemoryDB().EventHandler(ctx, &data) -} +} \ No newline at end of file diff --git a/server/internal/queues/httpqueues/sys_opt_log_queue.go b/server/internal/queues/httpqueues/sys_opt_log_queue.go index bf594c1..26be050 100644 --- a/server/internal/queues/httpqueues/sys_opt_log_queue.go +++ b/server/internal/queues/httpqueues/sys_opt_log_queue.go @@ -1,7 +1,7 @@ // package queues // @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 httpqueues diff --git a/server/internal/router/agent.go b/server/internal/router/agent.go index cc6fbb7..688300f 100644 --- a/server/internal/router/agent.go +++ b/server/internal/router/agent.go @@ -1,7 +1,7 @@ // package router // @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 router diff --git a/server/internal/router/common.go b/server/internal/router/common.go index caf1501..9f69d35 100644 --- a/server/internal/router/common.go +++ b/server/internal/router/common.go @@ -1,7 +1,7 @@ // package router // @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 router diff --git a/server/internal/router/full_sync_monitor_test.go b/server/internal/router/full_sync_monitor_test.go new file mode 100644 index 0000000..cd10fe7 --- /dev/null +++ b/server/internal/router/full_sync_monitor_test.go @@ -0,0 +1,28 @@ +package router + +import ( + "testing" + + "github.com/gogf/gf/v2/util/gmeta" + v1 "git.5iot.com/iotlcm-public/iotlcm-admin-svr/api/iot/v1" +) + +func TestFullSyncMonitorPermissionDeclarations(t *testing.T) { + // A slice of pairs rather than a map: request structs may contain slice + // fields (for example the channel scope), which are not hashable. + checks := []struct { + request any + want string + }{ + {v1.FullSyncRunsListReq{}, "cpm:iot:fullSyncRuns:view"}, + {v1.FullSyncRunDetailReq{}, "cpm:iot:fullSyncRuns:view"}, + {v1.FullSyncRunFailuresReq{}, "cpm:iot:fullSyncRuns:failures"}, + {v1.FullSyncRunStartReq{}, "cpm:iot:fullSyncRuns:start"}, + {v1.FullSyncChannelOptionsReq{}, "cpm:iot:fullSyncRuns:view"}, + } + for _, check := range checks { + if got := gmeta.Get(check.request, "x-check-permission").String(); got != check.want { + t.Fatalf("%T permission = %q, want %q", check.request, got, check.want) + } + } +} diff --git a/server/internal/router/full_sync_replay_test.go b/server/internal/router/full_sync_replay_test.go new file mode 100644 index 0000000..e16cb14 --- /dev/null +++ b/server/internal/router/full_sync_replay_test.go @@ -0,0 +1,20 @@ +package router + +import ( + "strings" + "testing" + + "github.com/gogf/gf/v2/util/gmeta" + "git.5iot.com/iotlcm-public/iotlcm-admin-svr/api/iot/v1" +) + +func TestFullSyncReplayPermissionDeclarations(t *testing.T) { + post := gmeta.Get(v1.FullSyncReplayReq{}, "x-check-permission").String() + get := gmeta.Get(v1.FullSyncReplayStatusReq{}, "x-check-permission").String() + if post != "cpm:iot:fullSyncRuns:replay" || get != "cpm:iot:fullSyncRuns:replay:view" { + t.Fatalf("permission metadata = %q, %q", post, get) + } + if strings.TrimSpace(post) == "" || strings.TrimSpace(get) == "" { + t.Fatal("missing permission") + } +} diff --git a/server/internal/router/gen.go b/server/internal/router/gen.go index a4ef9ac..f500c60 100644 --- a/server/internal/router/gen.go +++ b/server/internal/router/gen.go @@ -1,7 +1,7 @@ // package router // @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 router diff --git a/server/internal/router/gen_codes.go b/server/internal/router/gen_codes.go index 8e55751..629a404 100644 --- a/server/internal/router/gen_codes.go +++ b/server/internal/router/gen_codes.go @@ -1,7 +1,7 @@ // package router // @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 router diff --git a/server/internal/router/genrouter/init.go b/server/internal/router/genrouter/init.go index 963919d..a77527b 100644 --- a/server/internal/router/genrouter/init.go +++ b/server/internal/router/genrouter/init.go @@ -1,7 +1,7 @@ // package genrouter // @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 genrouter diff --git a/server/internal/router/genrouter/test_demo.go b/server/internal/router/genrouter/test_demo.go index 27aa967..639f179 100644 --- a/server/internal/router/genrouter/test_demo.go +++ b/server/internal/router/genrouter/test_demo.go @@ -1,7 +1,7 @@ // Package genrouter // @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 genrouter diff --git a/server/internal/router/iot.go b/server/internal/router/iot.go index 7abbd6d..33df2d7 100644 --- a/server/internal/router/iot.go +++ b/server/internal/router/iot.go @@ -1,7 +1,7 @@ // package router // @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 router diff --git a/server/internal/router/message_log_unread_test.go b/server/internal/router/message_log_unread_test.go new file mode 100644 index 0000000..acba5db --- /dev/null +++ b/server/internal/router/message_log_unread_test.go @@ -0,0 +1,36 @@ +package router + +import ( + "testing" + + "git.5iot.com/iotlcm-public/iotlcm-admin-svr/api/common/v1" + + "github.com/gogf/gf/v2/util/gmeta" +) + +// 右上角未读消息提醒依赖这三个接口,路径、方法或权限码被改动都会导致提醒静默失效。 +// 权限码必须是 cpc:current:user:没有菜单权限的登录用户也要能获取自己的未读消息。 +func TestMessageLogUnreadNotificationDeclarations(t *testing.T) { + cases := []struct { + name string + request interface{} + path string + method string + permission string + }{ + {"unreadList", v1.TMessageLogUnreadListReq{}, "/tMessageLog/unreadList", "get", "cpc:current:user"}, + {"read", v1.TMessageLogReadReq{}, "/tMessageLog/read", "post", "cpc:current:user"}, + {"readAll", v1.TMessageLogReadAllReq{}, "/tMessageLog/readAll", "post", "cpc:current:user"}, + } + for _, check := range cases { + if got := gmeta.Get(check.request, "path").String(); got != check.path { + t.Errorf("%s path = %q, want %q", check.name, got, check.path) + } + if got := gmeta.Get(check.request, "method").String(); got != check.method { + t.Errorf("%s method = %q, want %q", check.name, got, check.method) + } + if got := gmeta.Get(check.request, "x-check-permission").String(); got != check.permission { + t.Errorf("%s permission = %q, want %q", check.name, got, check.permission) + } + } +} diff --git a/server/internal/router/monitor.go b/server/internal/router/monitor.go index 88c17b8..395629c 100644 --- a/server/internal/router/monitor.go +++ b/server/internal/router/monitor.go @@ -1,7 +1,7 @@ // package router // @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 router diff --git a/server/internal/router/router.go b/server/internal/router/router.go index ff018ea..5761664 100644 --- a/server/internal/router/router.go +++ b/server/internal/router/router.go @@ -1,23 +1,36 @@ // package router // @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 router import ( "context" "git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/service" + "net/http" "os" + "path" "path/filepath" + "regexp" "strings" "github.com/gogf/gf/v2/frame/g" "github.com/gogf/gf/v2/net/ghttp" ) +var legacyExportStaticPath = regexp.MustCompile(`(?i)^/(?:app/)?upload/(?:[^/]+/)?user/file/(?:[^/]+/)?export(?:/|$)`) + // InitRouter 初始化路由 func InitRouter(ctx context.Context, s *ghttp.Server) { + // Run before static serving as well as API routing; legacy export files must + // never bypass download-center authorization through the upload mount. + s.BindHookHandler("/*", ghttp.HookBeforeServe, func(r *ghttp.Request) { + if legacyExportStaticPath.MatchString(path.Clean(strings.ReplaceAll(r.URL.Path, `\`, "/"))) { + r.Response.WriteStatus(http.StatusNotFound) + r.ExitAll() + } + }) uploadPath := g.Cfg().MustGet(ctx, "system.upload.local.path").String() if uploadPath != "" { s.AddStaticPath("/upload", resolveUploadStaticPath(uploadPath)) diff --git a/server/internal/router/system.go b/server/internal/router/system.go index c6c3a4b..e0124a6 100644 --- a/server/internal/router/system.go +++ b/server/internal/router/system.go @@ -1,7 +1,7 @@ // package router // @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 router diff --git a/server/internal/service/agent.go b/server/internal/service/agent.go index a65fe5c..55f1ce3 100644 --- a/server/internal/service/agent.go +++ b/server/internal/service/agent.go @@ -16,6 +16,7 @@ import ( type ( IAgentDept interface { + DeptExport(ctx context.Context, in *model.AgentDeptListParam) error DeptList(ctx context.Context, in *model.AgentDeptListParam) (list []*model.AgentEnterpriseCustomerModel, total int, err error) DeptView(ctx context.Context, deptId int64) (res *model.AgentEnterpriseCustomerModel, err error) DeptSave(ctx context.Context, in *model.AgentDeptSaveParam) (deptId int64, err error) @@ -46,12 +47,12 @@ type ( RoleSave(ctx context.Context, in *model.AgentRoleSaveParam) (roleId int64, err error) // RoleDelete 删除代理商角色,删除前校验本级机构默认角色不可被删除。 RoleDelete(ctx context.Context, roleIds []int64) (err error) - SyncInheritedRole(ctx context.Context, message *model.AgentRoleSyncMessage) error } IAgentScope interface { ScopeDeptIds(ctx context.Context) (deptIds []int64, err error) } IAgentUser interface { + UserExport(ctx context.Context, in *model.AgentUserListParam) error // UserList 查询用户列表 UserList(ctx context.Context, in *model.AgentUserListParam) (list []*model.AgentUserListModel, total int, err error) // UserView 查看用户详情 diff --git a/server/internal/service/common.go b/server/internal/service/common.go index d59e96c..a55909e 100644 --- a/server/internal/service/common.go +++ b/server/internal/service/common.go @@ -19,6 +19,18 @@ type ( List(ctx context.Context, in *model.BatchExecutionLogListParam) (list []*model.BatchExecutionLogListModel, totalCount int, err error) Summary(ctx context.Context, in *model.BatchExecutionLogDetailParam) (res *model.BatchExecutionLogDetailModel, err error) DetailList(ctx context.Context, in *model.BatchDetailListParam) (list []*model.BatchDetailListModel, totalCount int, err error) + // SyncReview 手动同步待核查批次的上游执行结果(只读补查既有受理号,不重发办理) + SyncReview(ctx context.Context, in *model.BatchExecutionLogSyncReviewParam) (res *model.BatchExecutionLogSyncReviewModel, err error) + } + ICommonDownloadCenter interface { + // List 下载中心列表(K2 仅本人 + K1 业务权限过滤) + List(ctx context.Context, in *model.DownloadCenterListParam) (list []*model.DownloadCenterListModel, totalCount int, err error) + // Recent 右上角全局入口:最近任务与进行中/已完成数量 + Recent(ctx context.Context, in *model.DownloadCenterRecentParam) (res *model.DownloadCenterRecentModel, err error) + // Download 下载导出文件,直接写文件流 + Download(ctx context.Context, in *model.DownloadCenterTaskParam) (err error) + // Remove 删除导出任务与文件 + Remove(ctx context.Context, in *model.DownloadCenterTaskParam) (err error) } ICommonBatchMain interface { // Model 批次主表ORM模型 @@ -26,6 +38,8 @@ type ( // List 获取批次主表列表 List(ctx context.Context, in *model.BatchMainListParam) (list []*model.BatchMainListModel, totalCount int, err error) SuccessList(ctx context.Context, in *model.BatchMainSuccessListParam) (list []*model.BatchMainSuccessListModel, totalCount int, err error) + // ExportWarehousing 导出入库批次列表(数据与入库管理页一致) + ExportWarehousing(ctx context.Context, in *model.BatchMainSuccessListParam) (err error) ListCards(ctx context.Context, in *model.BatchCardListParam) (list []*model.BatchCardListModel, totalCount int, err error) // Export 导出批次主表 Export(ctx context.Context, in *model.BatchMainListParam) (err error) @@ -222,6 +236,12 @@ type ( Delete(ctx context.Context, in *model.TMessageLogDeleteParam) (err error) // View 获取消息日志指定信息,并标记已读 View(ctx context.Context, in *model.TMessageLogViewParam) (res *model.TMessageLogViewModel, err error) + // UnreadList 获取当前登录用户的未读消息通知 + UnreadList(ctx context.Context, in *model.TMessageLogUnreadListParam) (list []*model.TMessageLogUnreadListModel, totalCount int, err error) + // Read 标记当前登录用户指定消息已读 + Read(ctx context.Context, in *model.TMessageLogReadParam) (err error) + // ReadAll 标记当前登录用户全部消息已读 + ReadAll(ctx context.Context, in *model.TMessageLogReadAllParam) (err error) } ICommonTNotifyLog interface { // Model 通知日志ORM模型 @@ -316,6 +336,7 @@ type ( var ( localCommonBatchExecutionLog ICommonBatchExecutionLog + localCommonDownloadCenter ICommonDownloadCenter localCommonBatchMain ICommonBatchMain localCommonAgentBalance ICommonAgentBalance localCommonTBalanceDetail ICommonTBalanceDetail @@ -345,6 +366,17 @@ func RegisterCommonBatchExecutionLog(i ICommonBatchExecutionLog) { localCommonBatchExecutionLog = i } +func CommonDownloadCenter() ICommonDownloadCenter { + if localCommonDownloadCenter == nil { + panic("implement not found for interface ICommonDownloadCenter, forgot register?") + } + return localCommonDownloadCenter +} + +func RegisterCommonDownloadCenter(i ICommonDownloadCenter) { + localCommonDownloadCenter = i +} + func CommonBatchMain() ICommonBatchMain { if localCommonBatchMain == nil { panic("implement not found for interface ICommonBatchMain, forgot register?") diff --git a/server/internal/service/fullsyncretry/client_test.go b/server/internal/service/fullsyncretry/client_test.go new file mode 100644 index 0000000..c506596 --- /dev/null +++ b/server/internal/service/fullsyncretry/client_test.go @@ -0,0 +1,159 @@ +package fullsyncretry + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/gogf/gf/v2/errors/gcode" + "github.com/gogf/gf/v2/errors/gerror" +) + +func TestReplayAndStatusProxy(t *testing.T) { + calls := 0 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + calls++ + if r.Header.Get("Authorization") != "" { + t.Error("Admin JWT must not be forwarded to Worker") + } + if r.Method == "POST" { + var body map[string]any + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + t.Error(err) + } + if len(body) != 1 || body["requestedBy"] != "42" { + t.Errorf("unexpected Worker request: %+v", body) + } + w.WriteHeader(http.StatusAccepted) + _, _ = w.Write([]byte(`{"replayRunId":"replay-1","status":"accepted"}`)) + return + } + _, _ = w.Write([]byte(`{"replayRunId":"replay-1","sourceRunId":"source","status":"completed","remaining":0,"enqueued":3}`)) + })) + defer server.Close() + client := Client{BaseURL: server.URL} + r, err := client.Replay(context.Background(), "source", "42", 0) + if err != nil || r.ReplayRunID != "replay-1" { + t.Fatalf("replay: %+v %v", r, err) + } + s, err := client.Get(context.Background(), "replay-1") + if err != nil || s.Enqueued != 3 || calls != 2 { + t.Fatalf("status: %+v %v, calls=%d", s, err, calls) + } +} + +func TestRunQueriesAndFailures(t *testing.T) { + calls := 0 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + calls++ + switch { + case r.Method == http.MethodGet && r.URL.Path == "/internal/v1/full-sync-runs": + if r.URL.Query().Get("syncKind") != "traffic_usage" || r.URL.Query().Get("limit") != "7" { + t.Fatalf("query = %s", r.URL.RawQuery) + } + _, _ = w.Write([]byte(`{"items":[{"source":"live","run":{"runId":"run-1","operation":"current_month_usage_full_sync","status":"running"}}],"total":1}`)) + case r.Method == http.MethodGet && r.URL.Path == "/internal/v1/full-sync-runs/run-1": + _, _ = w.Write([]byte(`{"source":"archive","run":{"runId":"run-1","operation":"current_month_usage_full_sync","status":"completed"},"rounds":[]}`)) + case r.Method == http.MethodGet && r.URL.Path == "/internal/v1/full-sync-runs/run-1/failures": + _, _ = w.Write([]byte(`{"items":[{"taskId":"task-1","cardId":1,"errorCategory":"business","errorMessage":"failed","round":2}],"nextCursor":"next"}`)) + default: + http.NotFound(w, r) + } + })) + defer server.Close() + client := Client{BaseURL: server.URL} + list, err := client.ListRuns(context.Background(), "traffic_usage", 7) + if err != nil || list.Total != 1 || len(list.Items) != 1 { + t.Fatalf("list = %+v, err = %v", list, err) + } + view, err := client.Run(context.Background(), "run-1") + if err != nil || view.Source != "archive" { + t.Fatalf("run = %+v, err = %v", view, err) + } + page, err := client.Failures(context.Background(), "run-1", 100, "cursor") + if err != nil || len(page.Items) != 1 || page.NextCursor != "next" || calls != 3 { + t.Fatalf("failures = %+v, err = %v, calls = %d", page, err, calls) + } +} + +func TestReplayWorkerTimeout(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { time.Sleep(50 * time.Millisecond) })) + defer server.Close() + client := Client{BaseURL: server.URL, HTTP: &http.Client{Timeout: time.Millisecond}} + _, err := client.Replay(context.Background(), "source", "42", 0) + if err == nil || !strings.Contains(err.Error(), "Worker unavailable") { + t.Fatalf("timeout: %v", err) + } +} + +func TestReplayValidationAndWorkerErrors(t *testing.T) { + calls := 0 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + calls++ + w.WriteHeader(http.StatusConflict) + _, _ = w.Write([]byte(`{"error":{"message":"ICCID secret"}}`)) + })) + defer server.Close() + client := Client{BaseURL: server.URL} + for _, id := range []string{"", "a/b", "{bad}", strings.Repeat("a", 129)} { + if _, err := client.Replay(context.Background(), id, "42", 0); gerror.Code(err) != gcode.CodeInvalidParameter { + t.Fatalf("id=%q: %v", id, err) + } + } + if calls != 0 { + t.Fatal("invalid source triggered Worker") + } + // The edge must reject an oversized round budget before calling Worker. + for _, rounds := range []int{-1, MaxReplayRounds + 1} { + if _, err := client.Replay(context.Background(), "source", "42", rounds); gerror.Code(err) != gcode.CodeInvalidParameter { + t.Fatalf("maxRounds=%d: %v", rounds, err) + } + } + if calls != 0 { + t.Fatal("invalid maxRounds triggered Worker") + } + _, err := client.Replay(context.Background(), "source", "42", 0) + if gerror.Code(err) != gcode.CodeInvalidParameter || strings.Contains(err.Error(), "ICCID") { + t.Fatalf("error leaked Worker body: %v", err) + } + for _, status := range []int{400, 401, 404, 409, 422, 500} { + if workerError(status) == nil { + t.Fatalf("missing error mapping for %d", status) + } + } +} + +// The round budget must reach Worker only when the caller asks for one, so an +// omitted value keeps the Worker-side configured default authoritative. +func TestReplayForwardsMaxRoundsOnlyWhenSet(t *testing.T) { + var bodies []map[string]any + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var body map[string]any + _ = json.NewDecoder(r.Body).Decode(&body) + bodies = append(bodies, body) + w.WriteHeader(http.StatusAccepted) + _, _ = w.Write([]byte(`{"replayRunId":"replay-1","status":"accepted"}`)) + })) + defer server.Close() + client := Client{BaseURL: server.URL} + + if _, err := client.Replay(context.Background(), "source", "42", 0); err != nil { + t.Fatal(err) + } + if _, err := client.Replay(context.Background(), "source", "42", 7); err != nil { + t.Fatal(err) + } + if len(bodies) != 2 { + t.Fatalf("expected 2 Worker calls, got %d", len(bodies)) + } + if _, present := bodies[0]["maxRounds"]; present { + t.Fatalf("omitted budget must not be sent: %+v", bodies[0]) + } + if got := bodies[1]["maxRounds"]; got != float64(7) { + t.Fatalf("explicit budget must be forwarded, got %+v", bodies[1]) + } +} diff --git a/server/internal/service/fullsyncretry/sync_kind_test.go b/server/internal/service/fullsyncretry/sync_kind_test.go new file mode 100644 index 0000000..a0a194c --- /dev/null +++ b/server/internal/service/fullsyncretry/sync_kind_test.go @@ -0,0 +1,39 @@ +package fullsyncretry + +import ( + "context" + "net/http" + "net/http/httptest" + "testing" + + "github.com/gogf/gf/v2/errors/gcode" + "github.com/gogf/gf/v2/errors/gerror" +) + +func TestListRunsAcceptsFiveKindsAndRejectsUnknownFilters(t *testing.T) { + var filters []string + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + filters = append(filters, r.URL.Query().Get("syncKind")) + _, _ = w.Write([]byte(`{"items":null,"total":0}`)) + })) + defer server.Close() + client := Client{BaseURL: server.URL} + kinds := []string{"", "traffic_usage", "sim_status", "imei", "traffic_usage_daily", "traffic_usage_monthly"} + for i, kind := range kinds { + result, err := client.ListRuns(context.Background(), kind, 100) + if err != nil || result.Items == nil || len(filters) != i+1 || filters[i] != kind { + t.Fatalf("kind=%q result=%+v filters=%v error=%v", kind, result, filters, err) + } + } + for _, query := range []struct { + kind string + limit int + }{{"unknown", 10}, {"IMEI", 10}, {"", -1}, {"imei", 101}} { + if _, err := client.ListRuns(context.Background(), query.kind, query.limit); gerror.Code(err) != gcode.CodeInvalidParameter { + t.Fatalf("invalid filter %+v: %v", query, err) + } + } + if len(filters) != len(kinds) { + t.Fatal("invalid filter reached Worker") + } +} diff --git a/server/internal/service/simwarning.go b/server/internal/service/simwarning.go index 10bc3d7..7a8b123 100644 --- a/server/internal/service/simwarning.go +++ b/server/internal/service/simwarning.go @@ -13,6 +13,7 @@ type ( ScanExpire(ctx context.Context) PreloadExpireCandidates(ctx context.Context) ScanExpireCandidates(ctx context.Context) + NotifyMonthlyExpiry(ctx context.Context) ConfigList(ctx context.Context, in *model.SimWarningConfigListParam) (list []*model.SimWarningConfigModel, totalCount int, err error) ConfigView(ctx context.Context, in *model.SimWarningConfigViewParam) (res *model.SimWarningConfigModel, err error) ConfigSave(ctx context.Context, in *model.SimWarningConfigSaveParam) (res *model.SimWarningConfigSaveModel, err error) diff --git a/server/internal/service/system.go b/server/internal/service/system.go index b4e72a0..3c41fa7 100644 --- a/server/internal/service/system.go +++ b/server/internal/service/system.go @@ -208,7 +208,7 @@ type ( SaveContentLocal(ctx context.Context, content []byte, newFileType int, fileName string, subDirName string, isAddDate int) (result model.UploadResponse, err error) UploadLocal(ctx context.Context, file *ghttp.UploadFile, newFileType int, useOriginalName int, subDirName string, notAddDate int) (result model.UploadResponse, err error) UploadLocalWithFileName(ctx context.Context, file *ghttp.UploadFile, newFileType int, fileName string, subDirName string, notAddDate int) (result model.UploadResponse, err error) - DownloadLocal(ctx context.Context, file *entity.SysOss) (err error) + DownloadLocal(ctx context.Context, file *entity.SysOss, beforeSend ...func() error) (err error) DeleteLocal(ctx context.Context, file *entity.SysOss) (err error) CheckType(ctx context.Context, checkFileType string, file *ghttp.UploadFile) (err error) CheckSize(ctx context.Context, checkFileType string, file *ghttp.UploadFile) (err error) diff --git a/server/internal/tasks/full_sync_tasks_test.go b/server/internal/tasks/full_sync_tasks_test.go new file mode 100644 index 0000000..32ba877 --- /dev/null +++ b/server/internal/tasks/full_sync_tasks_test.go @@ -0,0 +1,18 @@ +package tasks + +import "testing" + +func TestAllFullSyncTaskRegistrations(t *testing.T) { + for name, method := range map[string]string{ + "start_traffic_usage_full_sync": "StartTrafficUsageFullSync", + "syns_sim_status_queue": "SynsSimStatusQueue", + "syns_imei_queue": "SynsImeiQueue", + "sync_traffic_usage_daily": "SyncTrafficUsageDaily", + "sync_traffic_usage_monthly": "SyncTrafficUsageMonthly", + "start_traffic_usage_monthly_history": "StartTrafficUsageMonthlyHistory", + } { + if got := GetInnerTaskName(name); got != method { + t.Errorf("task %q registered as %q, want %q", name, got, method) + } + } +} diff --git a/server/internal/tasks/monthly_expiry.go b/server/internal/tasks/monthly_expiry.go new file mode 100644 index 0000000..678049f --- /dev/null +++ b/server/internal/tasks/monthly_expiry.go @@ -0,0 +1,26 @@ +package tasks + +import ( + "context" + "time" + + "git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/service" +) + +const ( + monthlyExpiryJobPeriod = 24 * time.Hour + monthlyExpiryJobTTL = 6 * time.Hour +) + +func init() { + RegisterInnerTask("NotifyMonthlyPackageExpiry", "NotifyMonthlyPackageExpiry") +} + +// NotifyMonthlyPackageExpiry uses an independent sys_job target and Redis lock. +func (t Task) NotifyMonthlyPackageExpiry() { + t.runWithRedisJobLock(context.Background(), redisJobLockOptions{ + InvokeTarget: "NotifyMonthlyPackageExpiry", + Period: monthlyExpiryJobPeriod, + TTL: monthlyExpiryJobTTL, + }, service.SimWarning().NotifyMonthlyExpiry) +} diff --git a/server/internal/tasks/monthly_expiry_test.go b/server/internal/tasks/monthly_expiry_test.go new file mode 100644 index 0000000..782e110 --- /dev/null +++ b/server/internal/tasks/monthly_expiry_test.go @@ -0,0 +1,81 @@ +package tasks + +import ( + "os" + "regexp" + "strings" + "testing" + "time" + _ "time/tzdata" + + "github.com/robfig/cron/v3" +) + +func monthlyExpiryMigration(t *testing.T) string { + t.Helper() + data, err := os.ReadFile("../../resource/migrations/20261014_monthly_package_expiry_notice.sql") + if err != nil { + t.Fatal(err) + } + return regexp.MustCompile(`(?m)--[^\r\n]*`).ReplaceAllString(string(data), "") +} + +func TestMonthlyExpiryDeploymentSchedule(t *testing.T) { + sql := monthlyExpiryMigration(t) + matches := regexp.MustCompile(`'CRON_TZ=[^']+'`).FindString(sql) + if matches == "" { + t.Fatal("migration must specify cron expression") + } + parser := cron.NewParser(cron.Second | cron.Minute | cron.Hour | cron.Dom | cron.Month | cron.Dow | cron.Descriptor) + schedule, err := parser.Parse(strings.Trim(matches, "'")) + if err != nil { + t.Fatalf("deployment expression must parse with the real scheduler: %v", err) + } + for _, tc := range []struct{ start, expected string }{ + {"2026-10-14T01:00:00Z", "2026-10-15T01:00:00Z"}, + {"2026-10-15T01:00:00Z", "2026-11-15T01:00:00Z"}, + {"2026-12-15T01:00:00Z", "2027-01-15T01:00:00Z"}, + {"2028-02-15T01:00:00Z", "2028-03-15T01:00:00Z"}, + } { + start, _ := time.Parse(time.RFC3339, tc.start) + expected, _ := time.Parse(time.RFC3339, tc.expected) + if got := schedule.Next(start); !got.Equal(expected) { + t.Errorf("from %s: got %s, want %s", tc.start, got, expected) + } + } + for _, alias := range []string{"ScanSimExpireCandidateWarning", "scan_sim_expire_candidate_warning"} { + if got := GetInnerTaskName(alias); got != "ScanSimExpireCandidateWarning" { + t.Errorf("existing alias %s no longer registered: %s", alias, got) + } + } + if got := GetInnerTaskName("NotifyMonthlyPackageExpiry"); got != "NotifyMonthlyPackageExpiry" { + t.Errorf("monthly task not registered independently: %s", got) + } + if got, want := jobLockKey("NotifyMonthlyPackageExpiry", time.Date(2026, 10, 15, 9, 0, 0, 0, time.UTC), monthlyExpiryJobPeriod), jobLockKey("scan_sim_expire_candidate_warning", time.Date(2026, 10, 15, 9, 0, 0, 0, time.UTC), warningExpireScanJobPeriod); got == want { + t.Fatal("monthly and legacy tasks must not share the same lock") + } +} + +// 只检查迁移脚本结构,不连接数据库或执行 SQL;不替代部署前的 MySQL 验证。 +func TestMonthlyExpiryDeploymentInsertOnly(t *testing.T) { + sql := strings.Join(strings.Fields(monthlyExpiryMigration(t)), " ") + if strings.Count(strings.ToUpper(sql), "INSERT INTO SYS_JOB") != 1 { + t.Fatal("migration must insert only one independent sys_job") + } + if regexp.MustCompile(`(?i)\bUPDATE\s+sys_job\b|\bDELETE\s+FROM\s+sys_job\b|\bjob_id\b`).MatchString(sql) { + t.Fatal("migration must not update/delete jobs or provide a generated job_id") + } + for _, required := range []string{ + "'NotifyMonthlyPackageExpiry'", + "'CRON_TZ=Asia/Shanghai 0 0 9 15 * *'", + "1, 1, '1'", + "WHERE NOT EXISTS ( SELECT 1 FROM sys_job WHERE invoke_target = 'NotifyMonthlyPackageExpiry' )", + } { + if !strings.Contains(sql, required) { + t.Errorf("migration missing required independent paused job behavior: %q", required) + } + } + if strings.Contains(sql, "ScanSimExpireCandidateWarning") || strings.Contains(sql, "scan_sim_expire_candidate_warning") { + t.Fatal("migration must not touch legacy job targets") + } +} diff --git a/server/internal/tasks/sim_warning.go b/server/internal/tasks/sim_warning.go index 53ee0cf..4646f33 100644 --- a/server/internal/tasks/sim_warning.go +++ b/server/internal/tasks/sim_warning.go @@ -7,7 +7,7 @@ import ( ) const ( - warningTrafficJobPeriod = 15 * time.Minute + warningTrafficJobPeriod = -time.Second warningTrafficJobTTL = 45 * time.Minute warningExpirePreloadJobPeriod = 24 * time.Hour warningExpirePreloadJobTTL = 6 * time.Hour @@ -42,7 +42,7 @@ func (t Task) ScanSimWarning() { func (t Task) ScanSimTrafficWarning() { t.runWithRedisJobLock(context.Background(), redisJobLockOptions{ - InvokeTarget: "scan_sim_traffic_warning", + InvokeTarget: "scan_sim_warning", Period: warningTrafficJobPeriod, TTL: warningTrafficJobTTL, }, service.SimWarning().ScanTraffic) @@ -50,7 +50,7 @@ func (t Task) ScanSimTrafficWarning() { func (t Task) ScanSimFlowPoolWarning() { t.runWithRedisJobLock(context.Background(), redisJobLockOptions{ - InvokeTarget: "check_flow_pool_warning", + InvokeTarget: "scan_sim_warning", Period: warningTrafficJobPeriod, TTL: warningTrafficJobTTL, }, service.SimWarning().ScanFlowPool) diff --git a/server/internal/tasks/traffic_usage_monthly_test.go b/server/internal/tasks/traffic_usage_monthly_test.go new file mode 100644 index 0000000..0909057 --- /dev/null +++ b/server/internal/tasks/traffic_usage_monthly_test.go @@ -0,0 +1,32 @@ +package tasks + +import ( + "testing" + "time" +) + +func TestTrafficUsageMonthlyTaskRegistrations(t *testing.T) { + tests := map[string]string{ + "sync_traffic_usage_monthly": "SyncTrafficUsageMonthly", + "start_traffic_usage_full_sync": "StartTrafficUsageFullSync", + } + for name, method := range tests { + if actual := GetInnerTaskName(name); actual != method { + t.Fatalf("task %q registered as %q, want %q", name, actual, method) + } + } +} + +func TestTrafficUsageJobLockUsesStableActiveScope(t *testing.T) { + first := time.Date(2026, 9, 20, 23, 59, 0, 0, time.Local) + second := first.Add(2 * time.Minute) + if got := jobLockPeriodKey(first, -1); got != "active" { + t.Fatalf("first active key = %q", got) + } + if got := jobLockPeriodKey(second, -1); got != "active" { + t.Fatalf("second active key = %q", got) + } + if jobLockKey("sync_traffic_usage_daily", first, -1) != jobLockKey("sync_traffic_usage_daily", second, -1) { + t.Fatal("active job lock key must remain stable across midnight") + } +} diff --git a/server/main.go b/server/main.go index 30c2f7a..6b88310 100644 --- a/server/main.go +++ b/server/main.go @@ -1,7 +1,7 @@ // package main // @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 main diff --git a/server/resource/generate/default/curd/api.go.template b/server/resource/generate/default/curd/api.go.template index 8a8df72..f29250e 100644 --- a/server/resource/generate/default/curd/api.go.template +++ b/server/resource/generate/default/curd/api.go.template @@ -1,7 +1,7 @@ // Package @{.varName | ToLower} // @Link https://github.com/xiujiecn/xiu-admin // @Copyright Copyright (c) @{NowYear} LiXiujie -// @Author Lxj +// @Author Lxj // @License https://github.com/xiujiecn/xiu-admin/blob/master/LICENSE // @AutoGenerate Version @{.xiuVersion} // @@ -10,8 +10,8 @@ package v1 //@{.varName | ToLower} import ( "github.com/gogf/gf/v2/frame/g" "@{.importInput}" - "iotlcm/internal/model/response" - @{ if eq .options.Step.IsTreeTable true }"iotlcm/utility/tree"@{end} + "git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/model/response" + @{ if eq .options.Step.IsTreeTable true }"git.5iot.com/iotlcm-public/iotlcm-admin-svr/utility/tree"@{end} ) // ListReq 查询@{.tableComment}列表 diff --git a/server/resource/generate/default/curd/controller.go.template b/server/resource/generate/default/curd/controller.go.template index 53646bd..6b9acad 100644 --- a/server/resource/generate/default/curd/controller.go.template +++ b/server/resource/generate/default/curd/controller.go.template @@ -1,7 +1,7 @@ // Package @{.templateGroup} // @Link https://github.com/xiujiecn/xiu-admin // @Copyright Copyright (c) @{NowYear} LiXiujie -// @Author Lxj +// @Author Lxj // @License https://github.com/xiujiecn/xiu-admin/blob/master/LICENSE // @AutoGenerate Version @{.xiuVersion} // @@ -10,7 +10,7 @@ package @{.templateGroup} import ( "context" v1 "@{.importApi}" - "iotlcm/internal/model/input/form" + "git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/model/input/form" "@{.importInput}" "@{.importService}" ) diff --git a/server/resource/generate/default/curd/input.go.template b/server/resource/generate/default/curd/input.go.template index 9fa278e..adc2fab 100644 --- a/server/resource/generate/default/curd/input.go.template +++ b/server/resource/generate/default/curd/input.go.template @@ -1,7 +1,7 @@ // Package @{.templateGroup}in // @Link https://github.com/xiujiecn/xiu-admin // @Copyright Copyright (c) @{NowYear} LiXiujie -// @Author Lxj +// @Author Lxj // @License https://github.com/xiujiecn/xiu-admin/blob/master/LICENSE // @AutoGenerate Version @{.xiuVersion} // @@ -15,12 +15,12 @@ import ( "github.com/gogf/gf/v2/errors/gerror" "github.com/gogf/gf/v2/frame/g" "github.com/gogf/gf/v2/os/gtime" - "iotlcm/internal/consts" - "iotlcm/internal/model/entity" - "iotlcm/internal/model/request" - "iotlcm/utility/validate" - @{ if eq .options.Step.HasHookMemberSummary true }"iotlcm/internal/library/xgorm/hook"@{end} - @{ if eq .options.Step.IsTreeTable true }"iotlcm/utility/tree"@{end} + "git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/consts" + "git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/model/entity" + "git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/model/request" + "git.5iot.com/iotlcm-public/iotlcm-admin-svr/utility/validate" + @{ if eq .options.Step.HasHookMemberSummary true }"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/xgorm/hook"@{end} + @{ if eq .options.Step.IsTreeTable true }"git.5iot.com/iotlcm-public/iotlcm-admin-svr/utility/tree"@{end} ) @{ if eq .options.Step.HasEdit true } // @{.varName}UpdateFields 修改@{.tableComment}字段过滤 diff --git a/server/resource/generate/default/curd/logic.go.template b/server/resource/generate/default/curd/logic.go.template index 1681ae0..c5b9e4f 100644 --- a/server/resource/generate/default/curd/logic.go.template +++ b/server/resource/generate/default/curd/logic.go.template @@ -1,7 +1,7 @@ // Package @{.templateGroup} // @Link https://github.com/xiujiecn/xiu-admin // @Copyright Copyright (c) @{NowYear} LiXiujie -// @Author Lxj +// @Author Lxj // @License https://github.com/xiujiecn/xiu-admin/blob/master/LICENSE // @AutoGenerate Version @{.xiuVersion} // @@ -16,21 +16,21 @@ import ( "github.com/gogf/gf/v2/os/gctx" "github.com/gogf/gf/v2/os/gtime" "github.com/gogf/gf/v2/util/gconv" - "iotlcm/internal/dao" - "iotlcm/internal/consts" - "iotlcm/internal/library/contexts" - "iotlcm/internal/library/xgorm" - "iotlcm/internal/library/xgorm/handler" - "iotlcm/internal/library/xgorm/hook" + "git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/dao" + "git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/consts" + "git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/contexts" + "git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/xgorm" + "git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/xgorm/handler" + "git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/xgorm/hook" "@{.importInput}" "@{.importService}" - "iotlcm/utility/convert" - "iotlcm/utility/excel" - "iotlcm/utility/validate" - @{ if eq .options.Step.IsAddon true }isc "iotlcm/internal/service"@{end} - @{ if eq .options.Step.IsTreeTable true }"iotlcm/utility/tree"@{end} - @{ if eq .options.Step.HasFuncDict true }"iotlcm/internal/library/dict"@{end} - @{ if eq .options.Step.HasFuncDict true }"iotlcm/internal/model"@{end} + "git.5iot.com/iotlcm-public/iotlcm-admin-svr/utility/convert" + "git.5iot.com/iotlcm-public/iotlcm-admin-svr/utility/excel" + "git.5iot.com/iotlcm-public/iotlcm-admin-svr/utility/validate" + @{ if eq .options.Step.IsAddon true }isc "git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/service"@{end} + @{ if eq .options.Step.IsTreeTable true }"git.5iot.com/iotlcm-public/iotlcm-admin-svr/utility/tree"@{end} + @{ if eq .options.Step.HasFuncDict true }"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/dict"@{end} + @{ if eq .options.Step.HasFuncDict true }"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/model"@{end} ) type s@{.servFunName} struct{} diff --git a/server/resource/generate/default/curd/router.go.template b/server/resource/generate/default/curd/router.go.template index ce51d72..737bc56 100644 --- a/server/resource/generate/default/curd/router.go.template +++ b/server/resource/generate/default/curd/router.go.template @@ -1,7 +1,7 @@ // Package genrouter // @Link https://github.com/xiujiecn/xiu-admin // @Copyright Copyright (c) @{NowYear} LiXiujie -// @Author Lxj +// @Author Lxj // @License https://github.com/xiujiecn/xiu-admin/blob/master/LICENSE // @AutoGenerate Version @{.xiuVersion} // diff --git a/server/resource/migrations/20260920_card_channel_assoc_chan_key.sql b/server/resource/migrations/20260920_card_channel_assoc_chan_key.sql index cf6fdf6..8b88244 100644 --- a/server/resource/migrations/20260920_card_channel_assoc_chan_key.sql +++ b/server/resource/migrations/20260920_card_channel_assoc_chan_key.sql @@ -1,4 +1,4 @@ -- card_channel_assoc.channel_id historically mixed primary IDs and numeric channel keys. -- Store the actual t_sim_channels.chan_key so every environment resolves associations identically. ALTER TABLE `card_channel_assoc` - MODIFY COLUMN `channel_id` VARCHAR(64) NOT NULL COMMENT '通道编号'; + MODIFY COLUMN `channel_id` VARCHAR(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL COMMENT '通道编号'; diff --git a/server/resource/migrations/20261014_monthly_package_expiry_notice.sql b/server/resource/migrations/20261014_monthly_package_expiry_notice.sql new file mode 100644 index 0000000..345fd2c --- /dev/null +++ b/server/resource/migrations/20261014_monthly_package_expiry_notice.sql @@ -0,0 +1,11 @@ +-- 套餐到期提醒:独立月度任务;不修改、启用或复用旧 ScanSimExpireCandidateWarning。 +-- sys_job.job_id 为 AUTO_INCREMENT,由数据库生成;本脚本仅提供部署使用,本次不执行。 +-- 默认暂停:核实实际短信参数、部署配置并确认调度时间后,再在任务管理页启用。 +-- 启用/修改任务需通过管理页刷新进程内调度,直接改库不刷新调度。 +-- cron 以 Asia/Shanghai 每月15日09:00 执行;站内通知与短信独立,短信需要审核模板参数。 +INSERT INTO sys_job (job_name, job_params, job_group, invoke_target, cron_expression, misfire_policy, concurrent, status, remark, created_at, updated_at) +SELECT '套餐到期提醒(企业月度汇总)', '', '3', 'NotifyMonthlyPackageExpiry', + 'CRON_TZ=Asia/Shanghai 0 0 9 15 * *', 1, 1, '1', '独立月度到期通知任务;默认暂停', NOW(), NOW() +WHERE NOT EXISTS ( + SELECT 1 FROM sys_job WHERE invoke_target = 'NotifyMonthlyPackageExpiry' +); diff --git a/server/resource/migrations/20261019_export_execution_log_metadata_backfill.sql b/server/resource/migrations/20261019_export_execution_log_metadata_backfill.sql new file mode 100644 index 0000000..7494a91 --- /dev/null +++ b/server/resource/migrations/20261019_export_execution_log_metadata_backfill.sql @@ -0,0 +1,16 @@ +-- 补齐异步导出执行日志元数据及运行结果。 +-- batch_type=14 仅由 Web 控制台导出中心创建;此类导出按批量操作展示。 +-- 运行结果跟随既有异步状态映射:0/1 -> 待运行,2 -> 运行中,3 -> 成功,4 -> 失败,5 -> 部分成功,6 -> 待核查。 +-- 仅更新导出类型,不影响其他批次。可重复执行。 +UPDATE batch_main +SET source_channel = 'console', + execution_mode = 2, + run_result = CASE batch_async_status + WHEN 2 THEN 1 + WHEN 3 THEN 2 + WHEN 4 THEN 3 + WHEN 5 THEN 4 + WHEN 6 THEN 5 + ELSE 0 + END +WHERE batch_type = 14; diff --git a/server/resource/migrations/20261021_batch_detail_not_executed.sql b/server/resource/migrations/20261021_batch_detail_not_executed.sql new file mode 100644 index 0000000..03b93fc --- /dev/null +++ b/server/resource/migrations/20261021_batch_detail_not_executed.sql @@ -0,0 +1,17 @@ +-- 明细状态 5=未执行:本卡校验通过,但整批因其他卡校验失败而未执行。 +-- 部署前备份并核对目标租户的 detail_status 字典;此脚本只补默认租户且不覆盖已有标签。 +INSERT INTO `sys_dict_data` ( + `tenant_id`, `dict_sort`, `dict_label`, `dict_value`, `dict_type`, `css_class`, `list_class`, + `is_default`, `created_dept`, `created_by`, `created_at`, `remark` +) +SELECT '000000', 5, '未执行', '5', 'detail_status', '', 'warning', + 'N', 100, 1, NOW(), '本卡校验通过,本批存在校验失败卡,整批未执行' +FROM `sys_dict_type` AS dict_type +WHERE dict_type.`tenant_id` = '000000' + AND dict_type.`dict_type` = 'detail_status' + AND NOT EXISTS ( + SELECT 1 FROM `sys_dict_data` AS dict_data + WHERE dict_data.`tenant_id` = '000000' + AND dict_data.`dict_type` = 'detail_status' + AND dict_data.`dict_value` = '5' + ); diff --git a/server/storage/data/pool_warn_config_permission.sql b/server/storage/data/pool_warn_config_permission.sql new file mode 100644 index 0000000..750ba1d --- /dev/null +++ b/server/storage/data/pool_warn_config_permission.sql @@ -0,0 +1,99 @@ +-- ============================================================ +-- 流量池预警配置独立权限码(业务受理入口卡片可独立隐藏) +-- ============================================================ +-- 背景: +-- 「业务受理」页的入口卡片按 accessCodes 过滤显隐。 +-- 「流量预警配置」与「流量池预警配置」原先共用同一组 +-- cpm:iot:tSimWarningConfig:* 权限码,导致收权时两个入口一起消失。 +-- 本脚本为流量池预警配置新增专属权限项 +-- cpm:iot:tSimPoolWarnConfig:*,与原权限项并存。 +-- +-- 前置条件:Admin / UI 版本已包含「入口卡片按权限独立隐藏」的改动。 +-- +-- 兼容性: +-- 1) sys_menu 保留原有 4 条 tSimWarningConfig 权限项,不动。 +-- 2) 新增的 4 条 tSimPoolWarnConfig 权限项,按「拥有对应旧权限项的角色」自动授权, +-- 因此存量角色在执行本脚本后: +-- - 仍能看到「流量预警配置」入口(旧权限码保留) +-- - 仍能看到「流量池预警配置」入口(新权限码已自动补授) +-- - 调用 /iot/tSimWarningConfig/* 接口不会 403 +-- (后端已把接口的 x-check-permission 改为「新码 OR 旧码」) +-- 3) 后续若只想关闭「流量池预警配置」,管理员在角色管理里去掉新权限项即可, +-- 不再影响「流量预警配置」。 +-- 4) 超级管理员(role_id=1)走独立的超管判定,不依赖菜单权限,无需授权。 +-- +-- 执行范围:生产 / 预演 / 演示 环境各自的 db_iotlcm 库。 +-- 幂等:可重复执行(先删后插)。 +-- +-- ============================================================ + +-- ------------------------------------------------------------ +-- 1) 新增 4 条流量池预警配置权限项(挂在「业务受理」菜单下) +-- ------------------------------------------------------------ +DELETE FROM sys_menu + WHERE perms IN ( + 'iot:tSimPoolWarnConfig:excel:template', + 'iot:tSimPoolWarnConfig:excel:submit', + 'iot:tSimPoolWarnConfig:input:submit', + 'iot:tSimPoolWarnConfig:range:submit' + ); + +INSERT INTO sys_menu + (menu_name, parent_id, level, order_num, path, component, query_param, + is_frame, is_cache, menu_type, visible, status, perms, icon, remark) +SELECT permissions.name, parent.menu_id, 2, permissions.order_num, '', '', '', + 1, 0, 'F', '0', '0', permissions.perms, '#', '流量池预警配置(独立权限)' +FROM sys_menu AS parent +JOIN ( + SELECT '下载流量池预警配置Excel模板' AS name, 20 AS order_num, 'iot:tSimPoolWarnConfig:excel:template' AS perms + UNION ALL SELECT '提交流量池预警配置Excel', 21, 'iot:tSimPoolWarnConfig:excel:submit' + UNION ALL SELECT '批量输入流量池预警配置', 22, 'iot:tSimPoolWarnConfig:input:submit' + UNION ALL SELECT '连号流量池预警配置', 23, 'iot:tSimPoolWarnConfig:range:submit' +) AS permissions +WHERE parent.component = 'iotlcm/tCommunication/tBusinessAcceptance/index'; + +-- ------------------------------------------------------------ +-- 2) 角色授权:把新权限项授予「原本就拥有对应旧权限项」的角色 +-- (即拥有 tSimWarningConfig 权限的角色,同步获得 tSimPoolWarnConfig) +-- 注意:sys_role_menu 的 (tenant_id, role_id, menu_id) 是联合主键, +-- data_scope 为 NOT NULL,沿用旧授权行的取值。 +-- ------------------------------------------------------------ +DELETE FROM sys_role_menu + WHERE menu_id IN ( + SELECT menu_id FROM sys_menu + WHERE perms IN ( + 'iot:tSimPoolWarnConfig:excel:template', + 'iot:tSimPoolWarnConfig:excel:submit', + 'iot:tSimPoolWarnConfig:input:submit', + 'iot:tSimPoolWarnConfig:range:submit' + ) + ); + +INSERT INTO sys_role_menu (tenant_id, role_id, menu_id, data_scope) +SELECT old.tenant_id, old.role_id, new.menu_id, old.data_scope +FROM sys_role_menu AS old +JOIN sys_menu AS old_menu ON old_menu.menu_id = old.menu_id +JOIN sys_menu AS new + ON new.parent_id = old_menu.parent_id + AND new.perms = REPLACE(old_menu.perms, 'iot:tSimWarningConfig:', 'iot:tSimPoolWarnConfig:') +WHERE old_menu.perms IN ( + 'iot:tSimWarningConfig:excel:template', + 'iot:tSimWarningConfig:excel:submit', + 'iot:tSimWarningConfig:input:submit', + 'iot:tSimWarningConfig:range:submit' + ); + +-- ------------------------------------------------------------ +-- 3) 校验(执行后手工跑,确认与预期一致) +-- ------------------------------------------------------------ +-- SELECT perms, COUNT(*) FROM sys_menu +-- WHERE perms LIKE 'iot:tSimPoolWarnConfig:%' GROUP BY perms; +-- 期望 4 行,各 1 条 +-- +-- SELECT r.role_id, r.role_name, COUNT(*) AS new_perms +-- FROM sys_role_menu rm +-- JOIN sys_menu m ON m.menu_id = rm.menu_id +-- JOIN sys_role r ON r.role_id = rm.role_id +-- WHERE m.perms LIKE 'iot:tSimPoolWarnConfig:%' +-- GROUP BY r.role_id, r.role_name; +-- 期望:与拥有 tSimWarningConfig 权限的角色集合完全相同 diff --git a/server/storage/data/xiu_admin.sql b/server/storage/data/xiu_admin.sql index 616fa9c..4d1133e 100644 --- a/server/storage/data/xiu_admin.sql +++ b/server/storage/data/xiu_admin.sql @@ -176,21 +176,21 @@ CREATE TABLE `sys_dept` ( -- Records of sys_dept -- ---------------------------- BEGIN; -INSERT INTO `sys_dept` (`dept_id`, `tenant_id`, `parent_id`, `ancestors`, `dept_name`, `dept_category`, `order_num`, `leader`, `phone`, `email`, `status`, `created_dept`, `created_by`, `created_at`, `updated_by`, `updated_at`, `deleted_by`, `deleted_at`, `dept_type` ) VALUES (100, '000000', 0, '0,', 'XXX科技', NULL, 0, NULL, '15888888888', 'xxx@example.com', '0', 103, 1, '2025-02-13 11:56:36', NULL, '2025-02-27 22:21:39', NULL, NULL, 1); -INSERT INTO `sys_dept` (`dept_id`, `tenant_id`, `parent_id`, `ancestors`, `dept_name`, `dept_category`, `order_num`, `leader`, `phone`, `email`, `status`, `created_dept`, `created_by`, `created_at`, `updated_by`, `updated_at`, `deleted_by`, `deleted_at`) VALUES (101, '000000', 100, '0,100,', '济南总公司', NULL, 1, NULL, '15888888888', 'xxx@example.com', '0', 103, 1, '2025-02-13 11:56:36', NULL, '2025-02-27 22:21:39', NULL, NULL); -INSERT INTO `sys_dept` (`dept_id`, `tenant_id`, `parent_id`, `ancestors`, `dept_name`, `dept_category`, `order_num`, `leader`, `phone`, `email`, `status`, `created_dept`, `created_by`, `created_at`, `updated_by`, `updated_at`, `deleted_by`, `deleted_at`) VALUES (102, '000000', 100, '0,100,', '上海分公司', NULL, 2, NULL, '15888888888', 'xxx@example.com', '0', 103, 1, '2025-02-13 11:56:36', NULL, '2025-02-27 22:21:40', NULL, NULL); -INSERT INTO `sys_dept` (`dept_id`, `tenant_id`, `parent_id`, `ancestors`, `dept_name`, `dept_category`, `order_num`, `leader`, `phone`, `email`, `status`, `created_dept`, `created_by`, `created_at`, `updated_by`, `updated_at`, `deleted_by`, `deleted_at`) VALUES (103, '000000', 101, '0,100,101,', '研发部门', NULL, 1, 1, '15888888888', 'xxx@example.com', '0', 103, 1, '2025-02-13 11:56:36', NULL, '2025-02-27 22:21:40', NULL, NULL); -INSERT INTO `sys_dept` (`dept_id`, `tenant_id`, `parent_id`, `ancestors`, `dept_name`, `dept_category`, `order_num`, `leader`, `phone`, `email`, `status`, `created_dept`, `created_by`, `created_at`, `updated_by`, `updated_at`, `deleted_by`, `deleted_at`) VALUES (104, '000000', 101, '0,100,101,', '市场部门', NULL, 2, NULL, '15888888888', 'xxx@example.com', '0', 103, 1, '2025-02-13 11:56:36', NULL, '2025-02-27 22:21:40', NULL, NULL); -INSERT INTO `sys_dept` (`dept_id`, `tenant_id`, `parent_id`, `ancestors`, `dept_name`, `dept_category`, `order_num`, `leader`, `phone`, `email`, `status`, `created_dept`, `created_by`, `created_at`, `updated_by`, `updated_at`, `deleted_by`, `deleted_at`) VALUES (105, '000000', 101, '0,100,101,', '测试部门', NULL, 3, NULL, '15888888888', 'xxx@example.com', '0', 103, 1, '2025-02-13 11:56:36', NULL, '2025-02-27 22:21:40', NULL, NULL); -INSERT INTO `sys_dept` (`dept_id`, `tenant_id`, `parent_id`, `ancestors`, `dept_name`, `dept_category`, `order_num`, `leader`, `phone`, `email`, `status`, `created_dept`, `created_by`, `created_at`, `updated_by`, `updated_at`, `deleted_by`, `deleted_at`) VALUES (106, '000000', 101, '0,100,101,', '财务部门', NULL, 4, NULL, '15888888888', 'xxx@example.com', '0', 103, 1, '2025-02-13 11:56:36', NULL, '2025-02-27 22:21:40', NULL, NULL); -INSERT INTO `sys_dept` (`dept_id`, `tenant_id`, `parent_id`, `ancestors`, `dept_name`, `dept_category`, `order_num`, `leader`, `phone`, `email`, `status`, `created_dept`, `created_by`, `created_at`, `updated_by`, `updated_at`, `deleted_by`, `deleted_at`) VALUES (107, '000000', 101, '0,100,101,', '运维部门', NULL, 5, NULL, '15888888888', 'xxx@example.com', '0', 103, 1, '2025-02-13 11:56:36', NULL, '2025-02-27 22:21:40', NULL, NULL); -INSERT INTO `sys_dept` (`dept_id`, `tenant_id`, `parent_id`, `ancestors`, `dept_name`, `dept_category`, `order_num`, `leader`, `phone`, `email`, `status`, `created_dept`, `created_by`, `created_at`, `updated_by`, `updated_at`, `deleted_by`, `deleted_at`) VALUES (108, '000000', 102, '0,100,102,', '市场部门', NULL, 1, NULL, '15888888888', 'xxx@example.com', '0', 103, 1, '2025-02-13 11:56:36', NULL, '2025-02-27 22:21:40', NULL, NULL); -INSERT INTO `sys_dept` (`dept_id`, `tenant_id`, `parent_id`, `ancestors`, `dept_name`, `dept_category`, `order_num`, `leader`, `phone`, `email`, `status`, `created_dept`, `created_by`, `created_at`, `updated_by`, `updated_at`, `deleted_by`, `deleted_at`) VALUES (109, '000000', 102, '0,100,102,', '财务部门', NULL, 2, NULL, '15888888888', 'xxx@example.com', '0', 103, 1, '2025-02-13 11:56:36', NULL, '2025-02-27 22:21:41', NULL, NULL); -INSERT INTO `sys_dept` (`dept_id`, `tenant_id`, `parent_id`, `ancestors`, `dept_name`, `dept_category`, `order_num`, `leader`, `phone`, `email`, `status`, `created_dept`, `created_by`, `created_at`, `updated_by`, `updated_at`, `deleted_by`, `deleted_at`) VALUES (110, '000000', 100, '0,100,', '青岛分公司', '33', 3, 0, '15888888888', 'lxj521w@example.com', '0', 103, 1, '2025-02-27 22:25:56', 1, '2025-02-27 22:31:24', NULL, NULL); +INSERT INTO `sys_dept` (`dept_id`, `tenant_id`, `parent_id`, `ancestors`, `dept_name`, `dept_category`, `order_num`, `leader`, `phone`, `email`, `status`, `created_dept`, `created_by`, `created_at`, `updated_by`, `updated_at`, `deleted_by`, `deleted_at`, `dept_type` ) VALUES (100, '000000', 0, '0,', 'XXX科技', NULL, 0, NULL, '15800008888', 'xxx@example.com', '0', 103, 1, '2025-02-13 11:56:36', NULL, '2025-02-27 22:21:39', NULL, NULL, 1); +INSERT INTO `sys_dept` (`dept_id`, `tenant_id`, `parent_id`, `ancestors`, `dept_name`, `dept_category`, `order_num`, `leader`, `phone`, `email`, `status`, `created_dept`, `created_by`, `created_at`, `updated_by`, `updated_at`, `deleted_by`, `deleted_at`) VALUES (101, '000000', 100, '0,100,', '济南总公司', NULL, 1, NULL, '15800008888', 'xxx@example.com', '0', 103, 1, '2025-02-13 11:56:36', NULL, '2025-02-27 22:21:39', NULL, NULL); +INSERT INTO `sys_dept` (`dept_id`, `tenant_id`, `parent_id`, `ancestors`, `dept_name`, `dept_category`, `order_num`, `leader`, `phone`, `email`, `status`, `created_dept`, `created_by`, `created_at`, `updated_by`, `updated_at`, `deleted_by`, `deleted_at`) VALUES (102, '000000', 100, '0,100,', '上海分公司', NULL, 2, NULL, '15800008888', 'xxx@example.com', '0', 103, 1, '2025-02-13 11:56:36', NULL, '2025-02-27 22:21:40', NULL, NULL); +INSERT INTO `sys_dept` (`dept_id`, `tenant_id`, `parent_id`, `ancestors`, `dept_name`, `dept_category`, `order_num`, `leader`, `phone`, `email`, `status`, `created_dept`, `created_by`, `created_at`, `updated_by`, `updated_at`, `deleted_by`, `deleted_at`) VALUES (103, '000000', 101, '0,100,101,', '研发部门', NULL, 1, 1, '15800008888', 'xxx@example.com', '0', 103, 1, '2025-02-13 11:56:36', NULL, '2025-02-27 22:21:40', NULL, NULL); +INSERT INTO `sys_dept` (`dept_id`, `tenant_id`, `parent_id`, `ancestors`, `dept_name`, `dept_category`, `order_num`, `leader`, `phone`, `email`, `status`, `created_dept`, `created_by`, `created_at`, `updated_by`, `updated_at`, `deleted_by`, `deleted_at`) VALUES (104, '000000', 101, '0,100,101,', '市场部门', NULL, 2, NULL, '15800008888', 'xxx@example.com', '0', 103, 1, '2025-02-13 11:56:36', NULL, '2025-02-27 22:21:40', NULL, NULL); +INSERT INTO `sys_dept` (`dept_id`, `tenant_id`, `parent_id`, `ancestors`, `dept_name`, `dept_category`, `order_num`, `leader`, `phone`, `email`, `status`, `created_dept`, `created_by`, `created_at`, `updated_by`, `updated_at`, `deleted_by`, `deleted_at`) VALUES (105, '000000', 101, '0,100,101,', '测试部门', NULL, 3, NULL, '15800008888', 'xxx@example.com', '0', 103, 1, '2025-02-13 11:56:36', NULL, '2025-02-27 22:21:40', NULL, NULL); +INSERT INTO `sys_dept` (`dept_id`, `tenant_id`, `parent_id`, `ancestors`, `dept_name`, `dept_category`, `order_num`, `leader`, `phone`, `email`, `status`, `created_dept`, `created_by`, `created_at`, `updated_by`, `updated_at`, `deleted_by`, `deleted_at`) VALUES (106, '000000', 101, '0,100,101,', '财务部门', NULL, 4, NULL, '15800008888', 'xxx@example.com', '0', 103, 1, '2025-02-13 11:56:36', NULL, '2025-02-27 22:21:40', NULL, NULL); +INSERT INTO `sys_dept` (`dept_id`, `tenant_id`, `parent_id`, `ancestors`, `dept_name`, `dept_category`, `order_num`, `leader`, `phone`, `email`, `status`, `created_dept`, `created_by`, `created_at`, `updated_by`, `updated_at`, `deleted_by`, `deleted_at`) VALUES (107, '000000', 101, '0,100,101,', '运维部门', NULL, 5, NULL, '15800008888', 'xxx@example.com', '0', 103, 1, '2025-02-13 11:56:36', NULL, '2025-02-27 22:21:40', NULL, NULL); +INSERT INTO `sys_dept` (`dept_id`, `tenant_id`, `parent_id`, `ancestors`, `dept_name`, `dept_category`, `order_num`, `leader`, `phone`, `email`, `status`, `created_dept`, `created_by`, `created_at`, `updated_by`, `updated_at`, `deleted_by`, `deleted_at`) VALUES (108, '000000', 102, '0,100,102,', '市场部门', NULL, 1, NULL, '15800008888', 'xxx@example.com', '0', 103, 1, '2025-02-13 11:56:36', NULL, '2025-02-27 22:21:40', NULL, NULL); +INSERT INTO `sys_dept` (`dept_id`, `tenant_id`, `parent_id`, `ancestors`, `dept_name`, `dept_category`, `order_num`, `leader`, `phone`, `email`, `status`, `created_dept`, `created_by`, `created_at`, `updated_by`, `updated_at`, `deleted_by`, `deleted_at`) VALUES (109, '000000', 102, '0,100,102,', '财务部门', NULL, 2, NULL, '15800008888', 'xxx@example.com', '0', 103, 1, '2025-02-13 11:56:36', NULL, '2025-02-27 22:21:41', NULL, NULL); +INSERT INTO `sys_dept` (`dept_id`, `tenant_id`, `parent_id`, `ancestors`, `dept_name`, `dept_category`, `order_num`, `leader`, `phone`, `email`, `status`, `created_dept`, `created_by`, `created_at`, `updated_by`, `updated_at`, `deleted_by`, `deleted_at`) VALUES (110, '000000', 100, '0,100,', '青岛分公司', '33', 3, 0, '15800008888', 'xxx@example.com', '0', 103, 1, '2025-02-27 22:25:56', 1, '2025-02-27 22:31:24', NULL, NULL); INSERT INTO `sys_dept` (`dept_id`, `tenant_id`, `parent_id`, `ancestors`, `dept_name`, `dept_category`, `order_num`, `leader`, `phone`, `email`, `status`, `created_dept`, `created_by`, `created_at`, `updated_by`, `updated_at`, `deleted_by`, `deleted_at`) VALUES (111, '000000', 110, '0,100,110,', '市场部门', '', 1, 0, '', '', '0', 103, 1, '2025-02-27 22:31:49', 1, '2025-02-27 22:31:50', NULL, '2025-02-27 22:31:52'); INSERT INTO `sys_dept` (`dept_id`, `tenant_id`, `parent_id`, `ancestors`, `dept_name`, `dept_category`, `order_num`, `leader`, `phone`, `email`, `status`, `created_dept`, `created_by`, `created_at`, `updated_by`, `updated_at`, `deleted_by`, `deleted_at`) VALUES (112, '000000', 110, '0,100,110,', '打点', '', 3, 0, '', '', '0', 103, 1, '2025-02-27 22:32:43', 1, '2025-02-27 22:32:43', NULL, '2025-02-27 22:32:49'); INSERT INTO `sys_dept` (`dept_id`, `tenant_id`, `parent_id`, `ancestors`, `dept_name`, `dept_category`, `order_num`, `leader`, `phone`, `email`, `status`, `created_dept`, `created_by`, `created_at`, `updated_by`, `updated_at`, `deleted_by`, `deleted_at`) VALUES (113, '000000', 110, '0,100,110,', '市场部门', '', 1, 0, '', '', '0', 103, 1, '2025-03-02 08:59:25', 1, '2025-03-02 08:59:26', NULL, NULL); -INSERT INTO `sys_dept` (`dept_id`, `tenant_id`, `parent_id`, `ancestors`, `dept_name`, `dept_category`, `order_num`, `leader`, `phone`, `email`, `status`, `created_dept`, `created_by`, `created_at`, `updated_by`, `updated_at`, `deleted_by`, `deleted_at`, `dept_type`) VALUES (117, '100006', 0, '', '秀杰智联', NULL, 0, 10, NULL, NULL, '0', NULL, 0, '2025-03-16 14:36:52', 0, '2025-03-16 14:36:52', NULL, NULL, 1); +INSERT INTO `sys_dept` (`dept_id`, `tenant_id`, `parent_id`, `ancestors`, `dept_name`, `dept_category`, `order_num`, `leader`, `phone`, `email`, `status`, `created_dept`, `created_by`, `created_at`, `updated_by`, `updated_at`, `deleted_by`, `deleted_at`, `dept_type`) VALUES (117, '100006', 0, '', '示例企业', NULL, 0, 10, NULL, NULL, '0', NULL, 0, '2025-03-16 14:36:52', 0, '2025-03-16 14:36:52', NULL, NULL, 1); COMMIT; -- ---------------------------- @@ -1007,18 +1007,18 @@ CREATE TABLE `sys_tenant` ( -- ---------------------------- BEGIN; INSERT INTO `sys_tenant` ( - `id`, `tenant_id`, `contact_user_name`, `contact_phone`, `company_name`, - `license_number`, `address`, `intro`, `domain`, `remark`, - `package_id`, `admin_role_id`, `admin_dept_id`, `admin_user_id`, `expire_time`, - `account_count`, `status`, `created_dept`, `created_by`, `created_at`, + `id`, `tenant_id`, `contact_user_name`, `contact_phone`, `company_name`, + `license_number`, `address`, `intro`, `domain`, `remark`, + `package_id`, `admin_role_id`, `admin_dept_id`, `admin_user_id`, `expire_time`, + `account_count`, `status`, `created_dept`, `created_by`, `created_at`, `updated_by`, `updated_at`, `deleted_by`, `deleted_at`) VALUES ( - 1, '000000', '管理组', '15888888888', 'XXX有限公司', - NULL, NULL, '多租户通用后台管理管理系统', NULL, '备注', - NULL, 1, 103, 1, NULL, - -1, '0', 103, 1, '2025-02-13 11:56:36', + 1, '000000', '管理组', '15800008888', 'XXX有限公司', + NULL, NULL, '多租户通用后台管理管理系统', NULL, '备注', + NULL, 1, 103, 1, NULL, + -1, '0', 103, 1, '2025-02-13 11:56:36', NULL, NULL, NULL, NULL); -INSERT INTO `sys_tenant` (`id`, `tenant_id`, `contact_user_name`, `contact_phone`, `company_name`, `license_number`, `address`, `intro`, `domain`, `remark`, `package_id`, `admin_role_id`, `admin_dept_id`, `admin_user_id`, `expire_time`, `account_count`, `status`, `created_dept`, `created_by`, `created_at`, `updated_by`, `updated_at`, `deleted_by`, `deleted_at`) VALUES (6, '100006', '李先生', '18600000000', '秀杰智联', '3332233', '地址', '企业介绍', 'xiujiezhilian.com', '备注', 9, 117, 10, 1, '2026-03-16 00:00:00', -1, '0', 103, 1, '2025-03-16 14:36:52', 1, '2025-03-16 14:37:11', NULL, NULL); +INSERT INTO `sys_tenant` (`id`, `tenant_id`, `contact_user_name`, `contact_phone`, `company_name`, `license_number`, `address`, `intro`, `domain`, `remark`, `package_id`, `admin_role_id`, `admin_dept_id`, `admin_user_id`, `expire_time`, `account_count`, `status`, `created_dept`, `created_by`, `created_at`, `updated_by`, `updated_at`, `deleted_by`, `deleted_at`) VALUES (6, '100006', '李先生', '18600000000', '示例企业', '3332233', '地址', '企业介绍', 'xiujiezhilian.com', '备注', 9, 117, 10, 1, '2026-03-16 00:00:00', -1, '0', 103, 1, '2025-03-16 14:36:52', 1, '2025-03-16 14:37:11', NULL, NULL); COMMIT; -- ---------------------------- @@ -1085,13 +1085,13 @@ CREATE TABLE `sys_user` ( -- Records of sys_user -- ---------------------------- BEGIN; -INSERT INTO `sys_user` (`user_id`, `tenant_id`, `dept_id`, `user_name`, `nick_name`, `user_type`, `email`, `phonenumber`, `sex`, `avatar`, `salt`, `password`, `status`, `login_ip`, `login_date`, `created_dept`, `created_by`, `created_at`, `updated_by`, `updated_at`, `deleted_by`, `deleted_at`, `remark`) VALUES (1, '000000', 103, 'admin', '好心情', 'sys_user', 'lxj521w@example.com', '15888888887', '0', 'https://example.com/avatar.png', '123456', '5f1d7a84db00d2fce00b31a7fc73224f', '0', '127.0.0.1', '2025-02-13 11:56:36', 103, 1, '2025-02-13 11:56:36', 1, '2025-03-26 13:44:18', NULL, NULL, '管理员'); +INSERT INTO `sys_user` (`user_id`, `tenant_id`, `dept_id`, `user_name`, `nick_name`, `user_type`, `email`, `phonenumber`, `sex`, `avatar`, `salt`, `password`, `status`, `login_ip`, `login_date`, `created_dept`, `created_by`, `created_at`, `updated_by`, `updated_at`, `deleted_by`, `deleted_at`, `remark`) VALUES (1, '000000', 103, 'admin', '好心情', 'sys_user', 'xxx@example.com', '15800008887', '0', 'https://iot.example.com/wp-content/uploads/2024/09/96x96.png', '123456', '5f1d7a84db00d2fce00b31a7fc73224f', '0', '127.0.0.1', '2025-02-13 11:56:36', 103, 1, '2025-02-13 11:56:36', 1, '2025-03-26 13:44:18', NULL, NULL, '管理员'); INSERT INTO `sys_user` (`user_id`, `tenant_id`, `dept_id`, `user_name`, `nick_name`, `user_type`, `email`, `phonenumber`, `sex`, `avatar`, `salt`, `password`, `status`, `login_ip`, `login_date`, `created_dept`, `created_by`, `created_at`, `updated_by`, `updated_at`, `deleted_by`, `deleted_at`, `remark`) VALUES (3, '000000', 108, 'test', '本部门及以下 密码666666', 'sys_user', '', '', '0', NULL, 'ddd', '$2a$10$b8yUzN0C71sbz.PhNOCgJe.Tu1yWC3RNrTyjSQ8p1W0.aaUXUJ.Ne', '0', '127.0.0.1', '2025-02-13 11:56:36', 103, 1, '2025-02-13 11:56:36', 3, '2025-02-13 11:56:36', NULL, NULL, NULL); INSERT INTO `sys_user` (`user_id`, `tenant_id`, `dept_id`, `user_name`, `nick_name`, `user_type`, `email`, `phonenumber`, `sex`, `avatar`, `salt`, `password`, `status`, `login_ip`, `login_date`, `created_dept`, `created_by`, `created_at`, `updated_by`, `updated_at`, `deleted_by`, `deleted_at`, `remark`) VALUES (4, '000000', 102, 'test1', '仅本人 密码666666', 'sys_user', '', '', '0', NULL, 'ddd', '$2a$10$b8yUzN0C71sbz.PhNOCgJe.Tu1yWC3RNrTyjSQ8p1W0.aaUXUJ.Ne', '0', '127.0.0.1', '2025-02-13 11:56:36', 103, 1, '2025-02-13 11:56:36', 4, '2025-02-13 11:56:36', NULL, NULL, NULL); -INSERT INTO `sys_user` (`user_id`, `tenant_id`, `dept_id`, `user_name`, `nick_name`, `user_type`, `email`, `phonenumber`, `sex`, `avatar`, `salt`, `password`, `status`, `login_ip`, `login_date`, `created_dept`, `created_by`, `created_at`, `updated_by`, `updated_at`, `deleted_by`, `deleted_at`, `remark`) VALUES (5, '000000', 103, 'test2', '好心情', '', 'li@example.com', '15888888887', '0', '', 'mjhhs', 'de396f3b10c2020d35161d8f45d0db52', '0', '', NULL, 103, 1, '2025-02-25 21:39:16', 1, '2025-02-25 22:09:49', 1, '2025-02-25 22:09:49', ''); -INSERT INTO `sys_user` (`user_id`, `tenant_id`, `dept_id`, `user_name`, `nick_name`, `user_type`, `email`, `phonenumber`, `sex`, `avatar`, `salt`, `password`, `status`, `login_ip`, `login_date`, `created_dept`, `created_by`, `created_at`, `updated_by`, `updated_at`, `deleted_by`, `deleted_at`, `remark`) VALUES (6, '000000', 100, 'test3', '好心情', '', 'li@example.com', '15888888889', '0', '', '5nyxW', '8f2dd9b2f537dc0f3a4adf7238ab9939', '0', '', NULL, 103, 1, '2025-02-25 22:43:18', 1, '2025-02-25 23:27:08', 1, '2025-02-25 23:27:08', ''); +INSERT INTO `sys_user` (`user_id`, `tenant_id`, `dept_id`, `user_name`, `nick_name`, `user_type`, `email`, `phonenumber`, `sex`, `avatar`, `salt`, `password`, `status`, `login_ip`, `login_date`, `created_dept`, `created_by`, `created_at`, `updated_by`, `updated_at`, `deleted_by`, `deleted_at`, `remark`) VALUES (5, '000000', 103, 'test2', '好心情', '', 'xxx@example.com', '15800008887', '0', '', 'mjhhs', 'de396f3b10c2020d35161d8f45d0db52', '0', '', NULL, 103, 1, '2025-02-25 21:39:16', 1, '2025-02-25 22:09:49', 1, '2025-02-25 22:09:49', ''); +INSERT INTO `sys_user` (`user_id`, `tenant_id`, `dept_id`, `user_name`, `nick_name`, `user_type`, `email`, `phonenumber`, `sex`, `avatar`, `salt`, `password`, `status`, `login_ip`, `login_date`, `created_dept`, `created_by`, `created_at`, `updated_by`, `updated_at`, `deleted_by`, `deleted_at`, `remark`) VALUES (6, '000000', 100, 'test3', '好心情', '', 'xxx@example.com', '15800008889', '0', '', '5nyxW', '8f2dd9b2f537dc0f3a4adf7238ab9939', '0', '', NULL, 103, 1, '2025-02-25 22:43:18', 1, '2025-02-25 23:27:08', 1, '2025-02-25 23:27:08', ''); INSERT INTO `sys_user` (`user_id`, `tenant_id`, `dept_id`, `user_name`, `nick_name`, `user_type`, `email`, `phonenumber`, `sex`, `avatar`, `salt`, `password`, `status`, `login_ip`, `login_date`, `created_dept`, `created_by`, `created_at`, `updated_by`, `updated_at`, `deleted_by`, `deleted_at`, `remark`) VALUES (10, '100006', 117, 'xiujiezhilian', '李先生', 'sys_user', '', '18600000000', '2', NULL, '5nyxW', '68c6e105523f29f1a97607f6739cd245', '0', '', NULL, 0, 0, '2025-03-16 14:36:52', 0, '2025-03-16 14:36:52', NULL, NULL, NULL); -INSERT INTO `sys_user` (`user_id`, `tenant_id`, `dept_id`, `user_name`, `nick_name`, `user_type`, `email`, `phonenumber`, `sex`, `avatar`, `salt`, `password`, `status`, `login_ip`, `login_date`, `created_dept`, `created_by`, `created_at`, `updated_by`, `updated_at`, `deleted_by`, `deleted_at`, `remark`) VALUES (11, '000000', 101, 'test001', '济南测试1', '', '563242931@example.com', '', '0', '', 'mLwlN', '70824cefa9bc3da395ea16a48118e477', '0', '', NULL, 103, 1, '2025-03-17 13:52:46', 1, '2025-03-17 13:55:24', NULL, NULL, ''); +INSERT INTO `sys_user` (`user_id`, `tenant_id`, `dept_id`, `user_name`, `nick_name`, `user_type`, `email`, `phonenumber`, `sex`, `avatar`, `salt`, `password`, `status`, `login_ip`, `login_date`, `created_dept`, `created_by`, `created_at`, `updated_by`, `updated_at`, `deleted_by`, `deleted_at`, `remark`) VALUES (11, '000000', 101, 'test001', '济南测试1', '', 'xxx@example.com', '', '0', '', 'mLwlN', '70824cefa9bc3da395ea16a48118e477', '0', '', NULL, 103, 1, '2025-03-17 13:52:46', 1, '2025-03-17 13:55:24', NULL, NULL, ''); COMMIT; -- ---------------------------- @@ -1181,7 +1181,7 @@ INSERT INTO `sys_user_online` (`online_id`, `tenant_id`, `uuid`, `user_name`, `c INSERT INTO `sys_user_online` (`online_id`, `tenant_id`, `uuid`, `user_name`, `client_key`, `device_type`, `ipaddr`, `login_location`, `browser`, `os`, `token`, `login_time`, `expire_time`, `deleted_at`) VALUES (59, '000000', 'a8ae3c2d058b4da5a8ef4cca0ab559c2', 'admin', 'web', 'web', '127.0.0.1', '内网IP', 'Chrome', 'Windows', 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJVVUlEIjoiYThhZTNjMmQwNThiNGRhNWE4ZWY0Y2NhMGFiNTU5YzIiLCJJRCI6MSwiVXNlcm5hbWUiOiJhZG1pbiIsIk5pY2tOYW1lIjoi5aW95b-D5oOFIiwiRGVwdElkIjoxMDMsIlRlbmFudElkIjoiMDAwMDAwIiwiUm9sZXMiOlt7IlJvbGVJZCI6MSwiRGF0YVNjb3BlIjoiMSJ9XSwiTG9naW5BdCI6MTc0MzA1ODc0NSwiQnVmZmVyVGltZSI6ODY0MDAsImlzcyI6InhpdWppZSIsImF1ZCI6WyJYaXVqaWVBZG1pbiJdLCJleHAiOjE3NDM2NjM1NDUsIm5iZiI6MTc0MzA1ODc0NX0.cNboyNDe_vcFU9KF1aeuucuwB8fFgTYVzvZi2FCQEx4', '2025-03-27 14:59:06', '2025-04-03 14:59:05', NULL); INSERT INTO `sys_user_online` (`online_id`, `tenant_id`, `uuid`, `user_name`, `client_key`, `device_type`, `ipaddr`, `login_location`, `browser`, `os`, `token`, `login_time`, `expire_time`, `deleted_at`) VALUES (60, '000000', '0e875fd0eaea45299e23b1ad95657cb1', 'admin', 'web', 'web', '::1', '山东省 济南市', 'Chrome', 'macOS', 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJVVUlEIjoiMGU4NzVmZDBlYWVhNDUyOTllMjNiMWFkOTU2NTdjYjEiLCJJRCI6MSwiVXNlcm5hbWUiOiJhZG1pbiIsIk5pY2tOYW1lIjoi5aW95b-D5oOFIiwiRGVwdElkIjoxMDMsIlRlbmFudElkIjoiMDAwMDAwIiwiUm9sZXMiOlt7IlJvbGVJZCI6MSwiRGF0YVNjb3BlIjoiMSJ9XSwiTG9naW5BdCI6MTc0MzA2ODM0MywiQnVmZmVyVGltZSI6ODY0MDAsImlzcyI6InhpdWppZSIsImF1ZCI6WyJYaXVqaWVBZG1pbiJdLCJleHAiOjE3NDM2NzMxNDMsIm5iZiI6MTc0MzA2ODM0M30.PrTAkOp6YvWMhnXgKAo5hHFNB9U8SVU8q7Wck7q6QDM', '2025-03-27 17:39:04', '2025-04-03 17:39:03', NULL); INSERT INTO `sys_user_online` (`online_id`, `tenant_id`, `uuid`, `user_name`, `client_key`, `device_type`, `ipaddr`, `login_location`, `browser`, `os`, `token`, `login_time`, `expire_time`, `deleted_at`) VALUES (61, '000000', 'sss', 'test001', '1111', '', '', '', '', '', '', NULL, NULL, '2025-03-27 17:41:50'); -INSERT INTO `sys_user_online` (`online_id`, `tenant_id`, `uuid`, `user_name`, `client_key`, `device_type`, `ipaddr`, `login_location`, `browser`, `os`, `token`, `login_time`, `expire_time`, `deleted_at`) VALUES (62, '000000', 'aa@example.com', '1111', '', '', '', '', '', '', '', NULL, NULL, '2025-03-27 17:41:52'); +INSERT INTO `sys_user_online` (`online_id`, `tenant_id`, `uuid`, `user_name`, `client_key`, `device_type`, `ipaddr`, `login_location`, `browser`, `os`, `token`, `login_time`, `expire_time`, `deleted_at`) VALUES (62, '000000', 'xxx@example.com', '1111', '', '', '', '', '', '', '', NULL, NULL, '2025-03-27 17:41:52'); COMMIT; -- ---------------------------- diff --git a/server/utility/convert/convert.go b/server/utility/convert/convert.go index 8b4be61..42a125c 100644 --- a/server/utility/convert/convert.go +++ b/server/utility/convert/convert.go @@ -1,7 +1,7 @@ // package convert // @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 convert diff --git a/server/utility/excel/excel.go b/server/utility/excel/excel.go index dbce1a4..40b75f4 100644 --- a/server/utility/excel/excel.go +++ b/server/utility/excel/excel.go @@ -1,7 +1,7 @@ // package excel // @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 excel diff --git a/server/utility/external_request_test.go b/server/utility/external_request_test.go index a859783..9f105ae 100644 --- a/server/utility/external_request_test.go +++ b/server/utility/external_request_test.go @@ -83,8 +83,8 @@ func TestSMTPSuccessAndRejectedRecipient(t *testing.T) { } }) err := SendSMTP(context.Background(), SMTPOptions{ - Address: address, Host: "localhost", From: "sender@example.com", - To: []string{"recipient@example.com"}, Data: []byte("Subject: test\r\n\r\nmessage\r\n"), Timeout: time.Second, + Address: address, Host: "localhost", From: "xxx@example.com", + To: []string{"xxx@example.com"}, Data: []byte("Subject: test\r\n\r\nmessage\r\n"), Timeout: time.Second, }) if (err != nil) != reject { t.Fatalf("unexpected SMTP result: rejected=%t error=%v", reject, err) @@ -120,8 +120,8 @@ func TestSMTPTimeoutInterruptsGreetingTLSAndProtocol(t *testing.T) { }) started := time.Now() err := SendSMTP(context.Background(), SMTPOptions{ - Address: address, Host: "localhost", SSL: scenario == "tls", From: "sender@example.com", - To: []string{"recipient@example.com"}, Data: []byte("message"), Timeout: 70 * time.Millisecond, + Address: address, Host: "localhost", SSL: scenario == "tls", From: "xxx@example.com", + To: []string{"xxx@example.com"}, Data: []byte("message"), Timeout: 70 * time.Millisecond, }) if err == nil || time.Since(started) > time.Second { t.Fatalf("SMTP stall was not interrupted: %v", err) diff --git a/server/utility/gfredis/gfredis.go b/server/utility/gfredis/gfredis.go index 0b00771..ebab2b2 100644 --- a/server/utility/gfredis/gfredis.go +++ b/server/utility/gfredis/gfredis.go @@ -31,7 +31,7 @@ type Redis struct { // Config is redis configuration. type Config struct { - // Address It supports single and cluster redis server. Multiple addresses joined with char ','. Eg: 192.0.2.1:6379, 192.0.2.2:6379. + // Address It supports single and cluster redis server. Multiple addresses joined with char ','. Eg: 127.0.0.1:6379, 127.0.0.1:6379. Address string `json:"address"` Db int `json:"db"` // Redis db. User string `json:"user"` // Username for AUTH. diff --git a/server/utility/ip_lookup_test.go b/server/utility/ip_lookup_test.go index f33ee73..a448554 100644 --- a/server/utility/ip_lookup_test.go +++ b/server/utility/ip_lookup_test.go @@ -156,7 +156,7 @@ func TestAsyncIPLookupLimitsConcurrentQueries(t *testing.T) { } func TestGetCityByIPSkipsPrivateAndInvalidAddresses(t *testing.T) { - for _, ip := range []string{"127.0.0.1", "[::1]", "::1", "10.1.2.3", "172.23.0.6", "192.168.100.29", "fe80::1", "0.0.0.0"} { + for _, ip := range []string{"127.0.0.1", "[::1]", "::1", "127.0.0.1", "127.0.0.1", "127.0.0.1", "fe80::1", "0.0.0.0"} { if got := GetCityByIp(ip); got != "内网IP" { t.Errorf("%s: got %q", ip, got) } diff --git a/server/utility/queue/queue_test.go b/server/utility/queue/queue_test.go new file mode 100644 index 0000000..8a541df --- /dev/null +++ b/server/utility/queue/queue_test.go @@ -0,0 +1,100 @@ +package queue + +import ( + "context" + "encoding/json" + "fmt" + "testing" + "time" + + _ "github.com/gogf/gf/contrib/nosql/redis/v2" + "github.com/gogf/gf/v2/os/gctx" +) + +type testMemoryConsumer struct { +} + +func (c *testMemoryConsumer) GetTopic() string { + return "test" +} + +func (c *testMemoryConsumer) GetType() QueueType { + return QueueTypeMemory +} + +func (c *testMemoryConsumer) Handle(ctx context.Context, p Payload) error { + fmt.Println("testMemoryConsumer", p) + return nil +} + +type testRedisConsumer struct { + QueueType QueueType +} + +func (c *testRedisConsumer) GetTopic() string { + return "test" +} + +func (c *testRedisConsumer) GetType() QueueType { + return c.QueueType +} + +func (c *testRedisConsumer) Handle(ctx context.Context, p Payload) error { + fmt.Printf("testRedisConsumer %+v \n", p) + var data string + json.Unmarshal(p.Data, &data) + fmt.Printf("testRedisConsumer %+v \n", data) + + time.Sleep(1 * time.Second) + return nil +} + +func TestQueue(t *testing.T) { + ctx := context.Background() + NewProducer(ctx, QueueTypeMemory, "test", nil) + NewConsumer(ctx, &testMemoryConsumer{}, nil) + Push(ctx, "test", "content1") + Push(ctx, "test", "content2") + Push(ctx, "test", "content3") + time.Sleep(1 * time.Second) +} + +func TestRedisQueue(t *testing.T) { + ctx := gctx.New() + + NewProducer(ctx, QueueTypeRedis, "test", &Config{ + RedisName: "default", + }) + NewConsumer(ctx, &testRedisConsumer{QueueType: QueueTypeRedis}, &Config{ + RedisName: "default", + }) + time.Sleep(2 * time.Second) + Push(ctx, "test", "content1") + Push(ctx, "test", "content2") + Push(ctx, "test", "content3") + time.Sleep(5 * time.Second) +} + +func TestRedisBroadcast(t *testing.T) { + ctx := gctx.New() + NewProducer(ctx, QueueTypeRedisBroadcast, "test", &Config{ + RedisName: "default", + }) + consumerHandle := &testRedisConsumer{QueueType: QueueTypeRedisBroadcast} + NewConsumer(ctx, consumerHandle, &Config{ + RedisName: "default", + ClientId: "group1", + }) + NewConsumer(ctx, consumerHandle, &Config{ + RedisName: "default", + ClientId: "group2", + }) + Push(ctx, "test", "content1") + Push(ctx, "test", "content2") + Push(ctx, "test", "content3") + time.Sleep(50 * time.Second) + UnregisterConsumer(ctx, "test", "group1") + UnregisterConsumer(ctx, "test", "group2") + UnregisterProducer(ctx, "test") + time.Sleep(5 * time.Second) +} diff --git a/server/utility/tree/tree.go b/server/utility/tree/tree.go index 9a0a27d..33e615e 100644 --- a/server/utility/tree/tree.go +++ b/server/utility/tree/tree.go @@ -1,7 +1,7 @@ // package tree // @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 tree diff --git a/server/utility/utility.go b/server/utility/utility.go index d64f952..da691d2 100644 --- a/server/utility/utility.go +++ b/server/utility/utility.go @@ -1,7 +1,7 @@ // package utility // @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 utility diff --git a/server/utility/version/version.go b/server/utility/version/version.go index e718bcf..c048c6b 100644 --- a/server/utility/version/version.go +++ b/server/utility/version/version.go @@ -1,7 +1,7 @@ // package version // @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 version @@ -24,20 +24,14 @@ func ShowLogo(buildVersion, buildTime, commitID string) { CommitID = commitID //版本号 - fmt.Println(` - ██╗██╗███████╗ ██╗ ██████╗ ████████╗ - ██║██║██╔════╝ ██║██╔═══██╗╚══██╔══╝ - ██║██║█████╗ ██║██║ ██║ ██║ -██ ██║██║██╔══╝ ██║██║ ██║ ██║ -╚█████╔╝██║███████╗ ██║╚██████╔╝ ██║ - ╚════╝ ╚═╝╚══════╝ ╚═╝ ╚═════╝ ╚═╝`) + fmt.Println(" __ __ _ _ _ _ \n \\ \\/ /(_) _ _ / \\ __| | _ __ ___ (_) _ __ \n \\ / | || | | | / _ \\ / _` || '_ ` _ \\ | || '_ \\ \n / \\ | || |_| | / ___ \\| (_| || | | | | || || | | |\n /_/\\_\\|_| \\__,_| /_/ \\_\\\\__,_||_| |_| |_||_||_| |_|") fmt.Println("") fmt.Println("Version :", buildVersion) fmt.Println("BuildTime :", buildTime) fmt.Println("CommitID :", commitID) fmt.Println("") - fmt.Println("Copyright:", "2026 The iotLCM Authors") + fmt.Println("Copyright:", "XiuJieZhiLian Technology Co.,Ltd") } func AppName(ctx context.Context) string {