feat(channel-monitor): apply template via subset picker; CC 2.1.114 baseline doc

Apply flow:
- POST /admin/channel-monitor-templates/:id/apply now requires monitor_ids
  (non-empty array). Service applies the template only to the selected
  subset, gated by AND template_id = :id (so users can't sneak in
  unrelated monitor IDs).
- New GET /admin/channel-monitor-templates/:id/monitors returns the
  associated monitor briefs (id/name/provider/enabled) for the picker.
- ApplyToMonitors signature gains monitorIDs []int64; empty list returns
  ErrChannelMonitorTemplateApplyEmpty.

Frontend:
- New MonitorTemplateApplyPickerDialog.vue: list of associated monitors
  with checkboxes (default all checked), 全选 / 全不选 shortcuts, live
  selected/total count. Submit calls apply(id, ids).
- MonitorTemplateManagerDialog replaces the old ConfirmDialog flow with
  the picker; onApplied refetches the list to refresh associated counts.

i18n: applyPicker* + common.selectAll keys.

chore: bump version to 0.1.114.33

The CC 2.1.114 (sdk-cli) UA / APIKeyBetaHeader / JSON metadata.user_id
baseline (already verified working via the in-process apply on prod
template id=1) is documented in internal/pkg/claude/constants.go and
is what the seed template in the manager UI should follow.
This commit is contained in:
erio
2026-04-21 14:39:19 +08:00
parent a296425994
commit 6925ac25c4
10 changed files with 341 additions and 51 deletions

View File

@@ -179,17 +179,56 @@ func (h *ChannelMonitorRequestTemplateHandler) Delete(c *gin.Context) {
response.Success(c, nil)
}
type channelMonitorTemplateApplyRequest struct {
// MonitorIDs 必填、非空:用户在 picker 里勾选的要被覆盖的监控 ID 列表。
// 仅当对应监控当前 template_id == :id 时才会真的被覆盖。
MonitorIDs []int64 `json:"monitor_ids" binding:"required,min=1"`
}
// Apply POST /api/v1/admin/channel-monitor-templates/:id/apply
// 一键把模板当前配置覆盖到所有关联监控上
// 把模板当前配置覆盖到 monitor_ids 列表里的关联监控picker 选中的子集)
func (h *ChannelMonitorRequestTemplateHandler) Apply(c *gin.Context) {
id, ok := parseTemplateID(c)
if !ok {
return
}
affected, err := h.templateService.ApplyToMonitors(c.Request.Context(), id)
var req channelMonitorTemplateApplyRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.ErrorFrom(c, infraerrors.BadRequest("VALIDATION_ERROR", err.Error()))
return
}
affected, err := h.templateService.ApplyToMonitors(c.Request.Context(), id, req.MonitorIDs)
if err != nil {
response.ErrorFrom(c, err)
return
}
response.Success(c, gin.H{"affected": affected})
}
type associatedMonitorBriefResponse struct {
ID int64 `json:"id"`
Name string `json:"name"`
Provider string `json:"provider"`
Enabled bool `json:"enabled"`
}
// AssociatedMonitors GET /api/v1/admin/channel-monitor-templates/:id/monitors
// 列出关联监控picker 弹窗用)。
func (h *ChannelMonitorRequestTemplateHandler) AssociatedMonitors(c *gin.Context) {
id, ok := parseTemplateID(c)
if !ok {
return
}
items, err := h.templateService.ListAssociatedMonitors(c.Request.Context(), id)
if err != nil {
response.ErrorFrom(c, err)
return
}
out := make([]associatedMonitorBriefResponse, 0, len(items))
for _, m := range items {
out = append(out, associatedMonitorBriefResponse{
ID: m.ID, Name: m.Name, Provider: m.Provider, Enabled: m.Enabled,
})
}
response.Success(c, gin.H{"items": out})
}

View File

@@ -103,11 +103,13 @@ func (r *channelMonitorRequestTemplateRepository) List(ctx context.Context, para
return out, nil
}
// ApplyToMonitors 把模板当前配置批量覆盖到 template_id = id 的监控
//
// 用一条 UPDATE 完成extra_headers / body_override_mode / body_override 都覆盖
// 走 ent 的 UpdateMany 保证走 ent hooks走原生 SQL 也可以但 ent jsonb 序列化更省心。
func (r *channelMonitorRequestTemplateRepository) ApplyToMonitors(ctx context.Context, id int64) (int64, error) {
// ApplyToMonitors 把模板当前配置覆盖到 monitorIDs 列表里的关联监控。
// WHERE 双重过滤template_id = id AND id IN (monitorIDs),防止用户传了未关联本模板的 id
// 就被覆盖。走 ent UpdateMany 保留 hooks
func (r *channelMonitorRequestTemplateRepository) ApplyToMonitors(ctx context.Context, id int64, monitorIDs []int64) (int64, error) {
if len(monitorIDs) == 0 {
return 0, nil
}
client := clientFromContext(ctx, r.client)
tpl, err := client.ChannelMonitorRequestTemplate.Query().
Where(channelmonitorrequesttemplate.IDEQ(id)).
@@ -117,7 +119,10 @@ func (r *channelMonitorRequestTemplateRepository) ApplyToMonitors(ctx context.Co
}
updater := client.ChannelMonitor.Update().
Where(channelmonitor.TemplateIDEQ(id)).
Where(
channelmonitor.TemplateIDEQ(id),
channelmonitor.IDIn(monitorIDs...),
).
SetExtraHeaders(emptyHeadersIfNilRepo(tpl.ExtraHeaders)).
SetBodyOverrideMode(defaultBodyModeRepo(tpl.BodyOverrideMode))
if tpl.BodyOverride != nil {
@@ -144,6 +149,28 @@ func (r *channelMonitorRequestTemplateRepository) CountAssociatedMonitors(ctx co
return int64(count), nil
}
// ListAssociatedMonitors 列出模板关联的所有监控简略字段。
// ORDER BY name 稳定输出方便前端展示。
func (r *channelMonitorRequestTemplateRepository) ListAssociatedMonitors(ctx context.Context, id int64) ([]*service.AssociatedMonitorBrief, error) {
rows, err := r.client.ChannelMonitor.Query().
Where(channelmonitor.TemplateIDEQ(id)).
Order(dbent.Asc(channelmonitor.FieldName)).
All(ctx)
if err != nil {
return nil, fmt.Errorf("list associated monitors for template %d: %w", id, err)
}
out := make([]*service.AssociatedMonitorBrief, 0, len(rows))
for _, row := range rows {
out = append(out, &service.AssociatedMonitorBrief{
ID: row.ID,
Name: row.Name,
Provider: string(row.Provider),
Enabled: row.Enabled,
})
}
return out, nil
}
// ---------- helpers ----------
func entToServiceTemplate(row *dbent.ChannelMonitorRequestTemplate) *service.ChannelMonitorRequestTemplate {

View File

@@ -587,6 +587,7 @@ func registerChannelMonitorRoutes(admin *gin.RouterGroup, h *handler.Handlers) {
templates.GET("/:id", h.Admin.ChannelMonitorTemplate.Get)
templates.PUT("/:id", h.Admin.ChannelMonitorTemplate.Update)
templates.DELETE("/:id", h.Admin.ChannelMonitorTemplate.Delete)
templates.GET("/:id/monitors", h.Admin.ChannelMonitorTemplate.AssociatedMonitors)
templates.POST("/:id/apply", h.Admin.ChannelMonitorTemplate.Apply)
}
}

View File

@@ -15,10 +15,23 @@ type ChannelMonitorRequestTemplateRepository interface {
Delete(ctx context.Context, id int64) error
List(ctx context.Context, params ChannelMonitorRequestTemplateListParams) ([]*ChannelMonitorRequestTemplate, error)
// ApplyToMonitors 把模板当前的 extra_headers / body_override_mode / body_override
// 批量覆盖到所有 template_id = id 的监控上。返回被覆盖的监控数量。
ApplyToMonitors(ctx context.Context, id int64) (int64, error)
// 批量覆盖到指定 monitorIDs 的监控上(同时还要求这些监控当前 template_id = id
// 防止误覆盖未关联的监控。monitorIDs 必须非空;空列表直接返回 0 不写库。
// 返回被覆盖的监控数量。
ApplyToMonitors(ctx context.Context, id int64, monitorIDs []int64) (int64, error)
// CountAssociatedMonitors 统计 template_id = id 的监控数(用于 UI 展示「应用到 N 个配置」)。
CountAssociatedMonitors(ctx context.Context, id int64) (int64, error)
// ListAssociatedMonitors 列出所有 template_id = id 的监控简略信息id/name/provider/enabled
// 给 apply picker UI 用,避免前端再做一次 list+filter。
ListAssociatedMonitors(ctx context.Context, id int64) ([]*AssociatedMonitorBrief, error)
}
// AssociatedMonitorBrief 模板关联监控的简略信息picker / 列表展示用)。
type AssociatedMonitorBrief struct {
ID int64
Name string
Provider string
Enabled bool
}
// ChannelMonitorRequestTemplateService 模板管理 service。
@@ -90,13 +103,17 @@ func (s *ChannelMonitorRequestTemplateService) Delete(ctx context.Context, id in
return nil
}
// ApplyToMonitors 把模板当前配置一键应用到所有关联监控。
// 返回被影响的监控数
func (s *ChannelMonitorRequestTemplateService) ApplyToMonitors(ctx context.Context, id int64) (int64, error) {
// ApplyToMonitors 把模板当前配置应用到 monitorIDs 列表里的关联监控。
// monitorIDs 必须非空且每个 id 都必须当前 template_id = id不满足条件的会被 SQL WHERE 过滤掉
// 返回实际被覆盖的监控数。
func (s *ChannelMonitorRequestTemplateService) ApplyToMonitors(ctx context.Context, id int64, monitorIDs []int64) (int64, error) {
if _, err := s.repo.GetByID(ctx, id); err != nil {
return 0, err
}
affected, err := s.repo.ApplyToMonitors(ctx, id)
if len(monitorIDs) == 0 {
return 0, ErrChannelMonitorTemplateApplyEmpty
}
affected, err := s.repo.ApplyToMonitors(ctx, id, monitorIDs)
if err != nil {
return 0, fmt.Errorf("apply template to monitors: %w", err)
}
@@ -108,6 +125,15 @@ func (s *ChannelMonitorRequestTemplateService) CountAssociatedMonitors(ctx conte
return s.repo.CountAssociatedMonitors(ctx, id)
}
// ListAssociatedMonitors 返回模板关联的所有监控简略信息。
// 给前端 apply picker 用handler 直接吐 JSON 不再做 join。
func (s *ChannelMonitorRequestTemplateService) ListAssociatedMonitors(ctx context.Context, id int64) ([]*AssociatedMonitorBrief, error) {
if _, err := s.repo.GetByID(ctx, id); err != nil {
return nil, err
}
return s.repo.ListAssociatedMonitors(ctx, id)
}
// ---------- 校验 & 工具 ----------
// validateTemplateCreateParams 聚合 create 入参校验,避免函数超过 30 行。

View File

@@ -71,4 +71,7 @@ var (
ErrChannelMonitorTemplateProviderMismatch = infraerrors.BadRequest(
"CHANNEL_MONITOR_TEMPLATE_PROVIDER_MISMATCH", "monitor provider does not match template provider",
)
ErrChannelMonitorTemplateApplyEmpty = infraerrors.BadRequest(
"CHANNEL_MONITOR_TEMPLATE_APPLY_EMPTY", "monitor_ids must be a non-empty array",
)
)