package service import ( "bytes" "encoding/json" "fmt" "io" "net" "net/http" "net/url" "sort" "strconv" "strings" "sync" "time" ) const ( maxOperationDetailBytes = 2048 maxLoggedRequestBytes = 2 << 20 ) type OperationLog struct { ID int64 `json:"id"` CreatedAt string `json:"createdAt"` Category string `json:"category"` Action string `json:"action"` Target string `json:"target,omitempty"` Method string `json:"method"` Path string `json:"path"` Params string `json:"params,omitempty"` Result string `json:"result"` StatusCode int `json:"statusCode"` Message string `json:"message,omitempty"` DurationMS int64 `json:"durationMs"` ClientIP string `json:"clientIp,omitempty"` } type OperationLogPage struct { Items []OperationLog `json:"items"` Total int `json:"total"` Page int `json:"page"` PageSize int `json:"pageSize"` } type OperationLogStore struct { mu sync.RWMutex logs []OperationLog nextID int64 now func() time.Time day string } func NewOperationLogStore() *OperationLogStore { store := newOperationLogStore(time.Now) store.scheduleMidnightClear() return store } func newOperationLogStore(now func() time.Time) *OperationLogStore { return &OperationLogStore{logs: make([]OperationLog, 0), now: now, day: logDay(now())} } func (s *OperationLogStore) Add(log OperationLog) { s.mu.Lock() defer s.mu.Unlock() s.clearExpiredLocked() s.nextID++ log.ID = s.nextID log.CreatedAt = s.now().Format("2006-01-02 15:04:05") s.logs = append(s.logs, log) } func (s *OperationLogStore) Query(keyword, category, result string, page, pageSize int) OperationLogPage { s.mu.Lock() defer s.mu.Unlock() s.clearExpiredLocked() keyword = strings.ToLower(strings.TrimSpace(keyword)) matched := make([]OperationLog, 0, len(s.logs)) for i := len(s.logs) - 1; i >= 0; i-- { item := s.logs[i] if category != "" && item.Category != category { continue } if result != "" && item.Result != result { continue } searchable := strings.ToLower(item.Action + " " + item.Target + " " + item.Path + " " + item.Params + " " + item.Message + " " + item.ClientIP) if keyword != "" && !strings.Contains(searchable, keyword) { continue } matched = append(matched, item) } total := len(matched) start := (page - 1) * pageSize if start > total { start = total } end := start + pageSize if end > total { end = total } items := append([]OperationLog(nil), matched[start:end]...) return OperationLogPage{Items: items, Total: total, Page: page, PageSize: pageSize} } func (s *OperationLogStore) Clear() { s.mu.Lock() defer s.mu.Unlock() s.logs = s.logs[:0] s.day = logDay(s.now()) } func (s *OperationLogStore) clearExpiredLocked() { today := logDay(s.now()) if s.day == today { return } s.logs = s.logs[:0] s.day = today } func (s *OperationLogStore) scheduleMidnightClear() { now := s.now() next := time.Date(now.Year(), now.Month(), now.Day()+1, 0, 0, 0, 0, now.Location()) time.AfterFunc(time.Until(next), func() { s.Clear() s.scheduleMidnightClear() }) } func logDay(value time.Time) string { return value.Format("2006-01-02") } type operationResponseWriter struct { http.ResponseWriter status int body bytes.Buffer } func (w *operationResponseWriter) WriteHeader(status int) { if w.status == 0 { w.status = status } w.ResponseWriter.WriteHeader(status) } func (w *operationResponseWriter) Write(p []byte) (int, error) { if w.status == 0 { w.status = http.StatusOK } if w.body.Len() < maxOperationDetailBytes { remaining := maxOperationDetailBytes - w.body.Len() if len(p) > remaining { _, _ = w.body.Write(p[:remaining]) } else { _, _ = w.body.Write(p) } } return w.ResponseWriter.Write(p) } func (w *operationResponseWriter) Flush() { if flusher, ok := w.ResponseWriter.(http.Flusher); ok { flusher.Flush() } } type replayReadCloser struct { io.Reader closer io.Closer } func (r *replayReadCloser) Close() error { return r.closer.Close() } func (s *Service) withOperationLogging(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if !shouldLogOperation(r.URL.Path) { next.ServeHTTP(w, r) return } body := captureRequestBody(r) category, action := s.describeOperation(r, body) started := time.Now() wrapped := &operationResponseWriter{ResponseWriter: w} next.ServeHTTP(wrapped, r) if wrapped.status == 0 { wrapped.status = http.StatusOK } result, message := operationResult(wrapped.status, wrapped.body.Bytes()) s.operationLogs.Add(OperationLog{ Category: category, Action: action, Target: operationTarget(r.URL.Query(), body), Method: r.Method, Path: r.URL.Path, Params: operationParams(r.URL.Query(), body), Result: result, StatusCode: wrapped.status, Message: message, DurationMS: time.Since(started).Milliseconds(), ClientIP: clientIP(r), }) }) } func shouldLogOperation(path string) bool { if path == "/api/operation-logs" || path == "/api/runtime-logs" || path == "/healthz" { return false } return strings.HasPrefix(path, "/api/") || strings.HasPrefix(path, "/v5/") || strings.HasPrefix(path, "/ec/") || strings.HasPrefix(path, "/sim/") } func captureRequestBody(r *http.Request) []byte { if r.Body == nil { return nil } original := r.Body raw, err := io.ReadAll(io.LimitReader(original, maxLoggedRequestBytes+1)) if err != nil { return nil } r.Body = &replayReadCloser{Reader: io.MultiReader(bytes.NewReader(raw), original), closer: original} if len(raw) > maxLoggedRequestBytes { return raw[:maxLoggedRequestBytes] } return raw } func (s *Service) describeOperation(r *http.Request, body []byte) (string, string) { path := r.URL.Path if strings.HasPrefix(path, "/api/") { return "管理台", s.describeAdminOperation(r, body) } category := "OneLink" endpoint := strings.TrimPrefix(strings.TrimPrefix(path, "/v5/"), "/") if strings.HasPrefix(path, "/sim/") { category = "Chan" endpoint = strings.TrimPrefix(path, "/sim/") } actions := map[string]string{ "ec/get/token": "获取 Token", "ec/query/sim-basic-info": "查询卡基础信息", "ec/query/sim-session": "查询在线状态", "ec/query/sim-status": "查询卡状态", "ec/query/sim-imei": "查询 IMEI", "ec/query/sim-data-usage-inpool": "查询流量池用量", "ec/query/sim-data-usage": "查询当月流量", "ec/query/sim-data-usage-monthly/batch": "批量查询月流量", "ec/query/sim-data-usage-daily/batch": "批量查询日流量", "ec/query/apn-info": "查询 APN", "ec/query/card-bind-status": "查询绑定状态", "ec/query/on-off-status": "查询开关机状态", "ec/query/region-limit-status": "查询区域限制", "ec/change/sim-status": "变更 SIM 状态", "ec/operate/sim-apn-function": "操作 APN", "query/sim-basic-info": "查询卡基础信息", "query/sim-session": "查询在线状态", "query/sim-status": "查询卡状态", "query/sim-status-batch": "批量查询卡状态", "query/sim-card-bind-status": "查询绑定状态", "query/sim-device-status": "查询设备状态", "query/sim-region-limit-status": "查询区域限制", "query/sim-imei": "查询 IMEI", "query/sim-datausage": "查询当月流量", "query/sim-datausage-monthly": "查询月流量", "query/sim-datausage-monthly-batch": "批量查询月流量", "query/sim-datausage-daily": "查询日流量", "query/sim-datausage-daily-batch": "批量查询日流量", "query/sim-apn-info": "查询 APN", "change/sim-status": "变更 SIM 状态", "change/sim-apn": "操作 APN", } if action := actions[endpoint]; action != "" { return category, action } return category, "访问 " + endpoint } func (s *Service) describeAdminOperation(r *http.Request, body []byte) string { key := r.Method + " " + r.URL.Path actions := map[string]string{ "GET /api/state": "查看管理台", "GET /api/card-groups": "查询卡分组", "POST /api/card-groups": "新增卡分组", "DELETE /api/card-groups": "删除卡分组", "GET /api/cards": "查询 SIM 卡", "DELETE /api/cards": "删除 SIM 卡", "GET /api/cards/export": "导出 SIM 卡", "POST /api/settings": "更新模拟设置", "GET /api/orders": "查询订单", "DELETE /api/orders": "清空订单", "POST /api/generate": "一键生成 SIM 卡", "POST /api/sync": "同步生成 SIM 卡", "DELETE /api/appids": "删除 AppID", } if key == "POST /api/appids" { appid := jsonStringField(body, "appid") for _, item := range s.store.Snapshot().AppIDs { if item.AppID == appid { return "更新 AppID" } } return "新增 AppID" } if key == "POST /api/cards" { msisdn := jsonStringField(body, "msisdn") for _, item := range s.store.Snapshot().Cards { if item.MSISDN == msisdn { return "编辑 SIM 卡" } } return "新增 SIM 卡" } if action := actions[key]; action != "" { return action } return "访问 " + r.URL.Path } func jsonStringField(body []byte, key string) string { var value map[string]interface{} if json.Unmarshal(body, &value) != nil { return "" } return fmt.Sprint(value[key]) } func operationTarget(query url.Values, body []byte) string { keys := []string{"appid", "msisdn", "iccid", "imsi", "id"} for _, key := range keys { if value := strings.TrimSpace(query.Get(key)); value != "" { return key + ": " + value } } var value map[string]interface{} if json.Unmarshal(body, &value) != nil { return "" } for _, key := range keys { if item := strings.TrimSpace(fmt.Sprint(value[key])); item != "" && item != "" { return key + ": " + item } } if simKeys, ok := value["simKeys"].([]interface{}); ok && len(simKeys) > 0 { if first, ok := simKeys[0].(map[string]interface{}); ok { for _, key := range []string{"msisdn", "iccid", "imsi"} { if item := strings.TrimSpace(fmt.Sprint(first[key])); item != "" && item != "" { return key + ": " + item } } } } return "" } func operationParams(query url.Values, body []byte) string { parts := make([]string, 0, 2) if len(query) > 0 { clean := make(url.Values, len(query)) keys := make([]string, 0, len(query)) for key := range query { keys = append(keys, key) } sort.Strings(keys) for _, key := range keys { if sensitiveOperationKey(key) { clean.Set(key, "***") } else { clean[key] = append([]string(nil), query[key]...) } } parts = append(parts, clean.Encode()) } if len(bytes.TrimSpace(body)) > 0 { var value interface{} if json.Unmarshal(body, &value) == nil { if raw, err := json.Marshal(redactOperationValue(value)); err == nil { parts = append(parts, string(raw)) } } else { parts = append(parts, "[non-JSON request body]") } } result := strings.Join(parts, " ") if len(result) > maxOperationDetailBytes { return result[:maxOperationDetailBytes] + "..." } return result } func redactOperationValue(value interface{}) interface{} { switch item := value.(type) { case map[string]interface{}: for key, child := range item { if sensitiveOperationKey(key) { item[key] = "***" } else { item[key] = redactOperationValue(child) } } case []interface{}: for i := range item { item[i] = redactOperationValue(item[i]) } } return value } func sensitiveOperationKey(key string) bool { key = strings.ToLower(key) return strings.Contains(key, "password") || strings.Contains(key, "token") || strings.Contains(key, "authorization") } func operationResult(status int, body []byte) (string, string) { if status >= http.StatusBadRequest { return "failed", responseMessage(body, http.StatusText(status)) } var value interface{} if json.Unmarshal(body, &value) != nil { return "success", "" } if item, ok := value.(map[string]interface{}); ok { if message, ok := item["error"].(string); ok && message != "" { return "failed", message } if code := fmt.Sprint(item["status"]); code != "" && code != "" && code != "0" { return "failed", fmt.Sprint(item["message"]) } } if items, ok := value.([]interface{}); ok { for _, raw := range items { item, ok := raw.(map[string]interface{}) if !ok { continue } if code, ok := item["code"].(float64); ok && code != chanSuccessCode { return "failed", fmt.Sprint(item["message"]) } } } return "success", "" } func responseMessage(body []byte, fallback string) string { var value map[string]interface{} if json.Unmarshal(body, &value) == nil { for _, key := range []string{"error", "message"} { if message := strings.TrimSpace(fmt.Sprint(value[key])); message != "" && message != "" { return message } } } return fallback } func clientIP(r *http.Request) string { if forwarded := strings.TrimSpace(strings.Split(r.Header.Get("X-Forwarded-For"), ",")[0]); forwarded != "" { return forwarded } host, _, err := net.SplitHostPort(r.RemoteAddr) if err == nil { return host } return r.RemoteAddr } func (s *Service) adminOperationLogs(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { w.WriteHeader(http.StatusMethodNotAllowed) return } page, pageSize := pagination(r) writeJSON(w, http.StatusOK, s.operationLogs.Query( r.URL.Query().Get("keyword"), r.URL.Query().Get("category"), r.URL.Query().Get("result"), page, pageSize, )) } func operationLogID(value int64) string { return strconv.FormatInt(value, 10) }