sync: 同步 v1.0.4 并脱敏配置与镜像

This commit is contained in:
MiFaZhan
2026-10-29 14:36:49 +08:00
parent 743e8e6e6e
commit 2206a393f7
516 changed files with 18119 additions and 2253 deletions
+1 -1
View File
@@ -1,7 +1,7 @@
// Package cmd
// @Link https://github.com/xiujiecn/xiu-admin
// @Copyright Copyright (c) 2025 LiXiujie
// @Author Lxj <li@xiujie.cn>
// @Author Lxj <xxx@example.com>
// @License https://github.com/xiujiecn/xiu-admin/blob/master/LICENSE
package cmd
+1 -1
View File
@@ -1,7 +1,7 @@
// Package cmd
// @Link https://github.com/xiujiecn/xiu-admin
// @Copyright Copyright (c) 2025 LiXiujie
// @Author Lxj <li@xiujie.cn>
// @Author Lxj <xxx@example.com>
// @License https://github.com/xiujiecn/xiu-admin/blob/master/LICENSE
package cmd
+9
View File
@@ -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",
+1 -1
View File
@@ -1,7 +1,7 @@
// Package cmd
// @Link https://github.com/xiujiecn/xiu-admin
// @Copyright Copyright (c) 2025 LiXiujie
// @Author Lxj <li@xiujie.cn>
// @Author Lxj <xxx@example.com>
// @License https://github.com/xiujiecn/xiu-admin/blob/master/LICENSE
package cmd
+1 -1
View File
@@ -1,7 +1,7 @@
// Package cmd
// @Link https://github.com/xiujiecn/xiu-admin
// @Copyright Copyright (c) 2025 LiXiujie
// @Author Lxj <li@xiujie.cn>
// @Author Lxj <xxx@example.com>
// @License https://github.com/xiujiecn/xiu-admin/blob/master/LICENSE
package cmd
+10 -4
View File
@@ -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"
+1 -1
View File
@@ -1,7 +1,7 @@
// Package consts
// @Link https://github.com/xiujiecn/xiu-admin
// @Copyright Copyright (c) 2025 LiXiujie
// @Author Lxj <li@xiujie.cn>
// @Author Lxj <xxx@example.com>
// @License https://github.com/xiujiecn/xiu-admin/blob/master/LICENSE
package consts
+1 -1
View File
@@ -1,7 +1,7 @@
// Package consts
// @Link https://github.com/xiujiecn/xiu-admin
// @Copyright Copyright (c) 2025 LiXiujie
// @Author Lxj <li@xiujie.cn>
// @Author Lxj <xxx@example.com>
// @License https://github.com/xiujiecn/xiu-admin/blob/master/LICENSE
package consts
+1 -1
View File
@@ -1,7 +1,7 @@
// Package consts
// @Link https://github.com/xiujiecn/xiu-admin
// @Copyright Copyright (c) 2025 LiXiujie
// @Author Lxj <li@xiujie.cn>
// @Author Lxj <xxx@example.com>
// @License https://github.com/xiujiecn/xiu-admin/blob/master/LICENSE
package consts
+1 -1
View File
@@ -1,7 +1,7 @@
// Package consts
// @Link https://github.com/xiujiecn/xiu-admin
// @Copyright Copyright (c) 2025 LiXiujie
// @Author Lxj <li@xiujie.cn>
// @Author Lxj <xxx@example.com>
// @License https://github.com/xiujiecn/xiu-admin/blob/master/LICENSE
package consts
+1 -1
View File
@@ -1,7 +1,7 @@
// Package consts
// @Link https://github.com/xiujiecn/xiu-admin
// @Copyright Copyright (c) 2025 LiXiujie
// @Author Lxj <li@xiujie.cn>
// @Author Lxj <xxx@example.com>
// @License https://github.com/xiujiecn/xiu-admin/blob/master/LICENSE
package consts
+1 -1
View File
@@ -1,7 +1,7 @@
// Package consts
// @Link https://github.com/xiujiecn/xiu-admin
// @Copyright Copyright (c) 2025 LiXiujie
// @Author Lxj <li@xiujie.cn>
// @Author Lxj <xxx@example.com>
// @License https://github.com/xiujiecn/xiu-admin/blob/master/LICENSE
package consts
+7 -4
View File
@@ -1,7 +1,7 @@
// Package consts
// @Link https://github.com/xiujiecn/xiu-admin
// @Copyright Copyright (c) 2025 LiXiujie
// @Author Lxj <li@xiujie.cn>
// @Author Lxj <xxx@example.com>
// @License https://github.com/xiujiecn/xiu-admin/blob/master/LICENSE
package 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"
)
// 数据库变动类型
+1 -1
View File
@@ -1,7 +1,7 @@
// Package consts
// @Link https://github.com/xiujiecn/xiu-admin
// @Copyright Copyright (c) 2025 LiXiujie
// @Author Lxj <li@xiujie.cn>
// @Author Lxj <xxx@example.com>
// @License https://github.com/xiujiecn/xiu-admin/blob/master/LICENSE
package consts
+1 -1
View File
@@ -1,7 +1,7 @@
// Package consts
// @Link https://github.com/xiujiecn/xiu-admin
// @Copyright Copyright (c) 2025 LiXiujie
// @Author Lxj <li@xiujie.cn>
// @Author Lxj <xxx@example.com>
// @License https://github.com/xiujiecn/xiu-admin/blob/master/LICENSE
package consts
@@ -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) {
@@ -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 {
@@ -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
}
@@ -1,7 +1,7 @@
// Package gen
// @Link https://github.com/xiujiecn/xiu-admin
// @Copyright Copyright (c) 2026 LiXiujie
// @Author Lxj <li@xiujie.cn>
// @Author Lxj <xxx@example.com>
// @License https://github.com/xiujiecn/xiu-admin/blob/master/LICENSE
// @AutoGenerate Version
package common
@@ -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)
@@ -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
}
@@ -1,7 +1,7 @@
// Package gen
// @Link https://github.com/xiujiecn/xiu-admin
// @Copyright Copyright (c) 2026 LiXiujie
// @Author Lxj <li@xiujie.cn>
// @Author Lxj <xxx@example.com>
// @License https://github.com/xiujiecn/xiu-admin/blob/master/LICENSE
// @AutoGenerate Version
package common
@@ -1,7 +1,7 @@
// Package gen
// @Link https://github.com/xiujiecn/xiu-admin
// @Copyright Copyright (c) 2026 LiXiujie
// @Author Lxj <li@xiujie.cn>
// @Author Lxj <xxx@example.com>
// @License https://github.com/xiujiecn/xiu-admin/blob/master/LICENSE
// @AutoGenerate Version
package common
@@ -1,7 +1,7 @@
// Package gen
// @Link https://github.com/xiujiecn/xiu-admin
// @Copyright Copyright (c) 2026 LiXiujie
// @Author Lxj <li@xiujie.cn>
// @Author Lxj <xxx@example.com>
// @License https://github.com/xiujiecn/xiu-admin/blob/master/LICENSE
// @AutoGenerate Version
package common
@@ -1,7 +1,7 @@
// Package gen
// @Link https://github.com/xiujiecn/xiu-admin
// @Copyright Copyright (c) 2026 LiXiujie
// @Author Lxj <li@xiujie.cn>
// @Author Lxj <xxx@example.com>
// @License https://github.com/xiujiecn/xiu-admin/blob/master/LICENSE
// @AutoGenerate Version
package common
@@ -1,7 +1,7 @@
// Package gen
// @Link https://github.com/xiujiecn/xiu-admin
// @Copyright Copyright (c) 2026 LiXiujie
// @Author Lxj <li@xiujie.cn>
// @Author Lxj <xxx@example.com>
// @License https://github.com/xiujiecn/xiu-admin/blob/master/LICENSE
// @AutoGenerate Version
package common
@@ -1,7 +1,7 @@
// Package gen
// @Link https://github.com/xiujiecn/xiu-admin
// @Copyright Copyright (c) 2026 LiXiujie
// @Author Lxj <li@xiujie.cn>
// @Author Lxj <xxx@example.com>
// @License https://github.com/xiujiecn/xiu-admin/blob/master/LICENSE
// @AutoGenerate Version
package common
@@ -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
}
@@ -1,7 +1,7 @@
// Package gen
// @Link https://github.com/xiujiecn/xiu-admin
// @Copyright Copyright (c) 2026 LiXiujie
// @Author Lxj <li@xiujie.cn>
// @Author Lxj <xxx@example.com>
// @License https://github.com/xiujiecn/xiu-admin/blob/master/LICENSE
// @AutoGenerate Version
package common
@@ -1,7 +1,7 @@
// Package iot
// @Link https://github.com/xiujiecn/xiu-admin
// @Copyright Copyright (c) 2026 LiXiujie
// @Author Lxj <li@xiujie.cn>
// @Author Lxj <xxx@example.com>
// @License https://github.com/xiujiecn/xiu-admin/blob/master/LICENSE
// @AutoGenerate Version
package common
+1 -1
View File
@@ -1,7 +1,7 @@
// Package gen
// @Link https://github.com/xiujiecn/xiu-admin
// @Copyright Copyright (c) 2025 LiXiujie
// @Author Lxj <li@xiujie.cn>
// @Author Lxj <xxx@example.com>
// @License https://github.com/xiujiecn/xiu-admin/blob/master/LICENSE
// @AutoGenerate Version
package gen
@@ -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")
}
}
@@ -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))
}
}
@@ -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
@@ -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
}
+1 -1
View File
@@ -1,7 +1,7 @@
// Package gen
// @Link https://github.com/xiujiecn/xiu-admin
// @Copyright Copyright (c) 2026 LiXiujie
// @Author Lxj <li@xiujie.cn>
// @Author Lxj <xxx@example.com>
// @License https://github.com/xiujiecn/xiu-admin/blob/master/LICENSE
// @AutoGenerate Version
package iot
@@ -1,7 +1,7 @@
// Package gen
// @Link https://github.com/xiujiecn/xiu-admin
// @Copyright Copyright (c) 2026 LiXiujie
// @Author Lxj <li@xiujie.cn>
// @Author Lxj <xxx@example.com>
// @License https://github.com/xiujiecn/xiu-admin/blob/master/LICENSE
// @AutoGenerate Version
package iot
@@ -1,7 +1,7 @@
// Package gen
// @Link https://github.com/xiujiecn/xiu-admin
// @Copyright Copyright (c) 2026 LiXiujie
// @Author Lxj <li@xiujie.cn>
// @Author Lxj <xxx@example.com>
// @License https://github.com/xiujiecn/xiu-admin/blob/master/LICENSE
// @AutoGenerate Version
package iot
@@ -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)
@@ -1,7 +1,7 @@
// Package gen
// @Link https://github.com/xiujiecn/xiu-admin
// @Copyright Copyright (c) 2026 LiXiujie
// @Author Lxj <li@xiujie.cn>
// @Author Lxj <xxx@example.com>
// @License https://github.com/xiujiecn/xiu-admin/blob/master/LICENSE
// @AutoGenerate Version
package iot
@@ -1,7 +1,7 @@
// Package gen
// @Link https://github.com/xiujiecn/xiu-admin
// @Copyright Copyright (c) 2026 LiXiujie
// @Author Lxj <li@xiujie.cn>
// @Author Lxj <xxx@example.com>
// @License https://github.com/xiujiecn/xiu-admin/blob/master/LICENSE
// @AutoGenerate Version
package iot
@@ -1,7 +1,7 @@
// Package gen
// @Link https://github.com/xiujiecn/xiu-admin
// @Copyright Copyright (c) 2026 LiXiujie
// @Author Lxj <li@xiujie.cn>
// @Author Lxj <xxx@example.com>
// @License https://github.com/xiujiecn/xiu-admin/blob/master/LICENSE
// @AutoGenerate Version
package iot
@@ -1,7 +1,7 @@
// Package gen
// @Link https://github.com/xiujiecn/xiu-admin
// @Copyright Copyright (c) 2026 LiXiujie
// @Author Lxj <li@xiujie.cn>
// @Author Lxj <xxx@example.com>
// @License https://github.com/xiujiecn/xiu-admin/blob/master/LICENSE
// @AutoGenerate Version
package iot
@@ -1,7 +1,7 @@
// Package gen
// @Link https://github.com/xiujiecn/xiu-admin
// @Copyright Copyright (c) 2026 LiXiujie
// @Author Lxj <li@xiujie.cn>
// @Author Lxj <xxx@example.com>
// @License https://github.com/xiujiecn/xiu-admin/blob/master/LICENSE
// @AutoGenerate Version
package iot
@@ -1,7 +1,7 @@
// Package gen
// @Link https://github.com/xiujiecn/xiu-admin
// @Copyright Copyright (c) 2026 LiXiujie
// @Author Lxj <li@xiujie.cn>
// @Author Lxj <xxx@example.com>
// @License https://github.com/xiujiecn/xiu-admin/blob/master/LICENSE
// @AutoGenerate Version
package iot
@@ -1,7 +1,7 @@
// Package gen
// @Link https://github.com/xiujiecn/xiu-admin
// @Copyright Copyright (c) 2026 LiXiujie
// @Author Lxj <li@xiujie.cn>
// @Author Lxj <xxx@example.com>
// @License https://github.com/xiujiecn/xiu-admin/blob/master/LICENSE
// @AutoGenerate Version
package iot
@@ -1,7 +1,7 @@
// Package gen
// @Link https://github.com/xiujiecn/xiu-admin
// @Copyright Copyright (c) 2026 LiXiujie
// @Author Lxj <li@xiujie.cn>
// @Author Lxj <xxx@example.com>
// @License https://github.com/xiujiecn/xiu-admin/blob/master/LICENSE
// @AutoGenerate Version
package iot
@@ -1,7 +1,7 @@
// Package gen
// @Link https://github.com/xiujiecn/xiu-admin
// @Copyright Copyright (c) 2025 LiXiujie
// @Author Lxj <li@xiujie.cn>
// @Author Lxj <xxx@example.com>
// @License https://github.com/xiujiecn/xiu-admin/blob/master/LICENSE
// @AutoGenerate Version
package system
@@ -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)
}
@@ -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)
}
+48 -48
View File
@@ -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.
@@ -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)
}
@@ -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.
+22
View File
@@ -0,0 +1,22 @@
// =================================================================================
// This file is auto-generated by the GoFrame CLI tool. You may modify it as needed.
// =================================================================================
package dao
import (
"git.5iot.com/iotlcm-public/iotlcm-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.
+21
View File
@@ -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.
+1 -1
View File
@@ -1,7 +1,7 @@
// Package addons
// @Link https://github.com/xiujiecn/xiu-admin
// @Copyright Copyright (c) 2025 LiXiujie
// @Author Lxj <li@xiujie.cn>
// @Author Lxj <xxx@example.com>
// @License https://github.com/xiujiecn/xiu-admin/blob/master/LICENSE
package addons
@@ -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"
)
@@ -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)
}
}
+29 -1
View File
@@ -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 "失败"
}
@@ -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")
}
}
+1 -1
View File
@@ -1,6 +1,6 @@
// Package bcache
// @Link https://github.com/xiujiecn/xiu-admin
// @Copyright Copyright (c) 2025 LiXiujie
// @Author Lxj <li@xiujie.cn>
// @Author Lxj <xxx@example.com>
// @License https://github.com/xiujiecn/xiu-admin/blob/master/LICENSE
package bcache
@@ -1,7 +1,7 @@
// Package bcache
// @Link https://github.com/xiujiecn/xiu-admin
// @Copyright Copyright (c) 2025 LiXiujie
// @Author Lxj <li@xiujie.cn>
// @Author Lxj <xxx@example.com>
// @License https://github.com/xiujiecn/xiu-admin/blob/master/LICENSE
package bcache
+1 -1
View File
@@ -1,7 +1,7 @@
// Package cache
// @Link https://github.com/xiujiecn/xiu-admin
// @Copyright Copyright (c) 2025 LiXiujie
// @Author Lxj <li@xiujie.cn>
// @Author Lxj <xxx@example.com>
// @License https://github.com/xiujiecn/xiu-admin/blob/master/LICENSE
package cache
+1 -1
View File
@@ -1,7 +1,7 @@
// Package file
// @Link https://github.com/xiujiecn/xiu-admin
// @Copyright Copyright (c) 2025 LiXiujie
// @Author Lxj <li@xiujie.cn>
// @Author Lxj <xxx@example.com>
// @License https://github.com/xiujiecn/xiu-admin/blob/master/LICENSE
package file
+1 -1
View File
@@ -1,7 +1,7 @@
// Package contexts
// @Link https://github.com/xiujiecn/xiu-admin
// @Copyright Copyright (c) 2025 LiXiujie
// @Author Lxj <li@xiujie.cn>
// @Author Lxj <xxx@example.com>
// @License https://github.com/xiujiecn/xiu-admin/blob/master/LICENSE
package contexts
+1 -1
View File
@@ -1,7 +1,7 @@
// Package event
// @Link https://github.com/xiujiecn/xiu-admin
// @Copyright Copyright (c) 2025 LiXiujie
// @Author Lxj <li@xiujie.cn>
// @Author Lxj <xxx@example.com>
// @License https://github.com/xiujiecn/xiu-admin/blob/master/LICENSE
package event
@@ -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
}
}
@@ -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()
}
}
@@ -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)
}
}
@@ -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)
}
}
@@ -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
}
@@ -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)
}
+6 -10
View File
@@ -1,7 +1,7 @@
// Package mcache
// @Link https://github.com/xiujiecn/xiu-admin
// @Copyright Copyright (c) 2025 LiXiujie
// @Author Lxj <li@xiujie.cn>
// @Author Lxj <xxx@example.com>
// @License https://github.com/xiujiecn/xiu-admin/blob/master/LICENSE
// 内存缓存管理
package 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
}
@@ -1,7 +1,7 @@
// Package mcache
// @Link https://github.com/xiujiecn/xiu-admin
// @Copyright Copyright (c) 2025 LiXiujie
// @Author Lxj <li@xiujie.cn>
// @Author Lxj <xxx@example.com>
// @License https://github.com/xiujiecn/xiu-admin/blob/master/LICENSE
package 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
}
@@ -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)
}
}
@@ -1,7 +1,7 @@
// Package mcache
// @Link https://github.com/xiujiecn/xiu-admin
// @Copyright Copyright (c) 2025 LiXiujie
// @Author Lxj <li@xiujie.cn>
// @Author Lxj <xxx@example.com>
// @License https://github.com/xiujiecn/xiu-admin/blob/master/LICENSE
package mcache
@@ -1,7 +1,7 @@
// Package mcache
// @Link https://github.com/xiujiecn/xiu-admin
// @Copyright Copyright (c) 2025 LiXiujie
// @Author Lxj <li@xiujie.cn>
// @Author Lxj <xxx@example.com>
// @License https://github.com/xiujiecn/xiu-admin/blob/master/LICENSE
package mcache
@@ -1,7 +1,7 @@
// Package mcache
// @Link https://github.com/xiujiecn/xiu-admin
// @Copyright Copyright (c) 2025 LiXiujie
// @Author Lxj <li@xiujie.cn>
// @Author Lxj <xxx@example.com>
// @License https://github.com/xiujiecn/xiu-admin/blob/master/LICENSE
package mcache
+83 -9
View File
@@ -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)
}
@@ -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))
}
}
+104 -4
View File
@@ -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
}
@@ -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
}
@@ -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)
}
}
@@ -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
}
@@ -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)
}
})
}
}
+88 -35
View File
@@ -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
}
+53 -9
View File
@@ -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)
}
@@ -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")
}
})
}
@@ -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)
}
}
}
+1 -1
View File
@@ -1,7 +1,7 @@
// Package websocket
// @Link https://github.com/xiujiecn/xiu-admin
// @Copyright Copyright (c) 2025 LiXiujie
// @Author Lxj <li@xiujie.cn>
// @Author Lxj <xxx@example.com>
// @License https://github.com/xiujiecn/xiu-admin/blob/master/LICENSE
package websocket
@@ -1,7 +1,7 @@
// Package websocket
// @Link https://github.com/xiujiecn/xiu-admin
// @Copyright Copyright (c) 2025 LiXiujie
// @Author Lxj <li@xiujie.cn>
// @Author Lxj <xxx@example.com>
// @License https://github.com/xiujiecn/xiu-admin/blob/master/LICENSE
package websocket
@@ -1,7 +1,7 @@
// Package websocket
// @Link https://github.com/xiujiecn/xiu-admin
// @Copyright Copyright (c) 2025 LiXiujie
// @Author Lxj <li@xiujie.cn>
// @Author Lxj <xxx@example.com>
// @License https://github.com/xiujiecn/xiu-admin/blob/master/LICENSE
package websocket
+1 -1
View File
@@ -1,7 +1,7 @@
// Package websocket
// @Link https://github.com/xiujiecn/xiu-admin
// @Copyright Copyright (c) 2025 LiXiujie
// @Author Lxj <li@xiujie.cn>
// @Author Lxj <xxx@example.com>
// @License https://github.com/xiujiecn/xiu-admin/blob/master/LICENSE
package websocket
+1 -1
View File
@@ -1,7 +1,7 @@
// Package websocket
// @Link https://github.com/xiujiecn/xiu-admin
// @Copyright Copyright (c) 2025 LiXiujie
// @Author Lxj <li@xiujie.cn>
// @Author Lxj <xxx@example.com>
// @License https://github.com/xiujiecn/xiu-admin/blob/master/LICENSE
package websocket
+1 -1
View File
@@ -1,7 +1,7 @@
// Package websocket
// @Link https://github.com/xiujiecn/xiu-admin
// @Copyright Copyright (c) 2025 LiXiujie
// @Author Lxj <li@xiujie.cn>
// @Author Lxj <xxx@example.com>
// @License https://github.com/xiujiecn/xiu-admin/blob/master/LICENSE
package websocket
+1 -1
View File
@@ -1,7 +1,7 @@
// Package worker
// @Link https://github.com/xiujiecn/xiu-admin
// @Copyright Copyright (c) 2025 LiXiujie
// @Author Lxj <li@xiujie.cn>
// @Author Lxj <xxx@example.com>
// @License https://github.com/xiujiecn/xiu-admin/blob/master/LICENSE
package worker
@@ -1,7 +1,7 @@
// package worker
// @Link https://github.com/xiujiecn/xiu-admin
// @Copyright Copyright (c) 2025 LiXiujie
// @Author Lxj <li@xiujie.cn>
// @Author Lxj <xxx@example.com>
// @License https://github.com/xiujiecn/xiu-admin/blob/master/LICENSE
package worker
+1 -1
View File
@@ -1,7 +1,7 @@
// package worker
// @Link https://github.com/xiujiecn/xiu-admin
// @Copyright Copyright (c) 2025 LiXiujie
// @Author Lxj <li@xiujie.cn>
// @Author Lxj <xxx@example.com>
// @License https://github.com/xiujiecn/xiu-admin/blob/master/LICENSE
package worker
@@ -1,7 +1,7 @@
// package genconsts
// @Link https://github.com/xiujiecn/xiu-admin
// @Copyright Copyright (c) 2025 LiXiujie
// @Author Lxj <li@xiujie.cn>
// @Author Lxj <xxx@example.com>
// @License https://github.com/xiujiecn/xiu-admin/blob/master/LICENSE
package genconsts
@@ -1,7 +1,7 @@
// package genconsts
// @Link https://github.com/xiujiecn/xiu-admin
// @Copyright Copyright (c) 2025 LiXiujie
// @Author Lxj <li@xiujie.cn>
// @Author Lxj <xxx@example.com>
// @License https://github.com/xiujiecn/xiu-admin/blob/master/LICENSE
package genconsts
@@ -1,7 +1,7 @@
// package gendao
// @Link https://github.com/xiujiecn/xiu-admin
// @Copyright Copyright (c) 2025 LiXiujie
// @Author Lxj <li@xiujie.cn>
// @Author Lxj <xxx@example.com>
// @License https://github.com/xiujiecn/xiu-admin/blob/master/LICENSE
package gendao
@@ -1,7 +1,7 @@
// package genmodel
// @Link https://github.com/xiujiecn/xiu-admin
// @Copyright Copyright (c) 2025 LiXiujie
// @Author Lxj <li@xiujie.cn>
// @Author Lxj <xxx@example.com>
// @License https://github.com/xiujiecn/xiu-admin/blob/master/LICENSE
package genmodel

Some files were not shown because too many files have changed in this diff Show More