package iot import ( "context" "strings" "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/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/service" "git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/service/fullsyncretry" ) type FullSyncMonitorController struct { client fullsyncretry.Client } func NewFullSyncMonitor() *FullSyncMonitorController { return &FullSyncMonitorController{client: fullsyncretry.FromEnvironment()} } func (c *FullSyncMonitorController) List(ctx context.Context, req *v1.FullSyncRunsListReq) (*v1.FullSyncRunsListRes, error) { result, err := c.client.ListRuns(ctx, req.SyncKind, req.Limit) if err != nil { return nil, err } return &v1.FullSyncRunsListRes{Items: result.Items, Total: result.Total}, nil } func (c *FullSyncMonitorController) Detail(ctx context.Context, req *v1.FullSyncRunDetailReq) (*v1.FullSyncRunDetailRes, error) { view, err := c.client.Run(ctx, req.RunID) if err != nil { return nil, err } kind, name := fullSyncKindName(view.Run.Operation) return &v1.FullSyncRunDetailRes{RunView: view, SyncKind: kind, SyncName: name}, nil } func (c *FullSyncMonitorController) Failures(ctx context.Context, req *v1.FullSyncRunFailuresReq) (*v1.FullSyncRunFailuresRes, error) { page, err := c.client.Failures(ctx, req.RunID, req.Limit, req.Cursor) if err != nil { return nil, err } return &v1.FullSyncRunFailuresRes{Items: page.Items, NextCursor: page.NextCursor}, nil } func (c *FullSyncMonitorController) Start(ctx context.Context, req *v1.FullSyncRunStartReq) (*v1.FullSyncRunStartRes, error) { userID := contexts.GetUserId(ctx) if userID == 0 { return nil, gerror.NewCode(gcode.CodeNotAuthorized, "Login required") } if !fullsyncretry.ValidSyncKind(req.SyncKind) { return nil, gerror.NewCode(gcode.CodeInvalidParameter, "Invalid full-sync type") } // A period field only applies to its own sync kind. Rejecting a stray field // keeps an operator from believing a backfill was scheduled when it was not. period, err := resolvePeriod(req) if err != nil { return nil, err } // Validate and canonicalize the channel scope before the active-run check so // a malformed request fails fast without touching Worker state. scope, err := model.NormalizeScope(&model.SyncScope{Channels: req.Channels, TriggerSource: model.TriggerSourceManual}) if err != nil { return nil, gerror.NewCode(gcode.CodeInvalidParameter, err.Error()) } list, err := c.client.ListRuns(ctx, req.SyncKind, 100) if err != nil { return nil, err } for _, item := range list.Items { if item.Run.Status == "producing" || item.Run.Status == "running" { return nil, gerror.NewCode(gcode.CodeInvalidParameter, "A full-sync run is already active") } } var runID string switch req.SyncKind { case "traffic_usage": runID, err = service.TrafficUsageMonthlySync().StartMonthlyRunScoped(ctx, scope) case "sim_status": runID, err = service.SimStatusQueue().StartFullSyncRunScoped(ctx, scope) case "imei": runID, err = service.ImeiQueue().StartImeiRunScoped(ctx, scope) case "traffic_usage_daily": runID, err = service.TrafficUsageDailySync().StartDailyRunScopedAt(ctx, scope, req.QueryDate) case "traffic_usage_monthly": runID, err = service.TrafficUsageMonthlySync().StartMonthlyHistoryRunScopedAt(ctx, scope, req.QueryMonth) } if err != nil { g.Log().Warningf(ctx, "full_sync_manual_start rejected actor=%d syncKind=%s scopeChannels=%d period=%s error=%q", userID, req.SyncKind, scope.ChannelCount(), period, err.Error()) return nil, err } g.Log().Infof(ctx, "full_sync_manual_start accepted actor=%d syncKind=%s runId=%s scopeChannels=%d scopeSample=%s period=%s", userID, req.SyncKind, runID, scope.ChannelCount(), strings.Join(scope.ChannelSample(10), ","), period) return &v1.FullSyncRunStartRes{RunID: runID, SyncKind: req.SyncKind, Status: "published", ScopeChannels: scope.ChannelCount(), QueryPeriod: period}, nil } // resolvePeriod returns the requested backfill period for req.SyncKind and // rejects a period field that does not belong to that sync kind. func resolvePeriod(req *v1.FullSyncRunStartReq) (string, error) { if req.QueryDate != "" && req.SyncKind != "traffic_usage_daily" { return "", gerror.NewCodef(gcode.CodeInvalidParameter, "queryDate is only valid for syncKind=traffic_usage_daily, got %s", req.SyncKind) } if req.QueryMonth != "" && req.SyncKind != "traffic_usage_monthly" { return "", gerror.NewCodef(gcode.CodeInvalidParameter, "queryMonth is only valid for syncKind=traffic_usage_monthly, got %s", req.SyncKind) } switch req.SyncKind { case "traffic_usage_daily": return req.QueryDate, nil case "traffic_usage_monthly": return req.QueryMonth, nil } return "", nil } // Channels returns the channels a partial sync can target, with their card // counts so an operator can judge the blast radius before selecting. func (c *FullSyncMonitorController) Channels(ctx context.Context, _ *v1.FullSyncChannelOptionsReq) (*v1.FullSyncChannelOptionsRes, error) { if contexts.GetUserId(ctx) == 0 { return nil, gerror.NewCode(gcode.CodeNotAuthorized, "Login required") } tenantID := contexts.GetTenantId(ctx) type channelRow struct { ChanKey string `orm:"chan_key"` ChanName string `orm:"chan_name"` TempKey string `orm:"temp_key"` Operator int `orm:"operator"` SyncEnabled int `orm:"sync_enabled"` CardCount int64 `orm:"card_count"` } var rows []*channelRow channels := dao.TSimChannels.Columns() cards := dao.TSimCardInfo.Columns() // Count cards per channel in one pass. The left join keeps channels that // currently hold no cards so they stay selectable (and visibly empty), and // both sides are tenant-scoped so the count cannot leak across tenants. // // The select list and the GROUP BY clause must be built from the same // column set; see channelOptionsQuery for why. query := channelOptionsQuery(channelQueryColumns{ ChanKey: channels.ChanKey, ChanName: channels.ChanName, TempKey: channels.TempKey, Operator: channels.Operator, SyncEnabled: channels.SyncEnabled, TenantID: channels.TenantId, }, cardQueryColumns{ Id: cards.Id, ChanKey: cards.ChanKey, TenantID: cards.TenantId, }, tenantID) err := dao.TSimChannels.Ctx(ctx). As(query.alias). LeftJoin(query.joinTable, query.joinOn). Fields(query.fields...). Where(query.tenantWhere, query.tenantID). Group(query.groupBy...). OrderAsc(query.orderBy). Scan(&rows) if err != nil { return nil, gerror.Wrap(err, "load full-sync channel options failed") } items := make([]v1.FullSyncChannelOption, 0, len(rows)) for _, row := range rows { if row == nil { continue } items = append(items, v1.FullSyncChannelOption{ ChanKey: row.ChanKey, ChanName: row.ChanName, TempKey: row.TempKey, Operator: row.Operator, CardCount: row.CardCount, SyncEnabled: row.SyncEnabled == 1, }) } return &v1.FullSyncChannelOptionsRes{Items: items}, nil } func fullSyncKindName(operation string) (string, string) { switch operation { case "current_month_usage_full_sync": return "traffic_usage", "流量全量同步" case "sim_status_queue_full_sync": return "sim_status", "卡状态全量同步" case "previous_day_usage_full_sync": return "traffic_usage_daily", "日流量全量同步" case "previous_month_usage_full_sync": return "traffic_usage_monthly", "月流量历史全量同步" case "imei_full_sync": return "imei", "IMEI 全量同步" default: return "", operation } } // channelQueryColumns / cardQueryColumns carry the physical column names the // channel-options query needs. They keep the builder a pure function so its // SQL shape can be asserted in a unit test. type channelQueryColumns struct { ChanKey string ChanName string TempKey string Operator string SyncEnabled string TenantID string } type cardQueryColumns struct { Id string ChanKey string TenantID string } // channelOptionsQueryShape holds the assembled pieces of the channel-options query // so the SQL shape can be asserted without a database. type channelOptionsQueryShape struct { alias string joinTable string joinOn string fields []any groupBy []any orderBy string tenantWhere string tenantID string } // channelOptionsQuery builds the channel candidate query. // // IMPORTANT: every non-aggregate column in the select list must also appear in // GROUP BY. MySQL runs with ONLY_FULL_GROUP_BY, and GoFrame sends the tenant // filter as a prepared-statement parameter. A bound parameter is not a // constant, so MySQL cannot use the unique key uk_key(tenant_id, chan_key) to // prove that chan_name/temp_key/operator/sync_enabled are functionally // dependent on the grouped column. Grouping by chan_key alone therefore fails // in production with: // // Error 1055: Expression #2 of SELECT list is not in GROUP BY clause and // contains nonaggregated column 'db_iotlcm.c.chan_name' which is not // functionally dependent on columns in GROUP BY clause // // Adding the remaining columns keeps the grouping semantically identical // because (tenant_id, chan_key) is unique, and it satisfies the SQL mode. func channelOptionsQuery(channels channelQueryColumns, cards cardQueryColumns, tenantID string) channelOptionsQueryShape { const alias = "c" const joinAlias = "ci" // Single source of truth: the non-aggregate columns that are both selected // and grouped. nonAggregate := []struct{ column, out string }{ {channels.ChanKey, "chan_key"}, {channels.ChanName, "chan_name"}, {channels.TempKey, "temp_key"}, {channels.Operator, "operator"}, {channels.SyncEnabled, "sync_enabled"}, } fields := make([]any, 0, len(nonAggregate)+1) groupBy := make([]any, 0, len(nonAggregate)) for _, column := range nonAggregate { fields = append(fields, alias+"."+column.column+" AS "+column.out) groupBy = append(groupBy, alias+"."+column.column) } fields = append(fields, "COUNT("+joinAlias+"."+cards.Id+") AS card_count") return channelOptionsQueryShape{ alias: alias, joinTable: dao.TSimCardInfo.Table() + " AS " + joinAlias, joinOn: alias + "." + channels.ChanKey + " = " + joinAlias + "." + cards.ChanKey + " AND " + joinAlias + "." + cards.TenantID + " = " + alias + "." + channels.TenantID, fields: fields, groupBy: groupBy, orderBy: alias + "." + channels.ChanKey, tenantWhere: alias + "." + channels.TenantID, tenantID: tenantID, } }