flyzto před 1 týdnem
rodič
revize
cb71eabf3d

+ 78 - 0
pinnacle/PINNACLE_PROXY_ENV.md

@@ -0,0 +1,78 @@
+# Pinnacle Proxy Env Config
+
+`/api/pinnacle` 不需要登录态,但每个调用方需要配置独立用户和密钥,并通过动态签名访问接口。
+
+## 配置方式
+
+在 `pinnacle/.env` 中配置:
+
+```env
+PINNACLE_PROXY_USERS=alice:aliceSecret:/events,/v3/sports;bob:bobSecret:*
+```
+
+格式:
+
+```txt
+用户名:密钥:权限列表;用户名:密钥:权限列表
+```
+
+说明:
+
+- `用户名`:前端请求时放在 `X-Api-User`。
+- `密钥`:只保存在服务端和可信调用方,不在请求中明文传递。
+- `权限列表`:逗号分隔,写 `/api/pinnacle` 后面的实际路径。
+- `*` 表示允许访问所有 `/api/pinnacle` 接口。
+
+## 示例
+
+```env
+PINNACLE_PROXY_USERS=viewer:viewerSecret:/events,/v2/currencies;trader:traderSecret:/events,/v4/bets/place,/v4/bets/parlay
+```
+
+对应权限:
+
+- `viewer` 可以访问 `/api/pinnacle/events` 和 `/api/pinnacle/v2/currencies`
+- `trader` 可以访问 `/api/pinnacle/events`、`/api/pinnacle/v4/bets/place`、`/api/pinnacle/v4/bets/parlay`
+
+## 全量权限
+
+如果要给某个用户所有 `/api/pinnacle` 接口权限,权限列表设置为 `*`:
+
+```env
+PINNACLE_PROXY_USERS=admin:adminSecret:*
+```
+
+多个用户时:
+
+```env
+PINNACLE_PROXY_USERS=admin:adminSecret:*;viewer:viewerSecret:/events,/v2/currencies
+```
+
+## 权限路径
+
+权限路径不包含 `/api/pinnacle` 前缀。
+
+例如接口是:
+
+```txt
+/api/pinnacle/events
+```
+
+权限中写:
+
+```txt
+/events
+```
+
+接口是:
+
+```txt
+/api/pinnacle/v4/bets/place
+```
+
+权限中写:
+
+```txt
+/v4/bets/place
+```
+

+ 78 - 24
pinnacle/PINNACLE_PROXY_AUTH.md → pinnacle/PINNACLE_PROXY_FRONTEND.md

@@ -1,42 +1,96 @@
-# Pinnacle Proxy API Auth
+# Pinnacle Proxy Frontend Auth
 
-`/api/pinnacle` 不需要登录态,但每个调用方需要配置独立用户和密钥,并通过动态签名访问接口
+前端访问 `/api/pinnacle` 时,需要使用动态签名。请求中不会直接传密钥
 
-## 服务端配置
+## 支持的接口
 
-在 `pinnacle/.env` 中配置
+本地数据接口
 
-```env
-PINNACLE_PROXY_USERS=alice:aliceSecret:/events,/v3/sports;bob:bobSecret:*
+```txt
+GET  /api/pinnacle/events
+POST /api/pinnacle/events
 ```
 
-格式
+Pinnacle Lines API
 
 ```txt
-用户名:密钥:权限列表;用户名:密钥:权限列表
+GET  /api/pinnacle/v2/line
+POST /api/pinnacle/v3/line/parlay
+POST /api/pinnacle/v1/line/teaser
+GET  /api/pinnacle/v2/line/special
+GET  /api/pinnacle/v3/sports
+GET  /api/pinnacle/v3/leagues
+GET  /api/pinnacle/v1/periods
+GET  /api/pinnacle/v2/inrunning
+GET  /api/pinnacle/v1/cancellationreasons
+GET  /api/pinnacle/v2/currencies
+GET  /api/pinnacle/v1/client/balance
 ```
 
-说明:
+Pinnacle Bets API:
+
+```txt
+POST /api/pinnacle/v4/bets/place
+POST /api/pinnacle/v4/bets/parlay
+POST /api/pinnacle/v4/bets/teaser
+POST /api/pinnacle/v4/bets/special
+GET  /api/pinnacle/v3/bets
+GET  /api/pinnacle/v3/bets/settled
+GET  /api/pinnacle/v1/regrades/wager-history
+GET  /api/pinnacle/v1/bets/betting-status
+```
 
-- `用户名`:前端请求时放在 `X-Api-User`。
-- `密钥`:只保存在服务端和可信调用方,不在请求中明文传递。
-- `权限列表`:逗号分隔,写 `/api/pinnacle` 后面的实际路径。
-- `*` 表示允许访问所有 `/api/pinnacle` 接口。
+签名中的 `PATH` 不包含 `/api/pinnacle` 前缀。例如请求 `/api/pinnacle/events`,签名时 `PATH` 使用 `/events`。
 
-示例:
+## /events 参数
 
-```env
-PINNACLE_PROXY_USERS=viewer:viewerSecret:/events,/v2/currencies;trader:traderSecret:/events,/v4/bets/place,/v4/bets/parlay
+`/events` 是本地数据接口,不请求上游 Pinnacle,直接读取服务端内存中的 `GLOBAL_DATA.gamesMap`。
+
+GET 支持 query 参数:
+
+```txt
+GET /api/pinnacle/events
+GET /api/pinnacle/events?id=123
+GET /api/pinnacle/events?ids=123,456
+GET /api/pinnacle/events?eventId=123
+GET /api/pinnacle/events?eventIds=123,456
 ```
 
-对应权限:
+POST 支持 JSON body
 
-- `viewer` 可以访问 `/api/pinnacle/events` 和 `/api/pinnacle/v2/currencies`
-- `trader` 可以访问 `/api/pinnacle/events`、`/api/pinnacle/v4/bets/place`、`/api/pinnacle/v4/bets/parlay`
+```json
+{ "id": 123 }
+```
+
+```json
+{ "ids": [123, 456] }
+```
+
+```json
+{ "eventId": 123 }
+```
+
+```json
+{ "eventIds": "123,456" }
+```
+
+参数优先级:
+
+```txt
+ids -> id -> eventIds -> eventId
+```
+
+返回说明:
+
+- 始终返回数组。
+- 传入单个 ID 时,返回匹配到的 0 或 1 条数据。
+- 传入多个 ID 时,返回匹配到的多条数据。
+- 不传 ID 时,返回当前 `GLOBAL_DATA.gamesMap` 中的全部赛事。
+- 找不到 ID 时不会报错,只是不返回对应项。
 
 ## 请求头
 
-前端每次请求 `/api/pinnacle` 都需要带:
+每次请求都需要带:
 
 ```txt
 X-Api-User: alice
@@ -47,6 +101,7 @@ X-Api-Signature: hmac-sha256-hex
 
 说明:
 
+- `X-Api-User`:服务端 `.env` 中配置的用户名。
 - `X-Api-Timestamp`:当前毫秒时间戳,允许和服务端相差 5 分钟。
 - `X-Api-Nonce`:每次请求都要不同,防止重放。
 - `X-Api-Signature`:用用户密钥对签名内容做 HMAC-SHA256。
@@ -84,7 +139,7 @@ abc123
 - `TIMESTAMP`:同 `X-Api-Timestamp`
 - `NONCE`:同 `X-Api-Nonce`
 
-## 前端示例
+## 签名工具
 
 浏览器端可使用 Web Crypto API:
 
@@ -151,7 +206,7 @@ const createPinnacleHeaders = async ({ method, path, query = {}, body, user, sec
 };
 ```
 
-GET 请求:
+## GET 示例
 
 ```js
 const user = 'alice';
@@ -172,7 +227,7 @@ const res = await fetch(`/api/pinnacle${path}?${search}`, { headers });
 const data = await res.json();
 ```
 
-POST 请求:
+## POST 示例
 
 ```js
 const user = 'trader';
@@ -213,4 +268,3 @@ const data = await res.json();
 - `401 API nonce has already been used`:nonce 重复。
 - `401 Invalid API signature`:签名不匹配。
 - `403 Forbidden`:用户没有该接口权限。
-

+ 29 - 3
pinnacle/libs/globalData.js

@@ -1,3 +1,5 @@
+import { parseIorDetail } from "./parseGameData.js";
+
 /**
  * 全局运行时数据
  */
@@ -53,15 +55,39 @@ const normalizeIds = (ids) => {
   return String(ids).split(',').map(id => id.trim()).filter(Boolean);
 }
 
+const getDateInTimezone = (offsetHours) => {
+  const nowUTC = new Date();
+  const targetTime = new Date(nowUTC.getTime() + offsetHours * 60 * 60 * 1000);
+  const year = targetTime.getUTCFullYear();
+  const month = String(targetTime.getUTCMonth() + 1).padStart(2, '0');
+  const day = String(targetTime.getUTCDate()).padStart(2, '0');
+  return `${year}-${month}-${day}`;
+}
+
+const isEventAvailable = (event) => {
+  if (!event?.starts) {
+    return false;
+  }
+
+  const startsTime = new Date(event.starts).getTime();
+  if (!Number.isFinite(startsTime)) {
+    return false;
+  }
+
+  const todayEndTime = new Date(`${getDateInTimezone(-4)} 23:59:59 GMT-4`).getTime();
+  const tomorrowEndTime = todayEndTime + 24 * 60 * 60 * 1000;
+  return startsTime >= Date.now() - 3 * 60 * 60 * 1000 && startsTime <= tomorrowEndTime;
+}
+
 export const getEventsByIds = (ids) => {
   const normalizedIds = normalizeIds(ids);
   const { gamesMap={} } = GLOBAL_DATA;
 
   if (!normalizedIds.length) {
-    return Object.values(gamesMap);
+    return Object.values(gamesMap).filter(isEventAvailable);
   }
 
-  return normalizedIds.map(id => gamesMap[id]).filter(Boolean);
+  return normalizedIds.map(id => gamesMap[id]).filter(Boolean).filter(isEventAvailable);
 }
 
 /**
@@ -80,4 +106,4 @@ export const getIorInfo = async(ior, id) => {
     return Promise.reject({ cause: 400, message: iorInfo.message, data: { id, ior } });
   }
   return Promise.resolve(iorInfo);
-}
+}