59eb34976a
iotlcm-admin-svr:管理后台服务。 - Go module:iotlcm → git.5iot.com/iotlcm-public/iotlcm-admin-svr(729 文件) - 凭据与地址脱敏:JWT signingKey(含代码里的 MustGet 默认值)、 MySQL DSN、Redis 口令、告警邮件发件人邮箱全部改为占位; 私网 IP → 文档地址段;库名 xiujie_admin → iotlcm - 真实手机号/邮箱 → 合法 11 位编造号 / example.com - 移除内部资产:CI 触发器、过时 DDL(sql/db_iotlcm.sql)、 私有依赖 go get 行、明文 registry 登录口令 - 启动 banner 统一 IOTLCM 方块字 + 社区惯例署名 - Dockerfile 改用公共基础镜像 - 拼错的 RADEME.md → README.md,README 重写 - 需要真实数据库的用例改为可用 IOTLCM_TEST_WITH_DB=1 显式启用 - 补 Apache-2.0 LICENSE,LF 行尾 + .gitattributes 数据库 schema 不随本仓发布(生产 DDL 已过时),README 已说明。
282 lines
11 KiB
Go
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,
|
|
}
|
|
}
|