Files
iotlcm/admin-svr/server/internal/controller/iot/full_sync_monitor.go
T

282 lines
11 KiB
Go

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,
}
}