457 lines
13 KiB
Go
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)
|
|
}
|