Jelajahi Sumber

新增客户端信息展示

flyzto 2 minggu lalu
induk
melakukan
cbb9562c72

+ 140 - 0
server/models/Clients.js

@@ -0,0 +1,140 @@
+import path from 'path';
+import { fileURLToPath } from 'url';
+
+import Cache from '../libs/cache.js';
+
+const __filename = fileURLToPath(import.meta.url);
+const __dirname = path.dirname(__filename);
+const ClientsCacheFile = path.join(__dirname, '../data/clients.cache');
+
+const CLIENT_HEADER_FIELDS = [
+  'X-Device',
+  'X-Version',
+  'X-Data-Type',
+  'X-Market-Type',
+  'X-Group-Sequence',
+  'X-Platform',
+];
+
+const CLIENT_KEY_FIELDS = [
+  'X-Data-Type',
+  'X-Market-Type',
+  'X-Group-Sequence',
+  'X-Platform',
+];
+
+const CLIENT_FIELD_NAMES = {
+  'X-Data-Type': 'dataType',
+  'X-Device': 'device',
+  'X-Group-Sequence': 'groupSequence',
+  'X-Market-Type': 'marketType',
+  'X-Platform': 'platform',
+  'X-Version': 'version',
+};
+
+const CLIENTS = {
+  Items: {},
+};
+
+let saveTimer = null;
+
+const normalizeHeaderValue = (value) => {
+  if (Array.isArray(value)) {
+    return value.join(',');
+  }
+  return value == null ? '' : String(value).trim();
+}
+
+const getRequestHeader = (req, field) => {
+  return normalizeHeaderValue(req.get?.(field));
+}
+
+const getClientHeaders = (req) => {
+  return CLIENT_HEADER_FIELDS.reduce((headers, field) => {
+    headers[field] = getRequestHeader(req, field);
+    return headers;
+  }, {});
+}
+
+const formatClientFields = (headers) => {
+  return CLIENT_HEADER_FIELDS.reduce((clientFields, field) => {
+    clientFields[CLIENT_FIELD_NAMES[field]] = headers[field];
+    return clientFields;
+  }, {});
+}
+
+const getClientKey = (headers) => {
+  return CLIENT_KEY_FIELDS
+  .map(field => `${field}:${headers[field] ?? ''}`)
+  .join('|');
+}
+
+const scheduleSaveClientsToCache = () => {
+  if (saveTimer) {
+    clearTimeout(saveTimer);
+  }
+  saveTimer = setTimeout(saveClientsToCache, 1000);
+}
+
+const recordRequest = (req) => {
+  const route = req.path;
+  const headers = getClientHeaders(req);
+  const clientFields = formatClientFields(headers);
+  const key = getClientKey(headers);
+  const now = Date.now();
+  const current = CLIENTS.Items[key] ?? {};
+
+  CLIENTS.Items[key] = {
+    key,
+    ...clientFields,
+    route,
+    firstRequestTime: current.firstRequestTime ?? now,
+    lastRequestTime: now,
+    requestCount: (current.requestCount ?? 0) + 1,
+  };
+
+  scheduleSaveClientsToCache();
+  return CLIENTS.Items[key];
+}
+
+const getClients = () => {
+  return Object.values(CLIENTS.Items)
+  .sort((a, b) => (b.lastRequestTime ?? 0) - (a.lastRequestTime ?? 0));
+}
+
+function saveClientsToCache() {
+  if (saveTimer) {
+    clearTimeout(saveTimer);
+    saveTimer = null;
+  }
+  Cache.setData(ClientsCacheFile, CLIENTS);
+}
+
+function loadClientsFromCache() {
+  const cachedClients = Cache.getData(ClientsCacheFile, true);
+  if (!cachedClients?.Items) {
+    return;
+  }
+  CLIENTS.Items = cachedClients.Items;
+}
+
+loadClientsFromCache();
+
+process.on('exit', saveClientsToCache);
+process.on('SIGINT', () => {
+  process.exit(0);
+});
+process.on('SIGTERM', () => {
+  process.exit(0);
+});
+process.on('SIGUSR2', () => {
+  process.exit(0);
+});
+
+const Clients = {
+  recordRequest,
+  getClients,
+};
+
+export { recordRequest, getClients };
+export default Clients;

+ 13 - 1
server/routes/pstery.js

@@ -2,12 +2,14 @@ import express from 'express';
 const router = express.Router();
 
 import Games from '../models/GamesPs.js';
+import Clients from '../models/Clients.js';
 
 /**
  * 更新比赛列表
  */
 router.post('/update_games_list', (req, res) => {
   const { platform, mk, games } = req.body ?? {};
+  Clients.recordRequest(req);
   Games.updateGamesList({ platform, mk, games })
   .then(() => {
     res.sendSuccess();
@@ -17,11 +19,13 @@ router.post('/update_games_list', (req, res) => {
   })
 });
 
+
 /**
  * 更新比赛盘口
  */
 router.post('/update_games_events', (req, res) => {
   const { platform, mk, games, outrights, timestamp, tp } = req.body ?? {};
+  Clients.recordRequest(req);
   Games.updateGamesEvents({ platform, mk, games, outrights, timestamp, tp })
   .then(updateCount => {
     res.sendSuccess({ updateCount });
@@ -51,10 +55,18 @@ router.post('/update_base_events', (req, res) => {
  */
 router.post('/update_leagues_list', (req, res) => {
   const { mk, leagues, platform } = req.body ?? {};
+  Clients.recordRequest(req);
   const updateCount = Games.updateLeaguesList({ mk, leagues, platform });
   res.sendSuccess({ updateCount });
 });
 
+/**
+ * 获取数据同步客户端列表
+ */
+router.get('/get_clients', (req, res) => {
+  res.sendSuccess(Clients.getClients());
+});
+
 /**
  * 更新比赛结果
  */
@@ -87,7 +99,7 @@ router.get('/get_filtered_leagues', (req, res) => {
  * 更新OB原始数据
  */
 router.post('/update_original_data', (req, res) => {
-  const { leagues, matches, platform } = req.body ?? {};
+  const { leagues, matches } = req.body ?? {};
   Games.updateOriginalData({ leagues, matches });
   res.sendSuccess();
 });

+ 1 - 0
web/apps/web-antd/src/locales/langs/en-US/page.json

@@ -14,6 +14,7 @@
     "title": "Match Management",
     "related": "Related Matches",
     "centerOrder": "Center Order",
+    "dataSync": "Data Sync",
     "dataTest": "Data Test",
     "oddsCurve": "Odds Curve"
   },

+ 2 - 1
web/apps/web-antd/src/locales/langs/zh-CN/page.json

@@ -13,7 +13,8 @@
   "match": {
     "title": "比赛管理",
     "related": "关联比赛",
-    "centerOrder": "中单记录",
+    "centerOrder": "策略列表",
+    "dataSync": "数据同步",
     "dataTest": "数据测试",
     "oddsCurve": "赔率曲线"
   },

+ 10 - 0
web/apps/web-antd/src/router/routes/modules/match.ts

@@ -23,6 +23,16 @@ const routes: RouteRecordRaw[] = [
           roles: ['admin'], // Only users with admin role can access this page
         },
       },
+      {
+        name: 'DataSync',
+        path: 'data-sync',
+        component: () => import('#/views/match/data-sync/index.vue'),
+        meta: {
+          icon: 'ion:sync-outline',
+          title: $t('page.match.dataSync'),
+          roles: ['admin'],
+        },
+      },
       {
         name: 'OddsCurve',
         path: 'odds-curve',

+ 383 - 0
web/apps/web-antd/src/views/match/data-sync/index.vue

@@ -0,0 +1,383 @@
+<script setup lang="ts">
+import { Page } from '@vben/common-ui';
+
+import { computed, h, onMounted, onUnmounted, ref } from 'vue';
+
+import { Button, message, Space, Switch, Table, Tag } from 'ant-design-vue';
+import dayjs from 'dayjs';
+
+import { requestClient } from '#/api/request';
+
+type SyncClient = {
+  dataType?: string;
+  device?: string;
+  firstRequestTime?: number;
+  groupSequence?: string;
+  key: string;
+  lastRequestTime?: number;
+  marketType?: string;
+  platform?: string;
+  requestCount?: number;
+  route?: string;
+  version?: string;
+};
+
+type SyncClientRow = SyncClient & {
+  children?: SyncClientRow[];
+  count?: number;
+  onlineCount?: number;
+  rowKey: string;
+  rowType: 'client' | 'dataType' | 'marketType' | 'platform';
+  title: string;
+};
+
+const CHAR_MAP: Record<string, Record<string, string>> = {
+  dataType: {
+    1: '列表',
+    2: '让球/大小',
+    3: '特别投注',
+  },
+  marketType: {
+    0: '早盘',
+    1: '今日',
+    2: '滚球',
+  },
+  platform: {
+    ay: 'IM',
+    hg: '皇冠',
+    im: 'IM',
+    ob: 'OB',
+    ps: '平博',
+    tq: 'OB',
+  },
+};
+
+const clients = ref<SyncClient[]>([]);
+const loading = ref(false);
+const autoRefresh = ref(true);
+const refreshTimer = ref<ReturnType<typeof setTimeout>>();
+
+const columns = [
+  {
+    dataIndex: 'title',
+    key: 'title',
+    title: '分类',
+    width: 180,
+  },
+  {
+    dataIndex: 'groupSequence',
+    key: 'groupSequence',
+    title: '分组序列',
+    width: 80,
+  },
+  {
+    dataIndex: 'requestCount',
+    key: 'requestCount',
+    title: '请求次数',
+    width: 100,
+  },
+  {
+    dataIndex: 'lastRequestTime',
+    key: 'lastRequestTime',
+    title: '最后请求时间',
+    width: 180,
+  },
+  {
+    dataIndex: 'firstRequestTime',
+    key: 'firstRequestTime',
+    title: '初次请求时间',
+    width: 180,
+  },
+  {
+    dataIndex: 'route',
+    key: 'route',
+    title: '请求接口',
+    width: 180,
+  },
+];
+
+const onlineThreshold = 5 * 60 * 1000;
+
+const onlineCount = computed(() => {
+  const now = Date.now();
+  return clients.value.filter(item => now - (item.lastRequestTime ?? 0) <= onlineThreshold).length;
+});
+
+const formatTime = (time?: number) => {
+  return time ? dayjs(time).format('YYYY-MM-DD HH:mm:ss') : '-';
+};
+
+const displayValue = (value?: number | string) => {
+  return value === undefined || value === '' ? '-' : value;
+};
+
+const displayMappedValue = (
+  type: keyof typeof CHAR_MAP,
+  value?: number | string,
+) => {
+  if (value === undefined || value === '') {
+    return '-';
+  }
+  return CHAR_MAP[type]?.[String(value)] ?? value;
+};
+
+const isOnline = (time?: number) => {
+  return !!time && Date.now() - time <= onlineThreshold;
+};
+
+const getGroupKey = (value?: string) => {
+  return value === undefined || value === '' ? '__empty__' : String(value);
+};
+
+const getGroupTitle = (type: keyof typeof CHAR_MAP, value?: string) => {
+  return displayMappedValue(type, value);
+};
+
+const getLatestTime = (items: SyncClient[]) => {
+  return Math.max(...items.map(item => item.lastRequestTime ?? 0));
+};
+
+const getRequestCount = (items: SyncClient[]) => {
+  return items.reduce((total, item) => total + (item.requestCount ?? 0), 0);
+};
+
+const getOnlineCount = (items: SyncClient[]) => {
+  return items.filter(item => isOnline(item.lastRequestTime)).length;
+};
+
+const createClientRow = (client: SyncClient): SyncClientRow => ({
+  ...client,
+  rowKey: `client:${client.key}`,
+  rowType: 'client',
+  title: client.device || '客户端',
+});
+
+const createGroupRow = (
+  rowType: SyncClientRow['rowType'],
+  rowKey: string,
+  title: string,
+  items: SyncClient[],
+  children: SyncClientRow[],
+): SyncClientRow => ({
+  rowKey,
+  key: rowKey,
+  rowType,
+  title,
+  children,
+  count: items.length,
+  lastRequestTime: getLatestTime(items),
+  onlineCount: getOnlineCount(items),
+  requestCount: getRequestCount(items),
+});
+
+const groupByField = (items: SyncClient[], field: keyof SyncClient) => {
+  return items.reduce<Record<string, SyncClient[]>>((groups, item) => {
+    const key = getGroupKey(item[field] as string | undefined);
+    if (!groups[key]) {
+      groups[key] = [];
+    }
+    groups[key].push(item);
+    return groups;
+  }, {});
+};
+
+const sortGroupEntries = (entries: Array<[string, SyncClient[]]>) => {
+  return entries.sort(([keyA], [keyB]) => {
+    if (keyA === '__empty__') {
+      return 1;
+    }
+    if (keyB === '__empty__') {
+      return -1;
+    }
+    return keyA.localeCompare(keyB, 'zh-CN', { numeric: true });
+  });
+};
+
+const groupedClients = computed<SyncClientRow[]>(() => {
+  const marketGroups = groupByField(clients.value, 'marketType');
+
+  return sortGroupEntries(Object.entries(marketGroups)).map(([marketType, marketItems]) => {
+    const dataGroups = groupByField(marketItems, 'dataType');
+    const dataChildren = sortGroupEntries(Object.entries(dataGroups)).map(([dataType, dataItems]) => {
+      const platformGroups = groupByField(dataItems, 'platform');
+      const platformChildren = sortGroupEntries(Object.entries(platformGroups)).map(([platform, platformItems]) => {
+        return createGroupRow(
+          'platform',
+          `market:${marketType}:data:${dataType}:platform:${platform}`,
+          getGroupTitle('platform', platform === '__empty__' ? undefined : platform),
+          platformItems,
+          platformItems.map(createClientRow),
+        );
+      });
+
+      return createGroupRow(
+        'dataType',
+        `market:${marketType}:data:${dataType}`,
+        getGroupTitle('dataType', dataType === '__empty__' ? undefined : dataType),
+        dataItems,
+        platformChildren,
+      );
+    });
+
+    return createGroupRow(
+      'marketType',
+      `market:${marketType}`,
+      getGroupTitle('marketType', marketType === '__empty__' ? undefined : marketType),
+      marketItems,
+      dataChildren,
+    );
+  });
+});
+
+const expandedRowKeys = computed(() => {
+  const keys: string[] = [];
+  const collectKeys = (rows: SyncClientRow[]) => {
+    rows.forEach((row) => {
+      if (row.children?.length) {
+        keys.push(row.rowKey);
+        collectKeys(row.children);
+      }
+    });
+  };
+  collectKeys(groupedClients.value);
+  return keys;
+});
+
+const renderExpandIcon = () => h('span', { class: 'hidden-expand-icon' });
+
+const clearRefreshTimer = () => {
+  if (refreshTimer.value) {
+    clearTimeout(refreshTimer.value);
+    refreshTimer.value = undefined;
+  }
+};
+
+const scheduleRefresh = () => {
+  clearRefreshTimer();
+  if (!autoRefresh.value) {
+    return;
+  }
+  refreshTimer.value = setTimeout(fetchClients, 10 * 1000);
+};
+
+const fetchClients = async () => {
+  loading.value = true;
+  try {
+    const data = await requestClient.get<SyncClient[]>('/pstery/get_clients');
+    clients.value = data ?? [];
+  }
+  catch (error) {
+    console.error('Failed to fetch sync clients:', error);
+    message.error('获取数据同步客户端失败');
+  }
+  finally {
+    loading.value = false;
+    scheduleRefresh();
+  }
+};
+
+const handleAutoRefreshChange = () => {
+  scheduleRefresh();
+};
+
+onMounted(() => {
+  fetchClients();
+});
+
+onUnmounted(() => {
+  clearRefreshTimer();
+});
+</script>
+
+<template>
+  <Page title="数据同步">
+    <div class="data-sync-toolbar">
+      <Space>
+        <Button type="primary" :loading="loading" @click="fetchClients">刷新</Button>
+        <span class="summary">
+          在线 {{ onlineCount }} / 总计 {{ clients.length }}
+        </span>
+      </Space>
+      <Space>
+        <span class="auto-label">自动刷新</span>
+        <Switch v-model:checked="autoRefresh" @change="handleAutoRefreshChange" />
+      </Space>
+    </div>
+
+    <Table
+      :columns="columns"
+      :data-source="groupedClients"
+      :expanded-row-keys="expandedRowKeys"
+      :expand-icon="renderExpandIcon"
+      :loading="loading"
+      :pagination="false"
+      :row-key="record => record.rowKey"
+      bordered
+      size="small"
+      :scroll="{ x: 900 }"
+    >
+      <template #bodyCell="{ column, record, text }">
+        <template v-if="column.key === 'title'">
+          <Space>
+            <Tag v-if="record.rowType === 'client'" :color="isOnline(record.lastRequestTime) ? 'green' : 'default'">
+              {{ isOnline(record.lastRequestTime) ? '在线' : '离线' }}
+            </Tag>
+            <span>{{ record.title }}</span>
+            <span v-if="record.rowType === 'client' && record.version" class="version-text">
+              {{ record.version }}
+            </span>
+            <span v-if="record.rowType !== 'client'" class="group-count">
+              {{ record.onlineCount ?? 0 }} / {{ record.count ?? 0 }}
+            </span>
+          </Space>
+        </template>
+        <template v-else-if="column.key === 'lastRequestTime'">
+          <span v-if="record.rowType === 'client'">{{ formatTime(record.lastRequestTime) }}</span>
+          <span v-else>-</span>
+        </template>
+        <template v-else-if="column.key === 'firstRequestTime'">
+          <span v-if="record.rowType === 'client'">{{ formatTime(record.firstRequestTime) }}</span>
+          <span v-else>-</span>
+        </template>
+        <template v-else-if="record.rowType !== 'client'">
+          -
+        </template>
+        <template v-else-if="column.key === 'requestCount'">
+          {{ record.requestCount ?? 0 }}
+        </template>
+        <template v-else>
+          {{ displayValue(text) }}
+        </template>
+      </template>
+    </Table>
+  </Page>
+</template>
+
+<style scoped>
+.data-sync-toolbar {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  gap: 12px;
+  margin-bottom: 12px;
+}
+
+.summary,
+.auto-label {
+  color: hsl(var(--muted-foreground));
+}
+
+.group-count {
+  color: hsl(var(--muted-foreground));
+  font-size: 12px;
+}
+
+.version-text {
+  color: hsl(var(--muted-foreground));
+  font-size: 12px;
+}
+
+:deep(.hidden-expand-icon) {
+  display: none;
+}
+</style>