Browse Source

代理平博接口

flyzto 1 week ago
parent
commit
1ed18bcef2

+ 11 - 3
pinnacle/libs/getAccount.js

@@ -1,5 +1,6 @@
 import Logs from "./logs.js";
 import { getData, setData } from "./cache.js";
+import { findCurrencyInfo } from "./globalData.js";
 
 const accountOptionCacheFile = 'data/accountOptionCache.json';
 
@@ -59,8 +60,15 @@ export const getAccountInfo = () => {
   const nowTime = Date.now();
 
   const accounts = process.env.PINNACLE_ACCOUNTS?.split(',').map(item => {
-    const [username, password, localAddress, platform] = item.split(':');
-    return { username, password, localAddress, platform };
+    const [username, password, localAddress, platform, currency='USD'] = item.split(':');
+    return {
+      username,
+      password,
+      localAddress,
+      platform,
+      currency,
+      currencyInfo: findCurrencyInfo(currency),
+    };
   }) ?? [];
 
   if (accounts.length == 0) {
@@ -117,4 +125,4 @@ process.on('SIGUSR2', () => {
   saveExit(0);
 });
 
-loadGlobalDataFromCache();
+loadGlobalDataFromCache();

+ 65 - 0
pinnacle/libs/globalData.js

@@ -0,0 +1,65 @@
+/**
+ * 全局运行时数据
+ */
+export const GLOBAL_DATA = {
+  filtedLeagues: [],
+  filtedGames: [],
+  gamesMap: {},
+  straightFixturesVersion: 0,
+  straightFixturesCount: 0,
+  specialFixturesVersion: 0,
+  specialFixturesCount: 0,
+  straightOddsVersion: 0,
+  // straightOddsCount: 0,
+  specialsOddsVersion: 0,
+  // specialsOddsCount: 0,
+  currencies: [],
+  currenciesUpdatedAt: 0,
+  requestErrorCount: 0,
+  loopActive: false,
+  loopResultTime: 0,
+};
+
+export const getCurrenciesInfo = () => {
+  const { currencies, currenciesUpdatedAt } = GLOBAL_DATA;
+  if (!currenciesUpdatedAt) {
+    return Promise.reject({
+      cause: 503,
+      message: 'currencies data is not ready',
+      data: { currenciesUpdatedAt },
+    });
+  }
+  return Promise.resolve({ data: currencies, updatedAt: currenciesUpdatedAt });
+}
+
+export const findCurrencyInfo = (currencyCode) => {
+  const code = currencyCode?.toUpperCase();
+  const currencies = GLOBAL_DATA.currencies?.currencies ?? GLOBAL_DATA.currencies;
+  if (!code || !Array.isArray(currencies)) {
+    return null;
+  }
+  return currencies.find(currency => currency.code?.toUpperCase() === code) ?? null;
+}
+
+const normalizeIds = (ids) => {
+  if (ids === undefined || ids === null || ids === '') {
+    return [];
+  }
+
+  if (Array.isArray(ids)) {
+    return ids.flatMap(normalizeIds);
+  }
+
+  return String(ids).split(',').map(id => id.trim()).filter(Boolean);
+}
+
+export const getEventsByIds = (ids) => {
+  const normalizedIds = normalizeIds(ids);
+  const { gamesMap={} } = GLOBAL_DATA;
+
+  if (!normalizedIds.length) {
+    return Object.values(gamesMap);
+  }
+
+  return normalizedIds.map(id => gamesMap[id]).filter(Boolean);
+}

+ 69 - 4
pinnacle/libs/pinnacleClient.js

@@ -23,18 +23,83 @@ const BaseURL = {
   pstery: "http://127.0.0.1:9055",
 }
 
+const betPostPaths = new Set([
+  '/v4/bets/place',
+  '/v4/bets/parlay',
+  '/v4/bets/teaser',
+  '/v4/bets/special',
+]);
+
+const cloneJsonData = (data) => {
+  if (data === undefined || data === null) {
+    return data;
+  }
+  return JSON.parse(JSON.stringify(data));
+}
+
+const toAccountCurrencyStake = (amount, accountInfo) => {
+  const value = Number(amount);
+  if (!Number.isFinite(value)) {
+    return amount;
+  }
+
+  const currency = accountInfo?.currency?.toUpperCase() || 'USD';
+  if (currency === 'USD') {
+    return Math.round(value);
+  }
+
+  const rate = Number(accountInfo?.currencyInfo?.rate);
+  if (!Number.isFinite(rate) || rate <= 0) {
+    const error = new Error(`currency rate is not ready: ${currency}`);
+    error.cause = 503;
+    error.data = { currency };
+    throw error;
+  }
+
+  return Math.round(value * rate);
+}
+
+const convertBetStakeToUsd = (data, accountInfo) => {
+  if (!data || typeof data !== 'object') {
+    return data;
+  }
+
+  const converted = cloneJsonData(data);
+  const requests = Array.isArray(converted.bets) ? converted.bets : [converted];
+  requests.forEach(request => {
+    if (!request || typeof request !== 'object') {
+      return;
+    }
+
+    if (request.stake !== undefined) {
+      request.stake = toAccountCurrencyStake(request.stake, accountInfo);
+    }
+
+    if (request.riskAmount !== undefined) {
+      request.riskAmount = toAccountCurrencyStake(request.riskAmount, accountInfo);
+    }
+  });
+
+  return converted;
+}
+
 export const pinnacleRequest = async (options, channel) => {
 
-  const { username, password, localAddress, platform } = getAccountInfo() ?? {};
-  const { url, ...optionsRest } = options;
+  const accountInfo = getAccountInfo() ?? {};
+  const { username, password, localAddress, platform } = accountInfo;
+  const { data, params, url, ...optionsRest } = options;
   if (!url || !channel && (!username || !password)) {
     throw new Error("url、username、password、channel is required");
   }
 
+  const requestData = !channel && optionsRest.method === 'POST' && betPostPaths.has(url)
+    ? convertBetStakeToUsd(data, accountInfo)
+    : data;
+
   Logs.outDev('pinnacle request', { url, channel, username, password, localAddress, platform });
 
   const authHeader = channel ? `Basic ${channel}` : `Basic ${Buffer.from(`${username}:${password}`).toString("base64")}`;
-  const axiosConfig = { ...axiosDefaultOptions, ...optionsRest, url, baseURL: BaseURL[platform] ?? BaseURL.pinnacle };
+  const axiosConfig = { ...axiosDefaultOptions, ...optionsRest, data: requestData, params, url, baseURL: BaseURL[platform] ?? BaseURL.pinnacle };
   Object.assign(axiosConfig.headers, {
     "Authorization": authHeader,
     "Accept": "application/json",
@@ -154,4 +219,4 @@ export const notifyException = async (message) => {
   .catch(err => {
     Logs.err('failed to notify exception:', err.message);
   });
-}
+}

+ 46 - 22
pinnacle/libs/syncData.js

@@ -5,6 +5,7 @@ import { pinnacleGet, getPsteryRelations, updateBaseEvents, notifyException } fr
 import { parseGame, parseIorDetail } from "./parseGameData.js";
 import Logs from "./logs.js";
 import { getData, setData } from "./cache.js";
+import { GLOBAL_DATA } from "./globalData.js";
 
 const __filename = fileURLToPath(import.meta.url);
 const __dirname = path.dirname(__filename);
@@ -14,27 +15,6 @@ const globalDataCacheFile = path.join(__dirname, '../data/globalDataCache.json')
 const TP = 'ps_9_9_1';
 const IS_DEV = process.env.NODE_ENV == 'development';
 
-/**
- * 全局数据
- * GLOBAL_DATA
- */
-const GLOBAL_DATA = {
-  filtedLeagues: [],
-  filtedGames: [],
-  gamesMap: {},
-  straightFixturesVersion: 0,
-  straightFixturesCount: 0,
-  specialFixturesVersion: 0,
-  specialFixturesCount: 0,
-  straightOddsVersion: 0,
-  // straightOddsCount: 0,
-  specialsOddsVersion: 0,
-  // specialsOddsCount: 0,
-  requestErrorCount: 0,
-  loopActive: false,
-  loopResultTime: 0,
-};
-
 /**
  * 重置版本计数
  */
@@ -520,6 +500,47 @@ const updateInRunning = async () => {
   });
 }
 
+/**
+ * 获取币种数据
+ * @returns {Promise<Array>}
+ */
+const getCurrencies = async () => {
+  return pinnacleGet('/v2/currencies');
+}
+
+/**
+ * 更新币种数据
+ * @returns {Promise<void>}
+ */
+const updateCurrencies = async () => {
+  return getCurrencies()
+  .then(data => {
+    GLOBAL_DATA.currencies = data;
+    GLOBAL_DATA.currenciesUpdatedAt = Date.now();
+    Logs.outDev('currencies updated');
+  });
+}
+
+const getCurrenciesLoopDelay = () => {
+  const minDelay = 10 * 60 * 1000;
+  const maxDelay = 15 * 60 * 1000;
+  return minDelay + Math.floor(Math.random() * (maxDelay - minDelay + 1));
+}
+
+/**
+ * 同步币种数据循环
+ */
+const currenciesLoop = () => {
+  updateCurrencies()
+  .catch(err => {
+    Logs.err('failed to update currencies:', err.message, err.source);
+  })
+  .finally(() => {
+    const delay = GLOBAL_DATA.currenciesUpdatedAt ? getCurrenciesLoopDelay() : 60 * 1000;
+    setTimeout(currenciesLoop, delay);
+  });
+}
+
 /**
  * 获取盘口数据
  * @returns {Object}
@@ -737,7 +758,10 @@ export const startSyncMarketsData = () => {
     GLOBAL_DATA.loopActive = true;
     return getFiltedGames();
   })
-  .then(pinnacleDataLoop);
+  .then(() => {
+    currenciesLoop();
+    pinnacleDataLoop();
+  });
 }
 
 /**

+ 2 - 0
pinnacle/main.js

@@ -4,6 +4,7 @@ import express from 'express';
 
 import Logs from "./libs/logs.js";
 import requireInternalToken from './middleware/requireInternalToken.js';
+import pinnacleProxyRoutes from './routes/pinnacleProxy.js';
 import tradingRoutes from './routes/trading.js';
 
 const app = express();
@@ -66,6 +67,7 @@ app.get('/health', (req, res) => {
   res.sendSuccess({ service: 'polymarket', status: 'ok' });
 });
 
+app.use('/api/pinnacle', pinnacleProxyRoutes);
 app.use('/api/trading', requireInternalToken, tradingRoutes);
 
 // 启动服务

+ 187 - 0
pinnacle/routes/pinnacleProxy.js

@@ -0,0 +1,187 @@
+import express from 'express';
+
+import { getCurrenciesInfo, getEventsByIds } from '../libs/globalData.js';
+import { pinnacleGet, pinnaclePost } from '../libs/pinnacleClient.js';
+
+const router = express.Router();
+const SECOND = 1000;
+const MINUTE = 60 * SECOND;
+
+const getCacheTtls = new Map([
+  ['/v3/sports', MINUTE],
+  ['/v2/inrunning', 2 * SECOND],
+  ['/v1/bets/betting-status', SECOND],
+  ['/v1/cancellationreasons', 10 * MINUTE],
+]);
+
+const getRateLimitTtls = new Map([
+  ['/v3/leagues', MINUTE],
+  ['/v3/bets', 2 * SECOND],
+]);
+
+const responseCache = new Map();
+const inFlightRequests = new Map();
+const lastRequests = new Map();
+
+const allowedEndpoints = new Map([
+  ['GET /v2/line', 'Get Straight Line - v2'],
+  ['POST /v3/line/parlay', 'Get Parlay Line - v3'],
+  ['POST /v1/line/teaser', 'Get Teaser Line - v1'],
+  ['GET /v2/line/special', 'Get Special Line - v2'],
+  ['GET /v3/sports', 'Get Sports - v3'],
+  ['GET /v3/leagues', 'Get Leagues - v3'],
+  ['GET /v1/periods', 'Get Periods - v1'],
+  ['GET /v2/inrunning', 'Get In-Running - v2'],
+  ['GET /v1/cancellationreasons', 'Get Cancellation Reasons - v1'],
+  ['GET /v2/currencies', 'Get Currencies - v2'],
+  ['GET /v1/client/balance', 'Get Client Balance - v1'],
+  ['POST /v4/bets/place', 'Place straight bet - v4'],
+  ['POST /v4/bets/parlay', 'Place parlay bet - v4'],
+  ['POST /v4/bets/teaser', 'Place teaser bet - v4'],
+  ['POST /v4/bets/special', 'Place specials bet - v4'],
+  ['GET /v3/bets', 'Get Bets - v3'],
+  ['GET /v3/bets/settled', 'Get Bets Settled - v3'],
+  ['GET /v1/regrades/wager-history', 'Get Regrades Wager History - v1'],
+  ['GET /v1/bets/betting-status', 'Get Betting Status'],
+]);
+
+const normalizePath = (path) => {
+  if (!path || path === '/') {
+    return '/';
+  }
+  return path.endsWith('/') ? path.slice(0, -1) : path;
+};
+
+const stableStringify = (value) => {
+  if (Array.isArray(value)) {
+    return `[${value.map(stableStringify).join(',')}]`;
+  }
+
+  if (value && typeof value === 'object') {
+    return `{${Object.keys(value).sort().map(key => `${JSON.stringify(key)}:${stableStringify(value[key])}`).join(',')}}`;
+  }
+
+  return JSON.stringify(value);
+};
+
+const getCacheKey = (method, targetPath, query) => `${method} ${targetPath} ${stableStringify(query)}`;
+
+const checkRateLimit = (targetPath) => {
+  const ttl = getRateLimitTtls.get(targetPath);
+  if (!ttl) {
+    return null;
+  }
+
+  const lastRequestAt = lastRequests.get(targetPath) ?? 0;
+  const retryAfterMs = lastRequestAt + ttl - Date.now();
+  if (retryAfterMs > 0) {
+    return retryAfterMs;
+  }
+
+  lastRequests.set(targetPath, Date.now());
+  return null;
+};
+
+const getCachedPinnacleData = async (targetPath, query) => {
+  if (targetPath === '/v2/currencies') {
+    const { data, updatedAt } = await getCurrenciesInfo();
+    return { cacheStatus: 'GLOBAL_DATA', data, updatedAt };
+  }
+
+  const ttl = getCacheTtls.get(targetPath);
+
+  if (!ttl) {
+    return { cacheStatus: 'BYPASS', data: await pinnacleGet(targetPath, query) };
+  }
+
+  const cacheKey = getCacheKey('GET', targetPath, query);
+  const cached = responseCache.get(cacheKey);
+  if (cached && Date.now() - cached.createdAt < ttl) {
+    return { cacheStatus: 'HIT', data: cached.data };
+  }
+
+  if (inFlightRequests.has(cacheKey)) {
+    return { cacheStatus: 'INFLIGHT', data: await inFlightRequests.get(cacheKey) };
+  }
+
+  const request = pinnacleGet(targetPath, query)
+  .then(data => {
+    responseCache.set(cacheKey, { createdAt: Date.now(), data });
+    return data;
+  })
+  .finally(() => {
+    inFlightRequests.delete(cacheKey);
+  });
+
+  inFlightRequests.set(cacheKey, request);
+  return { cacheStatus: cached ? 'STALE_REFRESH' : 'MISS', data: await request };
+};
+
+const getEventIdsFromRequest = (req) => {
+  const source = req.method === 'GET' ? req.query : req.body;
+  return source?.ids ?? source?.id ?? source?.eventIds ?? source?.eventId;
+};
+
+router.use(async (req, res) => {
+  const method = req.method.toUpperCase();
+  const targetPath = normalizePath(req.path);
+
+  if (targetPath === '/events' && (method === 'GET' || method === 'POST')) {
+    const ids = getEventIdsFromRequest(req);
+    return res.status(200).json(getEventsByIds(ids));
+  }
+
+  const endpointKey = `${method} ${targetPath}`;
+
+  if (!allowedEndpoints.has(endpointKey)) {
+    return res.status(404).json({
+      statusCode: 404,
+      code: -1,
+      message: 'Unsupported Pinnacle API endpoint',
+      data: { method, path: targetPath },
+    });
+  }
+
+  try {
+    if (method === 'GET') {
+      const retryAfterMs = checkRateLimit(targetPath);
+      if (retryAfterMs) {
+        res.set('Retry-After', String(Math.ceil(retryAfterMs / SECOND)));
+        return res.status(429).json({
+          statusCode: 429,
+          code: -1,
+          message: 'Pinnacle API endpoint is rate limited, please retry later',
+          data: {
+            path: targetPath,
+            retryAfterMs,
+          },
+        });
+      }
+    }
+
+    if (method === 'GET') {
+      const data = await getCachedPinnacleData(targetPath, req.query);
+      res.set('X-Pinnacle-Proxy-Cache', data.cacheStatus);
+      if (data.updatedAt) {
+        res.set('X-Pinnacle-Proxy-Updated-At', String(data.updatedAt));
+      }
+      return res.status(200).json(data.data);
+    }
+
+    const data = await pinnaclePost(targetPath, req.body);
+    return res.status(200).json(data);
+  }
+  catch (err) {
+    const status = err.response?.status ?? err.status ?? err.cause ?? 500;
+    const data = err.response?.data ?? err.data;
+    const message = data?.message ?? err.message ?? 'Pinnacle API request failed';
+
+    return res.status(status).json(data ?? {
+      statusCode: status,
+      code: -1,
+      message,
+    });
+  }
+});
+
+export default router;