Files

457 lines
13 KiB
Go

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 != "<nil>" {
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 != "<nil>" {
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 != "<nil>" && 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 != "<nil>" {
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)
}