@@ -28,6 +28,7 @@ func RegisterIssueRoutes(router *gin.Engine, rdb *redis.Client, db *adb.Database
2828 api := & IssueAPI {R : rdb , DB : db }
2929 router .GET ("/v1/issues/:issueID" , api .GetIssueByID )
3030 router .GET ("/v1/issues" , api .ListIssues )
31+ router .GET ("/v1/changelog/alertrules" , api .ListAlertRuleChangeLogs )
3132}
3233
3334func newRedisFromEnv () * redis.Client { return nil }
@@ -257,3 +258,138 @@ func (api *IssueAPI) ListIssues(c *gin.Context) {
257258 }
258259 c .JSON (http .StatusOK , resp )
259260}
261+
262+ // ===== Alert Rule ChangeLog =====
263+
264+ type alertRuleChangeValue struct {
265+ Name string `json:"name"`
266+ Old string `json:"old"`
267+ New string `json:"new"`
268+ }
269+
270+ type alertRuleChangeItem struct {
271+ Name string `json:"name"`
272+ EditTime string `json:"editTime"`
273+ Scope string `json:"scope"`
274+ Values []alertRuleChangeValue `json:"values"`
275+ Reason string `json:"reason"`
276+ }
277+
278+ type alertRuleChangeListResponse struct {
279+ Items []alertRuleChangeItem `json:"items"`
280+ Next string `json:"next,omitempty"`
281+ }
282+
283+ // ListAlertRuleChangeLogs implements GET /v1/changelog/alertrules?start=...&limit=...
284+ func (api * IssueAPI ) ListAlertRuleChangeLogs (c * gin.Context ) {
285+ if api .DB == nil {
286+ c .JSON (http .StatusInternalServerError , map [string ]any {"error" : map [string ]any {"code" : "INTERNAL_ERROR" , "message" : "database not configured" }})
287+ return
288+ }
289+
290+ start := strings .TrimSpace (c .Query ("start" ))
291+ limitStr := strings .TrimSpace (c .Query ("limit" ))
292+ if limitStr == "" {
293+ c .JSON (http .StatusBadRequest , map [string ]any {"error" : map [string ]any {"code" : "INVALID_PARAMETER" , "message" : "limit is required" }})
294+ return
295+ }
296+ limit , err := strconv .Atoi (limitStr )
297+ if err != nil || limit < 1 || limit > 100 {
298+ c .JSON (http .StatusBadRequest , map [string ]any {"error" : map [string ]any {"code" : "INVALID_PARAMETER" , "message" : "limit must be 1-100" }})
299+ return
300+ }
301+
302+ var (
303+ q string
304+ args []any
305+ )
306+ if start == "" {
307+ q = `
308+ SELECT alert_name, change_time, labels, old_threshold, new_threshold, change_type
309+ FROM alert_meta_change_logs
310+ WHERE change_type = 'Update'
311+ ORDER BY change_time DESC
312+ LIMIT $1`
313+ args = append (args , limit )
314+ } else {
315+ if _ , err := time .Parse (time .RFC3339 , start ); err != nil {
316+ if _ , err2 := time .Parse (time .RFC3339Nano , start ); err2 != nil {
317+ c .JSON (http .StatusBadRequest , map [string ]any {"error" : map [string ]any {"code" : "INVALID_PARAMETER" , "message" : "start must be ISO 8601 time" }})
318+ return
319+ }
320+ }
321+ q = `
322+ SELECT alert_name, change_time, labels, old_threshold, new_threshold, change_type
323+ FROM alert_meta_change_logs
324+ WHERE change_time <= $1 AND change_type = 'Update'
325+ ORDER BY change_time DESC
326+ LIMIT $2`
327+ args = append (args , start , limit )
328+ }
329+
330+ rows , err := api .DB .QueryContext (c .Request .Context (), q , args ... )
331+ if err != nil {
332+ c .JSON (http .StatusInternalServerError , map [string ]any {"error" : map [string ]any {"code" : "INTERNAL_ERROR" , "message" : err .Error ()}})
333+ return
334+ }
335+ defer rows .Close ()
336+
337+ items := make ([]alertRuleChangeItem , 0 , limit )
338+ var lastTime string
339+ for rows .Next () {
340+ var (
341+ name string
342+ changeTime time.Time
343+ labelsRaw string
344+ oldTh * float64
345+ newTh * float64
346+ changeType string
347+ )
348+ if err := rows .Scan (& name , & changeTime , & labelsRaw , & oldTh , & newTh , & changeType ); err != nil {
349+ continue
350+ }
351+ scope := ""
352+ var lm map [string ]any
353+ if err := json .Unmarshal ([]byte (labelsRaw ), & lm ); err == nil {
354+ if svc , ok := lm ["service" ].(string ); ok && svc != "" {
355+ scope = "service:" + svc
356+ if ver , ok := lm ["service_version" ].(string ); ok && ver != "" {
357+ scope = scope + "v" + ver
358+ }
359+ }
360+ }
361+
362+ values := make ([]alertRuleChangeValue , 0 , 2 )
363+ if oldTh != nil || newTh != nil {
364+ values = append (values , alertRuleChangeValue {
365+ Name : "threshold" ,
366+ Old : floatToString (oldTh ),
367+ New : floatToString (newTh ),
368+ })
369+ }
370+
371+ item := alertRuleChangeItem {
372+ Name : name ,
373+ EditTime : changeTime .UTC ().Format (time .RFC3339 ),
374+ Scope : scope ,
375+ Values : values ,
376+ Reason : "检测到异常且未发生告警,降低阈值以尽早发现问题" ,
377+ }
378+ items = append (items , item )
379+ lastTime = item .EditTime
380+ }
381+
382+ resp := alertRuleChangeListResponse {Items : items }
383+ if lastTime != "" {
384+ resp .Next = lastTime
385+ }
386+ c .JSON (http .StatusOK , resp )
387+ }
388+
389+ func floatToString (p * float64 ) string {
390+ if p == nil {
391+ return ""
392+ }
393+ s := strconv .FormatFloat (* p , 'f' , - 1 , 64 )
394+ return s
395+ }
0 commit comments