chore: 并入 iotlcm-onelink-mock 历史到 onelink-mock/(原提交 f398595)
This commit is contained in:
@@ -0,0 +1,456 @@
|
||||
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)
|
||||
}
|
||||
Reference in New Issue
Block a user