flyzto 1 неделя назад
Родитель
Сommit
2dd3388b1a
45 измененных файлов с 12351 добавлено и 3905 удалено
  1. 1 0
      .gitignore
  2. 401 0
      pinnacle/api-docs.html
  3. 44 0
      pinnacle/libs/cache.js
  4. 45 0
      pinnacle/libs/logs.js
  5. 101 0
      pinnacle/libs/pinnacleClient.js
  6. 3962 0
      pinnacle/linesapi.yaml
  7. 3962 0
      pinnacle/linesapi_zh.yaml
  8. 848 0
      pinnacle/main.js
  9. 22 0
      pinnacle/package.json
  10. 18 1
      server/init.js
  11. 174 0
      server/models/Control.js
  12. 0 531
      server/models/Games.js
  13. 655 153
      server/models/GamesPs.js
  14. 90 5
      server/models/Setting.js
  15. 0 1316
      server/package-lock.json
  16. 9 3
      server/package.json
  17. 54 0
      server/pannel.js
  18. 56 0
      server/routes/control.js
  19. 132 12
      server/routes/pstery.js
  20. 0 111
      server/routes/triangle.js
  21. 0 2
      server/server.js
  22. 21 9
      server/triangle/eventSolutions.js
  23. 11 45
      server/triangle/eventsMatch.js
  24. 225 9
      server/triangle/iorKeys.js
  25. 19 2
      server/triangle/settings.js
  26. 83 41
      server/triangle/totalProfitCalc.js
  27. 158 58
      server/triangle/trangleCalc.js
  28. 1 0
      web/apps/web-antd/src/api/request.ts
  29. 2 1
      web/apps/web-antd/src/locales/langs/en-US/page.json
  30. 2 1
      web/apps/web-antd/src/locales/langs/zh-CN/page.json
  31. 0 11
      web/apps/web-antd/src/router/routes/modules/match.ts
  32. 11 0
      web/apps/web-antd/src/router/routes/modules/system.ts
  33. 7 7
      web/apps/web-antd/src/views/_core/authentication/login.vue
  34. 44 47
      web/apps/web-antd/src/views/match/components/match_card.vue
  35. 379 0
      web/apps/web-antd/src/views/match/components/solution_item.vue
  36. 154 234
      web/apps/web-antd/src/views/match/datatest/index.vue
  37. 0 414
      web/apps/web-antd/src/views/match/related/index.vue
  38. 208 442
      web/apps/web-antd/src/views/match/solutions/index.vue
  39. 128 0
      web/apps/web-antd/src/views/system/control/index.vue
  40. 137 17
      web/apps/web-antd/src/views/system/parameter/index.vue
  41. 6 0
      web/apps/web-antd/vite.config.mts
  42. 4 0
      web/package.json
  43. 2 2
      web/packages/locales/src/langs/en-US/authentication.json
  44. 1 1
      web/packages/locales/src/langs/zh-CN/authentication.json
  45. 174 430
      web/pnpm-lock.yaml

+ 1 - 0
.gitignore

@@ -41,6 +41,7 @@ env/
 /build
 
 server/data
+pinnacle/data
 
 /cypress/videos/
 /cypress/screenshots/

Разница между файлами не показана из-за своего большого размера
+ 401 - 0
pinnacle/api-docs.html


+ 44 - 0
pinnacle/libs/cache.js

@@ -0,0 +1,44 @@
+import fs from 'fs';
+import path from 'path';
+
+export const getData = (file) => {
+  let data = null;
+
+  if (fs.existsSync(file)) {
+    const arrayBuffer = fs.readFileSync(file);
+
+    try {
+      data = JSON.parse(arrayBuffer.toString());
+    }
+    catch (e) {}
+  }
+
+  return Promise.resolve(data);
+}
+
+export const setData = (file, data, indent = 2) => {
+  return new Promise((resolve, reject) => {
+
+    if (typeof (data) != 'string') {
+      try {
+        data = JSON.stringify(data, null, indent);
+      }
+      catch (error) {
+        reject(error);
+      }
+    }
+
+    const directoryPath = path.dirname(file);
+    if(!fs.existsSync(directoryPath)) {
+      fs.mkdirSync(directoryPath, { recursive: true });
+    }
+
+    try {
+      fs.writeFileSync(file, data);
+      resolve();
+    }
+    catch (error) {
+      reject(error);
+    }
+  });
+}

+ 45 - 0
pinnacle/libs/logs.js

@@ -0,0 +1,45 @@
+import dayjs from 'dayjs';
+
+export class Logs {
+
+  static out(...args) {
+    const timeString = dayjs().format('YYYY-MM-DD HH:mm:ss.SSS');
+    if (typeof args[0] === 'string' && args[0].includes('%')) {
+      args[0] = `[${timeString}] ` + args[0];
+    }
+    else {
+      args.unshift(`[${timeString}]`);
+    }
+    console.log(...args);
+  }
+
+  static err(...args) {
+    const timeString = dayjs().format('YYYY-MM-DD HH:mm:ss.SSS');
+    if (typeof args[0] === 'string' && args[0].includes('%')) {
+      args[0] = `[${timeString}] ` + args[0];
+    }
+    else {
+      args.unshift(`[${timeString}]`);
+    }
+    console.error(...args);
+  }
+
+  static outDev(...args) {
+    if (process.env.NODE_ENV == 'development') {
+      this.out(...args);
+    }
+  }
+
+  static errDev(...args) {
+    if (process.env.NODE_ENV == 'development') {
+      this.err(...args);
+    }
+  }
+
+  static outLine(string) {
+    process.stdout.write("\u001b[1A");
+    process.stdout.write("\u001b[2K");
+    this.out(string);
+  }
+
+}

+ 101 - 0
pinnacle/libs/pinnacleClient.js

@@ -0,0 +1,101 @@
+import axios from "axios";
+import { HttpsProxyAgent } from "https-proxy-agent";
+import { Logs } from "./logs.js";
+
+const BaseURL = {
+  pinnacle: "https://api.pinnacle888.com",
+  pstery: "http://127.0.0.1:9055",
+}
+
+export const pinnacleRequest = async (options) => {
+  const {
+    endpoint,
+    params = {},
+    username,
+    password,
+    method = "GET",
+    data,
+    proxy,
+    timeout = 10000,
+  } = options;
+
+  if (!endpoint || !username || !password) {
+    throw new Error("endpoint、username、password is required");
+  }
+
+  const authHeader = `Basic ${Buffer.from(`${username}:${password}`).toString("base64")}`;
+
+  const axiosConfig = {
+    baseURL: BaseURL.pinnacle,
+    url: endpoint,
+    method,
+    headers: {
+      "Authorization": authHeader,
+      "Accept": "application/json",
+      "Content-Type": "application/json",
+    },
+    params,
+    data,
+    timeout,
+  };
+
+  if (proxy) {
+    axiosConfig.proxy = false;
+    axiosConfig.httpsAgent = new HttpsProxyAgent(proxy);
+  }
+
+  return axios(axiosConfig).then(res => res.data);
+
+}
+
+export const getPsteryRelations = async (mk=-1) => {
+  const axiosConfig = {
+    baseURL: BaseURL.pstery,
+    url: '/api/pstery/get_games_relation',
+    method: 'GET',
+    params: {
+      mk,
+    },
+    proxy: false,
+  };
+
+  return axios(axiosConfig).then(res => res.data);
+}
+
+export const updateBaseEvents = async (data) => {
+  const axiosConfig = {
+    baseURL: BaseURL.pstery,
+    url: '/api/pstery/update_base_events',
+    method: 'POST',
+    headers: {
+      'Content-Type': 'application/json',
+    },
+    data: JSON.stringify(data),
+    proxy: false,
+  };
+
+  axios(axiosConfig).then(res => res.data)
+  .then(() => {
+    Logs.outDev('update base events success', data);
+  })
+  .catch(err => {
+    Logs.err('failed to update base events:', err.message);
+  });
+}
+
+export const notifyException = async (message) => {
+  const axiosConfig = {
+    baseURL: BaseURL.pstery,
+    url: '/api/pstery/notify_exception',
+    method: 'POST',
+    data: { message },
+    proxy: false,
+  };
+  axios(axiosConfig).then(res => res.data)
+  .then(() => {
+    Logs.out('notify exception success');
+  })
+  .catch(err => {
+    Logs.err('failed to notify exception:', err.message);
+  });
+}

+ 3962 - 0
pinnacle/linesapi.yaml

@@ -0,0 +1,3962 @@
+swagger: '2.0'
+info:
+  version: 1.0.0
+  title: Pinnacle888 - Lines API Reference
+  description: |
+    All about odds and fixtures
+
+    # Authentication
+
+    API uses HTTP Basic access authentication.You need to send Authorization HTTP Request header:
+
+    `Authorization: Basic <Base64 value of UTF-8 encoded "username:password">`
+
+    Example:
+
+    `Authorization: Basic U03MyOT23YbzMDc6d3c3O1DQ1`
+  x-logo:
+    url: ''
+host: api.pinnacle888.com
+schemes:
+  - https
+security:
+  - basicAuth: []
+paths:
+  /v3/fixtures:
+    get:
+      tags:
+        - Fixtures
+      summary: Get Fixtures - v3
+      description: Returns all **non-settled** events for the given sport. Please note that it is possible that the event is in Get Fixtures response but not in Get Odds. This happens when the odds are not currently available for wagering. Please note that it is possible to receive the same exact response when using **since** parameter. This is rare and can be caused by internal updates of event properties.
+      operationId: Fixtures_V3_Get
+      consumes: []
+      produces:
+        - application/json
+      parameters:
+        - name: sportId
+          in: query
+          description: The sport id to retrieve the fixutres for.
+          required: true
+          type: integer
+          format: int32
+        - name: leagueIds
+          in: query
+          description: The leagueIds array may contain a list of comma separated league ids.
+          required: false
+          type: array
+          items:
+            type: integer
+            format: int32
+          collectionFormat: multi
+        - name: isLive
+          in: query
+          description: To retrieve ONLY live events set the value to 1 (isLive=1). Missing or any other value will result in retrieval of events regardless of their Live status.
+          required: false
+          type: boolean
+        - name: since
+          in: query
+          description: This is used to receive incremental updates. Use the value of last from previous fixtures response. When since parameter is not provided, the fixtures are delayed up to 1 minute to encourage the use of the parameter.
+          required: false
+          type: integer
+          format: int64
+        - name: eventIds
+          in: query
+          description: Comma separated list of event ids to filter by
+          required: false
+          type: array
+          items:
+            type: integer
+            format: int32
+          collectionFormat: multi
+      responses:
+        '200':
+          description: OK
+          schema:
+            $ref: '#/definitions/FixturesResponseV3'
+        '400':
+          description: BadRequest
+          schema:
+            $ref: '#/definitions/ErrorResponse'
+        '401':
+          description: Unauthorized
+          schema:
+            $ref: '#/definitions/ErrorResponse'
+        '403':
+          description: Forbidden
+          schema:
+            $ref: '#/definitions/ErrorResponse'
+        '500':
+          description: InternalServerError
+          schema:
+            $ref: '#/definitions/ExtendedErrorResponse'
+  /v2/fixtures/special:
+    get:
+      tags:
+        - Fixtures
+      summary: Get Special Fixtures - v2
+      description: Returns all **non-settled** specials for the given sport.
+      operationId: Fixtures_Special_V2_Get
+      consumes: []
+      produces:
+        - application/json
+      parameters:
+        - name: sportId
+          in: query
+          description: Id of a sport for which to retrieve the specials.
+          required: true
+          type: integer
+          format: int32
+        - name: leagueIds
+          in: query
+          description: The leagueIds array may contain a list of comma separated league ids.
+          required: false
+          type: array
+          items:
+            type: integer
+            format: int32
+          collectionFormat: multi
+        - name: since
+          in: query
+          description: This is used to receive incremental updates. Use the value of last field from the previous response. When since parameter is not provided, the fixtures are delayed up to 1 min to encourage the use of the parameter.
+          required: false
+          type: integer
+          format: int64
+        - name: category
+          in: query
+          description: The category the special falls under.
+          required: false
+          type: string
+        - name: eventId
+          in: query
+          description: Id of an event associated with a special.
+          required: false
+          type: integer
+          format: int64
+        - name: specialId
+          in: query
+          description: Id of the special.
+          required: false
+          type: integer
+          format: int64
+      responses:
+        '200':
+          description: OK
+          schema:
+            $ref: '#/definitions/SpecialsFixturesResponseV2'
+          examples:
+            application/json:
+              sportId: 4
+              last: 636433059508250600
+              leagues:
+                - id: 487
+                  specials:
+                    - id: 1
+                      betType: MULTI_WAY_HEAD_TO_HEAD
+                      name: Will the 4th quarter be odd or even?
+                      date: '2017-10-11T14:00:00Z'
+                      cutoff: '2017-10-11T14:00:00Z'
+                      category: 1/4 Totals
+                      units: ""
+                      status: I
+                      event:
+                        id: 1
+                        periodNumber: 0
+                      contestants:
+                        - id: 1
+                          name: Odd
+                          rotNum: 100
+                        - id: 2
+                          name: Even
+                          rotNum: 101
+                    - id: 2
+                      betType: MULTI_WAY_HEAD_TO_HEAD
+                      name: Will the 3rd quarter be odd or even?
+                      date: '2017-10-11T14:00:00Z'
+                      cutoff: '2017-10-11T14:00:00Z'
+                      category: 1/4 Totals
+                      units: ""
+                      status: I
+                      event:
+                        id: 1
+                        periodNumber: 0
+                      contestants:
+                        - id: 3
+                          name: Odd
+                          rotNum: 100
+                        - id: 4
+                          name: Even
+                          rotNum: 101
+                    - id: 3
+                      betType: MULTI_WAY_HEAD_TO_HEAD
+                      name: Will the 2nd quarter be odd or even?
+                      date: '2017-10-11T14:00:00Z'
+                      cutoff: '2017-10-11T14:00:00Z'
+                      category: 1/4 Totals
+                      units: ""
+                      status: H
+                      event:
+                        id: 1
+                        periodNumber: 0
+                      contestants:
+                        - id: 5
+                          name: Odd
+                          rotNum: 100
+                        - id: 6
+                          name: Even
+                          rotNum: 101
+                    - id: 4
+                      betType: MULTI_WAY_HEAD_TO_HEAD
+                      name: Will the 1st quarter be odd or even?
+                      date: '2017-10-11T14:00:00Z'
+                      cutoff: '2017-10-11T14:00:00Z'
+                      category: 1/4 Totals
+                      units: ""
+                      status: I
+                      event:
+                        id: 1
+                        periodNumber: 0
+                      contestants:
+                        - id: 7
+                          name: Odd
+                          rotNum: 100
+                        - id: 8
+                          name: Even
+                          rotNum: 101
+                    - id: 5
+                      betType: MULTI_WAY_HEAD_TO_HEAD
+                      name: Will the 4th quarter be odd or even?
+                      date: '2017-10-11T14:00:00Z'
+                      cutoff: '2017-10-11T14:00:00Z'
+                      category: 1/4 Totals
+                      units: ""
+                      status: null
+                      event:
+                        id: 2
+                        periodNumber: 0
+                      contestants:
+                        - id: 9
+                          name: Odd
+                          rotNum: 100
+                        - id: 10
+                          name: Even
+                          rotNum: 101
+                    - id: 6
+                      betType: MULTI_WAY_HEAD_TO_HEAD
+                      name: Will the 3rd quarter be odd or even?
+                      date: '2017-10-11T14:00:00Z'
+                      cutoff: '2017-10-11T14:00:00Z'
+                      category: 1/4 Totals
+                      units: ""
+                      status: I
+                      event:
+                        id: 2
+                        periodNumber: 0
+                      contestants:
+                        - id: 11
+                          name: Odd
+                          rotNum: 100
+                        - id: 12
+                          name: Even
+                          rotNum: 101
+                    - id: 7
+                      betType: MULTI_WAY_HEAD_TO_HEAD
+                      name: Will the 2nd quarter be odd or even?
+                      date: '2017-10-11T14:00:00Z'
+                      cutoff: '2017-10-11T14:00:00Z'
+                      category: 1/4 Totals
+                      units: ""
+                      status: I
+                      event:
+                        id: 2
+                        periodNumber: 0
+                      contestants:
+                        - id: 13
+                          name: Odd
+                          rotNum: 100
+                        - id: 14
+                          name: Even
+                          rotNum: 101
+                    - id: 8
+                      betType: MULTI_WAY_HEAD_TO_HEAD
+                      name: Will the 1st quarter be odd or even?
+                      date: '2017-10-11T14:00:00Z'
+                      cutoff: '2017-10-11T14:00:00Z'
+                      category: 1/4 Totals
+                      units: ""
+                      status: H
+                      event:
+                        id: 2
+                        periodNumber: 0
+                      contestants:
+                        - id: 15
+                          name: Odd
+                          rotNum: 100
+                        - id: 16
+                          name: Even
+                          rotNum: 101
+                    - id: 9
+                      betType: MULTI_WAY_HEAD_TO_HEAD
+                      name: Who will win the NBA finals?
+                      date: '2017-10-11T14:00:00Z'
+                      cutoff: '2017-10-11T14:00:00Z'
+                      category: Outright Winner
+                      units: ""
+                      status: I
+                      contestants:
+                        - id: 17
+                          name: Golden State Warriors
+                          rotNum: 100
+                        - id: 18
+                          name: Cleveland Cavaliers
+                          rotNum: 101
+                        - id: 19
+                          name: San Antonio Spurs
+                          rotNum: 102
+                        - id: 20
+                          name: Chicago Bulls
+                          rotNum: 103
+                - id: 578
+                  specials:
+                    - id: 10
+                      betType: MULTI_WAY_HEAD_TO_HEAD
+                      name: Who will win the WNBA finals?
+                      date: '2017-10-11T14:00:00Z'
+                      cutoff: '2017-10-11T14:00:00Z'
+                      category: Outright Winner
+                      units: ""
+                      status: I
+                      contestants:
+                        - id: 21
+                          name: Minnesota Lynx
+                          rotNum: 100
+                        - id: 22
+                          name: Indiana Fever
+                          rotNum: 101
+                        - id: 23
+                          name: Phoenix Mercury
+                          rotNum: 102
+                        - id: 24
+                          name: Chicago Sky
+                          rotNum: 103
+        '400':
+          description: BadRequest
+          schema:
+            $ref: '#/definitions/ErrorResponse'
+        '401':
+          description: Unauthorized
+          schema:
+            $ref: '#/definitions/ErrorResponse'
+        '403':
+          description: Forbidden
+          schema:
+            $ref: '#/definitions/ErrorResponse'
+        '500':
+          description: InternalServerError
+          schema:
+            $ref: '#/definitions/ExtendedErrorResponse'
+      deprecated: false
+  /v3/fixtures/settled:
+    get:
+      tags:
+        - Fixtures
+      summary: Get Settled Fixtures - v3
+      description: Returns fixtures settled in the last 24 hours for the given sport.
+      operationId: Fixtures_Settled_V3_Get
+      consumes: []
+      produces:
+        - application/json
+      parameters:
+        - name: sportId
+          in: query
+          required: true
+          description: Id of the sport for which to retrieve the settled.
+          type: integer
+          format: int32
+        - name: leagueIds
+          in: query
+          required: false
+          description: The leagueIds array may contain a list of comma separated league ids.
+          type: array
+          items:
+            type: integer
+            format: int32
+          collectionFormat: multi
+        - name: since
+          in: query
+          required: false
+          description: This is used to receive incremental updates. Use the value of last from previous response.
+          type: integer
+          format: int64
+      responses:
+        '200':
+          description: OK
+          schema:
+            $ref: '#/definitions/SettledFixturesSportV3'
+          examples:
+            application/json:
+              sportId: 0
+              last: 0
+              leagues:
+                - id: 0
+                  events:
+                    - id: 0
+                      periods:
+                        - number: 0
+                          status: 0
+                          settlementId: 0
+                          settledAt: '2017-09-03T18:21:22.3846289-07:00'
+                          team1Score: 0
+                          team2Score: 0
+                          cancellationReason:
+                            code: string
+                            details:
+                              correctTeam1Id: string
+                              correctTeam2Id: string
+                              correctListedPitcher1: string
+                              correctListedPitcher2: string
+                              correctSpread: '0.0'
+                              correctTotalPoints: '0.0'
+                              correctTeam1TotalPoints: '0.0'
+                              correctTeam2TotalPoints: '0.0'
+                              correctTeam1Score: '0'
+                              correctTeam2Score: '0'
+                              correctTeam1TennisSetsScore: '0'
+                              correctTeam2TennisSetsScore: '0'
+        '400':
+          description: BadRequest
+          schema:
+            $ref: '#/definitions/ErrorResponse'
+        '401':
+          description: Unauthorized
+          schema:
+            $ref: '#/definitions/ErrorResponse'
+        '403':
+          description: Forbidden
+          schema:
+            $ref: '#/definitions/ErrorResponse'
+        '500':
+          description: InternalServerError
+          schema:
+            $ref: '#/definitions/ExtendedErrorResponse'
+  /v3/fixtures/special/settled:
+    get:
+      tags:
+        - Fixtures
+      summary: Get Settled Special Fixtures - v3
+      description: Returns all specials which are settled in the last 24 hours for the given Sport.
+      operationId: Fixtures_Specials_Settled_V3_Get
+      consumes: []
+      produces:
+        - application/json
+      parameters:
+        - name: sportId
+          in: query
+          description: Id of the sport for which to retrieve the settled specials.
+          required: true
+          type: integer
+          format: int32
+        - name: leagueIds
+          in: query
+          description: Array of leagueIds. This is optional parameter.
+          required: false
+          type: array
+          items:
+            type: integer
+            format: int32
+          collectionFormat: multi
+        - name: since
+          in: query
+          description: This is used to receive incremental updates. Use the value of last from previous response.
+          required: false
+          type: integer
+          format: int64
+      responses:
+        '200':
+          description: OK
+          schema:
+            $ref: '#/definitions/SettledSpecialsResponseV3'
+          examples:
+            application/json:
+              sportId: 0
+              last: 0
+              leagues:
+                - id: 0
+                  specials:
+                    - id: 0
+                      status: 0
+                      settlementId: 0
+                      settledAt: '2017-10-11T15:05:50.996671Z'
+                      contestants:
+                        - id: 1
+                          name: Barranquilla
+                          outcome: "X"
+                        - id: 2
+                          name: Valledupar
+                          outcome: "X"
+                      cancellationReason:
+                        code: string
+                        details:
+                          correctTeam1Id: string
+                          correctTeam2Id: string
+                          correctListedPitcher1: string
+                          correctListedPitcher2: string
+                          correctSpread: '0.0'
+                          correctTotalPoints: '0.0'
+                          correctTeam1TotalPoints: '0.0'
+                          correctTeam2TotalPoints: '0.0'
+                          correctTeam1Score: '0'
+                          correctTeam2Score: '0'
+                          correctTeam1TennisSetsScore: '0'
+                          correctTeam2TennisSetsScore: '0'
+        '400':
+          description: BadRequest
+          schema:
+            $ref: '#/definitions/ErrorResponse'
+        '401':
+          description: Unauthorized
+          schema:
+            $ref: '#/definitions/ErrorResponse'
+        '403':
+          description: Forbidden
+          schema:
+            $ref: '#/definitions/ErrorResponse'
+        '500':
+          description: InternalServerError
+          schema:
+            $ref: '#/definitions/ExtendedErrorResponse'
+      deprecated: false
+  /v3/odds:
+    get:
+      tags:
+        - Odds
+      summary: Get Straight Odds - v3
+      description: Returns straight odds for all non-settled events. Please note that it is possible that the event is in Get Fixtures response but not in Get Odds. This happens when the odds are not currently available for wagering.
+      operationId: Odds_Straight_V3_Get
+      consumes: []
+      produces:
+        - application/json
+      parameters:
+        - name: sportId
+          in: query
+          description: The sportid for which to retrieve the odds.
+          required: true
+          type: integer
+          format: int32
+        - name: leagueIds
+          in: query
+          description: The leagueIds array may contain a list of comma separated league ids.
+          required: false
+          type: array
+          items:
+            type: integer
+            format: int32
+          collectionFormat: multi
+        - name: oddsFormat
+          in: query
+          description: Format in which we return the odds. Default is American. [American, Decimal, HongKong, Indonesian, Malay]
+          required: false
+          type: string
+          enum:
+            - American
+            - Decimal
+            - HongKong
+            - Indonesian
+            - Malay
+        - name: since
+          in: query
+          description: This is used to receive incremental updates. Use the value of last from previous odds response. When since parameter is not provided, the odds are delayed up to 1 min to encourage the use of the parameter. Please note that when using since parameter you will get in the response ONLY changed periods. If a period did not have any changes it will not be in the response.
+          required: false
+          type: integer
+          format: int64
+        - name: isLive
+          in: query
+          description: To retrieve ONLY live odds set the value to 1 (isLive=1). Otherwise response will have all odds.
+          required: false
+          type: boolean
+        - name: eventIds
+          in: query
+          description: Filter by EventIds
+          required: false
+          type: array
+          items:
+            type: integer
+            format: int64
+          collectionFormat: multi
+        - name: toCurrencyCode
+          in: query
+          description: 3 letter currency code as in the [/currency](https://pinny888.github.io/docs/?api=lines#tag/Others/operation/Currencies_V2_Get) response. Limits will be returned in the requested currency. Default is USD.
+          required: false
+          type: string
+      responses:
+        '200':
+          description: OK
+          schema:
+            $ref: '#/definitions/OddsResponseV3'
+        '400':
+          description: BadRequest
+          schema:
+            $ref: '#/definitions/ErrorResponse'
+        '401':
+          description: Unauthorized
+          schema:
+            $ref: '#/definitions/ErrorResponse'
+        '403':
+          description: Forbidden
+          schema:
+            $ref: '#/definitions/ErrorResponse'
+        '500':
+          description: InternalServerError
+          schema:
+            $ref: '#/definitions/ExtendedErrorResponse'
+      deprecated: true
+  /v4/odds:
+    get:
+      tags:
+        - Odds
+      summary: Get Straight Odds - v4
+      description: Returns straight odds for all non-settled events. Please note that it is possible that the event is in Get Fixtures response but not in Get Odds. This happens when the odds are not currently available for wagering.
+      operationId: Odds_Straight_V4_Get
+      consumes: []
+      produces:
+        - application/json
+      parameters:
+        - name: sportId
+          in: query
+          description: The sportid for which to retrieve the odds.
+          required: true
+          type: integer
+          format: int32
+        - name: leagueIds
+          in: query
+          description: The leagueIds array may contain a list of comma separated league ids.
+          required: false
+          type: array
+          items:
+            type: integer
+            format: int32
+          collectionFormat: multi
+        - name: oddsFormat
+          in: query
+          description: Format in which we return the odds. Default is American. [American, Decimal, HongKong, Indonesian, Malay]
+          required: false
+          type: string
+          enum:
+            - American
+            - Decimal
+            - HongKong
+            - Indonesian
+            - Malay
+        - name: since
+          in: query
+          description: This is used to receive incremental updates. Use the value of last from previous odds response. When since parameter is not provided, the odds are delayed up to 1 min to encourage the use of the parameter. Please note that when using since parameter you will get in the response ONLY changed periods. If a period did not have any changes it will not be in the response.
+          required: false
+          type: integer
+          format: int64
+        - name: isLive
+          in: query
+          description: To retrieve ONLY live odds set the value to 1 (isLive=1). Otherwise response will have all odds.
+          required: false
+          type: boolean
+        - name: eventIds
+          in: query
+          description: Filter by EventIds
+          required: false
+          type: array
+          items:
+            type: integer
+            format: int64
+          collectionFormat: multi
+        - name: toCurrencyCode
+          in: query
+          description: 3 letter currency code as in the [/currency](https://pinny888.github.io/docs/?api=lines#tag/Others/operation/Currencies_V2_Get) response. Limits will be returned in the requested currency. Default is USD.
+          required: false
+          type: string
+      responses:
+        '200':
+          description: OK
+          schema:
+            $ref: '#/definitions/OddsResponseV4'
+        '400':
+          description: BadRequest
+          schema:
+            $ref: '#/definitions/ErrorResponse'
+        '401':
+          description: Unauthorized
+          schema:
+            $ref: '#/definitions/ErrorResponse'
+        '403':
+          description: Forbidden
+          schema:
+            $ref: '#/definitions/ErrorResponse'
+        '500':
+          description: InternalServerError
+          schema:
+            $ref: '#/definitions/ExtendedErrorResponse'
+  /v3/odds/parlay:
+    get:
+      tags:
+        - Odds
+      summary: Get Parlay Odds - v3
+      description: Returns parlay odds for all non-settled events. Please note that it is possible that the event is in Get Fixtures response but notin Get Odds. This happens when the odds are not currently available for wagering.
+      operationId: Odds_Parlays_V3_Get
+      consumes: []
+      produces:
+        - application/json
+      parameters:
+        - name: sportId
+          in: query
+          description: The sportid for which to retrieve the odds.
+          required: true
+          type: integer
+          format: int32
+        - name: leagueIds
+          in: query
+          description: The leagueIds array may contain a list of comma separated league ids.
+          required: false
+          type: array
+          items:
+            type: integer
+            format: int32
+          collectionFormat: multi
+        - name: oddsFormat
+          in: query
+          description: Format in which we return the odds. Default is American. [American, Decimal, HongKong, Indonesian, Malay]
+          required: false
+          type: string
+          enum:
+            - American
+            - Decimal
+            - HongKong
+            - Indonesian
+            - Malay
+        - name: since
+          in: query
+          description: This is used to receive incremental updates. Use the value of last from previous odds response. When since parameter is not provided, the odds are delayed up to 1 min to encourage the use of the parameter. Please note that when using since parameter you will get in the response ONLY changed periods. If a period didn’t have any changes it will not be in the response.
+          required: false
+          type: integer
+          format: int64
+        - name: isLive
+          in: query
+          description: To retrieve ONLY live odds set the value to 1 (isLive=1). Otherwise response will have all odds.
+          required: false
+          type: boolean
+        - name: eventIds
+          in: query
+          description: Filter by EventIds
+          required: false
+          type: array
+          items:
+            type: integer
+            format: int64
+          collectionFormat: multi
+      responses:
+        '200':
+          description: OK
+          schema:
+            $ref: '#/definitions/ParlayOddsResponseV3'
+        '400':
+          description: BadRequest
+          schema:
+            $ref: '#/definitions/ErrorResponse'
+        '401':
+          description: Unauthorized
+          schema:
+            $ref: '#/definitions/ErrorResponse'
+        '403':
+          description: Forbidden
+          schema:
+            $ref: '#/definitions/ErrorResponse'
+        '500':
+          description: InternalServerError
+          schema:
+            $ref: '#/definitions/ExtendedErrorResponse'
+      deprecated: true
+  /v4/odds/parlay:
+    get:
+      tags:
+        - Odds
+      summary: Get Parlay Odds - v4
+      description: Returns parlay odds for all non-settled events. Please note that it is possible that the event is in Get Fixtures response but notin Get Odds. This happens when the odds are not currently available for wagering.
+      operationId: Odds_Parlays_V4_Get
+      consumes: []
+      produces:
+        - application/json
+      parameters:
+        - name: sportId
+          in: query
+          description: The sportid for which to retrieve the odds.
+          required: true
+          type: integer
+          format: int32
+        - name: leagueIds
+          in: query
+          description: The leagueIds array may contain a list of comma separated league ids.
+          required: false
+          type: array
+          items:
+            type: integer
+            format: int32
+          collectionFormat: multi
+        - name: oddsFormat
+          in: query
+          description: Format in which we return the odds. Default is American. [American, Decimal, HongKong, Indonesian, Malay]
+          required: false
+          type: string
+          enum:
+            - American
+            - Decimal
+            - HongKong
+            - Indonesian
+            - Malay
+        - name: since
+          in: query
+          description: This is used to receive incremental updates. Use the value of last from previous odds response. When since parameter is not provided, the odds are delayed up to 1 min to encourage the use of the parameter. Please note that when using since parameter you will get in the response ONLY changed periods. If a period didn’t have any changes it will not be in the response.
+          required: false
+          type: integer
+          format: int64
+        - name: isLive
+          in: query
+          description: To retrieve ONLY live odds set the value to 1 (isLive=1). Otherwise response will have all odds.
+          required: false
+          type: boolean
+        - name: eventIds
+          in: query
+          description: Filter by EventIds
+          required: false
+          type: array
+          items:
+            type: integer
+            format: int64
+          collectionFormat: multi
+      responses:
+        '200':
+          description: OK
+          schema:
+            $ref: '#/definitions/ParlayOddsResponseV4'
+        '400':
+          description: BadRequest
+          schema:
+            $ref: '#/definitions/ErrorResponse'
+        '401':
+          description: Unauthorized
+          schema:
+            $ref: '#/definitions/ErrorResponse'
+        '403':
+          description: Forbidden
+          schema:
+            $ref: '#/definitions/ErrorResponse'
+        '500':
+          description: InternalServerError
+          schema:
+            $ref: '#/definitions/ExtendedErrorResponse'
+  /v1/odds/teaser:
+    get:
+      tags:
+        - Odds
+      summary: Get Teaser Odds - v1
+      description: Returns odds for specified teaser.
+      operationId: Odds_Teasers_V1_Get
+      consumes: []
+      produces:
+        - application/json
+      parameters:
+        - name: teaserId
+          in: query
+          description: Unique identifier.Teaser details can be retrieved from a call to Get Teaser Groups endpoint.
+          required: true
+          type: integer
+          format: int64
+      responses:
+        '200':
+          description: OK
+          schema:
+            $ref: '#/definitions/TeaserOddsResponse'
+        '400':
+          description: BadRequest
+          schema:
+            $ref: '#/definitions/ErrorResponse'
+        '401':
+          description: Unauthorized
+          schema:
+            $ref: '#/definitions/ErrorResponse'
+        '403':
+          description: Forbidden
+          schema:
+            $ref: '#/definitions/ErrorResponse'
+        '500':
+          description: InternalServerError
+          schema:
+            $ref: '#/definitions/ExtendedErrorResponse'
+  /v2/odds/special:
+    get:
+      tags:
+        - Odds
+      summary: Get Special Odds - v2
+      description: Returns odds for specials for all non-settled events.
+      operationId: Odds_Special_V2_Get
+      consumes: []
+      produces:
+        - application/json
+      parameters:
+        - name: oddsFormat
+          in: query
+          description: Format the odds are returned in. [American, Decimal, HongKong, Indonesian, Malay]
+          required: false
+          type: string
+          enum:
+            - American
+            - Decimal
+            - HongKong
+            - Indonesian
+            - Malay
+        - name: sportId
+          in: query
+          description: Id of a sport for which to retrieve the specials.
+          required: true
+          type: integer
+          format: int32
+        - name: leagueIds
+          in: query
+          description: The leagueIds array may contain a list of comma separated league ids.
+          required: false
+          type: array
+          items:
+            type: integer
+            format: int32
+          collectionFormat: multi
+        - name: since
+          in: query
+          description: This is used to receive incremental updates. Use the value of last from previous response. When since parameter is not provided, the fixtures are delayed up to 1 min to encourage the use of the parameter.
+          required: false
+          type: integer
+          format: int64
+        - name: specialId
+          in: query
+          description: Id of the special. This is an optional argument.
+          required: false
+          type: integer
+          format: int64
+      responses:
+        '200':
+          description: OK
+          schema:
+            $ref: '#/definitions/SpecialOddsResponseV2'
+          examples:
+            application/json:
+              sportId: 4
+              last: 636433059510590700
+              leagues:
+                - id: 487
+                  specials:
+                    - id: 1
+                      maxBet: 100
+                      contestantLines:
+                        - id: 1
+                          lineId: 1001
+                          price: -199
+                          handicap: null
+                          max: 100
+                        - id: 2
+                          lineId: 1002
+                          price: -198
+                          handicap: null
+                          max: 100
+                    - id: 7
+                      maxBet: 100
+                      contestantLines:
+                        - id: 13
+                          lineId: 1013
+                          price: -187
+                          handicap: null
+                          max: 100
+                        - id: 14
+                          lineId: 1014
+                          price: -186
+                          handicap: null
+                          max: 100
+                - id: 578
+                  specials:
+                    - id: 10
+                      maxBet: 100
+                      contestantLines:
+                        - id: 21
+                          lineId: 1021
+                          price: -179
+                          handicap: null
+                          max: 100
+                        - id: 22
+                          lineId: 1022
+                          price: -178
+                          handicap: null
+                          max: 100
+                        - id: 23
+                          lineId: 1023
+                          price: -177
+                          handicap: null
+                          max: 100
+                        - id: 24
+                          lineId: 1024
+                          price: -176
+                          handicap: null
+                          max: 100
+        '400':
+          description: BadRequest
+          schema:
+            $ref: '#/definitions/ErrorResponse'
+        '401':
+          description: Unauthorized
+          schema:
+            $ref: '#/definitions/ErrorResponse'
+        '403':
+          description: Forbidden
+          schema:
+            $ref: '#/definitions/ErrorResponse'
+        '500':
+          description: InternalServerError
+          schema:
+            $ref: '#/definitions/ExtendedErrorResponse'
+      deprecated: false
+  /v2/line:
+    get:
+      tags:
+        - Line
+      summary: Get Straight Line - v2
+      description: Returns latest line.
+      operationId: Line_Straight_V2_Get
+      consumes: []
+      produces:
+        - application/json
+      parameters:
+        - name: leagueId
+          in: query
+          description: League Id.
+          required: true
+          type: integer
+          format: int32
+        - name: handicap
+          in: query
+          description: This is needed for SPREAD, TOTAL_POINTS and TEAM_TOTAL_POINTS bet types
+          required: true
+          type: number
+          format: double
+        - name: oddsFormat
+          in: query
+          description: Format in which we return the odds. Default is American.
+          required: true
+          type: string
+          enum:
+            - American
+            - Decimal
+            - HongKong
+            - Indonesian
+            - Malay
+        - name: sportId
+          in: query
+          description: Sport identification
+          required: true
+          type: integer
+          format: int32
+        - name: eventId
+          in: query
+          description: Event identification
+          required: true
+          type: integer
+          format: int64
+        - name: periodNumber
+          in: query
+          description: This represents the period of the match. Please check Get Periods endpoint for the list of currently supported periods per sport.
+          required: true
+          type: integer
+          format: int32
+        - name: betType
+          in: query
+          description: Bet Type
+          required: true
+          type: string
+          enum:
+            - SPREAD
+            - MONEYLINE
+            - TOTAL_POINTS
+            - TEAM_TOTAL_POINTS
+        - name: team
+          in: query
+          description: Chosen team type. This is needed only for SPREAD, MONEYLINE and TEAM_TOTAL_POINTS bet types
+          required: false
+          type: string
+          enum:
+            - Team1
+            - Team2
+            - Draw
+        - name: side
+          in: query
+          description: Chosen side. This is needed only for TOTAL_POINTS and TEAM_TOTAL_POINTS
+          required: false
+          type: string
+          enum:
+            - OVER
+            - UNDER
+      responses:
+        '200':
+          description: OK
+          schema:
+            $ref: '#/definitions/LineResponseV2'
+        '400':
+          description: BadRequest
+          schema:
+            $ref: '#/definitions/ErrorResponse'
+        '401':
+          description: Unauthorized
+          schema:
+            $ref: '#/definitions/ErrorResponse'
+        '403':
+          description: Forbidden
+          schema:
+            $ref: '#/definitions/ErrorResponse'
+        '500':
+          description: InternalServerError
+          schema:
+            $ref: '#/definitions/ExtendedErrorResponse'
+  /v3/line/parlay:
+    post:
+      tags:
+        - Line
+      summary: Get Parlay Line - v3
+      description: Returns parlay lines and calculate odds.
+      operationId: Line_Parlay_V3_Post
+      consumes:
+        - application/json
+      produces:
+        - application/json
+      parameters:
+        - in: body
+          name: request
+          required: true
+          schema:
+            $ref: '#/definitions/ParlayLinesRequestV3'
+      responses:
+        '200':
+          description: OK
+          schema:
+            $ref: '#/definitions/ParlayLinesResponseV3'
+        '400':
+          description: BadRequest
+          schema:
+            $ref: '#/definitions/ErrorResponse'
+        '401':
+          description: Unauthorized
+          schema:
+            $ref: '#/definitions/ErrorResponse'
+        '403':
+          description: Forbidden
+          schema:
+            $ref: '#/definitions/ErrorResponse'
+        '500':
+          description: InternalServerError
+          schema:
+            $ref: '#/definitions/ExtendedErrorResponse'
+  /v1/line/teaser:
+    post:
+      tags:
+        - Line
+      summary: Get Teaser Line - v1
+      description: Validates a teaser bet prior to submission. Returns bet limit and price on success.
+      operationId: Line_Teaser_V1_Post
+      consumes:
+        - application/json
+      produces:
+        - application/json
+      parameters:
+        - in: body
+          name: teaserLinesRequest
+          required: true
+          schema:
+            $ref: '#/definitions/LinesRequestTeaser'
+      responses:
+        '200':
+          description: OK
+          schema:
+            $ref: '#/definitions/TeaserLinesResponse'
+        '400':
+          description: BadRequest
+          schema:
+            $ref: '#/definitions/ErrorResponse'
+        '401':
+          description: Unauthorized
+          schema:
+            $ref: '#/definitions/ErrorResponse'
+        '403':
+          description: Forbidden
+          schema:
+            $ref: '#/definitions/ErrorResponse'
+        '500':
+          description: InternalServerError
+          schema:
+            $ref: '#/definitions/ExtendedErrorResponse'
+  /v2/line/special:
+    get:
+      tags:
+        - Line
+      operationId: Line_Special_V2_Get
+      summary: Get Special Line - v2
+      description: Returns special lines and calculate odds.
+      consumes: []
+      produces:
+        - application/json
+      parameters:
+        - name: oddsFormat
+          in: query
+          description: Format the odds are returned in. [American, Decimal, HongKong, Indonesian, Malay]
+          required: true
+          type: string
+          enum:
+            - American
+            - Decimal
+            - HongKong
+            - Indonesian
+            - Malay
+        - name: specialId
+          in: query
+          description: Id of the special.
+          required: true
+          type: integer
+          format: int64
+        - name: contestantId
+          in: query
+          description: Id of the contestant.
+          required: true
+          type: integer
+          format: int64
+        - name: handicap
+          in: query
+          description: handicap of the contestant. As contestant's handicap is a mutable property, it may happened that line/special returns status:SUCCESS, but with the different handicap from the one that client had at the moment of calling the line/special. One can specify handicap parameter in the request and if the contestant's handicap changed, it would return status:NOT_EXISTS. This way line/special is more aligned to how /line works.
+          required: false
+          type: number
+          format: double
+      responses:
+        '200':
+          description: OK
+          schema:
+            $ref: '#/definitions/SpecialLineResponse'
+        '400':
+          description: BadRequest
+          schema:
+            $ref: '#/definitions/ErrorResponse'
+        '401':
+          description: Unauthorized
+          schema:
+            $ref: '#/definitions/ErrorResponse'
+        '403':
+          description: Forbidden
+          schema:
+            $ref: '#/definitions/ErrorResponse'
+        '500':
+          description: InternalServerError
+          schema:
+            $ref: '#/definitions/ExtendedErrorResponse'
+      deprecated: false
+  /v3/sports:
+    get:
+      tags:
+        - Others
+      summary: Get Sports - v3
+      description: Returns all sports with the status whether they currently have lines or not.
+      operationId: Sports_V3_Get
+      consumes: []
+      produces:
+        - application/json
+      parameters: []
+      responses:
+        '200':
+          description: OK
+          schema:
+            $ref: '#/definitions/SportsResponseV3'
+        '401':
+          description: Unauthorized
+          schema:
+            $ref: '#/definitions/ErrorResponse'
+        '403':
+          description: Forbidden
+          schema:
+            $ref: '#/definitions/ErrorResponse'
+        '500':
+          description: InternalServerError
+          schema:
+            $ref: '#/definitions/LinesErrorResponse'
+  /v3/leagues:
+    get:
+      tags:
+        - Others
+      summary: Get Leagues - v3
+      description: Returns all sports leagues with the status whether they currently have lines or not.
+      operationId: Leagues_V3_Get
+      consumes: []
+      produces:
+        - application/json
+      parameters:
+        - name: sportId
+          in: query
+          description: Sport id for which the leagues are requested.
+          required: true
+          type: string
+      responses:
+        '200':
+          description: OK
+          schema:
+            $ref: '#/definitions/LeaguesV3'
+        '400':
+          description: BadRequest
+          schema:
+            $ref: '#/definitions/ErrorResponse'
+        '401':
+          description: Unauthorized
+          schema:
+            $ref: '#/definitions/ErrorResponse'
+        '403':
+          description: Forbidden
+          schema:
+            $ref: '#/definitions/ErrorResponse'
+        '500':
+          description: InternalServerError
+          schema:
+            $ref: '#/definitions/ExtendedErrorResponse'
+  /v1/periods:
+    get:
+      tags:
+        - Others
+      summary: Get Periods - v1
+      description: Returns all periods for a given sport.
+      operationId: Periods_V1_Get
+      consumes: []
+      produces:
+        - application/json
+      parameters:
+        - name: sportId
+          in: query
+          required: true
+          type: string
+      responses:
+        '200':
+          description: OK
+          schema:
+            $ref: '#/definitions/SportPeriod'
+          examples:
+            application/json:
+              periods:
+                - number: 0
+                  description: Match
+                  shortDescription: FT
+                - number: 1
+                  description: 1st Half
+                  shortDescription: 1st H
+                - number: 2
+                  description: 2nd Half
+                  shortDescription: 2nd H
+        '400':
+          description: BadRequest
+          schema:
+            $ref: '#/definitions/ErrorResponse'
+        '401':
+          description: Unauthorized
+          schema:
+            $ref: '#/definitions/ErrorResponse'
+        '403':
+          description: Forbidden
+          schema:
+            $ref: '#/definitions/ErrorResponse'
+        '500':
+          description: InternalServerError
+          schema:
+            $ref: '#/definitions/ExtendedErrorResponse'
+  /v2/inrunning:
+    get:
+      tags:
+        - Others
+      summary: Get In-Running - v2
+      description: Returns all live soccer events that have a status that indicates the event is in progress.
+      operationId: InRunning_V2_Get
+      consumes: []
+      produces:
+        - application/json
+      parameters: []
+      responses:
+        '200':
+          description: OK
+          schema:
+            $ref: '#/definitions/InRunningResponse'
+        '401':
+          description: Unauthorized
+          schema:
+            $ref: '#/definitions/ErrorResponse'
+        '403':
+          description: Forbidden
+          schema:
+            $ref: '#/definitions/ErrorResponse'
+        '500':
+          description: InternalServerError
+          schema:
+            $ref: '#/definitions/ExtendedLinesErrorResponse'
+  /v1/teaser/groups:
+    get:
+      tags:
+        - Others
+      summary: Get Teaser Groups - v1
+      description: Returns all teaser groups.
+      operationId: Teaser_Groups_V1_Get
+      consumes: []
+      produces:
+        - application/json
+      parameters:
+        - name: oddsFormat
+          in: query
+          description: Format the odds are returned in. [American, Decimal, HongKong, Indonesian, Malay]
+          required: true
+          type: string
+          enum:
+            - American
+            - Decimal
+            - HongKong
+            - Indonesian
+            - Malay
+      responses:
+        '200':
+          description: OK
+          schema:
+            $ref: '#/definitions/TeaserGroupsResponse'
+        '400':
+          description: BadRequest
+          schema:
+            $ref: '#/definitions/ErrorResponse'
+        '401':
+          description: Unauthorized
+          schema:
+            $ref: '#/definitions/ErrorResponse'
+        '403':
+          description: Forbidden
+          schema:
+            $ref: '#/definitions/ErrorResponse'
+        '500':
+          description: InternalServerError
+          schema:
+            $ref: '#/definitions/ExtendedErrorResponse'
+  /v1/cancellationreasons:
+    get:
+      tags:
+        - Others
+      summary: Get Cancellation Reasons - v1
+      description: Lookup for all the cancellation reasons
+      operationId: CancellationReasons_V1_Get
+      consumes: []
+      produces:
+        - application/json
+      parameters: []
+      responses:
+        '200':
+          description: OK
+          schema:
+            $ref: '#/definitions/CancellationReasonResponse'
+        '401':
+          description: Unauthorized
+          schema:
+            $ref: '#/definitions/ErrorResponse'
+        '403':
+          description: Forbidden
+          schema:
+            $ref: '#/definitions/ErrorResponse'
+        '500':
+          description: InternalServerError
+          schema:
+            $ref: '#/definitions/ExtendedErrorResponse'
+  /v2/currencies:
+    get:
+      tags:
+        - Others
+      summary: Get Currencies - v2
+      description: Returns the list of supported currencies
+      operationId: Currencies_V2_Get
+      consumes: []
+      produces:
+        - application/json
+      parameters: []
+      responses:
+        '200':
+          description: OK
+          schema:
+            $ref: '#/definitions/SuccessfulCurrenciesResponse'
+        '401':
+          description: Unauthorized
+          schema:
+            $ref: '#/definitions/ErrorResponse'
+        '403':
+          description: Forbidden
+          schema:
+            $ref: '#/definitions/ErrorResponse'
+        '500':
+          description: InternalServerError
+          schema:
+            $ref: '#/definitions/ExtendedErrorResponse'
+securityDefinitions:
+  basicAuth:
+    type: basic
+definitions:
+  ErrorResponse:
+    type: object
+    properties:
+      code:
+        type: string
+        description: Identifier representing the type of error that occurred.
+      message:
+        type: string
+        description: Description of the error.
+    description: Contains details of an error that was encountered.
+  ExtendedErrorResponse:
+    type: object
+    properties:
+      ref:
+        type: string
+      code:
+        type: string
+      message:
+        type: string
+  LinesErrorResponse:
+    type: object
+    properties:
+      status:
+        type: string
+      error:
+        $ref: '#/definitions/ErrorResponse'
+      code:
+        type: integer
+        format: int32
+        description: Code identifying an error that occurred.
+  ExtendedLinesErrorResponse:
+    type: object
+    properties:
+      ref:
+        type: string
+      status:
+        type: string
+      error:
+        $ref: '#/definitions/ErrorResponse'
+      code:
+        type: integer
+        format: int32
+        description: Code identifying an error that occurred.
+  CancellationReasonResponse:
+    type: object
+    properties:
+      cancellationReasons:
+        type: array
+        description: Contains a list of Cancellation Reasons.
+        items:
+          $ref: '#/definitions/CancellationReason'
+    description: Cancellation Response Data
+  CancellationReason:
+    type: object
+    properties:
+      code:
+        type: string
+        description: Cancellation code assigned by the server
+        example: FBS_CW_65
+      description:
+        type: string
+        description: Text description for the cancellation reason
+        example: The event was postponed
+    description: Cancellation Data
+  SuccessfulCurrenciesResponse:
+    type: object
+    properties:
+      currencies:
+        type: array
+        description: Currencies container.
+        items:
+          $ref: '#/definitions/Currency'
+  Currency:
+    type: object
+    properties:
+      code:
+        type: string
+        description: Currency code.
+        example: AED
+      name:
+        type: string
+        description: Currency name.
+        example: United Arab Emirates Dirham
+      rate:
+        type: number
+        format: double
+        description: Exchange rate to USD.
+        example: 3.6738
+  FixturesResponseV1:
+    type: object
+    properties:
+      sportId:
+        type: integer
+        format: int32
+        description: Same as requested sport Id.
+      last:
+        type: integer
+        format: int64
+        description: Use this value for the subsequent requests for since query parameter to get just the changes since previous response.
+      league:
+        type: array
+        description: Contains a list of Leagues.
+        items:
+          $ref: '#/definitions/FixturesLeagueV1'
+  FixturesLeagueV1:
+    type: object
+    properties:
+      id:
+        type: integer
+        format: int32
+        description: League ID.
+      events:
+        type: array
+        description: Contains a list of events.
+        items:
+          $ref: '#/definitions/FixtureV1'
+  FixtureV1:
+    type: object
+    properties:
+      id:
+        type: integer
+        format: int64
+        description: Event id.
+      starts:
+        type: string
+        format: date-time
+        description: Start time of the event in UTC.
+      home:
+        type: string
+        description: Home team name.
+      away:
+        type: string
+        description: Away team name.
+      rotNum:
+        type: string
+        description: Team1 rotation number. Please note that in the next version of /fixtures, rotNum property will be decomissioned. ParentId can be used instead to group the related events.
+      liveStatus:
+        type: integer
+        format: int32
+        description: |
+          Indicates live status of the event.
+
+          0 = No live betting will be offered on this event,
+          1 = Live betting event,
+          2 = Live betting will be offered on this event
+        enum:
+          - 0
+          - 1
+          - 2
+      homePitcher:
+        type: string
+        description: Home team pitcher. Only for Baseball.
+      awayPitcher:
+        type: string
+        description: Away team pitcher. Only for Baseball.
+      status:
+        type: string
+        description: |
+
+          Status of the event.
+
+          O = This is the starting status of a game. It means that the lines are open for betting,
+          H = This status indicates that the lines are temporarily unavailable for betting,
+          I = This status indicates that one or more lines have a red circle (lower maximum bet amount)
+        enum:
+          - O
+          - H
+          - I
+      parlayRestriction:
+        type: integer
+        format: int32
+        description: |
+
+          Parlay status of the event.
+
+          0 = Allowed to parlay, without restrictions,
+          1 = Not allowed to parlay this event,
+          2 = Allowed to parlay with the restrictions. You can not have more than one leg from the same event in the parlay. All events with the same rotation number are treated as same event.
+        enum:
+          - 0
+          - 1
+          - 2
+  FixturesResponseV3:
+    type: object
+    properties:
+      sportId:
+        type: integer
+        format: int32
+        description: Same as requested sport Id.
+      last:
+        type: integer
+        format: int64
+        description: Use this value for the subsequent requests for since query parameter to get just the changes since previous response.
+      league:
+        type: array
+        description: Contains a list of Leagues.
+        items:
+          $ref: '#/definitions/FixturesLeagueV3'
+  FixturesLeagueV3:
+    type: object
+    properties:
+      id:
+        type: integer
+        format: int32
+        description: League ID.
+      name:
+        type: string
+        description: League Name.
+      events:
+        type: array
+        description: Contains a list of events.
+        items:
+          $ref: '#/definitions/FixtureV3'
+  FixtureV3:
+    type: object
+    properties:
+      id:
+        type: integer
+        format: int64
+        description: Event id.
+      parentId:
+        type: integer
+        format: int64
+        description: If event is linked to another event, parentId will be populated.  Live event would have pre game event as parent id.
+      starts:
+        type: string
+        format: date-time
+        description: Start time of the event in UTC.
+      home:
+        type: string
+        description: Home team name.
+      away:
+        type: string
+        description: Away team name.
+      liveStatus:
+        type: integer
+        format: int32
+        description: |
+          Indicates live status of the event.
+
+          0 = No live betting will be offered on this event,
+          1 = Live betting event,
+          2 = Live betting will be offered on this event
+        enum:
+          - 0
+          - 1
+          - 2
+      homePitcher:
+        type: string
+        description: Home team pitcher. Only for Baseball.
+      awayPitcher:
+        type: string
+        description: Away team pitcher. Only for Baseball.
+      betAcceptanceType:
+        type: integer
+        format: int32
+        description: |
+
+          Soccer live event bet acceptance type. This type indicates that the live soccer event is offered to the corresponding customer.
+
+          0 = Not Applicable. No bet acceptance type restriction.
+          1 = Danger Zone.
+          2 = Live Delay.
+          3 = Both.
+        enum:
+          - 0
+          - 1
+          - 2
+          - 3
+      parlayRestriction:
+        type: integer
+        format: int32
+        description: |
+
+          Parlay status of the event.
+
+          0 = Allowed to parlay, without restrictions,
+          1 = Not allowed to parlay this event,
+          2 = Allowed to parlay with the restrictions. You cannot have more than one leg from the same event in the parlay. All events with the same rotation number are treated as same event.
+        enum:
+          - 0
+          - 1
+          - 2
+      altTeaser:
+        type: boolean
+        description: Whether an event is offer with alternative teaser points. Events with alternative teaser points may vary from teaser definition.
+      resultingUnit:
+        type: string
+        description: |
+          Specifies based on what the event will be resulted, e.g. Corners, Bookings
+      version:
+        type: integer
+        format: int64
+        description: |
+           Fixture version, goes up when there is a change in the fixture.
+  SettledFixturesSportV3:
+    type: object
+    properties:
+      sportId:
+        type: integer
+        format: int32
+        description: Same as requested sport Id.
+      last:
+        type: integer
+        format: int64
+        description: Use this value for the subsequent requests for since query parameter to get just the changes since previous response.
+      leagues:
+        type: array
+        description: Contains a list of Leagues.
+        items:
+          $ref: '#/definitions/SettledFixturesLeagueV3'
+  SettledFixturesLeagueV3:
+    type: object
+    properties:
+      id:
+        type: integer
+        format: int32
+        description: League Id.
+      events:
+        type: array
+        description: Contains a list of events.
+        items:
+          $ref: '#/definitions/SettledFixturesEventV3'
+  SettledFixturesEventV3:
+    type: object
+    properties:
+      id:
+        type: integer
+        format: int64
+        description: Event Id.
+      periods:
+        type: array
+        description: Contains a list of periods.
+        items:
+          $ref: '#/definitions/SettledFixturesPeriodV3'
+  SettledFixturesPeriodV3:
+    type: object
+    properties:
+      number:
+        type: integer
+        format: int32
+        description: This represents the period of the match. For example, for soccer we have 0 (Game), 1 (1st Half) & 2 (2nd Half)
+      status:
+        type: integer
+        format: int32
+        description: |
+          Period settlement status.
+
+          1 = Event period is settled,
+          2 = Event period is re-settled,
+          3 = Event period is cancelled,
+          4 = Event period is re-settled as cancelled,
+          5 = Event is deleted
+        enum:
+          - 1
+          - 2
+          - 3
+          - 4
+          - 5
+      settlementId:
+        type: integer
+        format: int64
+        description: Unique id of the settlement. In case of a re-settlement, a new settlementId and settledAt will be generated.
+      settledAt:
+        type: string
+        format: date-time
+        description: Date and time in UTC when the period was settled.
+      team1Score:
+        type: integer
+        format: int32
+        description: Team1 score.
+      team2Score:
+        type: integer
+        format: int32
+        description: Team2 score.
+      cancellationReason:
+        $ref: '#/definitions/CancellationReasonType'
+  CancellationReasonType:
+    type: object
+    properties:
+      code:
+        type: string
+        description: Cancellation Reason Code
+      details:
+        $ref: '#/definitions/CancellationReasonDetailsType'
+  CancellationReasonDetailsType:
+    type: object
+    properties:
+      key:
+        type: string
+      value:
+        type: string
+  InRunningResponse:
+    type: object
+    properties:
+      sports:
+        type: array
+        description: Sports container
+        items:
+          $ref: '#/definitions/InRunningSport'
+  InRunningSport:
+    type: object
+    properties:
+      id:
+        type: integer
+        format: int32
+        description: Sport Id
+      leagues:
+        type: array
+        description: Leagues container
+        items:
+          $ref: '#/definitions/InRunningLeague'
+  InRunningLeague:
+    type: object
+    properties:
+      id:
+        type: integer
+        format: int32
+        description: League Id
+      events:
+        type: array
+        description: Events container
+        items:
+          $ref: '#/definitions/InRunningEvent'
+  InRunningEvent:
+    type: object
+    properties:
+      id:
+        type: integer
+        format: int64
+        description: Game Id
+      state:
+        type: integer
+        format: int32
+        description: |
+          State of the game.
+
+          1 = First half in progress,
+          2 = Half time in progress,
+          3 = Second half in progress,
+          4 = End of regular time,
+          5 = First half extra time in progress,
+          6 = Extra time half time in progress,
+          7 = Second half extra time in progress,
+          8 = End of extra time,
+          9 = End of Game,
+          10 = Game is temporary suspended,
+          11 = Penalties in progress
+        enum:
+          - 1
+          - 2
+          - 3
+          - 4
+          - 5
+          - 6
+          - 7
+          - 8
+          - 9
+          - 10
+          - 11
+      elapsed:
+        type: integer
+        format: int32
+        description: Elapsed minutes
+  LeaguesV3:
+    type: object
+    properties:
+      leagues:
+        type: array
+        description: Leagues container
+        items:
+          $ref: '#/definitions/LeagueV3'
+  LeagueV3:
+    type: object
+    properties:
+      id:
+        type: integer
+        format: int32
+        description: League Id.
+      name:
+        type: string
+        description: Name of the league.
+      homeTeamType:
+        type: string
+        description: Specifies whether the home team is team1 or team2. You need this information to place a bet.
+      hasOfferings:
+        type: boolean
+        description: Whether the league currently has events or specials.
+      container:
+        type: string
+        description: Represents grouping for the league, usually a region/country
+      allowRoundRobins:
+        type: boolean
+        description: Specifies whether you can place parlay round robins on events in this league.
+      leagueSpecialsCount:
+        type: integer
+        format: int32
+        description: Indicates how many specials are in the given league.
+      eventSpecialsCount:
+        type: integer
+        format: int32
+        description: Indicates how many game specials are in the given league.
+      eventCount:
+        type: integer
+        format: int32
+        description: Indicates how many events are in the given league.
+  LineResponseV2:
+    type: object
+    properties:
+      status:
+        type: string
+        description: If the value is NOT_EXISTS, than this will be the only parameter in the response. All other params would be empty. [SUCCESS = OK, NOT_EXISTS = Line not offered anymore]
+        enum:
+          - SUCCESS
+          - NOT_EXISTS
+      price:
+        type: number
+        format: double
+        description: Latest price.
+      lineId:
+        type: integer
+        format: int64
+        description: Line identification needed to place a bet.
+      altLineId:
+        type: integer
+        format: int64
+        description: This would be needed to place the bet if the handicap is on alternate line, otherwise it will not be populated in the response.
+      team1Score:
+        type: integer
+        format: int32
+        description: Team 1 score for the period 0. Applicable to soccer only.
+      team2Score:
+        type: integer
+        format: int32
+        description: Team 2 score for the period 0. Applicable to soccer only.
+      team1RedCards:
+        type: integer
+        format: int32
+        description: Team 1 red cards for the period 0. Applicable to soccer only.
+      team2RedCards:
+        type: integer
+        format: int32
+        description: Team 2 red cards for the period 0. Applicable to soccer only.
+      maxRiskStake:
+        type: number
+        format: double
+        description: Maximum bettable risk amount.
+      minRiskStake:
+        type: number
+        format: double
+        description: Minimum bettable risk amount.
+      maxWinStake:
+        type: number
+        format: double
+        description: Maximum bettable win amount.
+      minWinStake:
+        type: number
+        format: double
+        description: Minimum bettable win amount.
+      effectiveAsOf:
+        type: string
+        description: Line is effective as of this date and time in UTC.
+      periodTeam1Score:
+        type: integer
+        format: int32
+        description: Team 1 score for the supported periods. Applicable to soccer only.
+      periodTeam2Score:
+        type: integer
+        format: int32
+        description: Team 2 score for the supported periods. Applicable to soccer only.
+      periodTeam1RedCards:
+        type: integer
+        format: int32
+        description: Team 1 red cards for the supported periods. Applicable to soccer only.
+      periodTeam2RedCards:
+        type: integer
+        format: int32
+        description: Team 2 red cards for the supported periods. Applicable to soccer only.
+  ParlayLinesRequestV3:
+    type: object
+    properties:
+      oddsFormat:
+        type: string
+        description: Odds in the response will be in this format. [American, Decimal, HongKong, Indonesian, Malay]
+        enum:
+          - American
+          - Decimal
+          - HongKong
+          - Indonesian
+          - Malay
+      legs:
+        type: array
+        description: This is a collection of legs
+        items:
+          $ref: '#/definitions/ParlayLineRequestV3'
+  ParlayLineRequestV3:
+    type: object
+    properties:
+      uniqueLegId:
+        type: string
+        description: This unique id of the leg. It used to identify and match leg in the response.
+      eventId:
+        type: integer
+        format: int64
+        description: Id of the event.
+      periodNumber:
+        type: integer
+        format: int32
+        description: This represents the period of the match. For example, for soccer we have 0 (Game), 1 (1st Half), 2 (2nd Half)
+      legBetType:
+        type: string
+        description: SPREAD, MONEYLINE, TOTAL_POINTS and TEAM_TOTAL_POINTS are supported.
+        enum:
+          - SPREAD
+          - MONEYLINE
+          - TOTAL_POINTS
+          - TEAM_TOTAL_POINTS
+      team:
+        type: string
+        description: Chosen team type. This is needed only for SPREAD and MONEYLINE wager types. [Team1, Team2, Draw (MONEYLINE only)]
+        enum:
+          - Team1
+          - Team2
+          - Draw
+      side:
+        type: string
+        description: Chosen side. This is needed only for TOTAL_POINTS wager type.  [OVER, UNDER]
+        enum:
+          - OVER
+          - UNDER
+      handicap:
+        type: number
+        format: double
+        description: This is needed for SPREAD and TOTAL_POINTS bet type.
+    required:
+      - uniqueLegId
+      - eventId
+      - periodNumber
+      - legBetType
+  ParlayLinesResponseV3:
+    type: object
+    properties:
+      status:
+        type: string
+        description: Status of the parlay [VALID = Parlay is valid, PROCESSED_WITH_ERROR = Parlay contains error(s)]
+        example: PROCESSED_WITH_ERROR
+        enum:
+          - VALID
+          - PROCESSED_WITH_ERROR
+      error:
+        type: string
+        description: INVALID_LEGS. Signifies that one or more legs are invalid. Populated only if status is PROCESSED_WITH_ERROR.
+        example: INVALID_LEGS
+      minRiskStake:
+        type: number
+        format: double
+        description: Minimum allowed stake amount.
+      maxParlayRiskStake:
+        type: number
+        format: double
+        description: Maximum allowed stake amount for parlay bets.
+      maxRoundRobinTotalRisk:
+        type: number
+        format: double
+        description: Maximum allowed total stake amount for all the parlay bets in the round robin.
+      maxRoundRobinTotalWin:
+        type: number
+        format: double
+        description: Maximum allowed total win amount for all the parlay bets in the round robin.
+      roundRobinOptionWithOdds:
+        type: array
+        description: Provides array with all acceptable Round Robin options with parlay odds for that option.
+        items:
+          $ref: '#/definitions/RoundRobinOptionWithOddsV3'
+      legs:
+        type: array
+        description: The collection of legs (the format of the object is described below).
+        items:
+          $ref: '#/definitions/ParlayLineLeg'
+    required:
+      - status
+  RoundRobinOptionWithOddsV3:
+    type: object
+    properties:
+      roundRobinOption:
+        type: string
+        description: |
+          RoundRobinOptions
+
+            Parlay = Single parlay that include all wagers (No Round Robin),
+            TwoLegRoundRobin = Multiple parlays having 2 wagers each (round robin style),
+            ThreeLegRoundRobin = Multiple parlays having 3 wagers each (round robin style),
+            FourLegRoundRobin = Multiple parlays having 4 wagers each (round robin style),
+            FiveLegRoundRobin = Multiple parlays having 5 wagers each (round robin style),
+            SixLegRoundRobin = Multiple parlays having 6 wagers each (round robin style),
+            SevenLegRoundRobin = Multiple parlays having 7 wagers each (round robin style),
+            EightLegRoundRobin = Multiple parlays having 8 wagers each (round robin style)
+        enum:
+          - Parlay
+          - TwoLegRoundRobin
+          - ThreeLegRoundRobin
+          - FourLegRoundRobin
+          - FiveLegRoundRobin
+          - SixLegRoundRobin
+          - SevenLegRoundRobin
+          - EightLegRoundRobin
+      odds:
+        type: number
+        format: double
+        description: Parlay odds for this option.
+      unroundedDecimalOdds:
+        type: number
+        format: double
+        description: Unrounded parlay odds in decimal format to be used for calculations only
+      numberOfBets:
+        type: number
+        format: int
+        description: Number of bets in the roundRobinOption.
+    required:
+      - roundRobinOption
+      - odds
+      - unroundedDecimalOdds
+  ParlayLineLeg:
+    type: object
+    properties:
+      status:
+        type: string
+        description: Status of the request. [VALID = Valid leg, PROCESSED_WITH_ERROR = Processed with error]
+        enum:
+          - VALID
+          - PROCESSED_WITH_ERROR
+      errorCode:
+        type: string
+        description: |
+          When Status is PROCESSED_WITH_ERROR, provides a code indicating the specific problem.
+
+            CORRELATED = The leg is correlated with another one,
+            CANNOT_PARLAY_LIVE_GAME = The wager is placed on Live game,
+            EVENT_NO_LONGER_AVAILABLE_FOR_BETTING = The event is no longer offered for Parlays,
+            EVENT_NOT_OFFERED_FOR_PARLAY = The event is not offered for Parlays,
+            LINE_DOES_NOT_BELONG_TO_EVENT = LineId does not match the EventId specified in the request,
+            WAGER_TYPE_NO_LONGER_AVAILABLE_FOR_BETTING = Wager Type no longer available for betting,
+            WAGER_TYPE_NOT_VALID_FOR_PARLAY =  Wager Type not valid for parlay,
+            WAGER_TYPE_CONFLICTS_WITH_OTHER_LEG = Wager Type conflicts with other leg
+        enum:
+
+          - CORRELATED
+          - CANNOT_PARLAY_LIVE_GAME
+          - EVENT_NO_LONGER_AVAILABLE_FOR_BETTING
+          - EVENT_NOT_OFFERED_FOR_PARLAY
+          - LINE_DOES_NOT_BELONG_TO_EVENT
+          - WAGER_TYPE_NO_LONGER_AVAILABLE_FOR_BETTING
+          - WAGER_TYPE_NOT_VALID_FOR_PARLAY
+          - WAGER_TYPE_CONFLICTS_WITH_OTHER_LEG
+      legId:
+        type: string
+        description: Echo of the legId from the request.
+      lineId:
+        type: integer
+        format: int64
+        description: Line identification.
+      altLineId:
+        type: integer
+        format: int64
+        description: If alternate Line was requested, the Id of that line will be returned.
+      price:
+        type: number
+        format: double
+        description: Price
+      correlatedLegs:
+        type: array
+        description: If errorCode is CORRELATED will contain legIds of all correlated legs.
+        items:
+          type: string
+    required:
+      - legId
+      - status
+  LinesRequestTeaser:
+    type: object
+    properties:
+      teaserId:
+        type: integer
+        format: int64
+        description: Unique identifier. Teaser details can be retrieved from a call to v1/teaser/groups endpoint.
+      oddsFormat:
+        type: string
+        description: Format the odds are returned in.. = [American, Decimal, HongKong, Indonesian, Malay]
+        enum:
+          - American
+          - Decimal
+          - HongKong
+          - Indonesian
+          - Malay
+      legs:
+        type: array
+        description: Collection of Teaser Legs.
+        items:
+          $ref: '#/definitions/TeaserLineRequest'
+    required:
+      - teaserId
+      - oddsFormat
+      - legs
+  TeaserLineRequest:
+    type: object
+    properties:
+      legId:
+        type: string
+        description: Client genereated GUID for uniquely identifying the leg.
+      eventId:
+        type: integer
+        format: int64
+        description: Unique identifier.
+      periodNumber:
+        type: integer
+        format: int32
+        description: Period of the match that is being bet on. v1/periods endpoint can be used to retrieve all periods for a sport.
+      betType:
+        type: string
+        description: Type of bet. Currently only SPREAD and TOTAL_POINTS are supported. [SPREAD, TOTAL_POINTS]
+        enum:
+          - SPREAD
+          - TOTAL_POINTS
+      team:
+        type: string
+        description: Team being bet on for a spread line. [Team1, Team2]
+        enum:
+          - Team1
+          - Team2
+      side:
+        type: string
+        description: Side of a total line being bet on. [OVER, UNDER]
+        enum:
+          - OVER
+          - UNDER
+      handicap:
+        type: number
+        format: double
+        description: Number of points.
+    required:
+      - legId
+      - eventId
+      - periodNumber
+      - betType
+      - handicap
+  TeaserLinesResponse:
+    type: object
+    properties:
+      status:
+        type: string
+        description: Status of the request. [VALID = Teaser is valid, PROCESSED_WITH_ERROR = Teaser contains one or more errors]
+        example: PROCESSED_WITH_ERROR
+        enum:
+          - VALID
+          - PROCESSED_WITH_ERROR
+      errorCode:
+        type: string
+        description: |
+          When Status is PROCESSED_WITH_ERROR, provides a code indicating the specific problem.
+
+            INVALID_LEGS = One or more of the legs is invalid,
+            SAME_EVENT_ONLY_REQUIRED = Teaser specified requires that all legs are from the same event,
+            TEASER_DISABLED = Teaser has been disabled and cannot be bet on,
+            TEASER_DOES_NOT_EXIST = The teaser identifier could not be found,
+            TOO_FEW_LEGS = You do not meet the minimum number of legs requirement for the teaser specified,
+            TOO_MANY_LEGS = You are above the maximum number of legs for the teaser specified,
+            UNKNOWN = An unknown error has occurred
+        enum:
+          - INVALID_LEGS
+          - SAME_EVENT_ONLY_REQUIRED
+          - TEASER_DISABLED
+          - TEASER_DOES_NOT_EXIST
+          - TOO_FEW_LEGS
+          - TOO_MANY_LEGS
+          - UNKNOWN
+      price:
+        type: number
+        format: double
+        description: Price for the bet.
+      minRiskStake:
+        type: number
+        format: double
+        description: Minimum bet amount for WIN_RISK_TYPE.RISK.
+      maxRiskStake:
+        type: number
+        format: double
+        description: Maximum bet amount for WIN_RISK_TYPE.RISK.
+      minWinStake:
+        type: number
+        format: double
+        description: Minimum bet amount for WIN_RISK_TYPE.WIN.
+      maxWinStake:
+        type: number
+        format: double
+        description: Maximum bet amount for WIN_RISK_TYPE.WIN.
+      legs:
+        type: array
+        description: Collection of Teaser Legs from the request.
+        items:
+          $ref: '#/definitions/TeaserLineLeg'
+    required:
+      - status
+      - legs
+  TeaserLineLeg:
+    type: object
+    properties:
+      status:
+        type: string
+        description: Status of the request. [VALID = Teaser is valid, PROCESSED_WITH_ERROR = Teaser contains error(s)]
+        example: PROCESSED_WITH_ERROR
+        enum:
+          - VALID
+          - PROCESSED_WITH_ERROR
+      errorCode:
+        type: string
+        description: |
+          When Status is PROCESSED_WITH_ERROR, provides a code indicating the specific problem.
+
+            EVENT_NOT_FOUND = The event specified could not be found,
+            POINTS_NO_LONGER_AVAILABLE = The points requested are no longer available. This means that the lines moved,
+            UNKNOWN = An unknown error has occured,
+            WAGER_TYPE_NOT_VALID_FOR_TEASER = The specified wager type is not valid for teasers
+        enum:
+          - EVENT_NOT_FOUND
+          - POINTS_NO_LONGER_AVAILABLE
+          - UNKNOWN
+          - WAGER_TYPE_NOT_VALID_FOR_TEASER
+      legId:
+        type: string
+        description: Echo of the unique id for the leg from the request.
+      lineId:
+        type: integer
+        format: int64
+        description: Line identification.
+    required:
+      - legId
+      - status
+  OddsResponseV3:
+    type: object
+    properties:
+      sportId:
+        type: integer
+        format: int32
+        description: Same as requested sport Id.
+      last:
+        type: integer
+        format: int64
+        description: Use this value for the subsequent requests for since query parameter to get just the changes since previous response.
+      leagues:
+        type: array
+        description: Contains a list of Leagues.
+        items:
+          $ref: '#/definitions/OddsLeagueV3'
+  OddsResponseV4:
+    type: object
+    properties:
+      sportId:
+        type: integer
+        format: int32
+        description: Same as requested sport Id.
+      last:
+        type: integer
+        format: int64
+        description: Use this value for the subsequent requests for since query parameter to get just the changes since previous response.
+      leagues:
+        type: array
+        description: Contains a list of Leagues.
+        items:
+          $ref: '#/definitions/OddsLeagueV4'
+  OddsLeagueV3:
+    type: object
+    properties:
+      id:
+        type: integer
+        format: int32
+        description: League Id.
+      events:
+        type: array
+        description: Contains a list of events.
+        items:
+          $ref: '#/definitions/OddsEventV3'
+  OddsLeagueV4:
+    type: object
+    properties:
+      id:
+        type: integer
+        format: int32
+        description: League Id.
+      events:
+        type: array
+        description: Contains a list of events.
+        items:
+          $ref: '#/definitions/OddsEventV4'
+  OddsEventV3:
+    type: object
+    properties:
+      id:
+        type: integer
+        format: int64
+        description: Event Id.
+      awayScore:
+        type: number
+        format: double
+        description: Away team score. Only for live soccer events.Supported only for full match period (number=0).
+      homeScore:
+        type: number
+        format: double
+        description: Home team score. Only for live soccer events.Supported only for full match period (number=0).
+      awayRedCards:
+        type: integer
+        format: int32
+        description: Away team red cards. Only for live soccer events. Supported only for full match period (number=0).
+      homeRedCards:
+        type: integer
+        format: int32
+        description: Home team red cards. Only for live soccer events.Supported only for full match period (number=0).
+      periods:
+        type: array
+        description: Contains a list of periods.
+        items:
+          $ref: '#/definitions/OddsPeriodV3'
+  OddsEventV4:
+    type: object
+    properties:
+      id:
+        type: integer
+        format: int64
+        description: Event Id.
+      awayScore:
+        type: number
+        format: double
+        description: Away team score. Only for live soccer events.Supported only for full match period (number=0).
+      homeScore:
+        type: number
+        format: double
+        description: Home team score. Only for live soccer events.Supported only for full match period (number=0).
+      awayRedCards:
+        type: integer
+        format: int32
+        description: Away team red cards. Only for live soccer events. Supported only for full match period (number=0).
+      homeRedCards:
+        type: integer
+        format: int32
+        description: Home team red cards. Only for live soccer events.Supported only for full match period (number=0).
+      periods:
+        type: array
+        description: Contains a list of periods.
+        items:
+          $ref: '#/definitions/OddsPeriodV4'
+  OddsPeriodV3:
+    type: object
+    properties:
+      lineId:
+        type: integer
+        format: int64
+        description: Line Id.
+      number:
+        type: integer
+        format: int32
+        description: This represents the period of the match. For example, for soccer we have  0 (Game), 1 (1st Half) & 2 (2nd Half)
+      cutoff:
+        type: string
+        format: date-time
+        description: Period’s wagering cut-off date in UTC.
+      status:
+        type: integer
+        format: int32
+        description: |
+              1 - online, period is open for betting
+              2 - offline, period is not open for betting
+        example: 1
+      maxSpread:
+        type: number
+        format: double
+        description: Maximum spread bet. Only in straight odds response.
+      maxMoneyline:
+        type: number
+        format: double
+        description: Maximum moneyline bet. Only in straight odds response.
+      maxTotal:
+        type: number
+        format: double
+        description: Maximum total points bet. Only in straight odds response.
+      maxTeamTotal:
+        type: number
+        format: double
+        description: Maximum team total points bet. Only in straight odds response.
+      moneylineUpdatedAt:
+        type: string
+        format: date-time
+        description: Date time of the last moneyline update.
+      spreadUpdatedAt:
+        type: string
+        format: date-time
+        description: Date time of the last spread update.
+      totalUpdatedAt:
+        type: string
+        format: date-time
+        description: Date time of the last total update.
+      teamTotalUpdatedAt:
+        type: string
+        format: date-time
+        description: Date time of the last team total update.
+      spreads:
+        type: array
+        description: Container for spread odds.
+        items:
+          $ref: '#/definitions/OddsSpreadV3'
+      moneyline:
+        $ref: '#/definitions/OddsMoneylineV3'
+      totals:
+        type: array
+        description: Container for team total points.
+        items:
+          $ref: '#/definitions/OddsTotalV3'
+      teamTotal:
+        $ref: '#/definitions/OddsTeamTotalsV3'
+      awayScore:
+        type: number
+        format: double
+        description: Period away team score. Only for live soccer events. Supported only for Match (number=0) and Extra Time (number=3).
+      homeScore:
+        type: number
+        format: double
+        description: Period home team score. Only for live soccer events. Supported only for Match (number=0) and Extra Time (number=3).
+      awayRedCards:
+        type: number
+        format: int32
+        description: Period away team red cards. Only for live soccer events. Supported only for Match (number=0) and Extra Time (number=3).
+      homeRedCards:
+        type: number
+        format: int32
+        description: Period home team red cards. Only for live soccer events. Supported only for Match (number=0) and Extra Time number=3).
+  OddsPeriodV4:
+    type: object
+    properties:
+      lineId:
+        type: integer
+        format: int64
+        description: Line Id.
+      number:
+        type: integer
+        format: int32
+        description: This represents the period of the match. For example, for soccer we have  0 (Game), 1 (1st Half) & 2 (2nd Half)
+      cutoff:
+        type: string
+        format: date-time
+        description: Period’s wagering cut-off date in UTC.
+      status:
+        type: integer
+        format: int32
+        description: |
+              1 - online, period is open for betting
+              2 - offline, period is not open for betting
+        example: 1
+      maxSpread:
+        type: number
+        format: double
+        description: Maximum spread bet. Only in straight odds response.
+      maxMoneyline:
+        type: number
+        format: double
+        description: Maximum moneyline bet. Only in straight odds response.
+      maxTotal:
+        type: number
+        format: double
+        description: Maximum total points bet. Only in straight odds response.
+      maxTeamTotal:
+        type: number
+        format: double
+        description: Maximum team total points bet. Only in straight odds response.
+      moneylineUpdatedAt:
+        type: string
+        format: date-time
+        description: Date time of the last moneyline update.
+      spreadUpdatedAt:
+        type: string
+        format: date-time
+        description: Date time of the last spread update.
+      totalUpdatedAt:
+        type: string
+        format: date-time
+        description: Date time of the last total update.
+      teamTotalUpdatedAt:
+        type: string
+        format: date-time
+        description: Date time of the last team total update.
+      spreads:
+        type: array
+        description: Container for spread odds.
+        items:
+          $ref: '#/definitions/OddsSpreadV4'
+      moneyline:
+        $ref: '#/definitions/OddsMoneylineV4'
+      totals:
+        type: array
+        description: Container for team total points.
+        items:
+          $ref: '#/definitions/OddsTotalV4'
+      teamTotal:
+        $ref: '#/definitions/OddsTeamTotalsV4'
+      awayScore:
+        type: number
+        format: double
+        description: Period away team score. Only for live soccer events. Supported only for Match (number=0) and Extra Time (number=3).
+      homeScore:
+        type: number
+        format: double
+        description: Period home team score. Only for live soccer events. Supported only for Match (number=0) and Extra Time (number=3).
+      awayRedCards:
+        type: number
+        format: int32
+        description: Period away team red cards. Only for live soccer events. Supported only for Match (number=0) and Extra Time (number=3).
+      homeRedCards:
+        type: number
+        format: int32
+        description: Period home team red cards. Only for live soccer events. Supported only for Match (number=0) and Extra Time number=3).
+  OddsSpreadV3:
+    type: object
+    properties:
+      altLineId:
+        type: integer
+        format: int64
+        description: This is present only if it’s alternative line.
+      hdp:
+        type: number
+        format: double
+        description: Home team handicap.
+      home:
+        type: number
+        format: double
+        description: Home team price.
+      away:
+        type: number
+        format: double
+        description: Away team price.
+      max:
+        type: number
+        format: double
+        nullable: true
+        description: Maximum bet volume. Present only on alternative lines, if set it overides `maxSpread` market limit.
+  OddsSpreadV4:
+    type: object
+    properties:
+      altLineId:
+        type: integer
+        format: int64
+        description: This is present only if it’s alternative line.
+      hdp:
+        type: number
+        format: double
+        description: Home team handicap.
+      home:
+        type: number
+        format: double
+        description: Home team price.
+      away:
+        type: number
+        format: double
+        description: Away team price.
+      max:
+        type: number
+        format: double
+        nullable: true
+        description: Maximum bet volume. Present only on alternative lines, if set it overides `maxSpread` market limit.
+  OddsMoneylineV3:
+    type: object
+    properties:
+      home:
+        type: number
+        format: double
+        description: Away team price
+      away:
+        type: number
+        format: double
+        description: Away team price.
+      draw:
+        type: number
+        format: double
+        description: Draw price. This is present only for events we offer price for draw.
+  OddsMoneylineV4:
+    type: object
+    properties:
+      home:
+        type: number
+        format: double
+        description: Away team price
+      away:
+        type: number
+        format: double
+        description: Away team price.
+      draw:
+        type: number
+        format: double
+        description: Draw price. This is present only for events we offer price for draw.
+  OddsTotalV3:
+    type: object
+    properties:
+      altLineId:
+        type: integer
+        format: int64
+        description: This is present only if it’s alternative line.
+      points:
+        type: number
+        format: double
+        description: Total points.
+      over:
+        type: number
+        format: double
+        description: Over price.
+      under:
+        type: number
+        format: double
+        description: Under price.
+      max:
+        type: number
+        format: double
+        nullable: true
+        description: Maximum bet volume. Present only on alternative lines, if set it overides `maxTotal` market limit.
+  OddsTotalV4:
+    type: object
+    properties:
+      altLineId:
+        type: integer
+        format: int64
+        description: This is present only if it’s alternative line.
+      points:
+        type: number
+        format: double
+        description: Total points.
+      over:
+        type: number
+        format: double
+        description: Over price.
+      under:
+        type: number
+        format: double
+        description: Under price.
+      max:
+        type: number
+        format: double
+        nullable: true
+        description: Maximum bet volume. Present only on alternative lines, if set it overides `maxTotal` market limit.
+  OddsTeamTotalsV3:
+    type: object
+    properties:
+      home:
+        $ref: '#/definitions/OddsTeamTotalV3'
+      away:
+        $ref: '#/definitions/OddsTeamTotalV3'
+  OddsTeamTotalsV4:
+    type: object
+    properties:
+      home:
+        type: array
+        description: Container for Home team's total points.
+        items:
+          $ref: '#/definitions/OddsTeamTotalV4'
+      away:
+        type: array
+        description: Container for Away team's total points.
+        items:
+          $ref: '#/definitions/OddsTeamTotalV4'
+  OddsTeamTotalV3:
+    type: object
+    properties:
+      points:
+        type: number
+        format: double
+        description: Total points.
+      over:
+        type: number
+        format: double
+        description: Over price.
+      under:
+        type: number
+        format: double
+        description: Under price.
+  OddsTeamTotalV4:
+    type: object
+    properties:
+      altLineId:
+        type: number
+        format: int64
+        description: This is present only if it’s alternative line.
+      points:
+        type: number
+        format: double
+        description: Total points.
+      over:
+        type: number
+        format: double
+        description: Over price.
+      under:
+        type: number
+        format: double
+        description: Under price.
+      max:
+        type: number
+        format: double
+        nullable: true
+        description: Maximum bet volume. Present only on alternative lines, if set it overides `maxTeamTotal` market limit.
+    required:
+      - points
+      - over
+      - under
+  ParlayOddsResponseV3:
+    type: object
+    properties:
+      sportId:
+        type: integer
+        format: int32
+        description: Same as requested sport Id.
+      last:
+        type: integer
+        format: int64
+        description: Use this value for the subsequent requests for since query parameter to get just the changes since previous response.
+      leagues:
+        type: array
+        description: Contains a list of Leagues.
+        items:
+          $ref: '#/definitions/ParlayOddsLeagueV3'
+    required:
+      - sportId
+      - last
+      - leagues
+  ParlayOddsResponseV4:
+    type: object
+    properties:
+      sportId:
+        type: integer
+        format: int32
+        description: Same as requested sport Id.
+      last:
+        type: integer
+        format: int64
+        description: Use this value for the subsequent requests for since query parameter to get just the changes since previous response.
+      leagues:
+        type: array
+        description: Contains a list of Leagues.
+        items:
+          $ref: '#/definitions/ParlayOddsLeagueV4'
+    required:
+      - sportId
+      - last
+      - leagues
+  ParlayOddsLeagueV3:
+    type: object
+    properties:
+      id:
+        type: integer
+        format: int32
+        description: League Id.
+      events:
+        type: array
+        description: Contains a list of events.
+        items:
+          $ref: '#/definitions/ParlayOddsEventV3'
+    required:
+      - id
+      - events
+  ParlayOddsLeagueV4:
+    type: object
+    properties:
+      id:
+        type: integer
+        format: int32
+        description: League Id.
+      events:
+        type: array
+        description: Contains a list of events.
+        items:
+          $ref: '#/definitions/ParlayOddsEventV4'
+    required:
+      - id
+      - events
+  ParlayOddsEventV3:
+    type: object
+    properties:
+      id:
+        type: integer
+        format: int64
+        description: Event Id.
+      awayScore:
+        type: number
+        format: double
+        description: Away team score. Only for live soccer events.
+      homeScore:
+        type: number
+        format: double
+        description: Home team score. Only for live soccer events.
+      awayRedCards:
+        type: integer
+        format: int32
+        description: Away team red cards. Only for live soccer events.
+      homeRedCards:
+        type: integer
+        format: int32
+        description: Home team red cards. Only for live soccer events.
+      periods:
+        type: array
+        description: Contains a list of periods.
+        items:
+          $ref: '#/definitions/ParlayOddsPeriodV3'
+    required:
+      - id
+      - periods
+  ParlayOddsEventV4:
+    type: object
+    properties:
+      id:
+        type: integer
+        format: int64
+        description: Event Id.
+      awayScore:
+        type: number
+        format: double
+        description: Away team score. Only for live soccer events.
+      homeScore:
+        type: number
+        format: double
+        description: Home team score. Only for live soccer events.
+      awayRedCards:
+        type: integer
+        format: int32
+        description: Away team red cards. Only for live soccer events.
+      homeRedCards:
+        type: integer
+        format: int32
+        description: Home team red cards. Only for live soccer events.
+      periods:
+        type: array
+        description: Contains a list of periods.
+        items:
+          $ref: '#/definitions/ParlayOddsPeriodV4'
+    required:
+      - id
+      - periods
+  ParlayOddsPeriodV3:
+    type: object
+    properties:
+      lineId:
+        type: integer
+        format: int64
+        description: Line Id.
+      number:
+        type: integer
+        format: int32
+        description: This represents the period of the match. For example, for soccer we have 0 (Game), 1 (1st Half) & 2 (2nd Half)
+      cutoff:
+        type: string
+        format: date-time
+        description: Period’s wagering cut-off date in UTC.
+      status:
+        type: integer
+        format: int32
+        description: |
+              1 - online, period is open for betting
+              2 - offline, period is not open for betting
+        example: 1
+      maxSpread:
+        type: number
+        format: double
+        description: Maximum spread bet. Only in straight odds response.
+      maxMoneyline:
+        type: number
+        format: double
+        description: Maximum moneyline bet. Only in straight odds response.
+      maxTotal:
+        type: number
+        format: double
+        description: Maximum total points bet. Only in straight odds response.
+      maxTeamTotal:
+        type: number
+        format: double
+        description: Maximum team total points bet. Only in straight odds response.
+      moneylineUpdatedAt:
+        type: number
+        format: double
+        description: Date time of the last moneyline update.
+      spreadUpdatedAt:
+        type: number
+        format: double
+        description: Date time of the last spread update.
+      totalUpdatedAt:
+        type: number
+        format: double
+        description: Date time of the last total update.
+      teamTotalUpdatedAt:
+        type: number
+        format: double
+        description: Date time of the last team total update.
+      spreads:
+        type: array
+        description: Container for spread odds.
+        items:
+          $ref: '#/definitions/ParlayOddsSpreadV3'
+      moneyline:
+        $ref: '#/definitions/ParlayOddsMoneylineV3'
+      totals:
+        type: array
+        description: Container for team total points.
+        items:
+          $ref: '#/definitions/ParlayOddsTotalV3'
+      teamTotal:
+        $ref: '#/definitions/ParlayOddsTeamTotalsV3'
+      awayScore:
+        type: number
+        format: double
+        description: Period away team score. Only for live soccer events. Supported only for Match (number=0) and Extra Time (number=3).
+      homeScore:
+        type: number
+        format: double
+        description: Period home team score. Only for live soccer events. Supported only for Match (number=0) and Extra Time (number=3).
+      awayRedCards:
+        type: number
+        format: double
+        description: Period away team red cards. Only for live soccer events. Supported only for Match (number=0) and Extra Time (number=3).
+      homeRedCards:
+        type: number
+        format: double
+        description: Period home team red cards. Only for live soccer events. Supported only for Match (number=0) and Extra Time number=3).
+    required:
+      - lineId
+      - number
+      - cutoff
+  ParlayOddsPeriodV4:
+    type: object
+    properties:
+      lineId:
+        type: integer
+        format: int64
+        description: Line Id.
+      number:
+        type: integer
+        format: int32
+        description: This represents the period of the match. For example, for soccer we have 0 (Game), 1 (1st Half) & 2 (2nd Half)
+      cutoff:
+        type: string
+        format: date-time
+        description: Period’s wagering cut-off date in UTC.
+      status:
+        type: integer
+        format: int32
+        description: |
+              1 - online, period is open for betting
+              2 - offline, period is not open for betting
+        example: 1
+      maxSpread:
+        type: number
+        format: double
+        description: Maximum spread bet. Only in straight odds response.
+      maxMoneyline:
+        type: number
+        format: double
+        description: Maximum moneyline bet. Only in straight odds response.
+      maxTotal:
+        type: number
+        format: double
+        description: Maximum total points bet. Only in straight odds response.
+      maxTeamTotal:
+        type: number
+        format: double
+        description: Maximum team total points bet. Only in straight odds response.
+      moneylineUpdatedAt:
+        type: number
+        format: double
+        description: Date time of the last moneyline update.
+      spreadUpdatedAt:
+        type: number
+        format: double
+        description: Date time of the last spread update.
+      totalUpdatedAt:
+        type: number
+        format: double
+        description: Date time of the last total update.
+      teamTotalUpdatedAt:
+        type: number
+        format: double
+        description: Date time of the last team total update.
+      spreads:
+        type: array
+        description: Container for spread odds.
+        items:
+          $ref: '#/definitions/ParlayOddsSpreadV4'
+      moneyline:
+        $ref: '#/definitions/ParlayOddsMoneylineV4'
+      totals:
+        type: array
+        description: Container for team total points.
+        items:
+          $ref: '#/definitions/ParlayOddsTotalV4'
+      teamTotal:
+        $ref: '#/definitions/ParlayOddsTeamTotalsV4'
+      awayScore:
+        type: number
+        format: double
+        description: Period away team score. Only for live soccer events. Supported only for Match (number=0) and Extra Time (number=3).
+      homeScore:
+        type: number
+        format: double
+        description: Period home team score. Only for live soccer events. Supported only for Match (number=0) and Extra Time (number=3).
+      awayRedCards:
+        type: number
+        format: double
+        description: Period away team red cards. Only for live soccer events. Supported only for Match (number=0) and Extra Time (number=3).
+      homeRedCards:
+        type: number
+        format: double
+        description: Period home team red cards. Only for live soccer events. Supported only for Match (number=0) and Extra Time number=3).
+    required:
+      - lineId
+      - number
+      - cutoff
+  ParlayOddsSpreadV3:
+    type: object
+    properties:
+      altLineId:
+        type: integer
+        format: int64
+        description: This is present only if it’s alternative line.
+      hdp:
+        type: number
+        format: double
+        description: Home team handicap.
+      home:
+        type: number
+        format: double
+        description: Home team price.
+      away:
+        type: number
+        format: double
+        description: Away team price.
+      max:
+        type: number
+        format: double
+        nullable: true
+        description: Maximum bet volume. Present only on alternative lines, if set it overides `maxSpread` market limit.
+    required:
+      - hdp
+      - home
+      - away
+  ParlayOddsSpreadV4:
+    type: object
+    properties:
+      altLineId:
+        type: integer
+        format: int64
+        description: This is present only if it’s alternative line.
+      hdp:
+        type: number
+        format: double
+        description: Home team handicap.
+      home:
+        type: number
+        format: double
+        description: Home team price.
+      away:
+        type: number
+        format: double
+        description: Away team price.
+      max:
+        type: number
+        format: double
+        nullable: true
+        description: Maximum bet volume. Present only on alternative lines, if set it overides `maxSpread` market limit.
+    required:
+      - hdp
+      - home
+      - away
+  ParlayOddsMoneylineV3:
+    type: object
+    properties:
+      home:
+        type: number
+        format: double
+        description: Away team price
+      away:
+        type: number
+        format: double
+        description: Away team price.
+      draw:
+        type: number
+        format: double
+        description: Draw price. This is present only for events we offer price for draw.
+    required:
+      - home
+      - away
+  ParlayOddsMoneylineV4:
+    type: object
+    properties:
+      home:
+        type: number
+        format: double
+        description: Away team price
+      away:
+        type: number
+        format: double
+        description: Away team price.
+      draw:
+        type: number
+        format: double
+        description: Draw price. This is present only for events we offer price for draw.
+    required:
+      - home
+      - away
+  ParlayOddsTotalV3:
+    $ref: '#/definitions/ParlayOddsTotalsV3'
+  ParlayOddsTotalV4:
+    $ref: '#/definitions/ParlayOddsTotalsV4'
+  ParlayOddsTeamTotalsV3:
+    type: object
+    properties:
+      away:
+        $ref: '#/definitions/ParlayOddsTotalsV3'
+      home:
+        $ref: '#/definitions/ParlayOddsTotalsV3'
+  ParlayOddsTeamTotalsV4:
+    type: object
+    properties:
+      away:
+        type: array
+        description: Container for Away team's total points.
+        items:
+          $ref: '#/definitions/ParlayOddsTeamTotalV4'
+      home:
+        type: array
+        description: Container for Home team's total points.
+        items:
+          $ref: '#/definitions/ParlayOddsTeamTotalV4'
+  ParlayOddsTotalsV3:
+    type: object
+    properties:
+      altLineId:
+        type: number
+        format: int64
+        description: Line Id for the alternate line. This is present only if it’s alternative line.
+      points:
+        type: number
+        format: double
+        description: Total points.
+      over:
+        type: number
+        format: double
+        description: Over price.
+      under:
+        type: number
+        format: double
+        description: Under price.
+      max:
+        type: number
+        format: double
+        nullable: true
+        description: Maximum bet volume. Present only on alternative lines, if set it overides `maxTotal` market limit.
+    required:
+      - points
+      - over
+      - under
+  ParlayOddsTotalsV4:
+    type: object
+    properties:
+      altLineId:
+        type: number
+        format: int64
+        description: Line Id for the alternate line. This is present only if it’s alternative line.
+      points:
+        type: number
+        format: double
+        description: Total points.
+      over:
+        type: number
+        format: double
+        description: Over price.
+      under:
+        type: number
+        format: double
+        description: Under price.
+      max:
+        type: number
+        format: double
+        nullable: true
+        description: Maximum bet volume. Present only on alternative lines, if set it overides `maxTotal` market limit.
+    required:
+      - points
+      - over
+      - under
+  ParlayOddsTeamTotalV4:
+    type: object
+    properties:
+      altLineId:
+        type: number
+        format: int64
+        description: This is present only if it’s alternative line.
+      points:
+        type: number
+        format: double
+        description: Total points.
+      over:
+        type: number
+        format: double
+        description: Over price.
+      under:
+        type: number
+        format: double
+        description: Under price.
+      max:
+        type: number
+        format: double
+        nullable: true
+        description: Maximum bet volume. Present only on alternative lines, if set it overides `maxTeamTotal` market limit.
+    required:
+      - points
+      - over
+      - under
+  TeaserOddsResponse:
+    type: object
+    properties:
+      teaserId:
+        type: integer
+        format: int64
+        description: Unique identifier. Teaser details can be retrieved from a call to Get Teaser Groups endpoint.
+      sportId:
+        type: integer
+        format: int32
+        description: Unique identifier. Sport details can be retrieved from a call to Get Sports endpoint.
+      leagues:
+        type: array
+        description: A collection of League.
+        items:
+          $ref: '#/definitions/TeaserOddsLeague'
+  TeaserOddsLeague:
+    type: object
+    properties:
+      id:
+        type: integer
+        format: int32
+        description: Unique identifier. League details can be retrieved from a call to Get Leagues endpoint.
+      events:
+        type: array
+        description: A collection of Event.
+        items:
+          $ref: '#/definitions/TeaserOddsEvent'
+  TeaserOddsEvent:
+    type: object
+    properties:
+      id:
+        type: integer
+        format: int64
+        description: Unique identifier.
+      periods:
+        type: array
+        description: A collection of periods indicating the period numbers available for betting.
+        items:
+          $ref: '#/definitions/TeaserOddsPeriod'
+  TeaserOddsPeriod:
+    type: object
+    properties:
+      number:
+        type: integer
+        format: int32
+        description: Period of the match that the request is for. Refer to v1/periods endpoint to retrieve all valid periods for a sport.
+      lineId:
+        type: integer
+        format: int64
+        description: Unique identifier.
+      spreadUpdatedAt:
+        type: string
+        format: date-time
+        description: Date time of the last spread update.
+      totalUpdatedAt:
+        type: string
+        format: date-time
+        description: Date time of the last total update.
+      spread:
+        $ref: '#/definitions/TeaserOddsSpread'
+      total:
+        $ref: '#/definitions/TeaserOddsTotalPoints'
+  TeaserOddsSpread:
+    type: object
+    properties:
+      maxBet:
+        type: number
+        format: double
+        description: Maximum bet amount.
+      homeHdp:
+        type: number
+        format: double
+        description: Home team handicap. Refer to Get Fixtures endpoint to determine home and away teams.
+      awayHdp:
+        type: number
+        format: double
+        description: Away team handicap. Refer to Get Fixtures endpoint to determine home and away teams.
+      altHdp:
+        type: boolean
+        description: Whether the spread is offer with alterantive teaser points. Events with alternative teaser points may vary from teaser definition.
+        example: false
+  TeaserOddsTotalPoints:
+    type: object
+    properties:
+      maxBet:
+        type: number
+        format: double
+        description: Maximum bet amount.
+      overPoints:
+        type: number
+        format: double
+        description: Over points.
+      underPoints:
+        type: number
+        format: double
+        description: Under points.
+  SportPeriod:
+    type: object
+    properties:
+      number:
+        type: integer
+        format: int32
+        description: Period Number
+      description:
+        type: string
+        description: Description for the period
+      shortDescription:
+        type: string
+        description: Short description for the period
+  SportsResponseV3:
+    type: object
+    properties:
+      sports:
+        type: array
+        description: Sports container.
+        items:
+          $ref: '#/definitions/SportV3'
+  SportV3:
+    type: object
+    properties:
+      id:
+        type: integer
+        format: int32
+        description: Sport Id.
+      name:
+        type: string
+        description: Sport name.
+      hasOfferings:
+        type: boolean
+        description: Whether the sport currently has events or specials.
+      leagueSpecialsCount:
+        type: integer
+        format: int32
+        description: Indicates how many specials are in the given sport.
+      eventSpecialsCount:
+        type: integer
+        format: int32
+        description: Indicates how many event specials are in the given sport.
+      eventCount:
+        type: integer
+        format: int32
+        description: Indicates how many events are in the given sport.
+  TeaserGroupsResponse:
+    type: object
+    properties:
+      teaserGroups:
+        type: array
+        description: A collection of TeaserGroups containing available teasers.
+        items:
+          $ref: '#/definitions/TeaserGroups'
+  TeaserGroups:
+    type: object
+    properties:
+      id:
+        type: integer
+        format: int64
+        description: Unique identifier.
+      name:
+        type: string
+        description: Friendly name for the Teaser Group
+      teasers:
+        type: array
+        description: A collection of Teaser.
+        items:
+          $ref: '#/definitions/TeaserGroupsTeaser'
+  TeaserGroupsTeaser:
+    type: object
+    properties:
+      id:
+        type: integer
+        format: int64
+        description: Unique identifier.
+      description:
+        type: string
+        description: Description for the Teaser.
+      sportId:
+        type: integer
+        format: int32
+        description: Unique Sport identifier. Sport details can be retrieved from a call to v2/sports endpoint.
+      minLegs:
+        type: integer
+        format: int32
+        description: Minimum number of legs that must be selected.
+      maxLegs:
+        type: integer
+        format: int32
+        description: Maximum number of legs that can be selected.
+      sameEventOnly:
+        type: boolean
+        description: If 'true' then all legs must be from the same event, otherwise legs can be from different events.
+      payouts:
+        type: array
+        description: A collection of Payout indicating all possible payout combinations.
+        items:
+          $ref: '#/definitions/TeaserGroupsPayout'
+      leagues:
+        type: array
+        description: A collection of Leagues available to the teaser.
+        items:
+          $ref: '#/definitions/TeaserGroupsLeague'
+  TeaserGroupsPayout:
+    type: object
+    properties:
+      numberOfLegs:
+        type: integer
+        format: int32
+        description: Number of legs that must be bet and won to get the associated price.
+      price:
+        type: number
+        format: double
+        description: Price of the bet given the specified number of legs.
+  TeaserGroupsLeague:
+    type: object
+    properties:
+      id:
+        type: integer
+        format: int32
+        description: Unique identifier. League details can be retrieved from a call to v2/leagues endpoint.
+      spread:
+        $ref: '#/definitions/TeaserGroupsBetType'
+      total:
+        $ref: '#/definitions/TeaserGroupsBetType'
+  TeaserGroupsBetType:
+    type: object
+    properties:
+      points:
+        type: number
+        format: double
+        description: Number of points the line will be teased for the given league.
+  SpecialsFixturesResponseV2:
+    type: object
+    properties:
+      sportId:
+        format: int32
+        description: Id of a sport for which to retrieve the odds.
+        type: integer
+      last:
+        format: int64
+        description: Used for retrieving changes only on subsequent requests. Provide this value as the Since paramter in subsequent calls to only retrieve changes.
+        type: integer
+      leagues:
+        description: Contains a list of Leagues.
+        type: array
+        items:
+          $ref: '#/definitions/SpecialsFixturesLeagueV2'
+  SpecialsFixturesLeagueV2:
+    type: object
+    properties:
+      id:
+        format: int32
+        description: FixturesLeague Id.
+        type: integer
+      specials:
+        description: A collection of Specials
+        type: array
+        items:
+          $ref: '#/definitions/SpecialFixtureV2'
+  SpecialFixtureV2:
+    type: object
+    properties:
+      id:
+        format: int64
+        description: Unique Id
+        type: integer
+      betType:
+        description: The type [MULTI_WAY_HEAD_TO_HEAD, SPREAD, OVER_UNDER]
+        enum:
+          - MULTI_WAY_HEAD_TO_HEAD
+          - SPREAD
+          - OVER_UNDER
+        type: string
+      name:
+        description: Name of the special.
+        type: string
+      date:
+        format: date-time
+        description: Date of the special in UTC.
+        type: string
+      cutoff:
+        format: date-time
+        description: Wagering cutoff date in UTC.
+        type: string
+      category:
+        description: The category that the special falls under.
+        type: string
+      units:
+        description: Measurment in the context of the special. This is applicable to specials bet type spead and over/under. In a hockey special this could be goals.
+        type: string
+      status:
+        description: |
+          Status of the Special
+
+          O = This is the starting status of a game. It means that the lines are open for betting,
+          H = This status indicates that the lines are temporarily unavailable for betting,
+          I = This status indicates that one or more lines have a red circle (a lower maximum bet amount)
+        enum:
+          - O
+          - H
+          - I
+        type: string
+      event:
+        $ref: '#/definitions/SpecialsFixturesEventV2'
+      contestants:
+        description: ContestantLines available for wagering.
+        type: array
+        items:
+          $ref: '#/definitions/SpecialsFixturesContestant'
+      liveStatus:
+        format: int32
+        description: |
+          When a special is linked to an event, we will return live status of the event, otherwise it will be 0. 0 = No live betting will be offered on this event, 1 = Live betting event, 2 = Live betting will be offered on this match, but on a different event.
+
+          Please note that live delay is applied when placing bets on special with LiveStatus=1
+        enum:
+          - 0
+          - 1
+          - 2
+        type: integer
+  SpecialsFixturesEventV2:
+    type: object
+    description: Optional event asscoaited with the special.
+    properties:
+      id:
+        format: int32
+        description: Event Id
+        type: integer
+      periodNumber:
+        format: int32
+        description: The period of the match. For example in soccer 0 (Game), 1 (1st Half) & 2 (2nd Half)
+        type: integer
+      home:
+        description: Home team name.
+        type: string
+      away:
+        description: Away team name.
+        type: string
+  SpecialsFixturesContestant:
+    type: object
+    properties:
+      id:
+        format: int64
+        description: Contestant Id.
+        type: integer
+      name:
+        description: Name of the contestant.
+        type: string
+      rotNum:
+        format: int32
+        description: Rotation Number.
+        type: integer
+  SettledSpecialsResponseV3:
+    description: Response dto for SettledSpecials request
+    type: object
+    properties:
+      sportId:
+        format: int32
+        description: Id of a sport for which to retrieve the odds.
+        type: integer
+      last:
+        format: int64
+        description: Last index for the settled fixture
+        type: integer
+      leagues:
+        description: List of Leagues.
+        type: array
+        items:
+          $ref: '#/definitions/SettledSpecialsLeagueV3'
+  SettledSpecialsLeagueV3:
+    description: League Dto to hold all settled specials for the league
+    type: object
+    properties:
+      id:
+        format: int32
+        description: League Id.
+        type: integer
+      specials:
+        description: A collection of Settled Specials
+        type: array
+        items:
+          $ref: '#/definitions/SettledSpecialV3'
+  SettledSpecialV3:
+    description: Settled Special
+    type: object
+    properties:
+      id:
+        format: int64
+        description: Id for the Settled Special
+        type: integer
+      status:
+        format: int32
+        description: Status of the settled special.
+        type: integer
+      settlementId:
+        format: int64
+        description: Id for the Settled Special
+        type: integer
+      settledAt:
+        format: date-time
+        description: Settled DateTime
+        type: string
+      cancellationReason:
+        $ref: '#/definitions/CancellationReasonType'
+        description: Cancellation Reason for Special Event
+      contestants:
+        description: A collection of contestants
+        type: array
+        items:
+          $ref: '#/definitions/SettledContestants'
+  SettledContestants:
+    description: Settled Special
+    type: object
+    properties:
+      id:
+        format: int64
+        description: Contestant Id.
+        type: integer
+      name:
+        description: Contestant name
+        type: string
+        example: Union Magdalena
+      outcome:
+        type: string
+        description: |
+          Contestant outcomes.
+
+          W = Won,
+          L = Lost,
+          X = Cancelled,
+          T = Tie,
+          Z = Scratched
+        enum:
+          - W
+          - L
+          - X
+          - T
+          - Z
+  SpecialLineResponse:
+    type: object
+    properties:
+      status:
+        description: Status [SUCCESS = OK, NOT_EXISTS = Line not offered anymore]
+        enum:
+          - SUCCESS
+          - NOT_EXISTS
+        type: string
+      specialId:
+        format: int64
+        description: Special Id.
+        type: integer
+      contestantId:
+        format: int64
+        description: Contestant Id.
+        type: integer
+      minRiskStake:
+        format: double
+        description: Minimum bettable risk amount.
+        type: number
+      maxRiskStake:
+        format: double
+        description: Maximum bettable risk amount.
+        type: number
+      minWinStake:
+        format: double
+        description: Minimum bettable win amount.
+        type: number
+      maxWinStake:
+        format: double
+        description: Maximum bettable win amount.
+        type: number
+      lineId:
+        format: int64
+        description: Line identification needed to place a bet.
+        type: integer
+      price:
+        format: double
+        description: Latest price.
+        type: number
+      handicap:
+        format: double
+        description: Handicap.
+        type: number
+  SpecialOddsResponseV2:
+    type: object
+    properties:
+      sportId:
+        format: int32
+        description: Id of a sport for which to retrieve the odds.
+        type: integer
+      last:
+        format: int64
+        description: Used for retrieving changes only on subsequent requests. Provide this value as the Since paramter in subsequent calls to only retrieve changes.
+        type: integer
+      leagues:
+        description: Contains a list of Leagues.
+        type: array
+        items:
+          $ref: '#/definitions/SpecialOddsLeagueV2'
+  SpecialOddsLeagueV2:
+    type: object
+    properties:
+      id:
+        format: int32
+        description: League id.
+        type: integer
+      specials:
+        description: A collection of FixturesSpecial.
+        type: array
+        items:
+          $ref: '#/definitions/SpecialOddsSpecialV2'
+  SpecialOddsSpecialV2:
+    type: object
+    properties:
+      id:
+        format: int64
+        description: Special Id.
+        type: integer
+      maxRisk:
+        format: double
+        description: Maximum risk amount.
+        type: number
+      contestantLines:
+        description: ContestantLines available for wagering on.
+        type: array
+        items:
+          $ref: '#/definitions/SpecialOddsContestantLineV2'
+  SpecialOddsContestantLineV2:
+    type: object
+    properties:
+      id:
+        format: int64
+        description: ContestantLine Id.
+        type: integer
+      lineId:
+        format: int64
+        description: Line identifier required for placing a bet.
+        type: integer
+      price:
+        format: double
+        description: Price of the line.
+        type: number
+      handicap:
+        format: double
+        description: 'A number indicating the spread, over/under etc.'
+        type: number
+      max:
+        format: double
+        description: Maximum bet volume amount per contestant. See [How to calculate max risk from the max volume](https://github.com/pinny888/pinny888.github.io/blob/main/FAQs.md#how-to-calculate-max-risk-from-the-max-volume-limits-in-odds)
+        type: number

+ 3962 - 0
pinnacle/linesapi_zh.yaml

@@ -0,0 +1,3962 @@
+swagger: '2.0'
+info:
+  version: 1.0.0
+  title: Pinnacle888 - Lines API 参考文档
+  description: |
+    关于赔率和赛程的所有信息
+
+    # 身份验证
+
+    API 使用 HTTP Basic 访问身份验证。您需要发送 Authorization HTTP 请求头:
+
+    `Authorization: Basic <Base64 value of UTF-8 encoded "username:password">`
+
+    示例:
+
+    `Authorization: Basic U03MyOT23YbzMDc6d3c3O1DQ1`
+  x-logo:
+    url: ''
+host: api.pinnacle888.com
+schemes:
+  - https
+security:
+  - basicAuth: []
+paths:
+  /v3/fixtures:
+    get:
+      tags:
+        - Fixtures
+      summary: 获取赛程 - v3
+      description: 返回指定运动的所有**未结算**赛事。请注意,赛事可能出现在 Get Fixtures 响应中,但不在 Get Odds 中。这种情况发生在赔率目前不可用于投注时。请注意,使用 **since** 参数时,可能会收到完全相同的响应。这种情况很少见,可能是由于赛事属性的内部更新导致的。
+      operationId: Fixtures_V3_Get
+      consumes: []
+      produces:
+        - application/json
+      parameters:
+        - name: sportId
+          in: query
+          description: 要获取赛程的运动 ID。
+          required: true
+          type: integer
+          format: int32
+        - name: leagueIds
+          in: query
+          description: leagueIds 数组可包含逗号分隔的联赛 ID 列表。
+          required: false
+          type: array
+          items:
+            type: integer
+            format: int32
+          collectionFormat: multi
+        - name: isLive
+          in: query
+          description: 要仅检索直播赛事,请将值设置为 1 (isLive=1)。缺少或任何其他值将导致检索所有赛事,无论其直播状态如何。
+          required: false
+          type: boolean
+        - name: since
+          in: query
+          description: 用于接收增量更新。使用上次 fixtures 响应中的 last 值。如果不提供 since 参数,赛程最多会延迟 1 分钟,以鼓励使用该参数。
+          required: false
+          type: integer
+          format: int64
+        - name: eventIds
+          in: query
+          description: 用于过滤的逗号分隔的赛事 ID 列表
+          required: false
+          type: array
+          items:
+            type: integer
+            format: int32
+          collectionFormat: multi
+      responses:
+        '200':
+          description: 成功
+          schema:
+            $ref: '#/definitions/FixturesResponseV3'
+        '400':
+          description: 错误请求
+          schema:
+            $ref: '#/definitions/ErrorResponse'
+        '401':
+          description: 未授权
+          schema:
+            $ref: '#/definitions/ErrorResponse'
+        '403':
+          description: 禁止访问
+          schema:
+            $ref: '#/definitions/ErrorResponse'
+        '500':
+          description: 服务器内部错误
+          schema:
+            $ref: '#/definitions/ExtendedErrorResponse'
+  /v2/fixtures/special:
+    get:
+      tags:
+        - Fixtures
+      summary: 获取特殊赛程 - v2
+      description: 返回指定运动的所有**未结算**特殊赛事。
+      operationId: Fixtures_Special_V2_Get
+      consumes: []
+      produces:
+        - application/json
+      parameters:
+        - name: sportId
+          in: query
+          description: 要检索特殊赛事的运动 ID。
+          required: true
+          type: integer
+          format: int32
+        - name: leagueIds
+          in: query
+          description: leagueIds 数组可包含逗号分隔的联赛 ID 列表。
+          required: false
+          type: array
+          items:
+            type: integer
+            format: int32
+          collectionFormat: multi
+        - name: since
+          in: query
+          description: 用于接收增量更新。使用上次响应中的 last 字段值。如果不提供 since 参数,赛程最多会延迟 1 分钟,以鼓励使用该参数。
+          required: false
+          type: integer
+          format: int64
+        - name: category
+          in: query
+          description: 特殊赛事所属的类别。
+          required: false
+          type: string
+        - name: eventId
+          in: query
+          description: 与特殊赛事关联的赛事 ID。
+          required: false
+          type: integer
+          format: int64
+        - name: specialId
+          in: query
+          description: 特殊赛事的 ID。
+          required: false
+          type: integer
+          format: int64
+      responses:
+        '200':
+          description: 成功
+          schema:
+            $ref: '#/definitions/SpecialsFixturesResponseV2'
+          examples:
+            application/json:
+              sportId: 4
+              last: 636433059508250600
+              leagues:
+                - id: 487
+                  specials:
+                    - id: 1
+                      betType: MULTI_WAY_HEAD_TO_HEAD
+                      name: Will the 4th quarter be odd or even?
+                      date: '2017-10-11T14:00:00Z'
+                      cutoff: '2017-10-11T14:00:00Z'
+                      category: 1/4 Totals
+                      units: ""
+                      status: I
+                      event:
+                        id: 1
+                        periodNumber: 0
+                      contestants:
+                        - id: 1
+                          name: Odd
+                          rotNum: 100
+                        - id: 2
+                          name: Even
+                          rotNum: 101
+                    - id: 2
+                      betType: MULTI_WAY_HEAD_TO_HEAD
+                      name: Will the 3rd quarter be odd or even?
+                      date: '2017-10-11T14:00:00Z'
+                      cutoff: '2017-10-11T14:00:00Z'
+                      category: 1/4 Totals
+                      units: ""
+                      status: I
+                      event:
+                        id: 1
+                        periodNumber: 0
+                      contestants:
+                        - id: 3
+                          name: Odd
+                          rotNum: 100
+                        - id: 4
+                          name: Even
+                          rotNum: 101
+                    - id: 3
+                      betType: MULTI_WAY_HEAD_TO_HEAD
+                      name: Will the 2nd quarter be odd or even?
+                      date: '2017-10-11T14:00:00Z'
+                      cutoff: '2017-10-11T14:00:00Z'
+                      category: 1/4 Totals
+                      units: ""
+                      status: H
+                      event:
+                        id: 1
+                        periodNumber: 0
+                      contestants:
+                        - id: 5
+                          name: Odd
+                          rotNum: 100
+                        - id: 6
+                          name: Even
+                          rotNum: 101
+                    - id: 4
+                      betType: MULTI_WAY_HEAD_TO_HEAD
+                      name: Will the 1st quarter be odd or even?
+                      date: '2017-10-11T14:00:00Z'
+                      cutoff: '2017-10-11T14:00:00Z'
+                      category: 1/4 Totals
+                      units: ""
+                      status: I
+                      event:
+                        id: 1
+                        periodNumber: 0
+                      contestants:
+                        - id: 7
+                          name: Odd
+                          rotNum: 100
+                        - id: 8
+                          name: Even
+                          rotNum: 101
+                    - id: 5
+                      betType: MULTI_WAY_HEAD_TO_HEAD
+                      name: Will the 4th quarter be odd or even?
+                      date: '2017-10-11T14:00:00Z'
+                      cutoff: '2017-10-11T14:00:00Z'
+                      category: 1/4 Totals
+                      units: ""
+                      status: null
+                      event:
+                        id: 2
+                        periodNumber: 0
+                      contestants:
+                        - id: 9
+                          name: Odd
+                          rotNum: 100
+                        - id: 10
+                          name: Even
+                          rotNum: 101
+                    - id: 6
+                      betType: MULTI_WAY_HEAD_TO_HEAD
+                      name: Will the 3rd quarter be odd or even?
+                      date: '2017-10-11T14:00:00Z'
+                      cutoff: '2017-10-11T14:00:00Z'
+                      category: 1/4 Totals
+                      units: ""
+                      status: I
+                      event:
+                        id: 2
+                        periodNumber: 0
+                      contestants:
+                        - id: 11
+                          name: Odd
+                          rotNum: 100
+                        - id: 12
+                          name: Even
+                          rotNum: 101
+                    - id: 7
+                      betType: MULTI_WAY_HEAD_TO_HEAD
+                      name: Will the 2nd quarter be odd or even?
+                      date: '2017-10-11T14:00:00Z'
+                      cutoff: '2017-10-11T14:00:00Z'
+                      category: 1/4 Totals
+                      units: ""
+                      status: I
+                      event:
+                        id: 2
+                        periodNumber: 0
+                      contestants:
+                        - id: 13
+                          name: Odd
+                          rotNum: 100
+                        - id: 14
+                          name: Even
+                          rotNum: 101
+                    - id: 8
+                      betType: MULTI_WAY_HEAD_TO_HEAD
+                      name: Will the 1st quarter be odd or even?
+                      date: '2017-10-11T14:00:00Z'
+                      cutoff: '2017-10-11T14:00:00Z'
+                      category: 1/4 Totals
+                      units: ""
+                      status: H
+                      event:
+                        id: 2
+                        periodNumber: 0
+                      contestants:
+                        - id: 15
+                          name: Odd
+                          rotNum: 100
+                        - id: 16
+                          name: Even
+                          rotNum: 101
+                    - id: 9
+                      betType: MULTI_WAY_HEAD_TO_HEAD
+                      name: Who will win the NBA finals?
+                      date: '2017-10-11T14:00:00Z'
+                      cutoff: '2017-10-11T14:00:00Z'
+                      category: Outright Winner
+                      units: ""
+                      status: I
+                      contestants:
+                        - id: 17
+                          name: Golden State Warriors
+                          rotNum: 100
+                        - id: 18
+                          name: Cleveland Cavaliers
+                          rotNum: 101
+                        - id: 19
+                          name: San Antonio Spurs
+                          rotNum: 102
+                        - id: 20
+                          name: Chicago Bulls
+                          rotNum: 103
+                - id: 578
+                  specials:
+                    - id: 10
+                      betType: MULTI_WAY_HEAD_TO_HEAD
+                      name: Who will win the WNBA finals?
+                      date: '2017-10-11T14:00:00Z'
+                      cutoff: '2017-10-11T14:00:00Z'
+                      category: Outright Winner
+                      units: ""
+                      status: I
+                      contestants:
+                        - id: 21
+                          name: Minnesota Lynx
+                          rotNum: 100
+                        - id: 22
+                          name: Indiana Fever
+                          rotNum: 101
+                        - id: 23
+                          name: Phoenix Mercury
+                          rotNum: 102
+                        - id: 24
+                          name: Chicago Sky
+                          rotNum: 103
+        '400':
+          description: 错误请求
+          schema:
+            $ref: '#/definitions/ErrorResponse'
+        '401':
+          description: 未授权
+          schema:
+            $ref: '#/definitions/ErrorResponse'
+        '403':
+          description: 禁止访问
+          schema:
+            $ref: '#/definitions/ErrorResponse'
+        '500':
+          description: 服务器内部错误
+          schema:
+            $ref: '#/definitions/ExtendedErrorResponse'
+      deprecated: false
+  /v3/fixtures/settled:
+    get:
+      tags:
+        - Fixtures
+      summary: 获取已结算赛程 - v3
+      description: 返回指定运动在过去 24 小时内已结算的赛程。
+      operationId: Fixtures_Settled_V3_Get
+      consumes: []
+      produces:
+        - application/json
+      parameters:
+        - name: sportId
+          in: query
+          required: true
+          description: 要检索已结算赛事的运动 ID。
+          type: integer
+          format: int32
+        - name: leagueIds
+          in: query
+          required: false
+          description: leagueIds 数组可包含逗号分隔的联赛 ID 列表。
+          type: array
+          items:
+            type: integer
+            format: int32
+          collectionFormat: multi
+        - name: since
+          in: query
+          required: false
+          description: 用于接收增量更新。使用上次响应中的 last 字段值。
+          type: integer
+          format: int64
+      responses:
+        '200':
+          description: 成功
+          schema:
+            $ref: '#/definitions/SettledFixturesSportV3'
+          examples:
+            application/json:
+              sportId: 0
+              last: 0
+              leagues:
+                - id: 0
+                  events:
+                    - id: 0
+                      periods:
+                        - number: 0
+                          status: 0
+                          settlementId: 0
+                          settledAt: '2017-09-03T18:21:22.3846289-07:00'
+                          team1Score: 0
+                          team2Score: 0
+                          cancellationReason:
+                            code: string
+                            details:
+                              correctTeam1Id: string
+                              correctTeam2Id: string
+                              correctListedPitcher1: string
+                              correctListedPitcher2: string
+                              correctSpread: '0.0'
+                              correctTotalPoints: '0.0'
+                              correctTeam1TotalPoints: '0.0'
+                              correctTeam2TotalPoints: '0.0'
+                              correctTeam1Score: '0'
+                              correctTeam2Score: '0'
+                              correctTeam1TennisSetsScore: '0'
+                              correctTeam2TennisSetsScore: '0'
+        '400':
+          description: 错误请求
+          schema:
+            $ref: '#/definitions/ErrorResponse'
+        '401':
+          description: 未授权
+          schema:
+            $ref: '#/definitions/ErrorResponse'
+        '403':
+          description: 禁止访问
+          schema:
+            $ref: '#/definitions/ErrorResponse'
+        '500':
+          description: 服务器内部错误
+          schema:
+            $ref: '#/definitions/ExtendedErrorResponse'
+  /v3/fixtures/special/settled:
+    get:
+      tags:
+        - Fixtures
+      summary: 获取已结算特殊赛程 - v3
+      description: 返回指定运动在过去 24 小时内已结算的所有特殊赛事。
+      operationId: Fixtures_Specials_Settled_V3_Get
+      consumes: []
+      produces:
+        - application/json
+      parameters:
+        - name: sportId
+          in: query
+          description: 要检索已结算特殊赛事的运动 ID。
+          required: true
+          type: integer
+          format: int32
+        - name: leagueIds
+          in: query
+          description: leagueIds 数组。这是可选参数。
+          required: false
+          type: array
+          items:
+            type: integer
+            format: int32
+          collectionFormat: multi
+        - name: since
+          in: query
+          description: 用于接收增量更新。使用上次响应中的 last 字段值。
+          required: false
+          type: integer
+          format: int64
+      responses:
+        '200':
+          description: 成功
+          schema:
+            $ref: '#/definitions/SettledSpecialsResponseV3'
+          examples:
+            application/json:
+              sportId: 0
+              last: 0
+              leagues:
+                - id: 0
+                  specials:
+                    - id: 0
+                      status: 0
+                      settlementId: 0
+                      settledAt: '2017-10-11T15:05:50.996671Z'
+                      contestants:
+                        - id: 1
+                          name: Barranquilla
+                          outcome: "X"
+                        - id: 2
+                          name: Valledupar
+                          outcome: "X"
+                      cancellationReason:
+                        code: string
+                        details:
+                          correctTeam1Id: string
+                          correctTeam2Id: string
+                          correctListedPitcher1: string
+                          correctListedPitcher2: string
+                          correctSpread: '0.0'
+                          correctTotalPoints: '0.0'
+                          correctTeam1TotalPoints: '0.0'
+                          correctTeam2TotalPoints: '0.0'
+                          correctTeam1Score: '0'
+                          correctTeam2Score: '0'
+                          correctTeam1TennisSetsScore: '0'
+                          correctTeam2TennisSetsScore: '0'
+        '400':
+          description: 错误请求
+          schema:
+            $ref: '#/definitions/ErrorResponse'
+        '401':
+          description: 未授权
+          schema:
+            $ref: '#/definitions/ErrorResponse'
+        '403':
+          description: 禁止访问
+          schema:
+            $ref: '#/definitions/ErrorResponse'
+        '500':
+          description: 服务器内部错误
+          schema:
+            $ref: '#/definitions/ExtendedErrorResponse'
+      deprecated: false
+  /v3/odds:
+    get:
+      tags:
+        - Odds
+      summary: 获取直盘赔率 - v3
+      description: 返回所有未结算赛事的直盘赔率。请注意,赛事可能出现在 Get Fixtures 响应中,但不在 Get Odds 中。这种情况发生在赔率目前不可用于投注时。
+      operationId: Odds_Straight_V3_Get
+      consumes: []
+      produces:
+        - application/json
+      parameters:
+        - name: sportId
+          in: query
+          description: 要检索赔率的运动 ID。
+          required: true
+          type: integer
+          format: int32
+        - name: leagueIds
+          in: query
+          description: leagueIds 数组可包含逗号分隔的联赛 ID 列表。
+          required: false
+          type: array
+          items:
+            type: integer
+            format: int32
+          collectionFormat: multi
+        - name: oddsFormat
+          in: query
+          description: 返回赔率的格式。默认为 American。[American, Decimal, HongKong, Indonesian, Malay]
+          required: false
+          type: string
+          enum:
+            - American
+            - Decimal
+            - HongKong
+            - Indonesian
+            - Malay
+        - name: since
+          in: query
+          description: 用于接收增量更新。使用上次 odds 响应中的 last 值。如果不提供 since 参数,赔率最多会延迟 1 分钟,以鼓励使用该参数。请注意,使用 since 参数时,响应中只包含已更改的周期。如果某个周期没有任何更改,它将不会出现在响应中。
+          required: false
+          type: integer
+          format: int64
+        - name: isLive
+          in: query
+          description: 要仅检索直播赔率,请将值设置为 1 (isLive=1)。否则响应将包含所有赔率。
+          required: false
+          type: boolean
+        - name: eventIds
+          in: query
+          description: 按 EventIds 过滤
+          required: false
+          type: array
+          items:
+            type: integer
+            format: int64
+          collectionFormat: multi
+        - name: toCurrencyCode
+          in: query
+          description: 3 字母货币代码,如 [/currency](https://pinny888.github.io/docs/?api=lines#tag/Others/operation/Currencies_V2_Get) 响应中所示。限额将以请求的货币返回。默认为 USD。
+          required: false
+          type: string
+      responses:
+        '200':
+          description: 成功
+          schema:
+            $ref: '#/definitions/OddsResponseV3'
+        '400':
+          description: 错误请求
+          schema:
+            $ref: '#/definitions/ErrorResponse'
+        '401':
+          description: 未授权
+          schema:
+            $ref: '#/definitions/ErrorResponse'
+        '403':
+          description: 禁止访问
+          schema:
+            $ref: '#/definitions/ErrorResponse'
+        '500':
+          description: 服务器内部错误
+          schema:
+            $ref: '#/definitions/ExtendedErrorResponse'
+      deprecated: true
+  /v4/odds:
+    get:
+      tags:
+        - Odds
+      summary: 获取直盘赔率 - v4
+      description: 返回所有未结算赛事的直盘赔率。请注意,赛事可能出现在 Get Fixtures 响应中,但不在 Get Odds 中。这种情况发生在赔率目前不可用于投注时。
+      operationId: Odds_Straight_V4_Get
+      consumes: []
+      produces:
+        - application/json
+      parameters:
+        - name: sportId
+          in: query
+          description: 要检索赔率的运动 ID。
+          required: true
+          type: integer
+          format: int32
+        - name: leagueIds
+          in: query
+          description: leagueIds 数组可包含逗号分隔的联赛 ID 列表。
+          required: false
+          type: array
+          items:
+            type: integer
+            format: int32
+          collectionFormat: multi
+        - name: oddsFormat
+          in: query
+          description: 返回赔率的格式。默认为 American。[American, Decimal, HongKong, Indonesian, Malay]
+          required: false
+          type: string
+          enum:
+            - American
+            - Decimal
+            - HongKong
+            - Indonesian
+            - Malay
+        - name: since
+          in: query
+          description: 用于接收增量更新。使用上次 odds 响应中的 last 值。如果不提供 since 参数,赔率最多会延迟 1 分钟,以鼓励使用该参数。请注意,使用 since 参数时,响应中只包含已更改的周期。如果某个周期没有任何更改,它将不会出现在响应中。
+          required: false
+          type: integer
+          format: int64
+        - name: isLive
+          in: query
+          description: 要仅检索直播赔率,请将值设置为 1 (isLive=1)。否则响应将包含所有赔率。
+          required: false
+          type: boolean
+        - name: eventIds
+          in: query
+          description: 按 EventIds 过滤
+          required: false
+          type: array
+          items:
+            type: integer
+            format: int64
+          collectionFormat: multi
+        - name: toCurrencyCode
+          in: query
+          description: 3 字母货币代码,如 [/currency](https://pinny888.github.io/docs/?api=lines#tag/Others/operation/Currencies_V2_Get) 响应中所示。限额将以请求的货币返回。默认为 USD。
+          required: false
+          type: string
+      responses:
+        '200':
+          description: 成功
+          schema:
+            $ref: '#/definitions/OddsResponseV4'
+        '400':
+          description: 错误请求
+          schema:
+            $ref: '#/definitions/ErrorResponse'
+        '401':
+          description: 未授权
+          schema:
+            $ref: '#/definitions/ErrorResponse'
+        '403':
+          description: 禁止访问
+          schema:
+            $ref: '#/definitions/ErrorResponse'
+        '500':
+          description: 服务器内部错误
+          schema:
+            $ref: '#/definitions/ExtendedErrorResponse'
+  /v3/odds/parlay:
+    get:
+      tags:
+        - Odds
+      summary: 获取串关赔率 - v3
+      description: 返回所有未结算赛事的串关赔率。请注意,赛事可能出现在 Get Fixtures 响应中,但不在 Get Odds 中。这种情况发生在赔率目前不可用于投注时。
+      operationId: Odds_Parlays_V3_Get
+      consumes: []
+      produces:
+        - application/json
+      parameters:
+        - name: sportId
+          in: query
+          description: 要检索赔率的运动 ID。
+          required: true
+          type: integer
+          format: int32
+        - name: leagueIds
+          in: query
+          description: leagueIds 数组可包含逗号分隔的联赛 ID 列表。
+          required: false
+          type: array
+          items:
+            type: integer
+            format: int32
+          collectionFormat: multi
+        - name: oddsFormat
+          in: query
+          description: 返回赔率的格式。默认为 American。[American, Decimal, HongKong, Indonesian, Malay]
+          required: false
+          type: string
+          enum:
+            - American
+            - Decimal
+            - HongKong
+            - Indonesian
+            - Malay
+        - name: since
+          in: query
+          description: 用于接收增量更新。使用上次 odds 响应中的 last 值。如果不提供 since 参数,赔率最多会延迟 1 分钟,以鼓励使用该参数。请注意,使用 since 参数时,响应中只包含已更改的周期。如果某个周期没有任何更改,它将不会出现在响应中。
+          required: false
+          type: integer
+          format: int64
+        - name: isLive
+          in: query
+          description: 要仅检索直播赔率,请将值设置为 1 (isLive=1)。否则响应将包含所有赔率。
+          required: false
+          type: boolean
+        - name: eventIds
+          in: query
+          description: 按 EventIds 过滤
+          required: false
+          type: array
+          items:
+            type: integer
+            format: int64
+          collectionFormat: multi
+      responses:
+        '200':
+          description: 成功
+          schema:
+            $ref: '#/definitions/ParlayOddsResponseV3'
+        '400':
+          description: 错误请求
+          schema:
+            $ref: '#/definitions/ErrorResponse'
+        '401':
+          description: 未授权
+          schema:
+            $ref: '#/definitions/ErrorResponse'
+        '403':
+          description: 禁止访问
+          schema:
+            $ref: '#/definitions/ErrorResponse'
+        '500':
+          description: 服务器内部错误
+          schema:
+            $ref: '#/definitions/ExtendedErrorResponse'
+      deprecated: true
+  /v4/odds/parlay:
+    get:
+      tags:
+        - Odds
+      summary: 获取串关赔率 - v4
+      description: 返回所有未结算赛事的串关赔率。请注意,赛事可能出现在 Get Fixtures 响应中,但不在 Get Odds 中。这种情况发生在赔率目前不可用于投注时。
+      operationId: Odds_Parlays_V4_Get
+      consumes: []
+      produces:
+        - application/json
+      parameters:
+        - name: sportId
+          in: query
+          description: 要检索赔率的运动 ID。
+          required: true
+          type: integer
+          format: int32
+        - name: leagueIds
+          in: query
+          description: leagueIds 数组可包含逗号分隔的联赛 ID 列表。
+          required: false
+          type: array
+          items:
+            type: integer
+            format: int32
+          collectionFormat: multi
+        - name: oddsFormat
+          in: query
+          description: 返回赔率的格式。默认为 American。[American, Decimal, HongKong, Indonesian, Malay]
+          required: false
+          type: string
+          enum:
+            - American
+            - Decimal
+            - HongKong
+            - Indonesian
+            - Malay
+        - name: since
+          in: query
+          description: 用于接收增量更新。使用上次 odds 响应中的 last 值。如果不提供 since 参数,赔率最多会延迟 1 分钟,以鼓励使用该参数。请注意,使用 since 参数时,响应中只包含已更改的周期。如果某个周期没有任何更改,它将不会出现在响应中。
+          required: false
+          type: integer
+          format: int64
+        - name: isLive
+          in: query
+          description: 要仅检索直播赔率,请将值设置为 1 (isLive=1)。否则响应将包含所有赔率。
+          required: false
+          type: boolean
+        - name: eventIds
+          in: query
+          description: 按 EventIds 过滤
+          required: false
+          type: array
+          items:
+            type: integer
+            format: int64
+          collectionFormat: multi
+      responses:
+        '200':
+          description: 成功
+          schema:
+            $ref: '#/definitions/ParlayOddsResponseV4'
+        '400':
+          description: 错误请求
+          schema:
+            $ref: '#/definitions/ErrorResponse'
+        '401':
+          description: 未授权
+          schema:
+            $ref: '#/definitions/ErrorResponse'
+        '403':
+          description: 禁止访问
+          schema:
+            $ref: '#/definitions/ErrorResponse'
+        '500':
+          description: 服务器内部错误
+          schema:
+            $ref: '#/definitions/ExtendedErrorResponse'
+  /v1/odds/teaser:
+    get:
+      tags:
+        - Odds
+      summary: 获取过关赔率 - v1
+      description: 返回指定过关的赔率。
+      operationId: Odds_Teasers_V1_Get
+      consumes: []
+      produces:
+        - application/json
+      parameters:
+        - name: teaserId
+          in: query
+          description: 唯一标识符。可以从 Get Teaser Groups 端点调用中检索过关详情。
+          required: true
+          type: integer
+          format: int64
+      responses:
+        '200':
+          description: 成功
+          schema:
+            $ref: '#/definitions/TeaserOddsResponse'
+        '400':
+          description: 错误请求
+          schema:
+            $ref: '#/definitions/ErrorResponse'
+        '401':
+          description: 未授权
+          schema:
+            $ref: '#/definitions/ErrorResponse'
+        '403':
+          description: 禁止访问
+          schema:
+            $ref: '#/definitions/ErrorResponse'
+        '500':
+          description: 服务器内部错误
+          schema:
+            $ref: '#/definitions/ExtendedErrorResponse'
+  /v2/odds/special:
+    get:
+      tags:
+        - Odds
+      summary: 获取特殊赔率 - v2
+      description: 返回所有未结算赛事的特殊赔率。
+      operationId: Odds_Special_V2_Get
+      consumes: []
+      produces:
+        - application/json
+      parameters:
+        - name: oddsFormat
+          in: query
+          description: 返回赔率的格式。[American, Decimal, HongKong, Indonesian, Malay]
+          required: false
+          type: string
+          enum:
+            - American
+            - Decimal
+            - HongKong
+            - Indonesian
+            - Malay
+        - name: sportId
+          in: query
+          description: 要检索特殊赛事的运动 ID。
+          required: true
+          type: integer
+          format: int32
+        - name: leagueIds
+          in: query
+          description: leagueIds 数组可包含逗号分隔的联赛 ID 列表。
+          required: false
+          type: array
+          items:
+            type: integer
+            format: int32
+          collectionFormat: multi
+        - name: since
+          in: query
+          description: 用于接收增量更新。使用上次响应中的 last 值。如果不提供 since 参数,赛程最多会延迟 1 分钟,以鼓励使用该参数。
+          required: false
+          type: integer
+          format: int64
+        - name: specialId
+          in: query
+          description: 特殊赛事的 ID。这是可选参数。
+          required: false
+          type: integer
+          format: int64
+      responses:
+        '200':
+          description: 成功
+          schema:
+            $ref: '#/definitions/SpecialOddsResponseV2'
+          examples:
+            application/json:
+              sportId: 4
+              last: 636433059510590700
+              leagues:
+                - id: 487
+                  specials:
+                    - id: 1
+                      maxBet: 100
+                      contestantLines:
+                        - id: 1
+                          lineId: 1001
+                          price: -199
+                          handicap: null
+                          max: 100
+                        - id: 2
+                          lineId: 1002
+                          price: -198
+                          handicap: null
+                          max: 100
+                    - id: 7
+                      maxBet: 100
+                      contestantLines:
+                        - id: 13
+                          lineId: 1013
+                          price: -187
+                          handicap: null
+                          max: 100
+                        - id: 14
+                          lineId: 1014
+                          price: -186
+                          handicap: null
+                          max: 100
+                - id: 578
+                  specials:
+                    - id: 10
+                      maxBet: 100
+                      contestantLines:
+                        - id: 21
+                          lineId: 1021
+                          price: -179
+                          handicap: null
+                          max: 100
+                        - id: 22
+                          lineId: 1022
+                          price: -178
+                          handicap: null
+                          max: 100
+                        - id: 23
+                          lineId: 1023
+                          price: -177
+                          handicap: null
+                          max: 100
+                        - id: 24
+                          lineId: 1024
+                          price: -176
+                          handicap: null
+                          max: 100
+        '400':
+          description: 错误请求
+          schema:
+            $ref: '#/definitions/ErrorResponse'
+        '401':
+          description: 未授权
+          schema:
+            $ref: '#/definitions/ErrorResponse'
+        '403':
+          description: 禁止访问
+          schema:
+            $ref: '#/definitions/ErrorResponse'
+        '500':
+          description: 服务器内部错误
+          schema:
+            $ref: '#/definitions/ExtendedErrorResponse'
+      deprecated: false
+  /v2/line:
+    get:
+      tags:
+        - Line
+      summary: 获取直盘线 - v2
+      description: 返回最新线。
+      operationId: Line_Straight_V2_Get
+      consumes: []
+      produces:
+        - application/json
+      parameters:
+        - name: leagueId
+          in: query
+          description: 联赛 ID。
+          required: true
+          type: integer
+          format: int32
+        - name: handicap
+          in: query
+          description: SPREAD、TOTAL_POINTS 和 TEAM_TOTAL_POINTS 投注类型需要此参数
+          required: true
+          type: number
+          format: double
+        - name: oddsFormat
+          in: query
+          description: 返回赔率的格式。默认为 American。
+          required: true
+          type: string
+          enum:
+            - American
+            - Decimal
+            - HongKong
+            - Indonesian
+            - Malay
+        - name: sportId
+          in: query
+          description: 运动标识
+          required: true
+          type: integer
+          format: int32
+        - name: eventId
+          in: query
+          description: 赛事标识
+          required: true
+          type: integer
+          format: int64
+        - name: periodNumber
+          in: query
+          description: 这代表比赛的周期。请查看 Get Periods 端点以获取每个运动当前支持的周期列表。
+          required: true
+          type: integer
+          format: int32
+        - name: betType
+          in: query
+          description: 投注类型
+          required: true
+          type: string
+          enum:
+            - SPREAD
+            - MONEYLINE
+            - TOTAL_POINTS
+            - TEAM_TOTAL_POINTS
+        - name: team
+          in: query
+          description: 选择的队伍类型。仅 SPREAD、MONEYLINE 和 TEAM_TOTAL_POINTS 投注类型需要此参数
+          required: false
+          type: string
+          enum:
+            - Team1
+            - Team2
+            - Draw
+        - name: side
+          in: query
+          description: 选择的侧。仅 TOTAL_POINTS 和 TEAM_TOTAL_POINTS 需要此参数
+          required: false
+          type: string
+          enum:
+            - OVER
+            - UNDER
+      responses:
+        '200':
+          description: 成功
+          schema:
+            $ref: '#/definitions/LineResponseV2'
+        '400':
+          description: 错误请求
+          schema:
+            $ref: '#/definitions/ErrorResponse'
+        '401':
+          description: 未授权
+          schema:
+            $ref: '#/definitions/ErrorResponse'
+        '403':
+          description: 禁止访问
+          schema:
+            $ref: '#/definitions/ErrorResponse'
+        '500':
+          description: 服务器内部错误
+          schema:
+            $ref: '#/definitions/ExtendedErrorResponse'
+  /v3/line/parlay:
+    post:
+      tags:
+        - Line
+      summary: 获取串关线 - v3
+      description: 返回串关线并计算赔率。
+      operationId: Line_Parlay_V3_Post
+      consumes:
+        - application/json
+      produces:
+        - application/json
+      parameters:
+        - in: body
+          name: request
+          required: true
+          schema:
+            $ref: '#/definitions/ParlayLinesRequestV3'
+      responses:
+        '200':
+          description: 成功
+          schema:
+            $ref: '#/definitions/ParlayLinesResponseV3'
+        '400':
+          description: 错误请求
+          schema:
+            $ref: '#/definitions/ErrorResponse'
+        '401':
+          description: 未授权
+          schema:
+            $ref: '#/definitions/ErrorResponse'
+        '403':
+          description: 禁止访问
+          schema:
+            $ref: '#/definitions/ErrorResponse'
+        '500':
+          description: 服务器内部错误
+          schema:
+            $ref: '#/definitions/ExtendedErrorResponse'
+  /v1/line/teaser:
+    post:
+      tags:
+        - Line
+      summary: 获取过关线 - v1
+      description: 在提交前验证过关投注。成功时返回投注限额和价格。
+      operationId: Line_Teaser_V1_Post
+      consumes:
+        - application/json
+      produces:
+        - application/json
+      parameters:
+        - in: body
+          name: teaserLinesRequest
+          required: true
+          schema:
+            $ref: '#/definitions/LinesRequestTeaser'
+      responses:
+        '200':
+          description: 成功
+          schema:
+            $ref: '#/definitions/TeaserLinesResponse'
+        '400':
+          description: 错误请求
+          schema:
+            $ref: '#/definitions/ErrorResponse'
+        '401':
+          description: 未授权
+          schema:
+            $ref: '#/definitions/ErrorResponse'
+        '403':
+          description: 禁止访问
+          schema:
+            $ref: '#/definitions/ErrorResponse'
+        '500':
+          description: 服务器内部错误
+          schema:
+            $ref: '#/definitions/ExtendedErrorResponse'
+  /v2/line/special:
+    get:
+      tags:
+        - Line
+      operationId: Line_Special_V2_Get
+      summary: 获取特殊线 - v2
+      description: 返回特殊线并计算赔率。
+      consumes: []
+      produces:
+        - application/json
+      parameters:
+        - name: oddsFormat
+          in: query
+          description: 返回赔率的格式。[American, Decimal, HongKong, Indonesian, Malay]
+          required: true
+          type: string
+          enum:
+            - American
+            - Decimal
+            - HongKong
+            - Indonesian
+            - Malay
+        - name: specialId
+          in: query
+          description: 特殊赛事的 ID。
+          required: true
+          type: integer
+          format: int64
+        - name: contestantId
+          in: query
+          description: 参赛者 ID。
+          required: true
+          type: integer
+          format: int64
+        - name: handicap
+          in: query
+          description: 参赛者的让分。由于参赛者的让分是一个可变属性,可能会发生 line/special 返回 status:SUCCESS,但让分与客户端在调用 line/special 时拥有的让分不同。可以在请求中指定 handicap 参数,如果参赛者的让分已更改,它将返回 status:NOT_EXISTS。这样 line/special 与 /line 的工作方式更加一致。
+          required: false
+          type: number
+          format: double
+      responses:
+        '200':
+          description: 成功
+          schema:
+            $ref: '#/definitions/SpecialLineResponse'
+        '400':
+          description: 错误请求
+          schema:
+            $ref: '#/definitions/ErrorResponse'
+        '401':
+          description: 未授权
+          schema:
+            $ref: '#/definitions/ErrorResponse'
+        '403':
+          description: 禁止访问
+          schema:
+            $ref: '#/definitions/ErrorResponse'
+        '500':
+          description: 服务器内部错误
+          schema:
+            $ref: '#/definitions/ExtendedErrorResponse'
+      deprecated: false
+  /v3/sports:
+    get:
+      tags:
+        - Others
+      summary: 获取运动项目 - v3
+      description: 返回所有运动项目及其当前是否有线的状态。
+      operationId: Sports_V3_Get
+      consumes: []
+      produces:
+        - application/json
+      parameters: []
+      responses:
+        '200':
+          description: 成功
+          schema:
+            $ref: '#/definitions/SportsResponseV3'
+        '401':
+          description: 未授权
+          schema:
+            $ref: '#/definitions/ErrorResponse'
+        '403':
+          description: 禁止访问
+          schema:
+            $ref: '#/definitions/ErrorResponse'
+        '500':
+          description: 服务器内部错误
+          schema:
+            $ref: '#/definitions/LinesErrorResponse'
+  /v3/leagues:
+    get:
+      tags:
+        - Others
+      summary: 获取联赛 - v3
+      description: 返回所有体育联赛及其当前是否有线的状态。
+      operationId: Leagues_V3_Get
+      consumes: []
+      produces:
+        - application/json
+      parameters:
+        - name: sportId
+          in: query
+          description: 请求联赛的运动 ID。
+          required: true
+          type: string
+      responses:
+        '200':
+          description: 成功
+          schema:
+            $ref: '#/definitions/LeaguesV3'
+        '400':
+          description: 错误请求
+          schema:
+            $ref: '#/definitions/ErrorResponse'
+        '401':
+          description: 未授权
+          schema:
+            $ref: '#/definitions/ErrorResponse'
+        '403':
+          description: 禁止访问
+          schema:
+            $ref: '#/definitions/ErrorResponse'
+        '500':
+          description: 服务器内部错误
+          schema:
+            $ref: '#/definitions/ExtendedErrorResponse'
+  /v1/periods:
+    get:
+      tags:
+        - Others
+      summary: 获取周期 - v1
+      description: 返回指定运动的所有周期。
+      operationId: Periods_V1_Get
+      consumes: []
+      produces:
+        - application/json
+      parameters:
+        - name: sportId
+          in: query
+          required: true
+          type: string
+      responses:
+        '200':
+          description: 成功
+          schema:
+            $ref: '#/definitions/SportPeriod'
+          examples:
+            application/json:
+              periods:
+                - number: 0
+                  description: 全场
+                  shortDescription: FT
+                - number: 1
+                  description: 上半场
+                  shortDescription: 1st H
+                - number: 2
+                  description: 下半场
+                  shortDescription: 2nd H
+        '400':
+          description: 错误请求
+          schema:
+            $ref: '#/definitions/ErrorResponse'
+        '401':
+          description: 未授权
+          schema:
+            $ref: '#/definitions/ErrorResponse'
+        '403':
+          description: 禁止访问
+          schema:
+            $ref: '#/definitions/ErrorResponse'
+        '500':
+          description: 服务器内部错误
+          schema:
+            $ref: '#/definitions/ExtendedErrorResponse'
+  /v2/inrunning:
+    get:
+      tags:
+        - Others
+      summary: 获取进行中赛事 - v2
+      description: 返回所有状态显示为正在进行中的直播足球赛事。
+      operationId: InRunning_V2_Get
+      consumes: []
+      produces:
+        - application/json
+      parameters: []
+      responses:
+        '200':
+          description: 成功
+          schema:
+            $ref: '#/definitions/InRunningResponse'
+        '401':
+          description: 未授权
+          schema:
+            $ref: '#/definitions/ErrorResponse'
+        '403':
+          description: 禁止访问
+          schema:
+            $ref: '#/definitions/ErrorResponse'
+        '500':
+          description: 服务器内部错误
+          schema:
+            $ref: '#/definitions/ExtendedLinesErrorResponse'
+  /v1/teaser/groups:
+    get:
+      tags:
+        - Others
+      summary: 获取过关组 - v1
+      description: 返回所有过关组。
+      operationId: Teaser_Groups_V1_Get
+      consumes: []
+      produces:
+        - application/json
+      parameters:
+        - name: oddsFormat
+          in: query
+          description: 返回赔率的格式。[American, Decimal, HongKong, Indonesian, Malay]
+          required: true
+          type: string
+          enum:
+            - American
+            - Decimal
+            - HongKong
+            - Indonesian
+            - Malay
+      responses:
+        '200':
+          description: 成功
+          schema:
+            $ref: '#/definitions/TeaserGroupsResponse'
+        '400':
+          description: 错误请求
+          schema:
+            $ref: '#/definitions/ErrorResponse'
+        '401':
+          description: 未授权
+          schema:
+            $ref: '#/definitions/ErrorResponse'
+        '403':
+          description: 禁止访问
+          schema:
+            $ref: '#/definitions/ErrorResponse'
+        '500':
+          description: 服务器内部错误
+          schema:
+            $ref: '#/definitions/ExtendedErrorResponse'
+  /v1/cancellationreasons:
+    get:
+      tags:
+        - Others
+      summary: 获取取消原因 - v1
+      description: 查找所有取消原因
+      operationId: CancellationReasons_V1_Get
+      consumes: []
+      produces:
+        - application/json
+      parameters: []
+      responses:
+        '200':
+          description: 成功
+          schema:
+            $ref: '#/definitions/CancellationReasonResponse'
+        '401':
+          description: 未授权
+          schema:
+            $ref: '#/definitions/ErrorResponse'
+        '403':
+          description: 禁止访问
+          schema:
+            $ref: '#/definitions/ErrorResponse'
+        '500':
+          description: 服务器内部错误
+          schema:
+            $ref: '#/definitions/ExtendedErrorResponse'
+  /v2/currencies:
+    get:
+      tags:
+        - Others
+      summary: 获取货币 - v2
+      description: 返回支持的货币列表
+      operationId: Currencies_V2_Get
+      consumes: []
+      produces:
+        - application/json
+      parameters: []
+      responses:
+        '200':
+          description: 成功
+          schema:
+            $ref: '#/definitions/SuccessfulCurrenciesResponse'
+        '401':
+          description: 未授权
+          schema:
+            $ref: '#/definitions/ErrorResponse'
+        '403':
+          description: 禁止访问
+          schema:
+            $ref: '#/definitions/ErrorResponse'
+        '500':
+          description: 服务器内部错误
+          schema:
+            $ref: '#/definitions/ExtendedErrorResponse'
+securityDefinitions:
+  basicAuth:
+    type: basic
+definitions:
+  ErrorResponse:
+    type: object
+    properties:
+      code:
+        type: string
+        description: 标识发生的错误类型的标识符。
+      message:
+        type: string
+        description: 错误描述。
+    description: 包含遇到的错误详细信息。
+  ExtendedErrorResponse:
+    type: object
+    properties:
+      ref:
+        type: string
+      code:
+        type: string
+      message:
+        type: string
+  LinesErrorResponse:
+    type: object
+    properties:
+      status:
+        type: string
+      error:
+        $ref: '#/definitions/ErrorResponse'
+      code:
+        type: integer
+        format: int32
+        description: 标识发生的错误的代码。
+  ExtendedLinesErrorResponse:
+    type: object
+    properties:
+      ref:
+        type: string
+      status:
+        type: string
+      error:
+        $ref: '#/definitions/ErrorResponse'
+      code:
+        type: integer
+        format: int32
+        description: 标识发生的错误的代码。
+  CancellationReasonResponse:
+    type: object
+    properties:
+      cancellationReasons:
+        type: array
+        description: 包含取消原因列表。
+        items:
+          $ref: '#/definitions/CancellationReason'
+    description: 取消原因响应数据
+  CancellationReason:
+    type: object
+    properties:
+      code:
+        type: string
+        description: 服务器分配的取消代码
+        example: FBS_CW_65
+      description:
+        type: string
+        description: 取消原因的文本描述
+        example: The event was postponed
+    description: 取消数据
+  SuccessfulCurrenciesResponse:
+    type: object
+    properties:
+      currencies:
+        type: array
+        description: 货币容器。
+        items:
+          $ref: '#/definitions/Currency'
+  Currency:
+    type: object
+    properties:
+      code:
+        type: string
+        description: 货币代码。
+        example: AED
+      name:
+        type: string
+        description: 货币名称。
+        example: United Arab Emirates Dirham
+      rate:
+        type: number
+        format: double
+        description: 对美元的汇率。
+        example: 3.6738
+  FixturesResponseV1:
+    type: object
+    properties:
+      sportId:
+        type: integer
+        format: int32
+        description: 与请求的运动 ID 相同。
+      last:
+        type: integer
+        format: int64
+        description: 在后续请求中使用此值作为 since 查询参数,以仅获取自上次响应以来的更改。
+      league:
+        type: array
+        description: 包含联赛列表。
+        items:
+          $ref: '#/definitions/FixturesLeagueV1'
+  FixturesLeagueV1:
+    type: object
+    properties:
+      id:
+        type: integer
+        format: int32
+        description: 联赛 ID。
+      events:
+        type: array
+        description: 包含赛事列表。
+        items:
+          $ref: '#/definitions/FixtureV1'
+  FixtureV1:
+    type: object
+    properties:
+      id:
+        type: integer
+        format: int64
+        description: 赛事 ID。
+      starts:
+        type: string
+        format: date-time
+        description: 赛事开始时间(UTC)。
+      home:
+        type: string
+        description: 主队名称。
+      away:
+        type: string
+        description: 客队名称。
+      rotNum:
+        type: string
+        description: Team1 轮换号码。请注意,在 /fixtures 的下一版本中,rotNum 属性将被弃用。可以使用 ParentId 来分组相关赛事。
+      liveStatus:
+        type: integer
+        format: int32
+        description: |
+          指示赛事的直播状态。
+
+          0 = 此赛事不提供直播投注,
+          1 = 直播投注赛事,
+          2 = 将为此赛事提供直播投注
+        enum:
+          - 0
+          - 1
+          - 2
+      homePitcher:
+        type: string
+        description: 主队投手。仅适用于棒球。
+      awayPitcher:
+        type: string
+        description: 客队投手。仅适用于棒球。
+      status:
+        type: string
+        description: |
+
+          赛事状态。
+
+          O = 这是比赛的起始状态。这意味着线开放投注,
+          H = 此状态表示线暂时不可用于投注,
+          I = 此状态表示一条或多条线有红圈(较低的最大投注金额)
+        enum:
+          - O
+          - H
+          - I
+      parlayRestriction:
+        type: integer
+        format: int32
+        description: |
+
+          赛事的串关状态。
+
+          0 = 允许串关,无限制,
+          1 = 不允许串关此赛事,
+          2 = 允许串关,但有限制。串关中不能有来自同一赛事的多个腿。具有相同轮换号码的所有赛事被视为同一赛事。
+        enum:
+          - 0
+          - 1
+          - 2
+  FixturesResponseV3:
+    type: object
+    properties:
+      sportId:
+        type: integer
+        format: int32
+        description: 与请求的运动 ID 相同。
+      last:
+        type: integer
+        format: int64
+        description: 在后续请求中使用此值作为 since 查询参数,以仅获取自上次响应以来的更改。
+      league:
+        type: array
+        description: 包含联赛列表。
+        items:
+          $ref: '#/definitions/FixturesLeagueV3'
+  FixturesLeagueV3:
+    type: object
+    properties:
+      id:
+        type: integer
+        format: int32
+        description: 联赛 ID。
+      name:
+        type: string
+        description: 联赛名称。
+      events:
+        type: array
+        description: 包含赛事列表。
+        items:
+          $ref: '#/definitions/FixtureV3'
+  FixtureV3:
+    type: object
+    properties:
+      id:
+        type: integer
+        format: int64
+        description: 赛事 ID。
+      parentId:
+        type: integer
+        format: int64
+        description: 如果赛事链接到另一个赛事,将填充 parentId。直播赛事将把赛前赛事作为父 ID。
+      starts:
+        type: string
+        format: date-time
+        description: 赛事开始时间(UTC)。
+      home:
+        type: string
+        description: 主队名称。
+      away:
+        type: string
+        description: 客队名称。
+      liveStatus:
+        type: integer
+        format: int32
+        description: |
+          指示赛事的直播状态。
+
+          0 = 此赛事不提供直播投注,
+          1 = 直播投注赛事,
+          2 = 将为此赛事提供直播投注
+        enum:
+          - 0
+          - 1
+          - 2
+      homePitcher:
+        type: string
+        description: 主队投手。仅适用于棒球。
+      awayPitcher:
+        type: string
+        description: 客队投手。仅适用于棒球。
+      betAcceptanceType:
+        type: integer
+        format: int32
+        description: |
+
+          足球直播赛事投注接受类型。此类型表示向相应客户提供直播足球赛事。
+
+          0 = 不适用。无投注接受类型限制。
+          1 = 危险区。
+          2 = 直播延迟。
+          3 = 两者都有。
+        enum:
+          - 0
+          - 1
+          - 2
+          - 3
+      parlayRestriction:
+        type: integer
+        format: int32
+        description: |
+
+          赛事的串关状态。
+
+          0 = 允许串关,无限制,
+          1 = 不允许串关此赛事,
+          2 = 允许串关,但有限制。串关中不能有来自同一赛事的多个腿。具有相同轮换号码的所有赛事被视为同一赛事。
+        enum:
+          - 0
+          - 1
+          - 2
+      altTeaser:
+        type: boolean
+        description: 赛事是否提供替代过关点数。具有替代过关点数的赛事可能与过关定义不同。
+      resultingUnit:
+        type: string
+        description: |
+          指定赛事基于什么结算,例如角球、黄牌
+      version:
+        type: integer
+        format: int64
+        description: |
+           赛程版本,当赛程有更改时会增加。
+  SettledFixturesSportV3:
+    type: object
+    properties:
+      sportId:
+        type: integer
+        format: int32
+        description: 与请求的运动 ID 相同。
+      last:
+        type: integer
+        format: int64
+        description: 在后续请求中使用此值作为 since 查询参数,以仅获取自上次响应以来的更改。
+      leagues:
+        type: array
+        description: 包含联赛列表。
+        items:
+          $ref: '#/definitions/SettledFixturesLeagueV3'
+  SettledFixturesLeagueV3:
+    type: object
+    properties:
+      id:
+        type: integer
+        format: int32
+        description: 联赛 ID。
+      events:
+        type: array
+        description: 包含赛事列表。
+        items:
+          $ref: '#/definitions/SettledFixturesEventV3'
+  SettledFixturesEventV3:
+    type: object
+    properties:
+      id:
+        type: integer
+        format: int64
+        description: 赛事 ID。
+      periods:
+        type: array
+        description: 包含周期列表。
+        items:
+          $ref: '#/definitions/SettledFixturesPeriodV3'
+  SettledFixturesPeriodV3:
+    type: object
+    properties:
+      number:
+        type: integer
+        format: int32
+        description: 这代表比赛的周期。例如,对于足球我们有 0(全场)、1(上半场)和 2(下半场)。
+      status:
+        type: integer
+        format: int32
+        description: |
+          周期结算状态。
+
+          1 = 赛事周期已结算,
+          2 = 赛事周期已重新结算,
+          3 = 赛事周期已取消,
+          4 = 赛事周期已重新结算为取消,
+          5 = 赛事已删除
+        enum:
+          - 1
+          - 2
+          - 3
+          - 4
+          - 5
+      settlementId:
+        type: integer
+        format: int64
+        description: 结算的唯一 ID。如果重新结算,将生成新的 settlementId 和 settledAt。
+      settledAt:
+        type: string
+        format: date-time
+        description: 周期结算的日期和时间(UTC)。
+      team1Score:
+        type: integer
+        format: int32
+        description: Team1 得分。
+      team2Score:
+        type: integer
+        format: int32
+        description: Team2 得分。
+      cancellationReason:
+        $ref: '#/definitions/CancellationReasonType'
+  CancellationReasonType:
+    type: object
+    properties:
+      code:
+        type: string
+        description: 取消原因代码
+      details:
+        $ref: '#/definitions/CancellationReasonDetailsType'
+  CancellationReasonDetailsType:
+    type: object
+    properties:
+      key:
+        type: string
+      value:
+        type: string
+  InRunningResponse:
+    type: object
+    properties:
+      sports:
+        type: array
+        description: 运动容器
+        items:
+          $ref: '#/definitions/InRunningSport'
+  InRunningSport:
+    type: object
+    properties:
+      id:
+        type: integer
+        format: int32
+        description: 运动 ID
+      leagues:
+        type: array
+        description: 联赛容器
+        items:
+          $ref: '#/definitions/InRunningLeague'
+  InRunningLeague:
+    type: object
+    properties:
+      id:
+        type: integer
+        format: int32
+        description: 联赛 ID
+      events:
+        type: array
+        description: 赛事容器
+        items:
+          $ref: '#/definitions/InRunningEvent'
+  InRunningEvent:
+    type: object
+    properties:
+      id:
+        type: integer
+        format: int64
+        description: 比赛 ID
+      state:
+        type: integer
+        format: int32
+        description: |
+          比赛状态。
+
+          1 = 上半场进行中,
+          2 = 半场休息进行中,
+          3 = 下半场进行中,
+          4 = 常规时间结束,
+          5 = 加时赛上半场进行中,
+          6 = 加时赛半场休息进行中,
+          7 = 加时赛下半场进行中,
+          8 = 加时赛结束,
+          9 = 比赛结束,
+          10 = 比赛暂时暂停,
+          11 = 点球大战进行中
+        enum:
+          - 1
+          - 2
+          - 3
+          - 4
+          - 5
+          - 6
+          - 7
+          - 8
+          - 9
+          - 10
+          - 11
+      elapsed:
+        type: integer
+        format: int32
+        description: 已过分钟数
+  LeaguesV3:
+    type: object
+    properties:
+      leagues:
+        type: array
+        description: 联赛容器
+        items:
+          $ref: '#/definitions/LeagueV3'
+  LeagueV3:
+    type: object
+    properties:
+      id:
+        type: integer
+        format: int32
+        description: 联赛 ID。
+      name:
+        type: string
+        description: 联赛名称。
+      homeTeamType:
+        type: string
+        description: 指定主队是 team1 还是 team2。您需要此信息来下注。
+      hasOfferings:
+        type: boolean
+        description: 联赛当前是否有赛事或特殊赛事。
+      container:
+        type: string
+        description: 代表联赛的分组,通常是地区/国家
+      allowRoundRobins:
+        type: boolean
+        description: 指定您是否可以在此联赛的赛事上下注串关循环赛。
+      leagueSpecialsCount:
+        type: integer
+        format: int32
+        description: 指示给定联赛中有多少特殊赛事。
+      eventSpecialsCount:
+        type: integer
+        format: int32
+        description: 指示给定联赛中有多少比赛特殊赛事。
+      eventCount:
+        type: integer
+        format: int32
+        description: 指示给定联赛中有多少赛事。
+  LineResponseV2:
+    type: object
+    properties:
+      status:
+        type: string
+        description: 如果值为 NOT_EXISTS,则这将是响应中的唯一参数。所有其他参数都将为空。[SUCCESS = 成功, NOT_EXISTS = 不再提供线]
+        enum:
+          - SUCCESS
+          - NOT_EXISTS
+      price:
+        type: number
+        format: double
+        description: 最新价格。
+      lineId:
+        type: integer
+        format: int64
+        description: 下注所需的线标识。
+      altLineId:
+        type: integer
+        format: int64
+        description: 如果让分在替代线上,则需要此参数来下注,否则响应中将不会填充此参数。
+      team1Score:
+        type: integer
+        format: int32
+        description: 周期 0 的 Team 1 得分。仅适用于足球。
+      team2Score:
+        type: integer
+        format: int32
+        description: 周期 0 的 Team 2 得分。仅适用于足球。
+      team1RedCards:
+        type: integer
+        format: int32
+        description: 周期 0 的 Team 1 红牌数。仅适用于足球。
+      team2RedCards:
+        type: integer
+        format: int32
+        description: 周期 0 的 Team 2 红牌数。仅适用于足球。
+      maxRiskStake:
+        type: number
+        format: double
+        description: 最大可投注风险金额。
+      minRiskStake:
+        type: number
+        format: double
+        description: 最小可投注风险金额。
+      maxWinStake:
+        type: number
+        format: double
+        description: 最大可投注赢利金额。
+      minWinStake:
+        type: number
+        format: double
+        description: 最小可投注赢利金额。
+      effectiveAsOf:
+        type: string
+        description: 线在此日期和时间(UTC)生效。
+      periodTeam1Score:
+        type: integer
+        format: int32
+        description: 支持周期的 Team 1 得分。仅适用于足球。
+      periodTeam2Score:
+        type: integer
+        format: int32
+        description: 支持周期的 Team 2 得分。仅适用于足球。
+      periodTeam1RedCards:
+        type: integer
+        format: int32
+        description: 支持周期的 Team 1 红牌数。仅适用于足球。
+      periodTeam2RedCards:
+        type: integer
+        format: int32
+        description: 支持周期的 Team 2 红牌数。仅适用于足球。
+  ParlayLinesRequestV3:
+    type: object
+    properties:
+      oddsFormat:
+        type: string
+        description: 响应中的赔率将采用此格式。[American, Decimal, HongKong, Indonesian, Malay]
+        enum:
+          - American
+          - Decimal
+          - HongKong
+          - Indonesian
+          - Malay
+      legs:
+        type: array
+        description: 这是腿的集合
+        items:
+          $ref: '#/definitions/ParlayLineRequestV3'
+  ParlayLineRequestV3:
+    type: object
+    properties:
+      uniqueLegId:
+        type: string
+        description: 腿的唯一 ID。用于在响应中识别和匹配腿。
+      eventId:
+        type: integer
+        format: int64
+        description: 赛事 ID。
+      periodNumber:
+        type: integer
+        format: int32
+        description: 这代表比赛的周期。例如,对于足球我们有 0(全场)、1(上半场)、2(下半场)。
+      legBetType:
+        type: string
+        description: 支持 SPREAD、MONEYLINE、TOTAL_POINTS 和 TEAM_TOTAL_POINTS。
+        enum:
+          - SPREAD
+          - MONEYLINE
+          - TOTAL_POINTS
+          - TEAM_TOTAL_POINTS
+      team:
+        type: string
+        description: 选择的队伍类型。仅 SPREAD 和 MONEYLINE 投注类型需要此参数。[Team1, Team2, Draw(仅限 MONEYLINE)]
+        enum:
+          - Team1
+          - Team2
+          - Draw
+      side:
+        type: string
+        description: 选择的侧。仅 TOTAL_POINTS 投注类型需要此参数。[OVER, UNDER]
+        enum:
+          - OVER
+          - UNDER
+      handicap:
+        type: number
+        format: double
+        description: SPREAD 和 TOTAL_POINTS 投注类型需要此参数。
+    required:
+      - uniqueLegId
+      - eventId
+      - periodNumber
+      - legBetType
+  ParlayLinesResponseV3:
+    type: object
+    properties:
+      status:
+        type: string
+        description: 串关状态 [VALID = 串关有效, PROCESSED_WITH_ERROR = 串关包含错误]
+        example: PROCESSED_WITH_ERROR
+        enum:
+          - VALID
+          - PROCESSED_WITH_ERROR
+      error:
+        type: string
+        description: INVALID_LEGS。表示一个或多个腿无效。仅在状态为 PROCESSED_WITH_ERROR 时填充。
+        example: INVALID_LEGS
+      minRiskStake:
+        type: number
+        format: double
+        description: 允许的最小投注金额。
+      maxParlayRiskStake:
+        type: number
+        format: double
+        description: 串关投注允许的最大投注金额。
+      maxRoundRobinTotalRisk:
+        type: number
+        format: double
+        description: 循环赛中所有串关投注允许的最大总投注金额。
+      maxRoundRobinTotalWin:
+        type: number
+        format: double
+        description: 循环赛中所有串关投注允许的最大总赢利金额。
+      roundRobinOptionWithOdds:
+        type: array
+        description: 提供所有可接受的循环赛选项数组,以及该选项的串关赔率。
+        items:
+          $ref: '#/definitions/RoundRobinOptionWithOddsV3'
+      legs:
+        type: array
+        description: 腿的集合(对象的格式如下所述)。
+        items:
+          $ref: '#/definitions/ParlayLineLeg'
+    required:
+      - status
+  RoundRobinOptionWithOddsV3:
+    type: object
+    properties:
+      roundRobinOption:
+        type: string
+        description: |
+          循环赛选项
+
+            Parlay = 包含所有投注的单个串关(非循环赛),
+            TwoLegRoundRobin = 多个串关,每个有 2 个投注(循环赛风格),
+            ThreeLegRoundRobin = 多个串关,每个有 3 个投注(循环赛风格),
+            FourLegRoundRobin = 多个串关,每个有 4 个投注(循环赛风格),
+            FiveLegRoundRobin = 多个串关,每个有 5 个投注(循环赛风格),
+            SixLegRoundRobin = 多个串关,每个有 6 个投注(循环赛风格),
+            SevenLegRoundRobin = 多个串关,每个有 7 个投注(循环赛风格),
+            EightLegRoundRobin = 多个串关,每个有 8 个投注(循环赛风格)
+        enum:
+          - Parlay
+          - TwoLegRoundRobin
+          - ThreeLegRoundRobin
+          - FourLegRoundRobin
+          - FiveLegRoundRobin
+          - SixLegRoundRobin
+          - SevenLegRoundRobin
+          - EightLegRoundRobin
+      odds:
+        type: number
+        format: double
+        description: 此选项的串关赔率。
+      unroundedDecimalOdds:
+        type: number
+        format: double
+        description: 未四舍五入的串关赔率(十进制格式),仅用于计算
+      numberOfBets:
+        type: number
+        format: int
+        description: roundRobinOption 中的投注数量。
+    required:
+      - roundRobinOption
+      - odds
+      - unroundedDecimalOdds
+  ParlayLineLeg:
+    type: object
+    properties:
+      status:
+        type: string
+        description: 请求状态。[VALID = 有效腿, PROCESSED_WITH_ERROR = 处理时出错]
+        enum:
+          - VALID
+          - PROCESSED_WITH_ERROR
+      errorCode:
+        type: string
+        description: |
+          当状态为 PROCESSED_WITH_ERROR 时,提供一个代码指示具体问题。
+
+            CORRELATED = 该腿与另一个腿相关,
+            CANNOT_PARLAY_LIVE_GAME = 投注放在直播比赛上,
+            EVENT_NO_LONGER_AVAILABLE_FOR_BETTING = 该赛事不再可用于串关,
+            EVENT_NOT_OFFERED_FOR_PARLAY = 该赛事不提供串关,
+            LINE_DOES_NOT_BELONG_TO_EVENT = LineId 与请求中指定的 EventId 不匹配,
+            WAGER_TYPE_NO_LONGER_AVAILABLE_FOR_BETTING = 投注类型不再可用于投注,
+            WAGER_TYPE_NOT_VALID_FOR_PARLAY = 投注类型对串关无效,
+            WAGER_TYPE_CONFLICTS_WITH_OTHER_LEG = 投注类型与其他腿冲突
+        enum:
+
+          - CORRELATED
+          - CANNOT_PARLAY_LIVE_GAME
+          - EVENT_NO_LONGER_AVAILABLE_FOR_BETTING
+          - EVENT_NOT_OFFERED_FOR_PARLAY
+          - LINE_DOES_NOT_BELONG_TO_EVENT
+          - WAGER_TYPE_NO_LONGER_AVAILABLE_FOR_BETTING
+          - WAGER_TYPE_NOT_VALID_FOR_PARLAY
+          - WAGER_TYPE_CONFLICTS_WITH_OTHER_LEG
+      legId:
+        type: string
+        description: 来自请求的 legId 的回显。
+      lineId:
+        type: integer
+        format: int64
+        description: 线标识。
+      altLineId:
+        type: integer
+        format: int64
+        description: 如果请求了替代线,将返回该线的 ID。
+      price:
+        type: number
+        format: double
+        description: 价格
+      correlatedLegs:
+        type: array
+        description: 如果 errorCode 为 CORRELATED,将包含所有相关腿的 legIds。
+        items:
+          type: string
+    required:
+      - legId
+      - status
+  LinesRequestTeaser:
+    type: object
+    properties:
+      teaserId:
+        type: integer
+        format: int64
+        description: 唯一标识符。可以从调用 v1/teaser/groups 端点检索过关详情。
+      oddsFormat:
+        type: string
+        description: 返回赔率的格式。[American, Decimal, HongKong, Indonesian, Malay]
+        enum:
+          - American
+          - Decimal
+          - HongKong
+          - Indonesian
+          - Malay
+      legs:
+        type: array
+        description: 过关腿的集合。
+        items:
+          $ref: '#/definitions/TeaserLineRequest'
+    required:
+      - teaserId
+      - oddsFormat
+      - legs
+  TeaserLineRequest:
+    type: object
+    properties:
+      legId:
+        type: string
+        description: 客户端生成的用于唯一识别腿的 GUID。
+      eventId:
+        type: integer
+        format: int64
+        description: 唯一标识符。
+      periodNumber:
+        type: integer
+        format: int32
+        description: 正在投注的比赛周期。可以使用 v1/periods 端点检索某项运动的所有周期。
+      betType:
+        type: string
+        description: 投注类型。目前仅支持 SPREAD 和 TOTAL_POINTS。[SPREAD, TOTAL_POINTS]
+        enum:
+          - SPREAD
+          - TOTAL_POINTS
+      team:
+        type: string
+        description: 让分盘投注的队伍。[Team1, Team2]
+        enum:
+          - Team1
+          - Team2
+      side:
+        type: string
+        description: 总分盘投注的侧。[OVER, UNDER]
+        enum:
+          - OVER
+          - UNDER
+      handicap:
+        type: number
+        format: double
+        description: 点数。
+    required:
+      - legId
+      - eventId
+      - periodNumber
+      - betType
+      - handicap
+  TeaserLinesResponse:
+    type: object
+    properties:
+      status:
+        type: string
+        description: 请求状态。[VALID = 过关有效, PROCESSED_WITH_ERROR = 过关包含一个或多个错误]
+        example: PROCESSED_WITH_ERROR
+        enum:
+          - VALID
+          - PROCESSED_WITH_ERROR
+      errorCode:
+        type: string
+        description: |
+          当状态为 PROCESSED_WITH_ERROR 时,提供一个代码指示具体问题。
+
+            INVALID_LEGS = 一个或多个腿无效,
+            SAME_EVENT_ONLY_REQUIRED = 指定的过关要求所有腿来自同一赛事,
+            TEASER_DISABLED = 过关已被禁用,无法投注,
+            TEASER_DOES_NOT_EXIST = 找不到过关标识符,
+            TOO_FEW_LEGS = 您不满足指定过关的最小腿数要求,
+            TOO_MANY_LEGS = 您超过了指定过关的最大腿数,
+            UNKNOWN = 发生未知错误
+        enum:
+          - INVALID_LEGS
+          - SAME_EVENT_ONLY_REQUIRED
+          - TEASER_DISABLED
+          - TEASER_DOES_NOT_EXIST
+          - TOO_FEW_LEGS
+          - TOO_MANY_LEGS
+          - UNKNOWN
+      price:
+        type: number
+        format: double
+        description: 投注价格。
+      minRiskStake:
+        type: number
+        format: double
+        description: WIN_RISK_TYPE.RISK 的最小投注金额。
+      maxRiskStake:
+        type: number
+        format: double
+        description: WIN_RISK_TYPE.RISK 的最大投注金额。
+      minWinStake:
+        type: number
+        format: double
+        description: WIN_RISK_TYPE.WIN 的最小投注金额。
+      maxWinStake:
+        type: number
+        format: double
+        description: WIN_RISK_TYPE.WIN 的最大投注金额。
+      legs:
+        type: array
+        description: 来自请求的过关腿集合。
+        items:
+          $ref: '#/definitions/TeaserLineLeg'
+    required:
+      - status
+      - legs
+  TeaserLineLeg:
+    type: object
+    properties:
+      status:
+        type: string
+        description: 请求状态。[VALID = 过关有效, PROCESSED_WITH_ERROR = 过关包含错误]
+        example: PROCESSED_WITH_ERROR
+        enum:
+          - VALID
+          - PROCESSED_WITH_ERROR
+      errorCode:
+        type: string
+        description: |
+          当状态为 PROCESSED_WITH_ERROR 时,提供一个代码指示具体问题。
+
+            EVENT_NOT_FOUND = 找不到指定的赛事,
+            POINTS_NO_LONGER_AVAILABLE = 请求的点数不再可用。这意味着线已移动,
+            UNKNOWN = 发生未知错误,
+            WAGER_TYPE_NOT_VALID_FOR_TEASER = 指定的投注类型对过关无效
+        enum:
+          - EVENT_NOT_FOUND
+          - POINTS_NO_LONGER_AVAILABLE
+          - UNKNOWN
+          - WAGER_TYPE_NOT_VALID_FOR_TEASER
+      legId:
+        type: string
+        description: 来自请求的腿的唯一 ID 回显。
+      lineId:
+        type: integer
+        format: int64
+        description: 线标识。
+    required:
+      - legId
+      - status
+  OddsResponseV3:
+    type: object
+    properties:
+      sportId:
+        type: integer
+        format: int32
+        description: 与请求的运动 ID 相同。
+      last:
+        type: integer
+        format: int64
+        description: 在后续请求中使用此值作为 since 查询参数,以仅获取自上次响应以来的更改。
+      leagues:
+        type: array
+        description: 包含联赛列表。
+        items:
+          $ref: '#/definitions/OddsLeagueV3'
+  OddsResponseV4:
+    type: object
+    properties:
+      sportId:
+        type: integer
+        format: int32
+        description: 与请求的运动 ID 相同。
+      last:
+        type: integer
+        format: int64
+        description: 在后续请求中使用此值作为 since 查询参数,以仅获取自上次响应以来的更改。
+      leagues:
+        type: array
+        description: 包含联赛列表。
+        items:
+          $ref: '#/definitions/OddsLeagueV4'
+  OddsLeagueV3:
+    type: object
+    properties:
+      id:
+        type: integer
+        format: int32
+        description: 联赛 ID。
+      events:
+        type: array
+        description: 包含赛事列表。
+        items:
+          $ref: '#/definitions/OddsEventV3'
+  OddsLeagueV4:
+    type: object
+    properties:
+      id:
+        type: integer
+        format: int32
+        description: 联赛 ID。
+      events:
+        type: array
+        description: 包含赛事列表。
+        items:
+          $ref: '#/definitions/OddsEventV4'
+  OddsEventV3:
+    type: object
+    properties:
+      id:
+        type: integer
+        format: int64
+        description: 赛事 ID。
+      awayScore:
+        type: number
+        format: double
+        description: 客队得分。仅适用于直播足球赛事。仅支持全场周期(number=0)。
+      homeScore:
+        type: number
+        format: double
+        description: 主队得分。仅适用于直播足球赛事。仅支持全场周期(number=0)。
+      awayRedCards:
+        type: integer
+        format: int32
+        description: 客队红牌数。仅适用于直播足球赛事。仅支持全场周期(number=0)。
+      homeRedCards:
+        type: integer
+        format: int32
+        description: 主队红牌数。仅适用于直播足球赛事。仅支持全场周期(number=0)。
+      periods:
+        type: array
+        description: 包含周期列表。
+        items:
+          $ref: '#/definitions/OddsPeriodV3'
+  OddsEventV4:
+    type: object
+    properties:
+      id:
+        type: integer
+        format: int64
+        description: 赛事 ID。
+      awayScore:
+        type: number
+        format: double
+        description: 客队得分。仅适用于直播足球赛事。仅支持全场周期(number=0)。
+      homeScore:
+        type: number
+        format: double
+        description: 主队得分。仅适用于直播足球赛事。仅支持全场周期(number=0)。
+      awayRedCards:
+        type: integer
+        format: int32
+        description: 客队红牌数。仅适用于直播足球赛事。仅支持全场周期(number=0)。
+      homeRedCards:
+        type: integer
+        format: int32
+        description: 主队红牌数。仅适用于直播足球赛事。仅支持全场周期(number=0)。
+      periods:
+        type: array
+        description: 包含周期列表。
+        items:
+          $ref: '#/definitions/OddsPeriodV4'
+  OddsPeriodV3:
+    type: object
+    properties:
+      lineId:
+        type: integer
+        format: int64
+        description: 线 ID。
+      number:
+        type: integer
+        format: int32
+        description: 这代表比赛的周期。例如,对于足球我们有 0(全场)、1(上半场)和 2(下半场)。
+      cutoff:
+        type: string
+        format: date-time
+        description: 周期的投注截止日期(UTC)。
+      status:
+        type: integer
+        format: int32
+        description: |
+              1 - 在线,周期开放投注
+              2 - 离线,周期不开放投注
+        example: 1
+      maxSpread:
+        type: number
+        format: double
+        description: 最大让分盘投注。仅在直盘赔率响应中。
+      maxMoneyline:
+        type: number
+        format: double
+        description: 最大独赢盘投注。仅在直盘赔率响应中。
+      maxTotal:
+        type: number
+        format: double
+        description: 最大总分盘投注。仅在直盘赔率响应中。
+      maxTeamTotal:
+        type: number
+        format: double
+        description: 最大队伍总分盘投注。仅在直盘赔率响应中。
+      moneylineUpdatedAt:
+        type: string
+        format: date-time
+        description: 最后一次独赢盘更新的日期时间。
+      spreadUpdatedAt:
+        type: string
+        format: date-time
+        description: 最后一次让分盘更新的日期时间。
+      totalUpdatedAt:
+        type: string
+        format: date-time
+        description: 最后一次总分盘更新的日期时间。
+      teamTotalUpdatedAt:
+        type: string
+        format: date-time
+        description: 最后一次队伍总分盘更新的日期时间。
+      spreads:
+        type: array
+        description: 让分盘赔率容器。
+        items:
+          $ref: '#/definitions/OddsSpreadV3'
+      moneyline:
+        $ref: '#/definitions/OddsMoneylineV3'
+      totals:
+        type: array
+        description: 队伍总分容器。
+        items:
+          $ref: '#/definitions/OddsTotalV3'
+      teamTotal:
+        $ref: '#/definitions/OddsTeamTotalsV3'
+      awayScore:
+        type: number
+        format: double
+        description: 周期客队得分。仅适用于直播足球赛事。仅支持全场(number=0)和加时赛(number=3)。
+      homeScore:
+        type: number
+        format: double
+        description: 周期主队得分。仅适用于直播足球赛事。仅支持全场(number=0)和加时赛(number=3)。
+      awayRedCards:
+        type: number
+        format: int32
+        description: 周期客队红牌数。仅适用于直播足球赛事。仅支持全场(number=0)和加时赛(number=3)。
+      homeRedCards:
+        type: number
+        format: int32
+        description: 周期主队红牌数。仅适用于直播足球赛事。仅支持全场(number=0)和加时赛(number=3)。
+  OddsPeriodV4:
+    type: object
+    properties:
+      lineId:
+        type: integer
+        format: int64
+        description: 线 ID。
+      number:
+        type: integer
+        format: int32
+        description: 这代表比赛的周期。例如,对于足球我们有 0(全场)、1(上半场)和 2(下半场)。
+      cutoff:
+        type: string
+        format: date-time
+        description: 周期的投注截止日期(UTC)。
+      status:
+        type: integer
+        format: int32
+        description: |
+              1 - 在线,周期开放投注
+              2 - 离线,周期不开放投注
+        example: 1
+      maxSpread:
+        type: number
+        format: double
+        description: 最大让分盘投注。仅在直盘赔率响应中。
+      maxMoneyline:
+        type: number
+        format: double
+        description: 最大独赢盘投注。仅在直盘赔率响应中。
+      maxTotal:
+        type: number
+        format: double
+        description: 最大总分盘投注。仅在直盘赔率响应中。
+      maxTeamTotal:
+        type: number
+        format: double
+        description: 最大队伍总分盘投注。仅在直盘赔率响应中。
+      moneylineUpdatedAt:
+        type: string
+        format: date-time
+        description: 最后一次独赢盘更新的日期时间。
+      spreadUpdatedAt:
+        type: string
+        format: date-time
+        description: 最后一次让分盘更新的日期时间。
+      totalUpdatedAt:
+        type: string
+        format: date-time
+        description: 最后一次总分盘更新的日期时间。
+      teamTotalUpdatedAt:
+        type: string
+        format: date-time
+        description: 最后一次队伍总分盘更新的日期时间。
+      spreads:
+        type: array
+        description: 让分盘赔率容器。
+        items:
+          $ref: '#/definitions/OddsSpreadV4'
+      moneyline:
+        $ref: '#/definitions/OddsMoneylineV4'
+      totals:
+        type: array
+        description: 队伍总分容器。
+        items:
+          $ref: '#/definitions/OddsTotalV4'
+      teamTotal:
+        $ref: '#/definitions/OddsTeamTotalsV4'
+      awayScore:
+        type: number
+        format: double
+        description: 周期客队得分。仅适用于直播足球赛事。仅支持全场(number=0)和加时赛(number=3)。
+      homeScore:
+        type: number
+        format: double
+        description: 周期主队得分。仅适用于直播足球赛事。仅支持全场(number=0)和加时赛(number=3)。
+      awayRedCards:
+        type: number
+        format: int32
+        description: 周期客队红牌数。仅适用于直播足球赛事。仅支持全场(number=0)和加时赛(number=3)。
+      homeRedCards:
+        type: number
+        format: int32
+        description: 周期主队红牌数。仅适用于直播足球赛事。仅支持全场(number=0)和加时赛(number=3)。
+  OddsSpreadV3:
+    type: object
+    properties:
+      altLineId:
+        type: integer
+        format: int64
+        description: 仅在为替代线时存在。
+      hdp:
+        type: number
+        format: double
+        description: 主队让分。
+      home:
+        type: number
+        format: double
+        description: 主队价格。
+      away:
+        type: number
+        format: double
+        description: 客队价格。
+      max:
+        type: number
+        format: double
+        nullable: true
+        description: 最大投注量。仅在替代线上存在,如果设置,将覆盖 `maxSpread` 市场限额。
+  OddsSpreadV4:
+    type: object
+    properties:
+      altLineId:
+        type: integer
+        format: int64
+        description: 仅在为替代线时存在。
+      hdp:
+        type: number
+        format: double
+        description: 主队让分。
+      home:
+        type: number
+        format: double
+        description: 主队价格。
+      away:
+        type: number
+        format: double
+        description: 客队价格。
+      max:
+        type: number
+        format: double
+        nullable: true
+        description: 最大投注量。仅在替代线上存在,如果设置,将覆盖 `maxSpread` 市场限额。
+  OddsMoneylineV3:
+    type: object
+    properties:
+      home:
+        type: number
+        format: double
+        description: 客队价格
+      away:
+        type: number
+        format: double
+        description: 客队价格。
+      draw:
+        type: number
+        format: double
+        description: 平局价格。仅在我们提供平局价格的事件中存在。
+  OddsMoneylineV4:
+    type: object
+    properties:
+      home:
+        type: number
+        format: double
+        description: 客队价格
+      away:
+        type: number
+        format: double
+        description: 客队价格。
+      draw:
+        type: number
+        format: double
+        description: 平局价格。仅在我们提供平局价格的事件中存在。
+  OddsTotalV3:
+    type: object
+    properties:
+      altLineId:
+        type: integer
+        format: int64
+        description: 仅在为替代线时存在。
+      points:
+        type: number
+        format: double
+        description: 总分。
+      over:
+        type: number
+        format: double
+        description: 大价格。
+      under:
+        type: number
+        format: double
+        description: 小价格。
+      max:
+        type: number
+        format: double
+        nullable: true
+        description: 最大投注量。仅在替代线上存在,如果设置,将覆盖 `maxTotal` 市场限额。
+  OddsTotalV4:
+    type: object
+    properties:
+      altLineId:
+        type: integer
+        format: int64
+        description: 仅在为替代线时存在。
+      points:
+        type: number
+        format: double
+        description: 总分。
+      over:
+        type: number
+        format: double
+        description: 大价格。
+      under:
+        type: number
+        format: double
+        description: 小价格。
+      max:
+        type: number
+        format: double
+        nullable: true
+        description: 最大投注量。仅在替代线上存在,如果设置,将覆盖 `maxTotal` 市场限额。
+  OddsTeamTotalsV3:
+    type: object
+    properties:
+      home:
+        $ref: '#/definitions/OddsTeamTotalV3'
+      away:
+        $ref: '#/definitions/OddsTeamTotalV3'
+  OddsTeamTotalsV4:
+    type: object
+    properties:
+      home:
+        type: array
+        description: 主队总分容器。
+        items:
+          $ref: '#/definitions/OddsTeamTotalV4'
+      away:
+        type: array
+        description: 客队总分容器。
+        items:
+          $ref: '#/definitions/OddsTeamTotalV4'
+  OddsTeamTotalV3:
+    type: object
+    properties:
+      points:
+        type: number
+        format: double
+        description: 总分。
+      over:
+        type: number
+        format: double
+        description: 大价格。
+      under:
+        type: number
+        format: double
+        description: 小价格。
+  OddsTeamTotalV4:
+    type: object
+    properties:
+      altLineId:
+        type: number
+        format: int64
+        description: 仅在为替代线时存在。
+      points:
+        type: number
+        format: double
+        description: 总分。
+      over:
+        type: number
+        format: double
+        description: 大价格。
+      under:
+        type: number
+        format: double
+        description: 小价格。
+      max:
+        type: number
+        format: double
+        nullable: true
+        description: 最大投注量。仅在替代线上存在,如果设置,将覆盖 `maxTeamTotal` 市场限额。
+    required:
+      - points
+      - over
+      - under
+  ParlayOddsResponseV3:
+    type: object
+    properties:
+      sportId:
+        type: integer
+        format: int32
+        description: 与请求的运动 ID 相同。
+      last:
+        type: integer
+        format: int64
+        description: 在后续请求中使用此值作为 since 查询参数,以仅获取自上次响应以来的更改。
+      leagues:
+        type: array
+        description: 包含联赛列表。
+        items:
+          $ref: '#/definitions/ParlayOddsLeagueV3'
+    required:
+      - sportId
+      - last
+      - leagues
+  ParlayOddsResponseV4:
+    type: object
+    properties:
+      sportId:
+        type: integer
+        format: int32
+        description: 与请求的运动 ID 相同。
+      last:
+        type: integer
+        format: int64
+        description: 在后续请求中使用此值作为 since 查询参数,以仅获取自上次响应以来的更改。
+      leagues:
+        type: array
+        description: 包含联赛列表。
+        items:
+          $ref: '#/definitions/ParlayOddsLeagueV4'
+    required:
+      - sportId
+      - last
+      - leagues
+  ParlayOddsLeagueV3:
+    type: object
+    properties:
+      id:
+        type: integer
+        format: int32
+        description: 联赛 ID。
+      events:
+        type: array
+        description: 包含赛事列表。
+        items:
+          $ref: '#/definitions/ParlayOddsEventV3'
+    required:
+      - id
+      - events
+  ParlayOddsLeagueV4:
+    type: object
+    properties:
+      id:
+        type: integer
+        format: int32
+        description: 联赛 ID。
+      events:
+        type: array
+        description: 包含赛事列表。
+        items:
+          $ref: '#/definitions/ParlayOddsEventV4'
+    required:
+      - id
+      - events
+  ParlayOddsEventV3:
+    type: object
+    properties:
+      id:
+        type: integer
+        format: int64
+        description: 赛事 ID。
+      awayScore:
+        type: number
+        format: double
+        description: 客队得分。仅适用于直播足球赛事。
+      homeScore:
+        type: number
+        format: double
+        description: 主队得分。仅适用于直播足球赛事。
+      awayRedCards:
+        type: integer
+        format: int32
+        description: 客队红牌数。仅适用于直播足球赛事。
+      homeRedCards:
+        type: integer
+        format: int32
+        description: 主队红牌数。仅适用于直播足球赛事。
+      periods:
+        type: array
+        description: 包含周期列表。
+        items:
+          $ref: '#/definitions/ParlayOddsPeriodV3'
+    required:
+      - id
+      - periods
+  ParlayOddsEventV4:
+    type: object
+    properties:
+      id:
+        type: integer
+        format: int64
+        description: 赛事 ID。
+      awayScore:
+        type: number
+        format: double
+        description: 客队得分。仅适用于直播足球赛事。
+      homeScore:
+        type: number
+        format: double
+        description: 主队得分。仅适用于直播足球赛事。
+      awayRedCards:
+        type: integer
+        format: int32
+        description: 客队红牌数。仅适用于直播足球赛事。
+      homeRedCards:
+        type: integer
+        format: int32
+        description: 主队红牌数。仅适用于直播足球赛事。
+      periods:
+        type: array
+        description: 包含周期列表。
+        items:
+          $ref: '#/definitions/ParlayOddsPeriodV4'
+    required:
+      - id
+      - periods
+  ParlayOddsPeriodV3:
+    type: object
+    properties:
+      lineId:
+        type: integer
+        format: int64
+        description: 线 ID。
+      number:
+        type: integer
+        format: int32
+        description: 这代表比赛的周期。例如,对于足球我们有 0(全场)、1(上半场)和 2(下半场)。
+      cutoff:
+        type: string
+        format: date-time
+        description: 周期的投注截止日期(UTC)。
+      status:
+        type: integer
+        format: int32
+        description: |
+              1 - 在线,周期开放投注
+              2 - 离线,周期不开放投注
+        example: 1
+      maxSpread:
+        type: number
+        format: double
+        description: 最大让分盘投注。仅在直盘赔率响应中。
+      maxMoneyline:
+        type: number
+        format: double
+        description: 最大独赢盘投注。仅在直盘赔率响应中。
+      maxTotal:
+        type: number
+        format: double
+        description: 最大总分盘投注。仅在直盘赔率响应中。
+      maxTeamTotal:
+        type: number
+        format: double
+        description: 最大队伍总分盘投注。仅在直盘赔率响应中。
+      moneylineUpdatedAt:
+        type: number
+        format: double
+        description: 最后一次独赢盘更新的日期时间。
+      spreadUpdatedAt:
+        type: number
+        format: double
+        description: 最后一次让分盘更新的日期时间。
+      totalUpdatedAt:
+        type: number
+        format: double
+        description: 最后一次总分盘更新的日期时间。
+      teamTotalUpdatedAt:
+        type: number
+        format: double
+        description: 最后一次队伍总分盘更新的日期时间。
+      spreads:
+        type: array
+        description: 让分盘赔率容器。
+        items:
+          $ref: '#/definitions/ParlayOddsSpreadV3'
+      moneyline:
+        $ref: '#/definitions/ParlayOddsMoneylineV3'
+      totals:
+        type: array
+        description: 队伍总分容器。
+        items:
+          $ref: '#/definitions/ParlayOddsTotalV3'
+      teamTotal:
+        $ref: '#/definitions/ParlayOddsTeamTotalsV3'
+      awayScore:
+        type: number
+        format: double
+        description: 周期客队得分。仅适用于直播足球赛事。仅支持全场(number=0)和加时赛(number=3)。
+      homeScore:
+        type: number
+        format: double
+        description: 周期主队得分。仅适用于直播足球赛事。仅支持全场(number=0)和加时赛(number=3)。
+      awayRedCards:
+        type: number
+        format: double
+        description: 周期客队红牌数。仅适用于直播足球赛事。仅支持全场(number=0)和加时赛(number=3)。
+      homeRedCards:
+        type: number
+        format: double
+        description: 周期主队红牌数。仅适用于直播足球赛事。仅支持全场(number=0)和加时赛(number=3)。
+    required:
+      - lineId
+      - number
+      - cutoff
+  ParlayOddsPeriodV4:
+    type: object
+    properties:
+      lineId:
+        type: integer
+        format: int64
+        description: 线 ID。
+      number:
+        type: integer
+        format: int32
+        description: 这代表比赛的周期。例如,对于足球我们有 0(全场)、1(上半场)和 2(下半场)。
+      cutoff:
+        type: string
+        format: date-time
+        description: 周期的投注截止日期(UTC)。
+      status:
+        type: integer
+        format: int32
+        description: |
+              1 - 在线,周期开放投注
+              2 - 离线,周期不开放投注
+        example: 1
+      maxSpread:
+        type: number
+        format: double
+        description: 最大让分盘投注。仅在直盘赔率响应中。
+      maxMoneyline:
+        type: number
+        format: double
+        description: 最大独赢盘投注。仅在直盘赔率响应中。
+      maxTotal:
+        type: number
+        format: double
+        description: 最大总分盘投注。仅在直盘赔率响应中。
+      maxTeamTotal:
+        type: number
+        format: double
+        description: 最大队伍总分盘投注。仅在直盘赔率响应中。
+      moneylineUpdatedAt:
+        type: number
+        format: double
+        description: 最后一次独赢盘更新的日期时间。
+      spreadUpdatedAt:
+        type: number
+        format: double
+        description: 最后一次让分盘更新的日期时间。
+      totalUpdatedAt:
+        type: number
+        format: double
+        description: 最后一次总分盘更新的日期时间。
+      teamTotalUpdatedAt:
+        type: number
+        format: double
+        description: 最后一次队伍总分盘更新的日期时间。
+      spreads:
+        type: array
+        description: 让分盘赔率容器。
+        items:
+          $ref: '#/definitions/ParlayOddsSpreadV4'
+      moneyline:
+        $ref: '#/definitions/ParlayOddsMoneylineV4'
+      totals:
+        type: array
+        description: 队伍总分容器。
+        items:
+          $ref: '#/definitions/ParlayOddsTotalV4'
+      teamTotal:
+        $ref: '#/definitions/ParlayOddsTeamTotalsV4'
+      awayScore:
+        type: number
+        format: double
+        description: 周期客队得分。仅适用于直播足球赛事。仅支持全场(number=0)和加时赛(number=3)。
+      homeScore:
+        type: number
+        format: double
+        description: 周期主队得分。仅适用于直播足球赛事。仅支持全场(number=0)和加时赛(number=3)。
+      awayRedCards:
+        type: number
+        format: double
+        description: 周期客队红牌数。仅适用于直播足球赛事。仅支持全场(number=0)和加时赛(number=3)。
+      homeRedCards:
+        type: number
+        format: double
+        description: 周期主队红牌数。仅适用于直播足球赛事。仅支持全场(number=0)和加时赛(number=3)。
+    required:
+      - lineId
+      - number
+      - cutoff
+  ParlayOddsSpreadV3:
+    type: object
+    properties:
+      altLineId:
+        type: integer
+        format: int64
+        description: 仅在为替代线时存在。
+      hdp:
+        type: number
+        format: double
+        description: 主队让分。
+      home:
+        type: number
+        format: double
+        description: 主队价格。
+      away:
+        type: number
+        format: double
+        description: 客队价格。
+      max:
+        type: number
+        format: double
+        nullable: true
+        description: 最大投注量。仅在替代线上存在,如果设置,将覆盖 `maxSpread` 市场限额。
+    required:
+      - hdp
+      - home
+      - away
+  ParlayOddsSpreadV4:
+    type: object
+    properties:
+      altLineId:
+        type: integer
+        format: int64
+        description: 仅在为替代线时存在。
+      hdp:
+        type: number
+        format: double
+        description: 主队让分。
+      home:
+        type: number
+        format: double
+        description: 主队价格。
+      away:
+        type: number
+        format: double
+        description: 客队价格。
+      max:
+        type: number
+        format: double
+        nullable: true
+        description: 最大投注量。仅在替代线上存在,如果设置,将覆盖 `maxSpread` 市场限额。
+    required:
+      - hdp
+      - home
+      - away
+  ParlayOddsMoneylineV3:
+    type: object
+    properties:
+      home:
+        type: number
+        format: double
+        description: 客队价格
+      away:
+        type: number
+        format: double
+        description: 客队价格。
+      draw:
+        type: number
+        format: double
+        description: 平局价格。仅在我们提供平局价格的事件中存在。
+    required:
+      - home
+      - away
+  ParlayOddsMoneylineV4:
+    type: object
+    properties:
+      home:
+        type: number
+        format: double
+        description: 客队价格
+      away:
+        type: number
+        format: double
+        description: 客队价格。
+      draw:
+        type: number
+        format: double
+        description: 平局价格。仅在我们提供平局价格的事件中存在。
+    required:
+      - home
+      - away
+  ParlayOddsTotalV3:
+    $ref: '#/definitions/ParlayOddsTotalsV3'
+  ParlayOddsTotalV4:
+    $ref: '#/definitions/ParlayOddsTotalsV4'
+  ParlayOddsTeamTotalsV3:
+    type: object
+    properties:
+      away:
+        $ref: '#/definitions/ParlayOddsTotalsV3'
+      home:
+        $ref: '#/definitions/ParlayOddsTotalsV3'
+  ParlayOddsTeamTotalsV4:
+    type: object
+    properties:
+      away:
+        type: array
+        description: 客队总分容器。
+        items:
+          $ref: '#/definitions/ParlayOddsTeamTotalV4'
+      home:
+        type: array
+        description: 主队总分容器。
+        items:
+          $ref: '#/definitions/ParlayOddsTeamTotalV4'
+  ParlayOddsTotalsV3:
+    type: object
+    properties:
+      altLineId:
+        type: number
+        format: int64
+        description: 替代线的线 ID。仅在为替代线时存在。
+      points:
+        type: number
+        format: double
+        description: 总分。
+      over:
+        type: number
+        format: double
+        description: 大价格。
+      under:
+        type: number
+        format: double
+        description: 小价格。
+      max:
+        type: number
+        format: double
+        nullable: true
+        description: 最大投注量。仅在替代线上存在,如果设置,将覆盖 `maxTotal` 市场限额。
+    required:
+      - points
+      - over
+      - under
+  ParlayOddsTotalsV4:
+    type: object
+    properties:
+      altLineId:
+        type: number
+        format: int64
+        description: 替代线的线 ID。仅在为替代线时存在。
+      points:
+        type: number
+        format: double
+        description: 总分。
+      over:
+        type: number
+        format: double
+        description: 大价格。
+      under:
+        type: number
+        format: double
+        description: 小价格。
+      max:
+        type: number
+        format: double
+        nullable: true
+        description: 最大投注量。仅在替代线上存在,如果设置,将覆盖 `maxTotal` 市场限额。
+    required:
+      - points
+      - over
+      - under
+  ParlayOddsTeamTotalV4:
+    type: object
+    properties:
+      altLineId:
+        type: number
+        format: int64
+        description: 仅在为替代线时存在。
+      points:
+        type: number
+        format: double
+        description: 总分。
+      over:
+        type: number
+        format: double
+        description: 大价格。
+      under:
+        type: number
+        format: double
+        description: 小价格。
+      max:
+        type: number
+        format: double
+        nullable: true
+        description: 最大投注量。仅在替代线上存在,如果设置,将覆盖 `maxTeamTotal` 市场限额。
+    required:
+      - points
+      - over
+      - under
+  TeaserOddsResponse:
+    type: object
+    properties:
+      teaserId:
+        type: integer
+        format: int64
+        description: 唯一标识符。可以从调用 Get Teaser Groups 端点检索过关详情。
+      sportId:
+        type: integer
+        format: int32
+        description: 唯一标识符。可以从调用 Get Sports 端点检索运动详情。
+      leagues:
+        type: array
+        description: 联赛集合。
+        items:
+          $ref: '#/definitions/TeaserOddsLeague'
+  TeaserOddsLeague:
+    type: object
+    properties:
+      id:
+        type: integer
+        format: int32
+        description: 唯一标识符。可以从调用 Get Leagues 端点检索联赛详情。
+      events:
+        type: array
+        description: 赛事集合。
+        items:
+          $ref: '#/definitions/TeaserOddsEvent'
+  TeaserOddsEvent:
+    type: object
+    properties:
+      id:
+        type: integer
+        format: int64
+        description: 唯一标识符。
+      periods:
+        type: array
+        description: 周期集合,指示可用于投注的周期编号。
+        items:
+          $ref: '#/definitions/TeaserOddsPeriod'
+  TeaserOddsPeriod:
+    type: object
+    properties:
+      number:
+        type: integer
+        format: int32
+        description: 请求的比赛周期。请参考 v1/periods 端点以检索某项运动的所有有效周期。
+      lineId:
+        type: integer
+        format: int64
+        description: 唯一标识符。
+      spreadUpdatedAt:
+        type: string
+        format: date-time
+        description: 最后一次让分盘更新的日期时间。
+      totalUpdatedAt:
+        type: string
+        format: date-time
+        description: 最后一次总分盘更新的日期时间。
+      spread:
+        $ref: '#/definitions/TeaserOddsSpread'
+      total:
+        $ref: '#/definitions/TeaserOddsTotalPoints'
+  TeaserOddsSpread:
+    type: object
+    properties:
+      maxBet:
+        type: number
+        format: double
+        description: 最大投注金额。
+      homeHdp:
+        type: number
+        format: double
+        description: 主队让分。请参考 Get Fixtures 端点以确定主队和客队。
+      awayHdp:
+        type: number
+        format: double
+        description: 客队让分。请参考 Get Fixtures 端点以确定主队和客队。
+      altHdp:
+        type: boolean
+        description: 让分是否提供替代过关点数。具有替代过关点数的事件可能与过关定义不同。
+        example: false
+  TeaserOddsTotalPoints:
+    type: object
+    properties:
+      maxBet:
+        type: number
+        format: double
+        description: 最大投注金额。
+      overPoints:
+        type: number
+        format: double
+        description: 大点数。
+      underPoints:
+        type: number
+        format: double
+        description: 小点数。
+  SportPeriod:
+    type: object
+    properties:
+      number:
+        type: integer
+        format: int32
+        description: 周期编号
+      description:
+        type: string
+        description: 周期的描述
+      shortDescription:
+        type: string
+        description: 周期的简短描述
+  SportsResponseV3:
+    type: object
+    properties:
+      sports:
+        type: array
+        description: 运动容器.
+        items:
+          $ref: '#/definitions/SportV3'
+  SportV3:
+    type: object
+    properties:
+      id:
+        type: integer
+        format: int32
+        description: 运动 ID.
+      name:
+        type: string
+        description: 运动名称。
+      hasOfferings:
+        type: boolean
+        description: 运动当前是否有赛事或特殊赛事。
+      leagueSpecialsCount:
+        type: integer
+        format: int32
+        description: 指示给定运动中有多少特殊赛事。
+      eventSpecialsCount:
+        type: integer
+        format: int32
+        description: 指示给定运动中有多少赛事特殊赛事。
+      eventCount:
+        type: integer
+        format: int32
+        description: 指示给定运动中有多少赛事。
+  TeaserGroupsResponse:
+    type: object
+    properties:
+      teaserGroups:
+        type: array
+        description: 包含可用过关的过关组集合。
+        items:
+          $ref: '#/definitions/TeaserGroups'
+  TeaserGroups:
+    type: object
+    properties:
+      id:
+        type: integer
+        format: int64
+        description: 唯一标识符。
+      name:
+        type: string
+        description: 过关组的友好名称
+      teasers:
+        type: array
+        description: 过关集合。
+        items:
+          $ref: '#/definitions/TeaserGroupsTeaser'
+  TeaserGroupsTeaser:
+    type: object
+    properties:
+      id:
+        type: integer
+        format: int64
+        description: 唯一标识符。
+      description:
+        type: string
+        description: 过关的描述。
+      sportId:
+        type: integer
+        format: int32
+        description: 唯一运动标识符。可以从调用 v2/sports 端点检索运动详情。
+      minLegs:
+        type: integer
+        format: int32
+        description: 必须选择的最小腿数。
+      maxLegs:
+        type: integer
+        format: int32
+        description: 可以选择的最大腿数。
+      sameEventOnly:
+        type: boolean
+        description: 如果为 'true',则所有腿必须来自同一赛事,否则腿可以来自不同赛事。
+      payouts:
+        type: array
+        description: 指示所有可能赔付组合的赔付集合。
+        items:
+          $ref: '#/definitions/TeaserGroupsPayout'
+      leagues:
+        type: array
+        description: 可用于过关的联赛集合。
+        items:
+          $ref: '#/definitions/TeaserGroupsLeague'
+  TeaserGroupsPayout:
+    type: object
+    properties:
+      numberOfLegs:
+        type: integer
+        format: int32
+        description: 必须投注并获胜才能获得相关价格的腿数。
+      price:
+        type: number
+        format: double
+        description: 给定指定腿数的投注价格。
+  TeaserGroupsLeague:
+    type: object
+    properties:
+      id:
+        type: integer
+        format: int32
+        description: 唯一标识符。可以从调用 v2/leagues 端点检索联赛详情。
+      spread:
+        $ref: '#/definitions/TeaserGroupsBetType'
+      total:
+        $ref: '#/definitions/TeaserGroupsBetType'
+  TeaserGroupsBetType:
+    type: object
+    properties:
+      points:
+        type: number
+        format: double
+        description: 对于给定联赛,线将被调整的点数。
+  SpecialsFixturesResponseV2:
+    type: object
+    properties:
+      sportId:
+        format: int32
+        description: 要检索赔率的运动 ID。
+        type: integer
+      last:
+        format: int64
+        description: 用于仅在后续请求中检索更改。在后续调用中提供此值作为 Since 参数,以仅检索更改。
+        type: integer
+      leagues:
+        description: 包含联赛列表。
+        type: array
+        items:
+          $ref: '#/definitions/SpecialsFixturesLeagueV2'
+  SpecialsFixturesLeagueV2:
+    type: object
+    properties:
+      id:
+        format: int32
+        description: 赛程联赛 ID。
+        type: integer
+      specials:
+        description: 特殊赛事集合
+        type: array
+        items:
+          $ref: '#/definitions/SpecialFixtureV2'
+  SpecialFixtureV2:
+    type: object
+    properties:
+      id:
+        format: int64
+        description: 唯一 ID
+        type: integer
+      betType:
+        description: 类型 [MULTI_WAY_HEAD_TO_HEAD, SPREAD, OVER_UNDER]
+        enum:
+          - MULTI_WAY_HEAD_TO_HEAD
+          - SPREAD
+          - OVER_UNDER
+        type: string
+      name:
+        description: 特殊赛事名称。
+        type: string
+      date:
+        format: date-time
+        description: 特殊赛事的日期(UTC)。
+        type: string
+      cutoff:
+        format: date-time
+        description: 投注截止日期(UTC)。
+        type: string
+      category:
+        description: 特殊赛事所属的类别。
+        type: string
+      units:
+        description: 特殊赛事上下文中的测量单位。这适用于特殊投注类型让分和大小。在曲棍球特殊赛事中,这可能是进球数。
+        type: string
+      status:
+        description: |
+          特殊赛事状态
+
+          O = 这是比赛的起始状态。这意味着线开放投注,
+          H = 此状态表示线暂时不可用于投注,
+          I = 此状态表示一条或多条线有红圈(较低的最大投注金额)
+        enum:
+          - O
+          - H
+          - I
+        type: string
+      event:
+        $ref: '#/definitions/SpecialsFixturesEventV2'
+      contestants:
+        description: 可用于投注的参赛者线。
+        type: array
+        items:
+          $ref: '#/definitions/SpecialsFixturesContestant'
+      liveStatus:
+        format: int32
+        description: |
+          当特殊赛事链接到赛事时,我们将返回赛事的直播状态,否则将为 0。0 = 此赛事不提供直播投注,1 = 直播投注赛事,2 = 将为此比赛提供直播投注,但在不同赛事上。
+
+          请注意,在 LiveStatus=1 的特殊赛事上下注时,会应用直播延迟。
+        enum:
+          - 0
+          - 1
+          - 2
+        type: integer
+  SpecialsFixturesEventV2:
+    type: object
+    description: 与特殊赛事关联的可选赛事。
+    properties:
+      id:
+        format: int32
+        description: 赛事 ID
+        type: integer
+      periodNumber:
+        format: int32
+        description: 比赛的周期。例如在足球中 0(全场)、1(上半场)和 2(下半场)
+        type: integer
+      home:
+        description: 主队名称。
+        type: string
+      away:
+        description: 客队名称。
+        type: string
+  SpecialsFixturesContestant:
+    type: object
+    properties:
+      id:
+        format: int64
+        description: 参赛者 ID。
+        type: integer
+      name:
+        description: 参赛者名称。
+        type: string
+      rotNum:
+        format: int32
+        description: 轮换号码。
+        type: integer
+  SettledSpecialsResponseV3:
+    description: 已结算特殊赛事请求的响应 dto
+    type: object
+    properties:
+      sportId:
+        format: int32
+        description: 要检索赔率的运动 ID。
+        type: integer
+      last:
+        format: int64
+        description: 已结算赛程的最后索引
+        type: integer
+      leagues:
+        description: 联赛列表。
+        type: array
+        items:
+          $ref: '#/definitions/SettledSpecialsLeagueV3'
+  SettledSpecialsLeagueV3:
+    description: 用于保存联赛所有已结算特殊赛事的联赛 Dto
+    type: object
+    properties:
+      id:
+        format: int32
+        description: 联赛 ID。
+        type: integer
+      specials:
+        description: 已结算特殊赛事集合
+        type: array
+        items:
+          $ref: '#/definitions/SettledSpecialV3'
+  SettledSpecialV3:
+    description: 已结算特殊赛事
+    type: object
+    properties:
+      id:
+        format: int64
+        description: 已结算特殊赛事的 ID
+        type: integer
+      status:
+        format: int32
+        description: 已结算特殊赛事的状态。
+        type: integer
+      settlementId:
+        format: int64
+        description: 已结算特殊赛事的 ID
+        type: integer
+      settledAt:
+        format: date-time
+        description: 结算日期时间
+        type: string
+      cancellationReason:
+        $ref: '#/definitions/CancellationReasonType'
+        description: 特殊赛事的取消原因
+      contestants:
+        description: 参赛者集合
+        type: array
+        items:
+          $ref: '#/definitions/SettledContestants'
+  SettledContestants:
+    description: 已结算特殊赛事
+    type: object
+    properties:
+      id:
+        format: int64
+        description: 参赛者 ID。
+        type: integer
+      name:
+        description: 参赛者名称
+        type: string
+        example: Union Magdalena
+      outcome:
+        type: string
+        description: |
+          参赛者结果。
+
+          W = 获胜,
+          L = 失败,
+          X = 取消,
+          T = 平局,
+          Z = 取消
+        enum:
+          - W
+          - L
+          - X
+          - T
+          - Z
+  SpecialLineResponse:
+    type: object
+    properties:
+      status:
+        description: 状态 [SUCCESS = 成功, NOT_EXISTS = 不再提供线]
+        enum:
+          - SUCCESS
+          - NOT_EXISTS
+        type: string
+      specialId:
+        format: int64
+        description: 特殊赛事 ID。
+        type: integer
+      contestantId:
+        format: int64
+        description: 参赛者 ID。
+        type: integer
+      minRiskStake:
+        format: double
+        description: 最小可投注风险金额。
+        type: number
+      maxRiskStake:
+        format: double
+        description: 最大可投注风险金额。
+        type: number
+      minWinStake:
+        format: double
+        description: 最小可投注赢利金额。
+        type: number
+      maxWinStake:
+        format: double
+        description: 最大可投注赢利金额。
+        type: number
+      lineId:
+        format: int64
+        description: 下注所需的线标识。
+        type: integer
+      price:
+        format: double
+        description: 最新价格。
+        type: number
+      handicap:
+        format: double
+        description: 让分。
+        type: number
+  SpecialOddsResponseV2:
+    type: object
+    properties:
+      sportId:
+        format: int32
+        description: 要检索赔率的运动 ID。
+        type: integer
+      last:
+        format: int64
+        description: 用于仅在后续请求中检索更改。在后续调用中提供此值作为 Since 参数,以仅检索更改。
+        type: integer
+      leagues:
+        description: 包含联赛列表。
+        type: array
+        items:
+          $ref: '#/definitions/SpecialOddsLeagueV2'
+  SpecialOddsLeagueV2:
+    type: object
+    properties:
+      id:
+        format: int32
+        description: 联赛 ID。
+        type: integer
+      specials:
+        description: 赛程特殊赛事集合。
+        type: array
+        items:
+          $ref: '#/definitions/SpecialOddsSpecialV2'
+  SpecialOddsSpecialV2:
+    type: object
+    properties:
+      id:
+        format: int64
+        description: 特殊赛事 ID。
+        type: integer
+      maxRisk:
+        format: double
+        description: 最大风险金额。
+        type: number
+      contestantLines:
+        description: 可用于投注的参赛者线。
+        type: array
+        items:
+          $ref: '#/definitions/SpecialOddsContestantLineV2'
+  SpecialOddsContestantLineV2:
+    type: object
+    properties:
+      id:
+        format: int64
+        description: 参赛者线 ID。
+        type: integer
+      lineId:
+        format: int64
+        description: 下注所需的线标识符。
+        type: integer
+      price:
+        format: double
+        description: 线的价格。
+        type: number
+      handicap:
+        format: double
+        description: 表示让分、大小等的数字。
+        type: number
+      max:
+        format: double
+        description: 每个参赛者的最大投注量金额。请参阅[如何从最大投注量计算最大风险](https://github.com/pinny888/pinny888.github.io/blob/main/FAQs.md#how-to-calculate-max-risk-from-the-max-volume-limits-in-odds)
+        type: number

+ 848 - 0
pinnacle/main.js

@@ -0,0 +1,848 @@
+import 'dotenv/config';
+
+import { pinnacleRequest, getPsteryRelations, updateBaseEvents, notifyException } from "./libs/pinnacleClient.js";
+import { Logs } from "./libs/logs.js";
+import { getData, setData } from "./libs/cache.js";
+
+
+const gamesMapCacheFile = 'data/gamesCache.json';
+const globalDataCacheFile = 'data/globalDataCache.json';
+const TP = 'ps_9_9_1';
+const IS_DEV = process.env.NODE_ENV == 'development';
+
+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,
+};
+
+const resetVersionsCount = () => {
+  GLOBAL_DATA.straightFixturesVersion = 0;
+  GLOBAL_DATA.specialFixturesVersion = 0;
+  GLOBAL_DATA.straightOddsVersion = 0;
+  GLOBAL_DATA.specialsOddsVersion = 0;
+
+  GLOBAL_DATA.straightFixturesCount = 0;
+  GLOBAL_DATA.specialFixturesCount = 0;
+}
+
+const incrementVersionsCount = () => {
+  GLOBAL_DATA.straightFixturesCount = 0;
+  GLOBAL_DATA.specialFixturesCount = 0;
+}
+
+
+/**
+ * 获取指定时区当前日期或时间
+ * @param {number} offsetHours - 时区相对 UTC 的偏移(例如:-4 表示 GMT-4)
+ * @param {boolean} [withTime=false] - 是否返回完整时间(默认只返回日期)
+ * @returns {string} 格式化的日期或时间字符串
+ */
+const getDateInTimezone = (offsetHours, withTime = false) => {
+  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');
+
+  if (!withTime) {
+    return `${year}-${month}-${day}`;
+  }
+
+  const hours = String(targetTime.getUTCHours()).padStart(2, '0');
+  const minutes = String(targetTime.getUTCMinutes()).padStart(2, '0');
+  const seconds = String(targetTime.getUTCSeconds()).padStart(2, '0');
+
+  return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`;
+}
+
+
+const pinnacleGet = async (endpoint, params) => {
+  return pinnacleRequest({
+    endpoint,
+    params,
+    username: process.env.PINNACLE_USERNAME,
+    password: process.env.PINNACLE_PASSWORD,
+    proxy: process.env.NODE_HTTP_PROXY,
+  })
+  .catch(err => {
+    const source = { endpoint, params };
+    if (err?.response?.data) {
+      const data = err.response.data;
+      Object.assign(source, { data });
+    }
+    err.source = source;
+    return Promise.reject(err);
+  });
+};
+
+
+// const pinnaclePost = async(endpoint, data) => {
+//   return pinnacleRequest({
+//     endpoint,
+//     data,
+//     method: 'POST',
+//     username: process.env.PINNACLE_USERNAME,
+//     password: process.env.PINNACLE_PASSWORD,
+//     proxy: process.env.NODE_HTTP_PROXY,
+//   });
+// };
+
+
+const isArrayEqualUnordered = (arr1, arr2) => {
+  if (arr1.length !== arr2.length) return false;
+  const s1 = [...arr1].sort();
+  const s2 = [...arr2].sort();
+  return s1.every((v, i) => v === s2[i]);
+}
+
+
+const getFiltedGames = () => {
+  return new Promise(resolve => {
+    const updateFiltedGames = () => {
+      getPsteryRelations()
+      .then(res => {
+        if (res.statusCode !== 200) {
+          throw new Error(res.message);
+        }
+        // Logs.outDev('update filted games', res.data);
+        const games = res.data.map(game => {
+          const { eventId, leagueId } = game?.rel?.ps ?? {};
+          return {
+            eventId,
+            leagueId,
+          };
+        });
+
+        const newFiltedLeagues = [...new Set(games.map(game => game.leagueId).filter(leagueId => leagueId))];
+
+        if (!isArrayEqualUnordered(newFiltedLeagues, GLOBAL_DATA.filtedLeagues)) {
+          Logs.outDev('filted leagues changed', newFiltedLeagues);
+          resetVersionsCount();
+          GLOBAL_DATA.filtedLeagues = newFiltedLeagues;
+        }
+
+        GLOBAL_DATA.filtedGames = games.map(game => game.eventId).filter(eventId => eventId);
+
+        resolve();
+        setTimeout(updateFiltedGames, 1000 * 60);
+      })
+      .catch(err => {
+        Logs.err('failed to update filted games:', err.message);
+        setTimeout(updateFiltedGames, 1000 * 5);
+      });
+    }
+    updateFiltedGames();
+  });
+}
+
+
+const getStraightFixtures = async () => {
+  if (!GLOBAL_DATA.filtedLeagues.length) {
+    return Promise.reject(new Error('no filted leagues'));
+  }
+  const leagueIds = GLOBAL_DATA.filtedLeagues.join(',');
+  let since = GLOBAL_DATA.straightFixturesVersion;
+  if (GLOBAL_DATA.straightFixturesCount >= 12) {
+    since = 0;
+    GLOBAL_DATA.straightFixturesCount = 0;
+  }
+  if (since == 0) {
+    Logs.outDev('full update straight fixtures');
+  }
+  return pinnacleGet('/v3/fixtures', { sportId: 29, leagueIds, since })
+  .then(data => {
+    const { league, last } = data;
+    if (!last) {
+      return {};
+    }
+    GLOBAL_DATA.straightFixturesVersion = last;
+    const games = league?.map(league => {
+      const { id: leagueId, events } = league;
+      return events?.map(event => {
+        const { starts } = event;
+        const timestamp = new Date(starts).getTime();
+        return { leagueId, ...event, timestamp };
+      });
+    })
+    .flat() ?? [];
+    const update = since == 0 ? 'full' : 'increment';
+    return { games, update };
+  });
+}
+
+
+const updateStraightFixtures = async () => {
+  return getStraightFixtures()
+  .then(data => {
+    const { games, update } = data;
+    const { gamesMap } = GLOBAL_DATA;
+    if (games?.length) {
+      games.forEach(game => {
+        const { id } = game;
+        if (!gamesMap[id]) {
+          gamesMap[id] = game;
+        }
+        else {
+          Object.assign(gamesMap[id], game);
+        }
+      });
+    }
+    if (update && update == 'full') {
+      const gamesSet = new Set(games.map(game => game.id));
+      Object.keys(gamesMap).forEach(key => {
+        if (!gamesSet.has(+key)) {
+          delete gamesMap[key];
+        }
+      });
+    }
+  });
+}
+
+
+const getStraightOdds = async () => {
+  if (!GLOBAL_DATA.filtedLeagues.length) {
+    return Promise.reject(new Error('no filted leagues'));
+  }
+  const leagueIds = GLOBAL_DATA.filtedLeagues.join(',');
+  const since = GLOBAL_DATA.straightOddsVersion;
+  // if (GLOBAL_DATA.straightOddsCount >= 27) {
+  //   since = 0;
+  //   GLOBAL_DATA.straightOddsCount = 3;
+  // }
+  if (since == 0) {
+    Logs.outDev('full update straight odds');
+  }
+  return pinnacleGet('/v3/odds', { sportId: 29, oddsFormat: 'Decimal', leagueIds, since })
+  .then(data => {
+    const { leagues, last } = data;
+    if (!last) {
+      return [];
+    }
+    GLOBAL_DATA.straightOddsVersion = last;
+    const games = leagues?.flatMap(league => league.events);
+    return games?.map(item => {
+      const { periods, ...rest } = item;
+      const period = periods?.find(period => period.number == 0);
+      if (!period) {
+        return rest;
+      }
+      return { ...rest, period };
+    }) ?? [];
+  });
+}
+
+
+const updateStraightOdds = async () => {
+  return getStraightOdds()
+  .then(games => {
+    if (games.length) {
+      const { gamesMap } = GLOBAL_DATA;
+      games.forEach(game => {
+        const { id, ...rest } = game;
+        const localGame = gamesMap[id];
+        if (localGame) {
+          Object.assign(localGame, rest);
+        }
+      });
+    }
+  });
+}
+
+
+const getSpecialFixtures = async () => {
+  if (!GLOBAL_DATA.filtedLeagues.length) {
+    return Promise.reject(new Error('no filted leagues'));
+  }
+  const leagueIds = GLOBAL_DATA.filtedLeagues.join(',');
+  let since = GLOBAL_DATA.specialFixturesVersion;
+  if (GLOBAL_DATA.specialFixturesCount >= 18) {
+    since = 0;
+    GLOBAL_DATA.specialFixturesCount = 6;
+  }
+  if (since == 0) {
+    Logs.outDev('full update special fixtures');
+  }
+  return pinnacleGet('/v2/fixtures/special', { sportId: 29, leagueIds, since })
+  .then(data => {
+    const { leagues, last } = data;
+    if (!last) {
+      return [];
+    }
+    GLOBAL_DATA.specialFixturesVersion = last;
+    const specials = leagues?.map(league => {
+      const { specials } = league;
+      return specials?.filter(special => special.event)
+      .map(special => {
+        const { event: { id: eventId }, ...rest } = special ?? { event: {} };
+        return { eventId, ...rest };
+      }) ?? [];
+    })
+    .flat()
+    .filter(special => {
+      if (special.name != 'Winning Margin' && special.name != 'Exact Total Goals') {
+        return false;
+      }
+      return true;
+    }) ?? [];
+    const update = since == 0 ? 'full' : 'increment';
+    return { specials, update };
+  });
+}
+
+
+const mergeContestants = (localContestants=[], remoteContestants=[]) => {
+  const localContestantsMap = new Map(localContestants.map(contestant => [contestant.id, contestant]));
+  remoteContestants.forEach(contestant => {
+    const localContestant = localContestantsMap.get(contestant.id);
+    if (localContestant) {
+      Object.assign(localContestant, contestant);
+    }
+    else {
+      localContestants.push(contestant);
+    }
+  });
+  const remoteContestantsMap = new Map(remoteContestants.map(contestant => [contestant.id, contestant]));
+  for (let i = localContestants.length - 1; i >= 0; i--) {
+    if (!remoteContestantsMap.has(localContestants[i].id)) {
+      localContestants.splice(i, 1);
+    }
+  }
+  return localContestants;
+}
+
+const updateSpecialFixtures = async () => {
+  return getSpecialFixtures()
+  .then(data => {
+    const { specials, update } = data;
+    if (specials?.length) {
+      const { gamesMap } = GLOBAL_DATA;
+      const gamesSpecialsMap = {};
+      specials.forEach(special => {
+        const { eventId } = special;
+        if (!gamesSpecialsMap[eventId]) {
+          gamesSpecialsMap[eventId] = {};
+        }
+        if (special.name == 'Winning Margin') {
+          gamesSpecialsMap[eventId].winningMargin = special;
+        }
+        else if (special.name == 'Exact Total Goals') {
+          gamesSpecialsMap[eventId].exactTotalGoals = special;
+        }
+      });
+
+      Object.keys(gamesSpecialsMap).forEach(eventId => {
+        if (!gamesMap[eventId]) {
+          return;
+        }
+
+        if (!gamesMap[eventId].specials) {
+          gamesMap[eventId].specials = {};
+        }
+
+        const localSpecials = gamesMap[eventId].specials;
+        const remoteSpecials = gamesSpecialsMap[eventId];
+
+        if (localSpecials.winningMargin && remoteSpecials.winningMargin) {
+          const { contestants: winningMarginContestants, ...winningMarginRest } = remoteSpecials.winningMargin;
+          Object.assign(localSpecials.winningMargin, winningMarginRest);
+          mergeContestants(localSpecials.winningMargin.contestants, winningMarginContestants);
+        }
+        else if (localSpecials.winningMargin && !remoteSpecials.winningMargin) {
+          Logs.outDev('delete winningMargin', localSpecials.winningMargin);
+          delete localSpecials.winningMargin;
+        }
+        else if (remoteSpecials.winningMargin) {
+          localSpecials.winningMargin = remoteSpecials.winningMargin;
+        }
+
+        if (localSpecials.exactTotalGoals && remoteSpecials.exactTotalGoals) {
+          const { contestants: exactTotalGoalsContestants, ...exactTotalGoalsRest } = remoteSpecials.exactTotalGoals;
+          Object.assign(localSpecials.exactTotalGoals, exactTotalGoalsRest);
+          mergeContestants(localSpecials.exactTotalGoals.contestants, exactTotalGoalsContestants);
+        }
+        else if (localSpecials.exactTotalGoals && !remoteSpecials.exactTotalGoals) {
+          Logs.outDev('delete exactTotalGoals', localSpecials.exactTotalGoals);
+          delete localSpecials.exactTotalGoals;
+        }
+        else if (remoteSpecials.exactTotalGoals) {
+          localSpecials.exactTotalGoals = remoteSpecials.exactTotalGoals;
+        }
+      });
+    }
+  });
+}
+
+
+const getSpecialsOdds = async () => {
+  if (!GLOBAL_DATA.filtedLeagues.length) {
+    return Promise.reject(new Error('no filted leagues'));
+  }
+  const leagueIds = GLOBAL_DATA.filtedLeagues.join(',');
+  const since = GLOBAL_DATA.specialsOddsVersion;
+  // if (GLOBAL_DATA.specialsOddsCount >= 33) {
+  //   since = 0;
+  //   GLOBAL_DATA.specialsOddsCount = 9;
+  // }
+  if (since == 0) {
+    Logs.outDev('full update specials odds');
+  }
+  return pinnacleGet('/v2/odds/special', { sportId: 29, oddsFormat: 'Decimal', leagueIds, since })
+  .then(data => {
+    const { leagues, last } = data;
+    if (!last) {
+      return [];
+    }
+    GLOBAL_DATA.specialsOddsVersion = last;
+    return leagues?.flatMap(league => league.specials);
+  });
+}
+
+
+const updateSpecialsOdds = async () => {
+  return getSpecialsOdds()
+  .then(specials => {
+    if (specials.length) {
+      const { gamesMap } = GLOBAL_DATA;
+      const contestants = Object.values(gamesMap)
+      .filter(game => game.specials)
+      .map(game => {
+        const { specials } = game;
+        const contestants = Object.values(specials).map(special => {
+          const { contestants } = special;
+          return contestants.map(contestant => [contestant.id, contestant]);
+        });
+        return contestants;
+      }).flat(2);
+      const contestantsMap = new Map(contestants);
+      const lines = specials.flatMap(special => special.contestantLines);
+      lines.forEach(line => {
+        const { id, handicap, lineId, max, price } = line;
+        const contestant = contestantsMap.get(id);
+        if (!contestant) {
+          return;
+        }
+        contestant.handicap = handicap;
+        contestant.lineId = lineId;
+        contestant.max = max;
+        contestant.price = price;
+      });
+    }
+  });
+}
+
+
+const getInRunning = async () => {
+  return pinnacleGet('/v2/inrunning')
+  .then(data => {
+    const sportId = 29;
+    const leagues = data.sports?.find(sport => sport.id == sportId)?.leagues ?? [];
+    return leagues.filter(league => {
+      const { id } = league;
+      const filtedLeaguesSet = new Set(GLOBAL_DATA.filtedLeagues);
+      return filtedLeaguesSet.has(id);
+    }).flatMap(league => league.events);
+  });
+}
+
+
+const updateInRunning = async () => {
+  return getInRunning()
+  .then(games => {
+    if (!games.length) {
+      return;
+    }
+    const { gamesMap } = GLOBAL_DATA;
+    games.forEach(game => {
+      const { id, state, elapsed } = game;
+      const localGame = gamesMap[id];
+      if (localGame) {
+        Object.assign(localGame, { state, elapsed });
+      }
+    });
+  });
+}
+
+
+const ratioAccept = (ratio) => {
+  if (ratio > 0) {
+    return 'a';
+  }
+  return '';
+}
+const ratioString = (ratio) => {
+  ratio = Math.abs(ratio);
+  ratio = ratio.toString();
+  ratio = ratio.replace(/\./, '');
+  return ratio;
+}
+
+const parseSpreads = (spreads, wm) => {
+  // 让分盘
+  if (!spreads?.length) {
+    return null;
+  }
+  const events = {};
+  spreads.forEach(spread => {
+    const { hdp, home, away } = spread;
+
+    // if (!(hdp % 1) || !!(hdp % 0.5)) {
+    //   // 整数或不能被0.5整除的让分盘不处理
+    //   return;
+    // }
+
+    const ratio_ro = hdp;
+    const ratio_r = ratio_ro - wm;
+    events[`ior_r${ratioAccept(ratio_r)}h_${ratioString(ratio_r)}`] = {
+      v: home,
+      r: wm != 0 ? `ior_r${ratioAccept(ratio_ro)}h_${ratioString(ratio_ro)}` : undefined
+    };
+    events[`ior_r${ratioAccept(-ratio_r)}c_${ratioString(ratio_r)}`] = {
+      v: away,
+      r: wm != 0 ? `ior_r${ratioAccept(-ratio_ro)}c_${ratioString(ratio_ro)}` : undefined
+    };
+  });
+  return events;
+}
+
+const parseMoneyline = (moneyline) => {
+  // 胜平负
+  if (!moneyline) {
+    return null;
+  }
+  const { home, away, draw } = moneyline;
+  return {
+    'ior_mh': { v: home },
+    'ior_mc': { v: away },
+    'ior_mn': { v: draw },
+  }
+}
+
+const parseTotals = (totals) => {
+  // 大小球盘
+  if (!totals?.length) {
+    return null;
+  }
+  const events = {};
+
+  totals.forEach(total => {
+    const { points, over, under } = total;
+    events[`ior_ouc_${ratioString(points)}`] = { v: over };
+    events[`ior_ouh_${ratioString(points)}`] = { v: under };
+  });
+  return events;
+}
+
+const parsePeriod = (period, wm) => {
+  const { cutoff='', status=0, spreads=[], moneyline={}, totals=[] } = period;
+  const cutoffTime = new Date(cutoff).getTime();
+  const nowTime = Date.now();
+
+  if (status != 1 || cutoffTime < nowTime) {
+    return null;
+  }
+
+  const events = {};
+  Object.assign(events, parseSpreads(spreads, wm));
+  Object.assign(events, parseMoneyline(moneyline));
+  Object.assign(events, parseTotals(totals));
+
+  return events;
+}
+
+const parseWinningMargin = (winningMargin, home, away) => {
+  const { cutoff='', status='', contestants=[] } = winningMargin;
+  const cutoffTime = new Date(cutoff).getTime();
+  const nowTime = Date.now();
+
+  if (status != 'O' || cutoffTime < nowTime || !contestants?.length) {
+    return null;
+  }
+
+  const events = {};
+  contestants.forEach(contestant => {
+    const { name, price } = contestant;
+    const nr = name.match(/\d+$/)?.[0];
+    if (!nr) {
+      return;
+    }
+    let side;
+    if (name.startsWith(home)) {
+      side = 'h';
+    }
+    else if (name.startsWith(away)) {
+      side = 'c';
+    }
+    else {
+      return;
+    }
+    events[`ior_wm${side}_${nr}`] = { v: price };
+  });
+  return events;
+}
+
+const parseExactTotalGoals = (exactTotalGoals) => {
+  const { cutoff='', status='', contestants=[] } = exactTotalGoals;
+  const cutoffTime = new Date(cutoff).getTime();
+  const nowTime = Date.now();
+
+  if (status != 'O' || cutoffTime < nowTime || !contestants?.length) {
+    return null;
+  }
+
+  const events = {};
+  contestants.forEach(contestant => {
+    const { name, price } = contestant;
+    if (+name >= 1 && +name <= 7) {
+      events[`ior_ot_${name}`] = { v: price };
+    }
+  });
+  return events;
+}
+
+const parseRbState = (state) => {
+  let stage = null;
+  if (state == 1) {
+    stage = '1H';
+  }
+  else if (state == 2) {
+    stage = 'HT';
+  }
+  else if (state == 3) {
+    stage = '2H';
+  }
+  else if (state >= 4) {
+    stage = 'ET';
+  }
+  return stage;
+}
+
+const parseGame = (game) => {
+  const { eventId=0, originId=0, period={}, specials={}, home, away, marketType, state, elapsed, homeScore=0, awayScore=0 } = game;
+  const { winningMargin={}, exactTotalGoals={} } = specials;
+  const wm = homeScore - awayScore;
+  const score = `${homeScore}-${awayScore}`;
+  const events = parsePeriod(period, wm) ?? {};
+  const stage = parseRbState(state);
+  const retime = elapsed ? `${elapsed}'` : '';
+  const evtime = Date.now();
+  Object.assign(events, parseWinningMargin(winningMargin, home, away));
+  Object.assign(events, parseExactTotalGoals(exactTotalGoals));
+  return { eventId, originId, events, evtime, stage, retime, score, wm, marketType };
+}
+
+
+const getGames = () => {
+  const { filtedGames, gamesMap } = GLOBAL_DATA;
+  const filtedGamesSet = new Set(filtedGames);
+  const nowTime = Date.now();
+  const gamesData = {};
+  Object.values(gamesMap).forEach(game => {
+    const { id, liveStatus, parentId, resultingUnit, timestamp } = game;
+
+    if (resultingUnit !== 'Regular') {
+      return false;  // 非常规赛事不处理
+    }
+
+    const gmtMinus4Date = getDateInTimezone(-4);
+    const todayEndTime = new Date(`${gmtMinus4Date} 23:59:59 GMT-4`).getTime();
+    const tomorrowEndTime = todayEndTime + 24 * 60 * 60 * 1000;
+    if (liveStatus == 1 && timestamp < nowTime) {
+      game.marketType = 2;  // 滚球赛事
+    }
+    else if (liveStatus != 1 && timestamp > nowTime && timestamp <= todayEndTime) {
+      game.marketType = 1;  // 今日赛事
+    }
+    else if (liveStatus != 1 && timestamp > todayEndTime && timestamp <= tomorrowEndTime) {
+      game.marketType = 0;  // 明日早盘赛事
+    }
+    else {
+      game.marketType = -1;  // 非近期赛事
+    }
+
+    if (game.marketType < 0) {
+      return false;  // 非近期赛事不处理
+    }
+
+    let actived = false;
+    if (liveStatus != 1 && filtedGamesSet.has(id)) {
+      actived = true;  // 在赛前列表中
+      game.eventId = id;
+      game.originId = 0;
+    }
+    else if (liveStatus == 1 && filtedGamesSet.has(parentId)) {
+      actived = true;  // 在滚球列表中
+      game.eventId = parentId;
+      game.originId = id;
+    }
+
+    if (actived) {
+      const gameInfo = parseGame(game);
+      const { marketType, ...rest } = gameInfo;
+      if (!gamesData[marketType]) {
+        gamesData[marketType] = [];
+      }
+      gamesData[marketType].push(rest);
+    }
+  });
+  return gamesData;
+}
+
+
+const pinnacleDataLoop = () => {
+  updateStraightFixtures()
+  .then(() => {
+    return Promise.all([
+      updateStraightOdds(),
+      updateSpecialFixtures(),
+      updateInRunning(),
+    ]);
+  })
+  .then(() => {
+    return updateSpecialsOdds();
+  })
+  .then(() => {
+    if (!GLOBAL_DATA.loopActive) {
+      GLOBAL_DATA.loopActive = true;
+      Logs.out('loop active');
+      notifyException('Pinnacle API startup.');
+    }
+
+    if (GLOBAL_DATA.requestErrorCount > 0) {
+      GLOBAL_DATA.requestErrorCount = 0;
+      Logs.out('request error count reset');
+    }
+
+    const nowTime = Date.now();
+    const loopDuration = nowTime - GLOBAL_DATA.loopResultTime;
+    GLOBAL_DATA.loopResultTime = nowTime;
+    if (loopDuration > 15000) {
+      Logs.out('loop duration is too long', loopDuration);
+    }
+    else {
+      Logs.outDev('loop duration', loopDuration);
+    }
+
+    const { straightFixturesVersion: sfv, specialFixturesVersion: pfv, straightOddsVersion: sov, specialsOddsVersion: pov } = GLOBAL_DATA;
+    const timestamp = Math.max(sfv, pfv, sov, pov);
+    const games = getGames();
+    const data = { games, timestamp, tp: TP };
+
+    updateBaseEvents(data);
+
+    if (IS_DEV) {
+      setData(gamesMapCacheFile, GLOBAL_DATA.gamesMap)
+      .then(() => {
+        Logs.outDev('games map saved');
+      })
+      .catch(err => {
+        Logs.err('failed to save games map', err.message);
+      });
+    }
+
+  })
+  .catch(err => {
+    Logs.err(err.message, err.source);
+    GLOBAL_DATA.requestErrorCount++;
+    if (GLOBAL_DATA.loopActive && GLOBAL_DATA.requestErrorCount > 5) {
+      const exceptionMessage = 'request errors have reached the limit';
+      Logs.out(exceptionMessage);
+      GLOBAL_DATA.loopActive = false;
+
+      Logs.out('loop inactive');
+      notifyException(`Pinnacle API paused. ${exceptionMessage}. ${err.message}`);
+    }
+  })
+  .finally(() => {
+    const { loopActive } = GLOBAL_DATA;
+    let loopDelay = 1000 * 5;
+    if (!loopActive) {
+      loopDelay = 1000 * 60;
+      resetVersionsCount();
+    }
+    else {
+      incrementVersionsCount();
+    }
+    setTimeout(pinnacleDataLoop, loopDelay);
+  });
+}
+
+
+/**
+ * 缓存GLOBAL_DATA数据到文件
+ */
+const saveGlobalDataToCache = async () => {
+  return setData(globalDataCacheFile, GLOBAL_DATA);
+}
+
+const loadGlobalDataFromCache = async () => {
+  return getData(globalDataCacheFile)
+  .then(data => {
+    if (!data) {
+      return;
+    }
+    Object.keys(GLOBAL_DATA).forEach(key => {
+      if (key in data) {
+        GLOBAL_DATA[key] = data[key];
+      }
+    });
+  });
+}
+
+// 监听进程退出事件,保存GLOBAL_DATA数据
+const saveExit = (code) => {
+  saveGlobalDataToCache()
+  .then(() => {
+    Logs.out('global data saved');
+  })
+  .catch(err => {
+    Logs.err('failed to save global data', err.message);
+  })
+  .finally(() => {
+    process.exit(code);
+  });
+}
+process.on('SIGINT', () => {
+  saveExit(0);
+});
+process.on('SIGTERM', () => {
+  saveExit(0);
+});
+process.on('SIGUSR2', () => {
+  saveExit(0);
+});
+
+
+(() => {
+  if (!process.env.PINNACLE_USERNAME || !process.env.PINNACLE_PASSWORD) {
+    Logs.err('USERNAME or PASSWORD is not set');
+    return;
+  }
+  loadGlobalDataFromCache()
+  .then(() => {
+    Logs.out('global data loaded');
+  })
+  .catch(err => {
+    Logs.err('failed to load global data', err.message);
+  })
+  .finally(() => {
+    GLOBAL_DATA.loopResultTime = Date.now();
+    GLOBAL_DATA.loopActive = true;
+    return getFiltedGames();
+  })
+  .then(pinnacleDataLoop);
+})();
+
+

+ 22 - 0
pinnacle/package.json

@@ -0,0 +1,22 @@
+{
+  "name": "pinnacle",
+  "version": "1.1.3",
+  "description": "",
+  "main": "main.js",
+  "type": "module",
+  "scripts": {
+    "dev": "nodemon --ignore data/ --ignore node_modules/ --inspect=9228 main.js",
+    "start": "pm2 start main.js --name pinnacle",
+    "patch": "npm version patch --no-git-tag-version",
+    "minor": "npm version minor --no-git-tag-version",
+    "major": "npm version major --no-git-tag-version"
+  },
+  "author": "",
+  "license": "ISC",
+  "dependencies": {
+    "axios": "^1.12.2",
+    "dayjs": "^1.11.18",
+    "dotenv": "^17.2.3",
+    "https-proxy-agent": "^7.0.6"
+  }
+}

+ 18 - 1
server/init.js

@@ -5,11 +5,28 @@ const Logs = require('./libs/logs');
 (() => {
   settingInit({
     innerDefaultAmount: 10000,
-    minProfitAmount: 0,
+    // minProfitAmount: 0,
     minShowAmount: 0,
     innerRebateRatio: 0,
     obRebateRatio: 0,
+    obRebateType: 0,
+    obMaxDiff: 0,
+    imRebateRatio: 0,
+    imRebateType: 0,
+    imMaxDiff: 0,
     hgRebateRatio: 0,
+    hgRebateType: 0,
+    hgRebateLower: 0,
+    hgMaxDiff: 0,
+    pcRebateRatio: 0,
+    pcRebateType: 0,
+    expireTimeEvents: 45000,
+    expireTimeSpecial: 60000,
+    subsidyTime: 0,
+    subsidyAmount: 0,
+    subsidyRbWmAmount: 0,
+    subsidyRbOtAmount: 0,
+    syncSettingEnabled: false,
     runWorkerEnabled: false,
   })
   .then(() => {

+ 174 - 0
server/models/Control.js

@@ -0,0 +1,174 @@
+const { exec } = require('child_process');
+const path = require('path');
+
+const GLOBAL_DATA = {
+  gitLock: false,
+  buildLock: false,
+};
+
+/**
+ * 拉取指定仓库的代码
+ * @param {string} repoPath 仓库路径
+ * @returns {Promise<string>} 拉取结果
+ */
+const gitPull = (repoPath) => {
+  if (GLOBAL_DATA.gitLock) {
+    return Promise.reject(new Error('git is locked'));
+  }
+  GLOBAL_DATA.gitLock = true;
+  return new Promise((resolve, reject) => {
+    const cwd = path.resolve(repoPath);
+    exec("git pull", { cwd }, (error, stdout, stderr) => {
+      GLOBAL_DATA.gitLock = false;
+      if (error) {
+        reject(new Error(stderr) || error);
+        return;
+      }
+      resolve(stdout || "No output");
+    });
+  });
+}
+
+/**
+ * 拉当前项目的代码
+ */
+const gitPullCurrent = () => {
+  const rootPath = path.resolve(__dirname, '../../');
+  return gitPull(rootPath);
+}
+
+/**
+ * 启动服务
+ * @param {string} serviceName 服务名称
+ * @returns {Promise<string>} 启动结果
+ */
+const pm2Start = (serviceName) => {
+  // if (process.env.NODE_ENV == 'development') {
+  //   return Promise.resolve('development mode, skip');
+  // }
+  return new Promise((resolve, reject) => {
+    exec(`pm2 start ${serviceName}`, (error, stdout, stderr) => {
+      if (error) {
+        reject(new Error(stderr) || error);
+        return;
+      }
+      resolve(stdout || "No output");
+    });
+  });
+}
+
+/**
+ * 停止服务
+ * @param {string} serviceName 服务名称
+ * @returns {Promise<string>} 停止结果
+ */
+const pm2Stop = (serviceName) => {
+  // if (process.env.NODE_ENV == 'development') {
+  //   return Promise.resolve('development mode, skip');
+  // }
+  return new Promise((resolve, reject) => {
+    exec(`pm2 stop ${serviceName}`, (error, stdout, stderr) => {
+      if (error) {
+        reject(new Error(stderr) || error);
+        return;
+      }
+      resolve(stdout || "No output");
+    });
+  });
+}
+
+/**
+ * PM2 重启服务
+ * @param {string} serviceName 服务名称
+ * @returns {Promise<string>} 重启结果
+ */
+const pm2Restart = (serviceName) => {
+  // if (process.env.NODE_ENV == 'development') {
+  //   return Promise.resolve('development mode, skip');
+  // }
+  return new Promise((resolve, reject) => {
+    exec(`pm2 restart ${serviceName}`, (error, stdout, stderr) => {
+      if (error) {
+        reject(new Error(stderr) || error);
+        return;
+      }
+      resolve(stdout || "No output");
+    });
+  });
+}
+
+/**
+ * 清除缓存
+ */
+const clearCache = (cachePath) => {
+  return new Promise((resolve, reject) => {
+    exec(`rm -rf ${cachePath}/*`, (error, stdout, stderr) => {
+      if (error) {
+        reject(new Error(stderr) || error);
+        return;
+      }
+      resolve(stdout || "No output");
+    });
+  });
+}
+
+/**
+ * 清除缓存并重启服务
+ */
+const pm2RestartClearCache = async (serviceName, cachePath) => {
+  const stopResult = await pm2Stop(serviceName);
+  const clearResult = await clearCache(cachePath);
+  const startResult = await pm2Start(serviceName);
+  return [stopResult, clearResult, startResult].join('\n\n\n====================\n\n\n');
+}
+
+/**
+ * PM2 重启 sporttery 服务
+ * @param {boolean} hot 是否热重启
+ * @returns {Promise<string>} 重启结果
+ */
+const pm2RestartSporttery = (hot) => {
+  if (hot) {
+    return pm2Restart('sporttery');
+  }
+  else {
+    return pm2RestartClearCache('sporttery', path.resolve(__dirname, '../../server/data'));
+  }
+}
+
+/**
+ * PM2 重启 pinnacle 服务
+ * @param {boolean} hot 是否热重启
+ * @returns {Promise<string>} 重启结果
+ */
+const pm2RestartPinnacle = (hot) => {
+  if (hot) {
+    return pm2Restart('pinnacle');
+  }
+  else {
+    return pm2RestartClearCache('pinnacle', path.resolve(__dirname, '../../pinnacle/data'));
+  }
+}
+
+/**
+ * 发布 web 服务
+ */
+const releaseWeb = () => {
+  if (GLOBAL_DATA.buildLock) {
+    return Promise.reject(new Error('build is locked'));
+  }
+  GLOBAL_DATA.buildLock = true;
+  const rootPath = path.resolve(__dirname, '../../web');
+  return new Promise((resolve, reject) => {
+    exec('npm run build', { cwd: rootPath }, (error, stdout, stderr) => {
+      GLOBAL_DATA.buildLock = false;
+      if (error) {
+        reject(new Error(stderr) || error);
+        return;
+      }
+      resolve(stdout || "No output");
+    });
+  });
+}
+
+module.exports = { gitPullCurrent, pm2RestartSporttery, pm2RestartPinnacle, releaseWeb };

+ 0 - 531
server/models/Games.js

@@ -1,531 +0,0 @@
-const mongoose = require('mongoose');
-const { Schema } = mongoose;
-
-const gameSchema = new Schema({
-  leagueId: { type: Number, required: true },
-  eventId: { type: Number, required: true },
-  leagueName: { type: String, required: true },
-  teamHomeName: { type: String, required: true },
-  teamAwayName: { type: String, required: true },
-  timestamp: { type: Number, required: true },
-  matchNumStr: { type: String, required: false }
-}, { _id: false });
-
-const relSchema = new Schema({
-  jc: { type: gameSchema },
-  ps: { type: gameSchema },
-  ob: { type: gameSchema },
-}, { _id: false });
-
-const relationSchema = new Schema({
-  id: { type: Number, required: true },
-  rel: { type: relSchema, required: true },
-}, {
-  toJSON: {
-    transform(doc, ret) {
-      delete ret._id;
-      delete ret.__v;
-    }
-  },
-  toObject: {
-    transform(doc, ret) {
-      delete ret._id;
-      delete ret.__v;
-    }
-  }
-});
-
-const Relation = mongoose.model('Relation', relationSchema);
-
-const childOptions = process.env.NODE_ENV == 'development' ? {
-  execArgv: ['--inspect=9228']
-} : {};
-// const { fork } = require('child_process');
-// const events_child = fork('./triangle/eventsMatch.js', [], childOptions);
-
-const axios = require('axios');
-const calcTotalProfit = require('../triangle/totalProfitCalc');
-
-const Logs = require('../libs/logs');
-
-const Setting = require('./Setting');
-
-const Request = {
-  callbacks: {},
-  count: 0,
-}
-
-const GAMES = {
-  Leagues: {},
-  List: {},
-  Relations: {},
-  Solutions: {},
-};
-
-/**
- * 精确浮点数字
- * @param {number} number
- * @param {number} x
- * @returns {number}
- */
-const fixFloat = (number, x=2) => {
-  return parseFloat(number.toFixed(x));
-}
-
-/**
- * 更新联赛列表
- */
-const syncLeaguesList = ({ mk, leagues }) => {
-  axios.post('https://api.isthe.me/api/p/syncLeague', { mk, leagues })
-  .then(res => {
-    Logs.out('syncLeaguesList', res.data);
-  })
-  .catch(err => {
-    Logs.out('syncLeaguesList', err.message);
-  });
-}
-const updateLeaguesList = ({ mk, leagues }) => {
-  const leaguesList = GAMES.Leagues;
-  if (JSON.stringify(leaguesList[mk]) != JSON.stringify(leagues)) {
-    leaguesList[mk] = leagues;
-    syncLeaguesList({ mk, leagues });
-    return leagues.length;
-  }
-  return 0;
-}
-
-/**
- * 获取筛选过的联赛
- */
-const getFilteredLeagues = async (mk) => {
-  return axios.get(`https://api.isthe.me/api/p/getLeagueTast?mk=${mk}`)
-  .then(res => {
-    if (res.data.code == 0) {
-      return res.data.data;
-    }
-    return Promise.reject(new Error(res.data.message));
-  });
-}
-
-/**
- * 更新比赛列表
- */
-const syncGamesList = ({ platform, mk, games }) => {
-  axios.post('https://api.isthe.me/api/p/syncGames', { platform, mk, games })
-  .then(res => {
-    Logs.out('syncGamesList', res.data);
-  })
-  .catch(err => {
-    Logs.out('syncGamesList', err.message);
-  });
-}
-const updateGamesList = (({ platform, mk, games } = {}) => {
-  syncGamesList({ platform, mk, games });
-  return new Promise((resolve, reject) => {
-    if (!platform || !games) {
-      return reject(new Error('PLATFORM_GAMES_INVALID'));
-    }
-
-    const marketType = mk == 0 ? 'early' : 'today';
-
-    let gamesList = games;
-
-    if (platform == 'jc') {
-      gamesList = [];
-
-      const gamesEvents = [];
-      const gamesOutrights = [];
-
-      games.forEach(game => {
-        const { eventId, events, evtime, special, sptime, ...gameInfo } = game;
-        gamesList.push({ eventId, ...gameInfo });
-        gamesEvents.push({ eventId, events, evtime });
-        gamesOutrights.push({ parentId: eventId, special, sptime });
-      });
-
-      updateGamesEvents({ platform, games: gamesEvents, outrights: gamesOutrights });
-    }
-
-
-    const timestamp = Date.now();
-
-    const GAMES_LIST = GAMES.List;
-
-    if (!GAMES_LIST[platform]) {
-      GAMES_LIST[platform] = {};
-    }
-    if (!GAMES_LIST[platform][marketType]) {
-      GAMES_LIST[platform][marketType] = { games: gamesList, timestamp };
-      return resolve({ add: gamesList.length, del: 0 });
-    }
-
-    const oldGames = GAMES_LIST[platform][marketType].games;
-    const newGames = gamesList;
-
-    const updateCount = {
-      add: 0,
-      del: 0,
-    };
-
-    const newMap = new Map(newGames.map(item => [item.eventId, item]));
-    for (let i = oldGames.length - 1; i >= 0; i--) {
-      if (!newMap.has(oldGames[i].eventId)) {
-        oldGames.splice(i, 1);
-        updateCount.del += 1;
-      }
-    }
-
-    const oldIds = new Set(oldGames.map(item => item.eventId));
-    const relatedGames = Object.values(GAMES.Relations).map(rel => rel[platform] ?? {});
-    const relatedMap = new Map(relatedGames.map(item => [item.eventId, item]));
-    newGames.forEach(item => {
-      if (!oldIds.has(item.eventId)) {
-        oldGames.push(item);
-        updateCount.add += 1;
-      }
-      if (relatedMap.has(item.eventId)) {
-        const relatedGame = relatedMap.get(item.eventId);
-        relatedGame.mk = mk;
-      }
-    });
-
-    GAMES_LIST[platform][marketType].timestamp = timestamp;
-
-    resolve(updateCount);
-  });
-});
-
-/**
- * 更新比赛盘口
- */
-const updateGamesEvents = ({ platform, mk, games, outrights }) => {
-  return new Promise((resolve, reject) => {
-    if (!platform || (!games && !outrights)) {
-      return reject(new Error('PLATFORM_GAMES_INVALID'));
-    }
-
-    const relatedGames = Object.values(GAMES.Relations).map(rel => rel[platform] ?? {});
-
-    if (!relatedGames.length) {
-      return resolve({ update: 0 });
-    }
-
-    const updateCount = {
-      update: 0
-    };
-
-    const relatedMap = new Map(relatedGames.map(item => [item.eventId, item]));
-
-    games?.forEach(game => {
-      const { eventId, evtime, events } = game;
-      const relatedGame = relatedMap.get(eventId);
-      if (relatedGame) {
-        relatedGame.evtime = evtime;
-        relatedGame.events = events;
-        updateCount.update ++;
-      }
-    });
-
-    outrights?.forEach(outright => {
-      const { parentId, sptime, special } = outright;
-      const relatedGame = relatedMap.get(parentId);
-      if (relatedGame) {
-        relatedGame.sptime = sptime;
-        relatedGame.special = special;
-        updateCount.update ++;
-      }
-    });
-
-    resolve(updateCount);
-  });
-}
-
-/**
- * 获取比赛列表
- */
-const getGamesList = () => {
-  const gamesListMap = {};
-  Object.keys(GAMES.List).forEach(platform => {
-    const { today, early } = GAMES.List[platform];
-    const todayList = today?.games ?? [];
-    const earlyList = early?.games ?? [];
-    const timestamp_today = today?.timestamp ?? 0;
-    const timestamp_early = early?.timestamp ?? 0;
-    const timestamp = Math.max(timestamp_today, timestamp_early);
-    gamesListMap[platform] = {
-      games: [...todayList, ...earlyList],
-      timestamp,
-      timestamp_today,
-      timestamp_early,
-    }
-  });
-  return gamesListMap;
-}
-
-/**
- * 获取比赛盘口
- */
-const getGamesEvents = ({ platform, relIds = [] } = {}) => {
-  const idSet = new Set(relIds);
-  const relations = { ...GAMES.Relations };
-  Object.keys(relations).forEach(id => {
-    if (idSet.size && !idSet.has(id)) {
-      delete relations[id];
-    }
-  });
-  if (platform) {
-    return Object.values(relations).map(rel => rel[platform] ?? {});
-  }
-  const gamesEvents = {};
-  Object.values(relations).forEach(rel => {
-    Object.keys(rel).forEach(platform => {
-      const game = rel[platform] ?? {};
-      const { eventId, events, special } = game;
-      if (!gamesEvents[platform]) {
-        gamesEvents[platform] = {};
-      }
-      gamesEvents[platform][eventId] = { ...events, ...special };
-    });
-  });
-  return gamesEvents;
-}
-
-/**
- * 更新关联比赛
- */
-const updateGamesRelation = async (relation) => {
-  const { id, rel } = relation;
-  return Relation.findOne({ id })
-  .then(result => {
-    if (!result) {
-      const gameRelation = new Relation(relation);
-      return gameRelation.save();
-    }
-    return Relation.updateOne({ id }, { $set: { rel } });
-  })
-  .then(result => {
-    GAMES.Relations[id] = rel;
-    return result;
-  });
-}
-
-/**
- * 删除关联比赛
- */
-const removeGamesRelation = async (id) => {
-  if (!id) {
-    return Promise.reject(new Error('ID_INVALID'));
-  }
-  return Relation.deleteOne({ id })
-  .then(result => {
-    delete GAMES.Relations[id];
-    return result;
-  });
-}
-
-/**
- * 获取关联比赛
- */
-const getGamesRelation = (listEvents) => {
-  const relationIds = Object.keys(GAMES.Relations);
-  if (listEvents) {
-    return relationIds.map(id => {
-      const rel = GAMES.Relations[id];
-      return { id, rel };
-    });
-  }
-  return relationIds.map(id => {
-    const rel = { ...GAMES.Relations[id] };
-    Object.keys(rel).forEach(platform => {
-      const game = { ...rel[platform] };
-      delete game.events;
-      delete game.evtime;
-      delete game.special;
-      delete game.sptime;
-      rel[platform] = game;
-    });
-    return { id, rel };
-  });
-}
-
-/**
- * 清理关联比赛
- */
-const relationsCleanup = () => {
-  const expireTime = Date.now() - 1000*60*5;
-  const gamesRelation = getGamesRelation();
-  gamesRelation.forEach(item => {
-    const { id, rel } = item;
-    const expire = Object.values(rel).find(event => {
-      return event.timestamp <= expireTime;
-    });
-    if (expire) {
-      Logs.out('relation cleanup', id);
-      removeGamesRelation(id);
-    }
-    return true;
-  });
-}
-
-/**
- * 从数据库中同步关联比赛
- */
-const relationSync = () => {
-  Relation.find().then(relation => relation.forEach(item => {
-    const { id, rel } = item.toObject();
-    GAMES.Relations[id] = rel;
-  }));
-}
-
-/**
- * 更新中单方案
- */
-const updateSolutions = (solutions) => {
-  if (solutions?.length) {
-    const solutionsHistory = GAMES.Solutions;
-    const updateIds = { add: [], update: [] }
-    solutions.forEach(item => {
-      const { sid, sol: { win_average } } = item;
-
-      if (!solutionsHistory[sid]) {
-        solutionsHistory[sid] = item;
-        updateIds.add.push({ sid, win_average });
-        return;
-      }
-
-      const historyWinAverage = solutionsHistory[sid].sol.win_average;
-      if (win_average != historyWinAverage) {
-        solutionsHistory[sid] = item;
-        updateIds.update.push({ sid, win_average, his_average: historyWinAverage, diff: fixFloat(win_average - historyWinAverage) });
-        return;
-      }
-
-      const { timestamp } = item;
-      solutionsHistory[sid].timestamp = timestamp;
-
-    });
-    // if (updateIds.add.length || updateIds.update.length) {
-    //   const solutionsList = Object.values(solutionsHistory).sort((a, b) => b.sol.win_average - a.sol.win_average);
-    //   Logs.outDev('solutions history update', JSON.stringify(solutionsList, null, 2), JSON.stringify(updateIds, null, 2));
-    // }
-  }
-}
-
-/**
- * 获取中单方案
- */
-const getSolutions = async () => {
-  const solutionsList = Object.values(GAMES.Solutions);
-  const relIds = solutionsList.map(item => item.info.id);
-  const gamesEvents = getGamesEvents({ relIds });
-  const gamesRelations = getGamesRelation();
-  const relationsMap = new Map(gamesRelations.map(item => [item.id, item.rel]));
-  const solutions = solutionsList.sort((a, b) => b.sol.win_average - a.sol.win_average).map(item => {
-    const { info: { id } } = item;
-    const relation = relationsMap.get(id);
-    return {
-      ...item,
-      info: { id, ...relation }
-    }
-  });
-  return { solutions, gamesEvents };
-}
-
-/**
- * 清理中单方案
- */
-const solutionsCleanup = () => {
-  const solutionsHistory = GAMES.Solutions;
-  Object.keys(solutionsHistory).forEach(sid => {
-    const { timestamp } = solutionsHistory[sid];
-    const nowTime = Date.now();
-    if (nowTime - timestamp > 1000*60) {
-      delete solutionsHistory[sid];
-      Logs.out('solution history timeout', sid);
-      return;
-    }
-    const solution = solutionsHistory[sid];
-    const eventTime = solution.info.timestamp;
-    if (nowTime > eventTime) {
-      delete solutionsHistory[sid];
-      Logs.out('solution history expired', sid);
-    }
-  });
-}
-
-const getTotalProfit = (sid1, sid2, gold_side_inner) => {
-  const preSolution = GAMES.Solutions[sid1];
-  const subSolution = GAMES.Solutions[sid2];
-  const relId1 = preSolution?.info?.id;
-  const relId2 = subSolution?.info?.id;
-  const relIds = [relId1, relId2];
-  const gamesEvents = getGamesEvents({ relIds });
-
-  const gamesRelations = getGamesRelation();
-  const relationsMap = new Map(gamesRelations.map(item => [item.id, item.rel]));
-  const preRelation = relationsMap.get(relId1);
-  const subRelation = relationsMap.get(relId2);
-  preSolution.info = { id: relId1, ...preRelation };
-  subSolution.info = { id: relId2, ...subRelation };
-
-  const sol1 = preSolution?.sol;
-  const sol2 = subSolution?.sol;
-  if (!sol1 || !sol2 || !gold_side_inner) {
-    return {};
-  }
-  const profit = calcTotalProfit(sol1, sol2, gold_side_inner);
-  return { profit, preSolution, subSolution, gamesEvents };
-}
-
-// const getSetting = async () => {
-//   return Setting.get();
-// }
-
-// const getDataFromChild = (type, callback) => {
-//   const id = ++Request.count;
-//   Request.callbacks[id] = callback;
-//   events_child.send({ method: 'get', id, type });
-// }
-
-// events_child.on('message', async (message) => {
-//   const { callbacks } = Request;
-//   const { method, id, type, data } = message;
-//   if (method == 'get' && id) {
-//     let responseData = null;
-//     if (type == 'getGamesRelation') {
-//       responseData = getGamesRelation(true);
-//     }
-//     else if (type == 'getSetting') {
-//       responseData = await getSetting();
-//     }
-//     // else if (type == 'getSolutionHistory') {
-//     //   responseData = getSolutionHistory();
-//     // }
-//     events_child.send({ type: 'response', id, data: responseData });
-//   }
-//   else if (method == 'post') {
-//     if (type == 'updateSolutions') {
-//       updateSolutions(data);
-//     }
-//   }
-//   else if (method == 'response' && id && callbacks[id]) {
-//     callbacks[id](data);
-//     delete callbacks[id];
-//   }
-// });
-
-relationSync();
-setInterval(() => {
-  relationsCleanup();
-}, 5000);
-
-setInterval(() => {
-  solutionsCleanup();
-}, 1000*30);
-
-module.exports = {
-  updateLeaguesList, getFilteredLeagues,
-  updateGamesList, updateGamesEvents, getGamesList,
-  updateGamesRelation, getGamesRelation, removeGamesRelation,
-  getGamesEvents, getSolutions, getTotalProfit,
-}

Разница между файлами не показана из-за своего большого размера
+ 655 - 153
server/models/GamesPs.js


+ 90 - 5
server/models/Setting.js

@@ -11,22 +11,47 @@ const systemSettingSchema = new Schema({
     required: true,
     default: 10000
   },
-  minProfitAmount: {
+  // minProfitAmount: {
+  //   type: Number,
+  //   required: true,
+  //   default: 0
+  // },
+  minShowAmount: {
     type: Number,
     required: true,
     default: 0
   },
-  minShowAmount: {
+  innerRebateRatio: {
     type: Number,
     required: true,
     default: 0
   },
-  innerRebateRatio: {
+  obRebateRatio: {
     type: Number,
     required: true,
     default: 0
   },
-  obRebateRatio: {
+  obRebateType: {
+    type: Number,
+    required: true,
+    default: 0
+  },
+  obMaxDiff: {
+    type: Number,
+    required: true,
+    default: 0
+  },
+  imRebateRatio: {
+    type: Number,
+    required: true,
+    default: 0
+  },
+  imRebateType: {
+    type: Number,
+    required: true,
+    default: 0
+  },
+  imMaxDiff: {
     type: Number,
     required: true,
     default: 0
@@ -36,11 +61,71 @@ const systemSettingSchema = new Schema({
     required: true,
     default: 0
   },
+  hgRebateType: {
+    type: Number,
+    required: true,
+    default: 0
+  },
+  hgRebateLower: {
+    type: Number,
+    required: true,
+    default: 0
+  },
+  hgMaxDiff: {
+    type: Number,
+    required: true,
+    default: 0
+  },
+  pcRebateRatio: {
+    type: Number,
+    required: true,
+    default: 0
+  },
+  pcRebateType: {
+    type: Number,
+    required: true,
+    default: 0
+  },
+  expireTimeEvents: {
+    type: Number,
+    required: true,
+    default: 45000
+  },
+  expireTimeSpecial: {
+    type: Number,
+    required: true,
+    default: 60000
+  },
+  subsidyTime: {
+    type: Number,
+    required: true,
+    default: 0
+  },
+  subsidyAmount: {
+    type: Number,
+    required: true,
+    default: 0
+  },
+  subsidyRbWmAmount: {
+    type: Number,
+    required: true,
+    default: 0
+  },
+  subsidyRbOtAmount: {
+    type: Number,
+    required: true,
+    default: 0
+  },
+  syncSettingEnabled: {
+    type: Boolean,
+    required: true,
+    default: false
+  },
   runWorkerEnabled: {
     type: Boolean,
     required: true,
     default: false
-  }
+  },
 }, {
   toJSON: {
     transform(doc, ret) {

+ 0 - 1316
server/package-lock.json

@@ -1,1316 +0,0 @@
-{
-  "name": "sporttery-server",
-  "version": "1.0.0",
-  "lockfileVersion": 3,
-  "requires": true,
-  "packages": {
-    "": {
-      "name": "sporttery-server",
-      "version": "1.0.0",
-      "license": "ISC",
-      "dependencies": {
-        "axios": "^1.8.4",
-        "bcryptjs": "^3.0.2",
-        "cookie-parser": "^1.4.7",
-        "dayjs": "^1.11.13",
-        "dotenv": "^16.5.0",
-        "express": "^5.1.0",
-        "jsonwebtoken": "^9.0.2",
-        "mongoose": "^8.13.2"
-      }
-    },
-    "node_modules/@mongodb-js/saslprep": {
-      "version": "1.2.2",
-      "resolved": "https://registry.npmmirror.com/@mongodb-js/saslprep/-/saslprep-1.2.2.tgz",
-      "integrity": "sha512-EB0O3SCSNRUFk66iRCpI+cXzIjdswfCs7F6nOC3RAGJ7xr5YhaicvsRwJ9eyzYvYRlCSDUO/c7g4yNulxKC1WA==",
-      "license": "MIT",
-      "dependencies": {
-        "sparse-bitfield": "^3.0.3"
-      }
-    },
-    "node_modules/@types/webidl-conversions": {
-      "version": "7.0.3",
-      "resolved": "https://registry.npmmirror.com/@types/webidl-conversions/-/webidl-conversions-7.0.3.tgz",
-      "integrity": "sha512-CiJJvcRtIgzadHCYXw7dqEnMNRjhGZlYK05Mj9OyktqV8uVT8fD2BFOB7S1uwBE3Kj2Z+4UyPmFw/Ixgw/LAlA==",
-      "license": "MIT"
-    },
-    "node_modules/@types/whatwg-url": {
-      "version": "11.0.5",
-      "resolved": "https://registry.npmmirror.com/@types/whatwg-url/-/whatwg-url-11.0.5.tgz",
-      "integrity": "sha512-coYR071JRaHa+xoEvvYqvnIHaVqaYrLPbsufM9BF63HkwI5Lgmy2QR8Q5K/lYDYo5AK82wOvSOS0UsLTpTG7uQ==",
-      "license": "MIT",
-      "dependencies": {
-        "@types/webidl-conversions": "*"
-      }
-    },
-    "node_modules/accepts": {
-      "version": "2.0.0",
-      "resolved": "https://registry.npmmirror.com/accepts/-/accepts-2.0.0.tgz",
-      "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==",
-      "license": "MIT",
-      "dependencies": {
-        "mime-types": "^3.0.0",
-        "negotiator": "^1.0.0"
-      },
-      "engines": {
-        "node": ">= 0.6"
-      }
-    },
-    "node_modules/asynckit": {
-      "version": "0.4.0",
-      "resolved": "https://registry.npmmirror.com/asynckit/-/asynckit-0.4.0.tgz",
-      "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==",
-      "license": "MIT"
-    },
-    "node_modules/axios": {
-      "version": "1.8.4",
-      "resolved": "https://registry.npmmirror.com/axios/-/axios-1.8.4.tgz",
-      "integrity": "sha512-eBSYY4Y68NNlHbHBMdeDmKNtDgXWhQsJcGqzO3iLUM0GraQFSS9cVgPX5I9b3lbdFKyYoAEGAZF1DwhTaljNAw==",
-      "license": "MIT",
-      "dependencies": {
-        "follow-redirects": "^1.15.6",
-        "form-data": "^4.0.0",
-        "proxy-from-env": "^1.1.0"
-      }
-    },
-    "node_modules/bcryptjs": {
-      "version": "3.0.2",
-      "resolved": "https://registry.npmmirror.com/bcryptjs/-/bcryptjs-3.0.2.tgz",
-      "integrity": "sha512-k38b3XOZKv60C4E2hVsXTolJWfkGRMbILBIe2IBITXciy5bOsTKot5kDrf3ZfufQtQOUN5mXceUEpU1rTl9Uog==",
-      "license": "BSD-3-Clause",
-      "bin": {
-        "bcrypt": "bin/bcrypt"
-      }
-    },
-    "node_modules/body-parser": {
-      "version": "2.2.0",
-      "resolved": "https://registry.npmmirror.com/body-parser/-/body-parser-2.2.0.tgz",
-      "integrity": "sha512-02qvAaxv8tp7fBa/mw1ga98OGm+eCbqzJOKoRt70sLmfEEi+jyBYVTDGfCL/k06/4EMk/z01gCe7HoCH/f2LTg==",
-      "license": "MIT",
-      "dependencies": {
-        "bytes": "^3.1.2",
-        "content-type": "^1.0.5",
-        "debug": "^4.4.0",
-        "http-errors": "^2.0.0",
-        "iconv-lite": "^0.6.3",
-        "on-finished": "^2.4.1",
-        "qs": "^6.14.0",
-        "raw-body": "^3.0.0",
-        "type-is": "^2.0.0"
-      },
-      "engines": {
-        "node": ">=18"
-      }
-    },
-    "node_modules/bson": {
-      "version": "6.10.3",
-      "resolved": "https://registry.npmmirror.com/bson/-/bson-6.10.3.tgz",
-      "integrity": "sha512-MTxGsqgYTwfshYWTRdmZRC+M7FnG1b4y7RO7p2k3X24Wq0yv1m77Wsj0BzlPzd/IowgESfsruQCUToa7vbOpPQ==",
-      "license": "Apache-2.0",
-      "engines": {
-        "node": ">=16.20.1"
-      }
-    },
-    "node_modules/buffer-equal-constant-time": {
-      "version": "1.0.1",
-      "resolved": "https://registry.npmmirror.com/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz",
-      "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==",
-      "license": "BSD-3-Clause"
-    },
-    "node_modules/bytes": {
-      "version": "3.1.2",
-      "resolved": "https://registry.npmmirror.com/bytes/-/bytes-3.1.2.tgz",
-      "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==",
-      "license": "MIT",
-      "engines": {
-        "node": ">= 0.8"
-      }
-    },
-    "node_modules/call-bind-apply-helpers": {
-      "version": "1.0.2",
-      "resolved": "https://registry.npmmirror.com/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
-      "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
-      "license": "MIT",
-      "dependencies": {
-        "es-errors": "^1.3.0",
-        "function-bind": "^1.1.2"
-      },
-      "engines": {
-        "node": ">= 0.4"
-      }
-    },
-    "node_modules/call-bound": {
-      "version": "1.0.4",
-      "resolved": "https://registry.npmmirror.com/call-bound/-/call-bound-1.0.4.tgz",
-      "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==",
-      "license": "MIT",
-      "dependencies": {
-        "call-bind-apply-helpers": "^1.0.2",
-        "get-intrinsic": "^1.3.0"
-      },
-      "engines": {
-        "node": ">= 0.4"
-      },
-      "funding": {
-        "url": "https://github.com/sponsors/ljharb"
-      }
-    },
-    "node_modules/combined-stream": {
-      "version": "1.0.8",
-      "resolved": "https://registry.npmmirror.com/combined-stream/-/combined-stream-1.0.8.tgz",
-      "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==",
-      "license": "MIT",
-      "dependencies": {
-        "delayed-stream": "~1.0.0"
-      },
-      "engines": {
-        "node": ">= 0.8"
-      }
-    },
-    "node_modules/content-disposition": {
-      "version": "1.0.0",
-      "resolved": "https://registry.npmmirror.com/content-disposition/-/content-disposition-1.0.0.tgz",
-      "integrity": "sha512-Au9nRL8VNUut/XSzbQA38+M78dzP4D+eqg3gfJHMIHHYa3bg067xj1KxMUWj+VULbiZMowKngFFbKczUrNJ1mg==",
-      "license": "MIT",
-      "dependencies": {
-        "safe-buffer": "5.2.1"
-      },
-      "engines": {
-        "node": ">= 0.6"
-      }
-    },
-    "node_modules/content-type": {
-      "version": "1.0.5",
-      "resolved": "https://registry.npmmirror.com/content-type/-/content-type-1.0.5.tgz",
-      "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==",
-      "license": "MIT",
-      "engines": {
-        "node": ">= 0.6"
-      }
-    },
-    "node_modules/cookie": {
-      "version": "0.7.2",
-      "resolved": "https://registry.npmmirror.com/cookie/-/cookie-0.7.2.tgz",
-      "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==",
-      "license": "MIT",
-      "engines": {
-        "node": ">= 0.6"
-      }
-    },
-    "node_modules/cookie-parser": {
-      "version": "1.4.7",
-      "resolved": "https://registry.npmmirror.com/cookie-parser/-/cookie-parser-1.4.7.tgz",
-      "integrity": "sha512-nGUvgXnotP3BsjiLX2ypbQnWoGUPIIfHQNZkkC668ntrzGWEZVW70HDEB1qnNGMicPje6EttlIgzo51YSwNQGw==",
-      "license": "MIT",
-      "dependencies": {
-        "cookie": "0.7.2",
-        "cookie-signature": "1.0.6"
-      },
-      "engines": {
-        "node": ">= 0.8.0"
-      }
-    },
-    "node_modules/cookie-parser/node_modules/cookie-signature": {
-      "version": "1.0.6",
-      "resolved": "https://registry.npmmirror.com/cookie-signature/-/cookie-signature-1.0.6.tgz",
-      "integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==",
-      "license": "MIT"
-    },
-    "node_modules/cookie-signature": {
-      "version": "1.2.2",
-      "resolved": "https://registry.npmmirror.com/cookie-signature/-/cookie-signature-1.2.2.tgz",
-      "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==",
-      "license": "MIT",
-      "engines": {
-        "node": ">=6.6.0"
-      }
-    },
-    "node_modules/dayjs": {
-      "version": "1.11.13",
-      "resolved": "https://registry.npmmirror.com/dayjs/-/dayjs-1.11.13.tgz",
-      "integrity": "sha512-oaMBel6gjolK862uaPQOVTA7q3TZhuSvuMQAAglQDOWYO9A91IrAOUJEyKVlqJlHE0vq5p5UXxzdPfMH/x6xNg==",
-      "license": "MIT"
-    },
-    "node_modules/debug": {
-      "version": "4.4.0",
-      "resolved": "https://registry.npmmirror.com/debug/-/debug-4.4.0.tgz",
-      "integrity": "sha512-6WTZ/IxCY/T6BALoZHaE4ctp9xm+Z5kY/pzYaCHRFeyVhojxlrm+46y68HA6hr0TcwEssoxNiDEUJQjfPZ/RYA==",
-      "license": "MIT",
-      "dependencies": {
-        "ms": "^2.1.3"
-      },
-      "engines": {
-        "node": ">=6.0"
-      },
-      "peerDependenciesMeta": {
-        "supports-color": {
-          "optional": true
-        }
-      }
-    },
-    "node_modules/delayed-stream": {
-      "version": "1.0.0",
-      "resolved": "https://registry.npmmirror.com/delayed-stream/-/delayed-stream-1.0.0.tgz",
-      "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==",
-      "license": "MIT",
-      "engines": {
-        "node": ">=0.4.0"
-      }
-    },
-    "node_modules/depd": {
-      "version": "2.0.0",
-      "resolved": "https://registry.npmmirror.com/depd/-/depd-2.0.0.tgz",
-      "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==",
-      "license": "MIT",
-      "engines": {
-        "node": ">= 0.8"
-      }
-    },
-    "node_modules/dotenv": {
-      "version": "16.5.0",
-      "resolved": "https://registry.npmmirror.com/dotenv/-/dotenv-16.5.0.tgz",
-      "integrity": "sha512-m/C+AwOAr9/W1UOIZUo232ejMNnJAJtYQjUbHoNTBNTJSvqzzDh7vnrei3o3r3m9blf6ZoDkvcw0VmozNRFJxg==",
-      "license": "BSD-2-Clause",
-      "engines": {
-        "node": ">=12"
-      },
-      "funding": {
-        "url": "https://dotenvx.com"
-      }
-    },
-    "node_modules/dunder-proto": {
-      "version": "1.0.1",
-      "resolved": "https://registry.npmmirror.com/dunder-proto/-/dunder-proto-1.0.1.tgz",
-      "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
-      "license": "MIT",
-      "dependencies": {
-        "call-bind-apply-helpers": "^1.0.1",
-        "es-errors": "^1.3.0",
-        "gopd": "^1.2.0"
-      },
-      "engines": {
-        "node": ">= 0.4"
-      }
-    },
-    "node_modules/ecdsa-sig-formatter": {
-      "version": "1.0.11",
-      "resolved": "https://registry.npmmirror.com/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz",
-      "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==",
-      "license": "Apache-2.0",
-      "dependencies": {
-        "safe-buffer": "^5.0.1"
-      }
-    },
-    "node_modules/ee-first": {
-      "version": "1.1.1",
-      "resolved": "https://registry.npmmirror.com/ee-first/-/ee-first-1.1.1.tgz",
-      "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==",
-      "license": "MIT"
-    },
-    "node_modules/encodeurl": {
-      "version": "2.0.0",
-      "resolved": "https://registry.npmmirror.com/encodeurl/-/encodeurl-2.0.0.tgz",
-      "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==",
-      "license": "MIT",
-      "engines": {
-        "node": ">= 0.8"
-      }
-    },
-    "node_modules/es-define-property": {
-      "version": "1.0.1",
-      "resolved": "https://registry.npmmirror.com/es-define-property/-/es-define-property-1.0.1.tgz",
-      "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
-      "license": "MIT",
-      "engines": {
-        "node": ">= 0.4"
-      }
-    },
-    "node_modules/es-errors": {
-      "version": "1.3.0",
-      "resolved": "https://registry.npmmirror.com/es-errors/-/es-errors-1.3.0.tgz",
-      "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
-      "license": "MIT",
-      "engines": {
-        "node": ">= 0.4"
-      }
-    },
-    "node_modules/es-object-atoms": {
-      "version": "1.1.1",
-      "resolved": "https://registry.npmmirror.com/es-object-atoms/-/es-object-atoms-1.1.1.tgz",
-      "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==",
-      "license": "MIT",
-      "dependencies": {
-        "es-errors": "^1.3.0"
-      },
-      "engines": {
-        "node": ">= 0.4"
-      }
-    },
-    "node_modules/es-set-tostringtag": {
-      "version": "2.1.0",
-      "resolved": "https://registry.npmmirror.com/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz",
-      "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==",
-      "license": "MIT",
-      "dependencies": {
-        "es-errors": "^1.3.0",
-        "get-intrinsic": "^1.2.6",
-        "has-tostringtag": "^1.0.2",
-        "hasown": "^2.0.2"
-      },
-      "engines": {
-        "node": ">= 0.4"
-      }
-    },
-    "node_modules/escape-html": {
-      "version": "1.0.3",
-      "resolved": "https://registry.npmmirror.com/escape-html/-/escape-html-1.0.3.tgz",
-      "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==",
-      "license": "MIT"
-    },
-    "node_modules/etag": {
-      "version": "1.8.1",
-      "resolved": "https://registry.npmmirror.com/etag/-/etag-1.8.1.tgz",
-      "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==",
-      "license": "MIT",
-      "engines": {
-        "node": ">= 0.6"
-      }
-    },
-    "node_modules/express": {
-      "version": "5.1.0",
-      "resolved": "https://registry.npmmirror.com/express/-/express-5.1.0.tgz",
-      "integrity": "sha512-DT9ck5YIRU+8GYzzU5kT3eHGA5iL+1Zd0EutOmTE9Dtk+Tvuzd23VBU+ec7HPNSTxXYO55gPV/hq4pSBJDjFpA==",
-      "license": "MIT",
-      "dependencies": {
-        "accepts": "^2.0.0",
-        "body-parser": "^2.2.0",
-        "content-disposition": "^1.0.0",
-        "content-type": "^1.0.5",
-        "cookie": "^0.7.1",
-        "cookie-signature": "^1.2.1",
-        "debug": "^4.4.0",
-        "encodeurl": "^2.0.0",
-        "escape-html": "^1.0.3",
-        "etag": "^1.8.1",
-        "finalhandler": "^2.1.0",
-        "fresh": "^2.0.0",
-        "http-errors": "^2.0.0",
-        "merge-descriptors": "^2.0.0",
-        "mime-types": "^3.0.0",
-        "on-finished": "^2.4.1",
-        "once": "^1.4.0",
-        "parseurl": "^1.3.3",
-        "proxy-addr": "^2.0.7",
-        "qs": "^6.14.0",
-        "range-parser": "^1.2.1",
-        "router": "^2.2.0",
-        "send": "^1.1.0",
-        "serve-static": "^2.2.0",
-        "statuses": "^2.0.1",
-        "type-is": "^2.0.1",
-        "vary": "^1.1.2"
-      },
-      "engines": {
-        "node": ">= 18"
-      },
-      "funding": {
-        "type": "opencollective",
-        "url": "https://opencollective.com/express"
-      }
-    },
-    "node_modules/finalhandler": {
-      "version": "2.1.0",
-      "resolved": "https://registry.npmmirror.com/finalhandler/-/finalhandler-2.1.0.tgz",
-      "integrity": "sha512-/t88Ty3d5JWQbWYgaOGCCYfXRwV1+be02WqYYlL6h0lEiUAMPM8o8qKGO01YIkOHzka2up08wvgYD0mDiI+q3Q==",
-      "license": "MIT",
-      "dependencies": {
-        "debug": "^4.4.0",
-        "encodeurl": "^2.0.0",
-        "escape-html": "^1.0.3",
-        "on-finished": "^2.4.1",
-        "parseurl": "^1.3.3",
-        "statuses": "^2.0.1"
-      },
-      "engines": {
-        "node": ">= 0.8"
-      }
-    },
-    "node_modules/follow-redirects": {
-      "version": "1.15.9",
-      "resolved": "https://registry.npmmirror.com/follow-redirects/-/follow-redirects-1.15.9.tgz",
-      "integrity": "sha512-gew4GsXizNgdoRyqmyfMHyAmXsZDk6mHkSxZFCzW9gwlbtOW44CDtYavM+y+72qD/Vq2l550kMF52DT8fOLJqQ==",
-      "funding": [
-        {
-          "type": "individual",
-          "url": "https://github.com/sponsors/RubenVerborgh"
-        }
-      ],
-      "license": "MIT",
-      "engines": {
-        "node": ">=4.0"
-      },
-      "peerDependenciesMeta": {
-        "debug": {
-          "optional": true
-        }
-      }
-    },
-    "node_modules/form-data": {
-      "version": "4.0.2",
-      "resolved": "https://registry.npmmirror.com/form-data/-/form-data-4.0.2.tgz",
-      "integrity": "sha512-hGfm/slu0ZabnNt4oaRZ6uREyfCj6P4fT/n6A1rGV+Z0VdGXjfOhVUpkn6qVQONHGIFwmveGXyDs75+nr6FM8w==",
-      "license": "MIT",
-      "dependencies": {
-        "asynckit": "^0.4.0",
-        "combined-stream": "^1.0.8",
-        "es-set-tostringtag": "^2.1.0",
-        "mime-types": "^2.1.12"
-      },
-      "engines": {
-        "node": ">= 6"
-      }
-    },
-    "node_modules/form-data/node_modules/mime-db": {
-      "version": "1.52.0",
-      "resolved": "https://registry.npmmirror.com/mime-db/-/mime-db-1.52.0.tgz",
-      "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
-      "license": "MIT",
-      "engines": {
-        "node": ">= 0.6"
-      }
-    },
-    "node_modules/form-data/node_modules/mime-types": {
-      "version": "2.1.35",
-      "resolved": "https://registry.npmmirror.com/mime-types/-/mime-types-2.1.35.tgz",
-      "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
-      "license": "MIT",
-      "dependencies": {
-        "mime-db": "1.52.0"
-      },
-      "engines": {
-        "node": ">= 0.6"
-      }
-    },
-    "node_modules/forwarded": {
-      "version": "0.2.0",
-      "resolved": "https://registry.npmmirror.com/forwarded/-/forwarded-0.2.0.tgz",
-      "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==",
-      "license": "MIT",
-      "engines": {
-        "node": ">= 0.6"
-      }
-    },
-    "node_modules/fresh": {
-      "version": "2.0.0",
-      "resolved": "https://registry.npmmirror.com/fresh/-/fresh-2.0.0.tgz",
-      "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==",
-      "license": "MIT",
-      "engines": {
-        "node": ">= 0.8"
-      }
-    },
-    "node_modules/function-bind": {
-      "version": "1.1.2",
-      "resolved": "https://registry.npmmirror.com/function-bind/-/function-bind-1.1.2.tgz",
-      "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
-      "license": "MIT",
-      "funding": {
-        "url": "https://github.com/sponsors/ljharb"
-      }
-    },
-    "node_modules/get-intrinsic": {
-      "version": "1.3.0",
-      "resolved": "https://registry.npmmirror.com/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
-      "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
-      "license": "MIT",
-      "dependencies": {
-        "call-bind-apply-helpers": "^1.0.2",
-        "es-define-property": "^1.0.1",
-        "es-errors": "^1.3.0",
-        "es-object-atoms": "^1.1.1",
-        "function-bind": "^1.1.2",
-        "get-proto": "^1.0.1",
-        "gopd": "^1.2.0",
-        "has-symbols": "^1.1.0",
-        "hasown": "^2.0.2",
-        "math-intrinsics": "^1.1.0"
-      },
-      "engines": {
-        "node": ">= 0.4"
-      },
-      "funding": {
-        "url": "https://github.com/sponsors/ljharb"
-      }
-    },
-    "node_modules/get-proto": {
-      "version": "1.0.1",
-      "resolved": "https://registry.npmmirror.com/get-proto/-/get-proto-1.0.1.tgz",
-      "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
-      "license": "MIT",
-      "dependencies": {
-        "dunder-proto": "^1.0.1",
-        "es-object-atoms": "^1.0.0"
-      },
-      "engines": {
-        "node": ">= 0.4"
-      }
-    },
-    "node_modules/gopd": {
-      "version": "1.2.0",
-      "resolved": "https://registry.npmmirror.com/gopd/-/gopd-1.2.0.tgz",
-      "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
-      "license": "MIT",
-      "engines": {
-        "node": ">= 0.4"
-      },
-      "funding": {
-        "url": "https://github.com/sponsors/ljharb"
-      }
-    },
-    "node_modules/has-symbols": {
-      "version": "1.1.0",
-      "resolved": "https://registry.npmmirror.com/has-symbols/-/has-symbols-1.1.0.tgz",
-      "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
-      "license": "MIT",
-      "engines": {
-        "node": ">= 0.4"
-      },
-      "funding": {
-        "url": "https://github.com/sponsors/ljharb"
-      }
-    },
-    "node_modules/has-tostringtag": {
-      "version": "1.0.2",
-      "resolved": "https://registry.npmmirror.com/has-tostringtag/-/has-tostringtag-1.0.2.tgz",
-      "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==",
-      "license": "MIT",
-      "dependencies": {
-        "has-symbols": "^1.0.3"
-      },
-      "engines": {
-        "node": ">= 0.4"
-      },
-      "funding": {
-        "url": "https://github.com/sponsors/ljharb"
-      }
-    },
-    "node_modules/hasown": {
-      "version": "2.0.2",
-      "resolved": "https://registry.npmmirror.com/hasown/-/hasown-2.0.2.tgz",
-      "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==",
-      "license": "MIT",
-      "dependencies": {
-        "function-bind": "^1.1.2"
-      },
-      "engines": {
-        "node": ">= 0.4"
-      }
-    },
-    "node_modules/http-errors": {
-      "version": "2.0.0",
-      "resolved": "https://registry.npmmirror.com/http-errors/-/http-errors-2.0.0.tgz",
-      "integrity": "sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ==",
-      "license": "MIT",
-      "dependencies": {
-        "depd": "2.0.0",
-        "inherits": "2.0.4",
-        "setprototypeof": "1.2.0",
-        "statuses": "2.0.1",
-        "toidentifier": "1.0.1"
-      },
-      "engines": {
-        "node": ">= 0.8"
-      }
-    },
-    "node_modules/iconv-lite": {
-      "version": "0.6.3",
-      "resolved": "https://registry.npmmirror.com/iconv-lite/-/iconv-lite-0.6.3.tgz",
-      "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==",
-      "license": "MIT",
-      "dependencies": {
-        "safer-buffer": ">= 2.1.2 < 3.0.0"
-      },
-      "engines": {
-        "node": ">=0.10.0"
-      }
-    },
-    "node_modules/inherits": {
-      "version": "2.0.4",
-      "resolved": "https://registry.npmmirror.com/inherits/-/inherits-2.0.4.tgz",
-      "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
-      "license": "ISC"
-    },
-    "node_modules/ipaddr.js": {
-      "version": "1.9.1",
-      "resolved": "https://registry.npmmirror.com/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
-      "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==",
-      "license": "MIT",
-      "engines": {
-        "node": ">= 0.10"
-      }
-    },
-    "node_modules/is-promise": {
-      "version": "4.0.0",
-      "resolved": "https://registry.npmmirror.com/is-promise/-/is-promise-4.0.0.tgz",
-      "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==",
-      "license": "MIT"
-    },
-    "node_modules/jsonwebtoken": {
-      "version": "9.0.2",
-      "resolved": "https://registry.npmmirror.com/jsonwebtoken/-/jsonwebtoken-9.0.2.tgz",
-      "integrity": "sha512-PRp66vJ865SSqOlgqS8hujT5U4AOgMfhrwYIuIhfKaoSCZcirrmASQr8CX7cUg+RMih+hgznrjp99o+W4pJLHQ==",
-      "license": "MIT",
-      "dependencies": {
-        "jws": "^3.2.2",
-        "lodash.includes": "^4.3.0",
-        "lodash.isboolean": "^3.0.3",
-        "lodash.isinteger": "^4.0.4",
-        "lodash.isnumber": "^3.0.3",
-        "lodash.isplainobject": "^4.0.6",
-        "lodash.isstring": "^4.0.1",
-        "lodash.once": "^4.0.0",
-        "ms": "^2.1.1",
-        "semver": "^7.5.4"
-      },
-      "engines": {
-        "node": ">=12",
-        "npm": ">=6"
-      }
-    },
-    "node_modules/jwa": {
-      "version": "1.4.1",
-      "resolved": "https://registry.npmmirror.com/jwa/-/jwa-1.4.1.tgz",
-      "integrity": "sha512-qiLX/xhEEFKUAJ6FiBMbes3w9ATzyk5W7Hvzpa/SLYdxNtng+gcurvrI7TbACjIXlsJyr05/S1oUhZrc63evQA==",
-      "license": "MIT",
-      "dependencies": {
-        "buffer-equal-constant-time": "1.0.1",
-        "ecdsa-sig-formatter": "1.0.11",
-        "safe-buffer": "^5.0.1"
-      }
-    },
-    "node_modules/jws": {
-      "version": "3.2.2",
-      "resolved": "https://registry.npmmirror.com/jws/-/jws-3.2.2.tgz",
-      "integrity": "sha512-YHlZCB6lMTllWDtSPHz/ZXTsi8S00usEV6v1tjq8tOUZzw7DpSDWVXjXDre6ed1w/pd495ODpHZYSdkRTsa0HA==",
-      "license": "MIT",
-      "dependencies": {
-        "jwa": "^1.4.1",
-        "safe-buffer": "^5.0.1"
-      }
-    },
-    "node_modules/kareem": {
-      "version": "2.6.3",
-      "resolved": "https://registry.npmmirror.com/kareem/-/kareem-2.6.3.tgz",
-      "integrity": "sha512-C3iHfuGUXK2u8/ipq9LfjFfXFxAZMQJJq7vLS45r3D9Y2xQ/m4S8zaR4zMLFWh9AsNPXmcFfUDhTEO8UIC/V6Q==",
-      "license": "Apache-2.0",
-      "engines": {
-        "node": ">=12.0.0"
-      }
-    },
-    "node_modules/lodash.includes": {
-      "version": "4.3.0",
-      "resolved": "https://registry.npmmirror.com/lodash.includes/-/lodash.includes-4.3.0.tgz",
-      "integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==",
-      "license": "MIT"
-    },
-    "node_modules/lodash.isboolean": {
-      "version": "3.0.3",
-      "resolved": "https://registry.npmmirror.com/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz",
-      "integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==",
-      "license": "MIT"
-    },
-    "node_modules/lodash.isinteger": {
-      "version": "4.0.4",
-      "resolved": "https://registry.npmmirror.com/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz",
-      "integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==",
-      "license": "MIT"
-    },
-    "node_modules/lodash.isnumber": {
-      "version": "3.0.3",
-      "resolved": "https://registry.npmmirror.com/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz",
-      "integrity": "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==",
-      "license": "MIT"
-    },
-    "node_modules/lodash.isplainobject": {
-      "version": "4.0.6",
-      "resolved": "https://registry.npmmirror.com/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz",
-      "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==",
-      "license": "MIT"
-    },
-    "node_modules/lodash.isstring": {
-      "version": "4.0.1",
-      "resolved": "https://registry.npmmirror.com/lodash.isstring/-/lodash.isstring-4.0.1.tgz",
-      "integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==",
-      "license": "MIT"
-    },
-    "node_modules/lodash.once": {
-      "version": "4.1.1",
-      "resolved": "https://registry.npmmirror.com/lodash.once/-/lodash.once-4.1.1.tgz",
-      "integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==",
-      "license": "MIT"
-    },
-    "node_modules/math-intrinsics": {
-      "version": "1.1.0",
-      "resolved": "https://registry.npmmirror.com/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
-      "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
-      "license": "MIT",
-      "engines": {
-        "node": ">= 0.4"
-      }
-    },
-    "node_modules/media-typer": {
-      "version": "1.1.0",
-      "resolved": "https://registry.npmmirror.com/media-typer/-/media-typer-1.1.0.tgz",
-      "integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==",
-      "license": "MIT",
-      "engines": {
-        "node": ">= 0.8"
-      }
-    },
-    "node_modules/memory-pager": {
-      "version": "1.5.0",
-      "resolved": "https://registry.npmmirror.com/memory-pager/-/memory-pager-1.5.0.tgz",
-      "integrity": "sha512-ZS4Bp4r/Zoeq6+NLJpP+0Zzm0pR8whtGPf1XExKLJBAczGMnSi3It14OiNCStjQjM6NU1okjQGSxgEZN8eBYKg==",
-      "license": "MIT"
-    },
-    "node_modules/merge-descriptors": {
-      "version": "2.0.0",
-      "resolved": "https://registry.npmmirror.com/merge-descriptors/-/merge-descriptors-2.0.0.tgz",
-      "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==",
-      "license": "MIT",
-      "engines": {
-        "node": ">=18"
-      },
-      "funding": {
-        "url": "https://github.com/sponsors/sindresorhus"
-      }
-    },
-    "node_modules/mime-db": {
-      "version": "1.54.0",
-      "resolved": "https://registry.npmmirror.com/mime-db/-/mime-db-1.54.0.tgz",
-      "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==",
-      "license": "MIT",
-      "engines": {
-        "node": ">= 0.6"
-      }
-    },
-    "node_modules/mime-types": {
-      "version": "3.0.1",
-      "resolved": "https://registry.npmmirror.com/mime-types/-/mime-types-3.0.1.tgz",
-      "integrity": "sha512-xRc4oEhT6eaBpU1XF7AjpOFD+xQmXNB5OVKwp4tqCuBpHLS/ZbBDrc07mYTDqVMg6PfxUjjNp85O6Cd2Z/5HWA==",
-      "license": "MIT",
-      "dependencies": {
-        "mime-db": "^1.54.0"
-      },
-      "engines": {
-        "node": ">= 0.6"
-      }
-    },
-    "node_modules/mongodb": {
-      "version": "6.15.0",
-      "resolved": "https://registry.npmmirror.com/mongodb/-/mongodb-6.15.0.tgz",
-      "integrity": "sha512-ifBhQ0rRzHDzqp9jAQP6OwHSH7dbYIQjD3SbJs9YYk9AikKEettW/9s/tbSFDTpXcRbF+u1aLrhHxDFaYtZpFQ==",
-      "license": "Apache-2.0",
-      "dependencies": {
-        "@mongodb-js/saslprep": "^1.1.9",
-        "bson": "^6.10.3",
-        "mongodb-connection-string-url": "^3.0.0"
-      },
-      "engines": {
-        "node": ">=16.20.1"
-      },
-      "peerDependencies": {
-        "@aws-sdk/credential-providers": "^3.188.0",
-        "@mongodb-js/zstd": "^1.1.0 || ^2.0.0",
-        "gcp-metadata": "^5.2.0",
-        "kerberos": "^2.0.1",
-        "mongodb-client-encryption": ">=6.0.0 <7",
-        "snappy": "^7.2.2",
-        "socks": "^2.7.1"
-      },
-      "peerDependenciesMeta": {
-        "@aws-sdk/credential-providers": {
-          "optional": true
-        },
-        "@mongodb-js/zstd": {
-          "optional": true
-        },
-        "gcp-metadata": {
-          "optional": true
-        },
-        "kerberos": {
-          "optional": true
-        },
-        "mongodb-client-encryption": {
-          "optional": true
-        },
-        "snappy": {
-          "optional": true
-        },
-        "socks": {
-          "optional": true
-        }
-      }
-    },
-    "node_modules/mongodb-connection-string-url": {
-      "version": "3.0.2",
-      "resolved": "https://registry.npmmirror.com/mongodb-connection-string-url/-/mongodb-connection-string-url-3.0.2.tgz",
-      "integrity": "sha512-rMO7CGo/9BFwyZABcKAWL8UJwH/Kc2x0g72uhDWzG48URRax5TCIcJ7Rc3RZqffZzO/Gwff/jyKwCU9TN8gehA==",
-      "license": "Apache-2.0",
-      "dependencies": {
-        "@types/whatwg-url": "^11.0.2",
-        "whatwg-url": "^14.1.0 || ^13.0.0"
-      }
-    },
-    "node_modules/mongoose": {
-      "version": "8.13.2",
-      "resolved": "https://registry.npmmirror.com/mongoose/-/mongoose-8.13.2.tgz",
-      "integrity": "sha512-riCBqZmNkYBWjXpM3qWLDQw7QmTKsVZDPhLXFJqC87+OjocEVpvS3dA2BPPUiLAu+m0/QmEj5pSXKhH+/DgerQ==",
-      "license": "MIT",
-      "dependencies": {
-        "bson": "^6.10.3",
-        "kareem": "2.6.3",
-        "mongodb": "~6.15.0",
-        "mpath": "0.9.0",
-        "mquery": "5.0.0",
-        "ms": "2.1.3",
-        "sift": "17.1.3"
-      },
-      "engines": {
-        "node": ">=16.20.1"
-      },
-      "funding": {
-        "type": "opencollective",
-        "url": "https://opencollective.com/mongoose"
-      }
-    },
-    "node_modules/mpath": {
-      "version": "0.9.0",
-      "resolved": "https://registry.npmmirror.com/mpath/-/mpath-0.9.0.tgz",
-      "integrity": "sha512-ikJRQTk8hw5DEoFVxHG1Gn9T/xcjtdnOKIU1JTmGjZZlg9LST2mBLmcX3/ICIbgJydT2GOc15RnNy5mHmzfSew==",
-      "license": "MIT",
-      "engines": {
-        "node": ">=4.0.0"
-      }
-    },
-    "node_modules/mquery": {
-      "version": "5.0.0",
-      "resolved": "https://registry.npmmirror.com/mquery/-/mquery-5.0.0.tgz",
-      "integrity": "sha512-iQMncpmEK8R8ncT8HJGsGc9Dsp8xcgYMVSbs5jgnm1lFHTZqMJTUWTDx1LBO8+mK3tPNZWFLBghQEIOULSTHZg==",
-      "license": "MIT",
-      "dependencies": {
-        "debug": "4.x"
-      },
-      "engines": {
-        "node": ">=14.0.0"
-      }
-    },
-    "node_modules/ms": {
-      "version": "2.1.3",
-      "resolved": "https://registry.npmmirror.com/ms/-/ms-2.1.3.tgz",
-      "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
-      "license": "MIT"
-    },
-    "node_modules/negotiator": {
-      "version": "1.0.0",
-      "resolved": "https://registry.npmmirror.com/negotiator/-/negotiator-1.0.0.tgz",
-      "integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==",
-      "license": "MIT",
-      "engines": {
-        "node": ">= 0.6"
-      }
-    },
-    "node_modules/object-inspect": {
-      "version": "1.13.4",
-      "resolved": "https://registry.npmmirror.com/object-inspect/-/object-inspect-1.13.4.tgz",
-      "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==",
-      "license": "MIT",
-      "engines": {
-        "node": ">= 0.4"
-      },
-      "funding": {
-        "url": "https://github.com/sponsors/ljharb"
-      }
-    },
-    "node_modules/on-finished": {
-      "version": "2.4.1",
-      "resolved": "https://registry.npmmirror.com/on-finished/-/on-finished-2.4.1.tgz",
-      "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==",
-      "license": "MIT",
-      "dependencies": {
-        "ee-first": "1.1.1"
-      },
-      "engines": {
-        "node": ">= 0.8"
-      }
-    },
-    "node_modules/once": {
-      "version": "1.4.0",
-      "resolved": "https://registry.npmmirror.com/once/-/once-1.4.0.tgz",
-      "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==",
-      "license": "ISC",
-      "dependencies": {
-        "wrappy": "1"
-      }
-    },
-    "node_modules/parseurl": {
-      "version": "1.3.3",
-      "resolved": "https://registry.npmmirror.com/parseurl/-/parseurl-1.3.3.tgz",
-      "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==",
-      "license": "MIT",
-      "engines": {
-        "node": ">= 0.8"
-      }
-    },
-    "node_modules/path-to-regexp": {
-      "version": "8.2.0",
-      "resolved": "https://registry.npmmirror.com/path-to-regexp/-/path-to-regexp-8.2.0.tgz",
-      "integrity": "sha512-TdrF7fW9Rphjq4RjrW0Kp2AW0Ahwu9sRGTkS6bvDi0SCwZlEZYmcfDbEsTz8RVk0EHIS/Vd1bv3JhG+1xZuAyQ==",
-      "license": "MIT",
-      "engines": {
-        "node": ">=16"
-      }
-    },
-    "node_modules/proxy-addr": {
-      "version": "2.0.7",
-      "resolved": "https://registry.npmmirror.com/proxy-addr/-/proxy-addr-2.0.7.tgz",
-      "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==",
-      "license": "MIT",
-      "dependencies": {
-        "forwarded": "0.2.0",
-        "ipaddr.js": "1.9.1"
-      },
-      "engines": {
-        "node": ">= 0.10"
-      }
-    },
-    "node_modules/proxy-from-env": {
-      "version": "1.1.0",
-      "resolved": "https://registry.npmmirror.com/proxy-from-env/-/proxy-from-env-1.1.0.tgz",
-      "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==",
-      "license": "MIT"
-    },
-    "node_modules/punycode": {
-      "version": "2.3.1",
-      "resolved": "https://registry.npmmirror.com/punycode/-/punycode-2.3.1.tgz",
-      "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==",
-      "license": "MIT",
-      "engines": {
-        "node": ">=6"
-      }
-    },
-    "node_modules/qs": {
-      "version": "6.14.0",
-      "resolved": "https://registry.npmmirror.com/qs/-/qs-6.14.0.tgz",
-      "integrity": "sha512-YWWTjgABSKcvs/nWBi9PycY/JiPJqOD4JA6o9Sej2AtvSGarXxKC3OQSk4pAarbdQlKAh5D4FCQkJNkW+GAn3w==",
-      "license": "BSD-3-Clause",
-      "dependencies": {
-        "side-channel": "^1.1.0"
-      },
-      "engines": {
-        "node": ">=0.6"
-      },
-      "funding": {
-        "url": "https://github.com/sponsors/ljharb"
-      }
-    },
-    "node_modules/range-parser": {
-      "version": "1.2.1",
-      "resolved": "https://registry.npmmirror.com/range-parser/-/range-parser-1.2.1.tgz",
-      "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==",
-      "license": "MIT",
-      "engines": {
-        "node": ">= 0.6"
-      }
-    },
-    "node_modules/raw-body": {
-      "version": "3.0.0",
-      "resolved": "https://registry.npmmirror.com/raw-body/-/raw-body-3.0.0.tgz",
-      "integrity": "sha512-RmkhL8CAyCRPXCE28MMH0z2PNWQBNk2Q09ZdxM9IOOXwxwZbN+qbWaatPkdkWIKL2ZVDImrN/pK5HTRz2PcS4g==",
-      "license": "MIT",
-      "dependencies": {
-        "bytes": "3.1.2",
-        "http-errors": "2.0.0",
-        "iconv-lite": "0.6.3",
-        "unpipe": "1.0.0"
-      },
-      "engines": {
-        "node": ">= 0.8"
-      }
-    },
-    "node_modules/router": {
-      "version": "2.2.0",
-      "resolved": "https://registry.npmmirror.com/router/-/router-2.2.0.tgz",
-      "integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==",
-      "license": "MIT",
-      "dependencies": {
-        "debug": "^4.4.0",
-        "depd": "^2.0.0",
-        "is-promise": "^4.0.0",
-        "parseurl": "^1.3.3",
-        "path-to-regexp": "^8.0.0"
-      },
-      "engines": {
-        "node": ">= 18"
-      }
-    },
-    "node_modules/safe-buffer": {
-      "version": "5.2.1",
-      "resolved": "https://registry.npmmirror.com/safe-buffer/-/safe-buffer-5.2.1.tgz",
-      "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
-      "funding": [
-        {
-          "type": "github",
-          "url": "https://github.com/sponsors/feross"
-        },
-        {
-          "type": "patreon",
-          "url": "https://www.patreon.com/feross"
-        },
-        {
-          "type": "consulting",
-          "url": "https://feross.org/support"
-        }
-      ],
-      "license": "MIT"
-    },
-    "node_modules/safer-buffer": {
-      "version": "2.1.2",
-      "resolved": "https://registry.npmmirror.com/safer-buffer/-/safer-buffer-2.1.2.tgz",
-      "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
-      "license": "MIT"
-    },
-    "node_modules/semver": {
-      "version": "7.7.1",
-      "resolved": "https://registry.npmmirror.com/semver/-/semver-7.7.1.tgz",
-      "integrity": "sha512-hlq8tAfn0m/61p4BVRcPzIGr6LKiMwo4VM6dGi6pt4qcRkmNzTcWq6eCEjEh+qXjkMDvPlOFFSGwQjoEa6gyMA==",
-      "license": "ISC",
-      "bin": {
-        "semver": "bin/semver.js"
-      },
-      "engines": {
-        "node": ">=10"
-      }
-    },
-    "node_modules/send": {
-      "version": "1.2.0",
-      "resolved": "https://registry.npmmirror.com/send/-/send-1.2.0.tgz",
-      "integrity": "sha512-uaW0WwXKpL9blXE2o0bRhoL2EGXIrZxQ2ZQ4mgcfoBxdFmQold+qWsD2jLrfZ0trjKL6vOw0j//eAwcALFjKSw==",
-      "license": "MIT",
-      "dependencies": {
-        "debug": "^4.3.5",
-        "encodeurl": "^2.0.0",
-        "escape-html": "^1.0.3",
-        "etag": "^1.8.1",
-        "fresh": "^2.0.0",
-        "http-errors": "^2.0.0",
-        "mime-types": "^3.0.1",
-        "ms": "^2.1.3",
-        "on-finished": "^2.4.1",
-        "range-parser": "^1.2.1",
-        "statuses": "^2.0.1"
-      },
-      "engines": {
-        "node": ">= 18"
-      }
-    },
-    "node_modules/serve-static": {
-      "version": "2.2.0",
-      "resolved": "https://registry.npmmirror.com/serve-static/-/serve-static-2.2.0.tgz",
-      "integrity": "sha512-61g9pCh0Vnh7IutZjtLGGpTA355+OPn2TyDv/6ivP2h/AdAVX9azsoxmg2/M6nZeQZNYBEwIcsne1mJd9oQItQ==",
-      "license": "MIT",
-      "dependencies": {
-        "encodeurl": "^2.0.0",
-        "escape-html": "^1.0.3",
-        "parseurl": "^1.3.3",
-        "send": "^1.2.0"
-      },
-      "engines": {
-        "node": ">= 18"
-      }
-    },
-    "node_modules/setprototypeof": {
-      "version": "1.2.0",
-      "resolved": "https://registry.npmmirror.com/setprototypeof/-/setprototypeof-1.2.0.tgz",
-      "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==",
-      "license": "ISC"
-    },
-    "node_modules/side-channel": {
-      "version": "1.1.0",
-      "resolved": "https://registry.npmmirror.com/side-channel/-/side-channel-1.1.0.tgz",
-      "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==",
-      "license": "MIT",
-      "dependencies": {
-        "es-errors": "^1.3.0",
-        "object-inspect": "^1.13.3",
-        "side-channel-list": "^1.0.0",
-        "side-channel-map": "^1.0.1",
-        "side-channel-weakmap": "^1.0.2"
-      },
-      "engines": {
-        "node": ">= 0.4"
-      },
-      "funding": {
-        "url": "https://github.com/sponsors/ljharb"
-      }
-    },
-    "node_modules/side-channel-list": {
-      "version": "1.0.0",
-      "resolved": "https://registry.npmmirror.com/side-channel-list/-/side-channel-list-1.0.0.tgz",
-      "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==",
-      "license": "MIT",
-      "dependencies": {
-        "es-errors": "^1.3.0",
-        "object-inspect": "^1.13.3"
-      },
-      "engines": {
-        "node": ">= 0.4"
-      },
-      "funding": {
-        "url": "https://github.com/sponsors/ljharb"
-      }
-    },
-    "node_modules/side-channel-map": {
-      "version": "1.0.1",
-      "resolved": "https://registry.npmmirror.com/side-channel-map/-/side-channel-map-1.0.1.tgz",
-      "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==",
-      "license": "MIT",
-      "dependencies": {
-        "call-bound": "^1.0.2",
-        "es-errors": "^1.3.0",
-        "get-intrinsic": "^1.2.5",
-        "object-inspect": "^1.13.3"
-      },
-      "engines": {
-        "node": ">= 0.4"
-      },
-      "funding": {
-        "url": "https://github.com/sponsors/ljharb"
-      }
-    },
-    "node_modules/side-channel-weakmap": {
-      "version": "1.0.2",
-      "resolved": "https://registry.npmmirror.com/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz",
-      "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==",
-      "license": "MIT",
-      "dependencies": {
-        "call-bound": "^1.0.2",
-        "es-errors": "^1.3.0",
-        "get-intrinsic": "^1.2.5",
-        "object-inspect": "^1.13.3",
-        "side-channel-map": "^1.0.1"
-      },
-      "engines": {
-        "node": ">= 0.4"
-      },
-      "funding": {
-        "url": "https://github.com/sponsors/ljharb"
-      }
-    },
-    "node_modules/sift": {
-      "version": "17.1.3",
-      "resolved": "https://registry.npmmirror.com/sift/-/sift-17.1.3.tgz",
-      "integrity": "sha512-Rtlj66/b0ICeFzYTuNvX/EF1igRbbnGSvEyT79McoZa/DeGhMyC5pWKOEsZKnpkqtSeovd5FL/bjHWC3CIIvCQ==",
-      "license": "MIT"
-    },
-    "node_modules/sparse-bitfield": {
-      "version": "3.0.3",
-      "resolved": "https://registry.npmmirror.com/sparse-bitfield/-/sparse-bitfield-3.0.3.tgz",
-      "integrity": "sha512-kvzhi7vqKTfkh0PZU+2D2PIllw2ymqJKujUcyPMd9Y75Nv4nPbGJZXNhxsgdQab2BmlDct1YnfQCguEvHr7VsQ==",
-      "license": "MIT",
-      "dependencies": {
-        "memory-pager": "^1.0.2"
-      }
-    },
-    "node_modules/statuses": {
-      "version": "2.0.1",
-      "resolved": "https://registry.npmmirror.com/statuses/-/statuses-2.0.1.tgz",
-      "integrity": "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==",
-      "license": "MIT",
-      "engines": {
-        "node": ">= 0.8"
-      }
-    },
-    "node_modules/toidentifier": {
-      "version": "1.0.1",
-      "resolved": "https://registry.npmmirror.com/toidentifier/-/toidentifier-1.0.1.tgz",
-      "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==",
-      "license": "MIT",
-      "engines": {
-        "node": ">=0.6"
-      }
-    },
-    "node_modules/tr46": {
-      "version": "5.1.1",
-      "resolved": "https://registry.npmmirror.com/tr46/-/tr46-5.1.1.tgz",
-      "integrity": "sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==",
-      "license": "MIT",
-      "dependencies": {
-        "punycode": "^2.3.1"
-      },
-      "engines": {
-        "node": ">=18"
-      }
-    },
-    "node_modules/type-is": {
-      "version": "2.0.1",
-      "resolved": "https://registry.npmmirror.com/type-is/-/type-is-2.0.1.tgz",
-      "integrity": "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==",
-      "license": "MIT",
-      "dependencies": {
-        "content-type": "^1.0.5",
-        "media-typer": "^1.1.0",
-        "mime-types": "^3.0.0"
-      },
-      "engines": {
-        "node": ">= 0.6"
-      }
-    },
-    "node_modules/unpipe": {
-      "version": "1.0.0",
-      "resolved": "https://registry.npmmirror.com/unpipe/-/unpipe-1.0.0.tgz",
-      "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==",
-      "license": "MIT",
-      "engines": {
-        "node": ">= 0.8"
-      }
-    },
-    "node_modules/vary": {
-      "version": "1.1.2",
-      "resolved": "https://registry.npmmirror.com/vary/-/vary-1.1.2.tgz",
-      "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==",
-      "license": "MIT",
-      "engines": {
-        "node": ">= 0.8"
-      }
-    },
-    "node_modules/webidl-conversions": {
-      "version": "7.0.0",
-      "resolved": "https://registry.npmmirror.com/webidl-conversions/-/webidl-conversions-7.0.0.tgz",
-      "integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==",
-      "license": "BSD-2-Clause",
-      "engines": {
-        "node": ">=12"
-      }
-    },
-    "node_modules/whatwg-url": {
-      "version": "14.2.0",
-      "resolved": "https://registry.npmmirror.com/whatwg-url/-/whatwg-url-14.2.0.tgz",
-      "integrity": "sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==",
-      "license": "MIT",
-      "dependencies": {
-        "tr46": "^5.1.0",
-        "webidl-conversions": "^7.0.0"
-      },
-      "engines": {
-        "node": ">=18"
-      }
-    },
-    "node_modules/wrappy": {
-      "version": "1.0.2",
-      "resolved": "https://registry.npmmirror.com/wrappy/-/wrappy-1.0.2.tgz",
-      "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
-      "license": "ISC"
-    }
-  }
-}

+ 9 - 3
server/package.json

@@ -1,11 +1,17 @@
 {
   "name": "sporttery-server",
-  "version": "1.0.0",
+  "version": "1.2.2",
   "main": "server.js",
   "scripts": {
-    "dev": "nodemon --inspect server.js",
+    "dev": "nodemon --ignore data/ --ignore node_modules/ --inspect server.js",
+    "dev:pannel": "nodemon --ignore data/ --ignore node_modules/ --inspect=9231 pannel.js",
+    "dev:sporttery": "pm2 start server.js --node-args='--inspect' --name sporttery --watch --ignore-watch 'data node_modules'",
     "start": "pm2 start server.js --name sporttery",
-    "init": "node init.js"
+    "start:pannel": "pm2 start pannel.js --name pannel",
+    "init": "node init.js",
+    "patch": "npm version patch --no-git-tag-version",
+    "minor": "npm version minor --no-git-tag-version",
+    "major": "npm version major --no-git-tag-version"
   },
   "keywords": [],
   "author": "",

+ 54 - 0
server/pannel.js

@@ -0,0 +1,54 @@
+const express = require('express');
+const dotenv = require('dotenv');
+const Logs = require('./libs/logs');
+
+const controlRoutes = require('./routes/control');
+const cookieParser = require('cookie-parser');
+const app = express();
+
+dotenv.config();
+
+// 添加 CORS 支持
+app.use((req, res, next) => {
+  res.header('Access-Control-Allow-Origin', '*');
+  res.header('Access-Control-Allow-Headers', 'Origin, X-Requested-With, Content-Type, Accept, Authorization');
+  res.header('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS');
+
+  if (req.method === 'OPTIONS') {
+    return res.sendStatus(200);
+  }
+  next();
+});
+
+// 中间件
+app.use(express.json({ limit: '10mb' }));
+app.use(cookieParser());
+
+app.use((req, res, next) => {
+  res.badRequest = (msg) => {
+    res.status(400).json({ statusCode: 400, code: -1, message: msg ?? 'Bad Request' });
+  }
+  res.unauthorized = (msg) => {
+    res.status(401).json({ statusCode: 401, code: -1,  message: msg ?? 'Unauthorized' });
+  }
+  res.notFound = (msg) => {
+    res.status(404).json({ statusCode: 404, code: -1,  message: msg ?? 'Not Found' });
+  }
+  res.serverError = (msg) => {
+    res.status(500).json({ statusCode: 500, code: -1,  message: msg ?? 'Internal Server Error' });
+  }
+  res.sendSuccess = (data, msg) => {
+    const response = { statusCode: 200, code: 0,  message: msg ?? 'OK' }
+    if (data) {
+      response.data = data;
+    }
+    res.status(200).json(response);
+  }
+  next();
+});
+
+app.use('/api/control', controlRoutes);
+
+// 启动服务
+const PORT = 9056;
+app.listen(PORT, () => console.log(`Server running on port ${PORT}`));

+ 56 - 0
server/routes/control.js

@@ -0,0 +1,56 @@
+
+const express = require('express');
+const router = express.Router();
+
+const authMiddleware = require('../middleware/authMiddleware');
+
+const Control = require('../models/Control');
+const Logs = require('../libs/logs');
+
+router.get('/update_code', authMiddleware, (req, res) => {
+  Control.gitPullCurrent()
+  .then(result => {
+    res.sendSuccess(result);
+  })
+  .catch(err => {
+    Logs.errDev('更新代码失败:', err);
+    res.badRequest(err.message);
+  });
+});
+
+router.get('/restart_sporttery', authMiddleware, (req, res) => {
+  const hot = req.query.hot === 'true';
+  Control.pm2RestartSporttery(hot)
+  .then(result => {
+    res.sendSuccess(result);
+  })
+  .catch(err => {
+    Logs.errDev('重启 sporttery 服务失败:', err);
+    res.badRequest(err.message);
+  });
+});
+
+router.get('/restart_pinnacle', authMiddleware, (req, res) => {
+  const hot = req.query.hot === 'true';
+  Control.pm2RestartPinnacle(hot)
+  .then(result => {
+    res.sendSuccess(result);
+  })
+  .catch(err => {
+    Logs.errDev('重启 pinnacle 服务失败:', err);
+    res.badRequest(err.message);
+  });
+});
+
+router.get('/release_web', authMiddleware, (req, res) => {
+  Control.releaseWeb()
+  .then(result => {
+    res.sendSuccess(result);
+  })
+  .catch(err => {
+    Logs.errDev('发布 web 服务失败:', err);
+    res.badRequest(err.message);
+  });
+});
+
+module.exports = router;

+ 132 - 12
server/routes/pstery.js

@@ -17,8 +17,8 @@ router.post('/update_games_list', (req, res) => {
 
 // 更新比赛盘口
 router.post('/update_games_events', (req, res) => {
-  const { platform, mk, games, outrights } = req.body ?? {};
-  Games.updateGamesEvents({ platform, mk, games, outrights })
+  const { platform, mk, games, outrights, timestamp, tp } = req.body ?? {};
+  Games.updateGamesEvents({ platform, mk, games, outrights, timestamp, tp })
   .then(updateCount => {
     res.sendSuccess({ updateCount });
   })
@@ -27,10 +27,22 @@ router.post('/update_games_events', (req, res) => {
   })
 });
 
+// 更新内盘盘口
+router.post('/update_base_events', (req, res) => {
+  const { games, timestamp, tp } = req.body ?? {};
+  Games.updateBaseEvents({ games, timestamp, tp })
+  .then(() => {
+    res.sendSuccess();
+  })
+  .catch(err => {
+    res.badRequest(err.message);
+  });
+});
+
 // 更新联赛列表
 router.post('/update_leagues_list', (req, res) => {
-  const { mk, leagues } = req.body ?? {};
-  const updateCount = Games.updateLeaguesList({ mk, leagues });
+  const { mk, leagues, platform } = req.body ?? {};
+  const updateCount = Games.updateLeaguesList({ mk, leagues, platform });
   res.sendSuccess({ updateCount });
 });
 
@@ -58,18 +70,85 @@ router.get('/get_filtered_leagues', (req, res) => {
   });
 });
 
+// 更新OB原始数据
+router.post('/update_original_data', (req, res) => {
+  const { leagues, matches, platform } = req.body ?? {};
+  Games.updateOriginalData({ leagues, matches });
+  res.sendSuccess();
+});
+
+// 获取OB原始数据
+router.get('/get_original_data', (req, res) => {
+  const obOriginalData = Games.getOriginalData();
+  res.sendSuccess(obOriginalData);
+});
+
 // 获取关联列表
 router.get('/get_games_relation', (req, res) => {
-  const { mk } = req.query;
-  const gamesRelation = Games.getGamesRelation({ mk });
+  const { mk, ids, le, lp } = req.query;
+  const gamesRelation = Games.getGamesRelation({
+    ids: ids?.split(',').map(item => +item.trim()).filter(item => !!item) ?? [],
+    listEvents: le === 'true',
+    listPC: lp === 'true',
+    mk: (/^-?\d$/).test(mk) ? +mk : -1
+  });
   res.sendSuccess(gamesRelation);
 });
 
 // 获取中单方案
 router.get('/get_solutions', (req, res) => {
-  Games.getSolutions()
-  .then(({ solutions, gamesEvents }) => {
-    res.sendSuccess({ solutions, gamesEvents });
+  const { win_min, with_events, show_lower, mk } = req.query;
+  Games.getSolutions({
+    win_min: win_min ? +win_min : undefined,
+    with_events: with_events === 'true',
+    show_lower: show_lower === 'true',
+    mk: mk ? +mk : -1
+  })
+  .then(({ solutions, gamesEvents, mkCount }) => {
+    res.sendSuccess({ solutions, gamesEvents, mkCount });
+  })
+  .catch(err => {
+    res.badRequest(err.message);
+  });
+});
+
+// 获取中单方案并按照比赛分组
+router.get('/get_games_solutions', (req, res) => {
+  const { win_min, with_events, show_lower, mk, tp, sk } = req.query;
+  Games.getGamesSolutions({
+    win_min: win_min ? +win_min : undefined,
+    with_events: with_events === 'true',
+    show_lower: show_lower === 'true',
+    mk: mk ? +mk : -1,
+    tp: tp ? +tp : 0,
+    sk: sk.trim() ? sk.trim() : undefined,
+  })
+  .then(gamesSolutions => {
+    res.sendSuccess(gamesSolutions);
+  })
+  .catch(err => {
+    res.badRequest(err.message);
+  });
+});
+
+// 获取单个中单方案
+router.get('/get_solution', (req, res) => {
+  const { sid } = req.query;
+  Games.getSolution(sid)
+  .then(solution => {
+    res.sendSuccess(solution);
+  })
+  .catch(err => {
+    res.badRequest(err.message);
+  });
+});
+
+// 通过比赛 ID 获取中单方案
+router.post('/solutions_by_ids', (req, res) => {
+  const { ids } = req.body ?? {};
+  Games.getSolutionsByIds(ids)
+  .then(solutions => {
+    res.sendSuccess(solutions);
   })
   .catch(err => {
     res.badRequest(err.message);
@@ -78,7 +157,18 @@ router.get('/get_solutions', (req, res) => {
 
 // 获取综合利润方案
 router.post('/calc_total_profit', (req, res) => {
-  const [sid1, sid2, inner_base, inner_rebate] = req.body;
+  const data = req.body;
+  let sid1, sid2, inner_base, inner_rebate;
+  if (Array.isArray(data)) {
+    [sid1, sid2, inner_base, inner_rebate] = data;
+  }
+  else {
+    sid1 = data.sids[0];
+    sid2 = data.sids[1];
+    inner_base = data.inner_base;
+    inner_rebate = data.inner_rebate;
+  }
+  // const [sid1, sid2, inner_base, inner_rebate] = req.body;
   Games.getTotalProfitWithSid(sid1, sid2, inner_base, inner_rebate)
   .then(totalProfit => {
     res.sendSuccess(totalProfit);
@@ -90,8 +180,20 @@ router.post('/calc_total_profit', (req, res) => {
 
 // 获取自定义综合利润
 router.post('/calc_custom_total_profit', (req, res) => {
-  const [betInfo1, betInfo2, fixed, inner_base, inner_rebate] = req.body;
-  Games.getTotalProfitWithBetInfo(betInfo1, betInfo2, fixed, inner_base, inner_rebate)
+  // const [bet_info_1, bet_info_2, fixed, inner_base, inner_rebate] = req.body;
+  const data = req.body;
+  let bet_info_1, bet_info_2, fixed, inner_base, inner_rebate;
+  if (Array.isArray(data)) {
+    [bet_info_1, bet_info_2, fixed, inner_base, inner_rebate] = data;
+  }
+  else {
+    bet_info_1 = data.bet_info[0];
+    bet_info_2 = data.bet_info[1];
+    inner_base = data.inner_base;
+    inner_rebate = data.inner_rebate;
+    fixed = data.fixed;
+  }
+  Games.getTotalProfitWithBetInfo(bet_info_1, bet_info_2, fixed, inner_base, inner_rebate)
   .then(totalProfit => {
     res.sendSuccess(totalProfit);
   })
@@ -100,4 +202,22 @@ router.post('/calc_custom_total_profit', (req, res) => {
   });
 });
 
+// 补单计算
+router.post('/calc_total_replacement', (req, res) => {
+  Games.getTotalReplacement(req.body)
+  .then(totalProfit => {
+    res.sendSuccess(totalProfit);
+  })
+  .catch(err => {
+    res.badRequest(err.message);
+  });
+});
+
+// 异常通知
+router.post('/notify_exception', (req, res) => {
+  const { message } = req.body;
+  Games.notifyException(message);
+  res.sendSuccess();
+});
+
 module.exports = router;

+ 0 - 111
server/routes/triangle.js

@@ -1,111 +0,0 @@
-const express = require('express');
-const router = express.Router();
-
-const authMiddleware = require('../middleware/authMiddleware');
-
-const Games = require('../models/Games');
-
-// 更新比赛列表
-router.post('/update_games_list', (req, res) => {
-  const { platform, mk, games } = req.body ?? {};
-  Games.updateGamesList({ platform, mk, games })
-  .then(updateCount => {
-    res.sendSuccess({ updateCount });
-  })
-  .catch(err => {
-    res.badRequest(err.message);
-  })
-});
-
-// 更新比赛盘口
-router.post('/update_games_events', (req, res) => {
-  const { platform, mk, games, outrights } = req.body ?? {};
-  Games.updateGamesEvents({ platform, mk, games, outrights })
-  .then(updateCount => {
-    res.sendSuccess({ updateCount });
-  })
-  .catch(err => {
-    res.badRequest(err.message);
-  })
-});
-
-// 更新联赛列表
-router.post('/update_leagues_list', (req, res) => {
-  const { mk, leagues } = req.body ?? {};
-  const updateCount = Games.updateLeaguesList({ mk, leagues });
-  res.sendSuccess({ updateCount });
-});
-
-// 获取筛选过的联赛
-router.get('/get_filtered_leagues', (req, res) => {
-  const { mk } = req.query;
-  Games.getFilteredLeagues(mk)
-  .then(filteredLeagues => {
-    res.sendSuccess(filteredLeagues);
-  })
-  .catch(err => {
-    res.badRequest(err.message);
-  });
-});
-
-// 保存关联比赛
-router.post('/update_games_relation', authMiddleware, (req, res) => {
-  const relation = req.body;
-  Games.updateGamesRelation(relation)
-  .then(ret => {
-    res.sendSuccess(ret);
-  })
-  .catch(err => {
-    res.badRequest(err.message);
-  });
-});
-
-// 删除关联比赛
-router.post('/remove_games_relation', authMiddleware, (req, res) => {
-  const { id } = req.body;
-  Games.removeGamesRelation(id)
-  .then(ret => {
-    res.sendSuccess(ret);
-  })
-  .catch(err => {
-    res.badRequest(err.message);
-  });
-});
-
-// 获取比赛列表
-router.get('/get_games_list', (req, res) => {
-  const gamesList = Games.getGamesList();
-  res.sendSuccess(gamesList);
-});
-
-// 获取关联列表
-router.get('/get_games_relation', (req, res) => {
-  const gamesRelation = Games.getGamesRelation();
-  res.sendSuccess(gamesRelation);
-});
-
-// 获取比赛盘口
-router.get('/get_games_events', authMiddleware, (req, res) => {
-  const { platform } = req.query;
-  const gamesEvents = Games.getGamesEvents({ platform });
-  res.sendSuccess(gamesEvents);
-});
-
-// 获取中单方案
-router.get('/get_solutions', authMiddleware, (req, res) => {
-  Games.getSolutions().then(solutions => {
-    res.sendSuccess(solutions);
-  })
-  .catch(err => {
-    res.badRequest(err.message);
-  });
-});
-
-// 计算总利润
-router.post('/calc_total_profit', authMiddleware, (req, res) => {
-  const { sid1, sid2, gold_side_inner } = req.body;
-  const totalProfit = Games.getTotalProfit(sid1, sid2, gold_side_inner);
-  res.sendSuccess(totalProfit);
-});
-
-module.exports = router;

+ 0 - 2
server/server.js

@@ -5,7 +5,6 @@ const Logs = require('./libs/logs');
 
 const userRoutes = require('./routes/user');
 const systemRoutes = require('./routes/system');
-const triangleRoutes = require('./routes/triangle');
 const psteryRoutes = require('./routes/pstery');
 const cookieParser = require('cookie-parser');
 const app = express();
@@ -53,7 +52,6 @@ app.use((req, res, next) => {
 
 app.use(['/api/user', '/api/auth'], userRoutes);
 app.use('/api/system', systemRoutes);
-app.use('/api/triangle', triangleRoutes);
 app.use('/api/pstery', psteryRoutes);
 
 // 启动服务

+ 21 - 9
server/triangle/eventSolutions.js

@@ -27,6 +27,9 @@ const triangleProfitCalc = (betInfo) => {
     rebate_side_a: A = 0,
     rebate_side_b: B = 0,
     rebate_side_c: C = 0,
+    rebate_type_side_a: TA = 0,
+    rebate_type_side_b: TB = 0,
+    rebate_type_side_c: TC = 0,
   } = betInfo;
 
   /**
@@ -50,11 +53,11 @@ const triangleProfitCalc = (betInfo) => {
     inner_rebate_value = z * inner_rebate;
   }
 
-  const k1 = a * (1 + A);
+  const k1 = TA == 1 ? a + A : a * (1 + A);
   const k2 = 1 - A;
-  const k3 = b * (1 + B);
+  const k3 = TB == 1 ? b + B : b * (1 + B);
   const k4 = 1 - B;
-  const k5 = c * (1 + C);
+  const k5 = TC == 1 ? c + C : c * (1 + C);
   const k6 = 1 - C;
 
   let win_side_a = 0, win_side_b = 0, win_side_c = 0;
@@ -111,15 +114,18 @@ const triangleGoldCalc = (betInfo) => {
     rebate_side_a: A = 0,
     rebate_side_b: B = 0,
     rebate_side_c: C = 0,
+    rebate_type_side_a: TA = 0,
+    rebate_type_side_b: TB = 0,
+    rebate_type_side_c: TC = 0,
   } = betInfo;
   if (typeof a !== 'number' || typeof b !== 'number' || typeof c !== 'number') {
     return;
   }
-  const k1 = a * (1 + A);
+  const k1 = TA == 1 ? a + A : a * (1 + A);
   const k2 = 1 - A;
-  const k3 = b * (1 + B);
+  const k3 = TB == 1 ? b + B : b * (1 + B);
   const k4 = 1 - B;
-  const k5 = c * (1 + C);
+  const k5 = TC == 1 ? c + C : c * (1 + C);
   const k6 = 1 - C;
   let x = inner_base;
   let y = (k1 + k2) * x / (k3 + k4);
@@ -163,6 +169,10 @@ const triangleGoldCalc = (betInfo) => {
     z = inner_base;
   }
 
+  if (x < 0 || y < 0 || z < 0) {
+    return;
+  }
+
   return {
     gold_side_a: fixFloat(x),
     gold_side_b: fixFloat(y),
@@ -184,16 +194,18 @@ const eventSolutions = (betInfo, showGolds=false) => {
   const {
     cross_type, inner_index, inner_rebate, inner_base,
     odds_side_a, odds_side_b, odds_side_c,
-    rebate_side_a,rebate_side_b, rebate_side_c,
+    rebate_side_a, rebate_side_b, rebate_side_c,
+    rebate_type_side_a, rebate_type_side_b, rebate_type_side_c,
   } = betInfo;
 
-
+  const win_average_rate = fixFloat(win_average / inner_base * 100);
   const win_profit_rate = fixFloat(win_average / (gold_side_a + gold_side_b + gold_side_c) * 100);
 
   let result = {
     odds_side_a, odds_side_b, odds_side_c,
     rebate_side_a, rebate_side_b, rebate_side_c,
-    win_average, win_profit_rate, cross_type,
+    rebate_type_side_a, rebate_type_side_b, rebate_type_side_c,
+    win_average, win_average_rate, win_profit_rate, cross_type,
     inner_index, inner_base, inner_rebate,
   }
 

+ 11 - 45
server/triangle/eventsMatch.js

@@ -1,5 +1,5 @@
 const Logs = require('../libs/logs');
-const { eventsCombination } = require('./trangleCalc');
+const { getPassableEvents, eventsCombination } = require('./trangleCalc');
 const { getSetting, updateSetting } = require('./settings');
 
 const Request = {
@@ -71,26 +71,8 @@ const getGamesRelation = () => {
   });
 }
 
-const updateSolutions = (solutions) => {
-  postDataToParent('updateSolutions', solutions);
-}
-
-const extractOdds = ({ evtime, events, sptime, special }) => {
-  const expireTimeEv = Date.now() - 30000;
-  const expireTimeSP = Date.now() - 45000;
-  let odds = {};
-  if (evtime > expireTimeEv) {
-    odds = { ...odds, ...events };
-  }
-  if (sptime > expireTimeSP) {
-    odds = { ...odds, ...special };
-  }
-  Object.keys(odds).forEach(ior => {
-    if (odds[ior] <= 0) {
-      delete odds[ior];
-    }
-  });
-  return odds;
+const updateSolutions = (solutions, eventsLogsMap) => {
+  postDataToParent('updateSolutions', { solutions, eventsLogsMap });
 }
 
 const eventMatch = () => {
@@ -108,35 +90,19 @@ const eventMatch = () => {
 
     GLOBAL_DATA.relationLength = relationLength;
 
-    const passableEvents = relations.map(({ id, rel }) => {
-      const eventsMap = {};
-      const oddsMap = {};
-      Object.keys(rel).forEach(platform => {
-        const { leagueName, teamHomeName, teamAwayName, timestamp, evtime, events, sptime, special } = rel[platform];
-        if (!events && !special) {
-          return;
-        }
-        if (platform == 'ps') {
-          eventsMap.info = { leagueName, teamHomeName, teamAwayName, id, timestamp };
-        }
-        const odds = extractOdds({ evtime, events, sptime, special });
-        Object.keys(odds).forEach(ior => {
-          if (!oddsMap[ior]) {
-            oddsMap[ior] = {};
-          }
-          oddsMap[ior][platform] = odds[ior];
-        });
-      });
-      eventsMap.odds = oddsMap;
-      return eventsMap;
-    })
-    .filter(item => item.info);
+    /** 日志 盘口信息 */
+    const eventsLogsMap = {
+      expireEvents: [],
+      removeEvents: []
+    };
+    /** 日志 End */
 
+    const passableEvents = getPassableEvents(relations, eventsLogsMap);
     const solutions = eventsCombination(passableEvents);
 
     // Logs.out('eventMatch solutions', solutions);
     if (solutions?.length) {
-      updateSolutions(solutions);
+      updateSolutions(solutions, eventsLogsMap);
     }
   })
   .finally(() => {

+ 225 - 9
server/triangle/iorKeys.js

@@ -1,5 +1,5 @@
 module.exports = {
-  A: [
+  'A:0': [
     ['ior_mh', 'ior_rac_025', 'ior_mn', 'la_wh_wa'],
     ['ior_mc', 'ior_rah_025', 'ior_mn', 'la_wh_wa'],
     ['ior_rh_05', 'ior_rac_025', 'ior_mn', 'la_wh_wa'],
@@ -25,7 +25,7 @@ module.exports = {
 
     ['ior_rh_025', 'ior_rc_025', 'ior_mn', 'lh_lh_wa'],
   ],
-  B: [
+  'A:1': [
     ['ior_rh_15', 'ior_rac_125', 'ior_wmh_1', 'la_wh_wa'],
     ['ior_rc_15', 'ior_rah_125', 'ior_wmc_1', 'la_wh_wa'],
 
@@ -56,7 +56,7 @@ module.exports = {
     ['ior_rh_125', 'ior_rac_075', 'ior_wmh_1', 'lh_lh_wa'],
     ['ior_rc_125', 'ior_rah_075', 'ior_wmc_1', 'lh_lh_wa'],
   ],
-  C: [
+  'A:2': [
     ['ior_rh_25', 'ior_rac_225', 'ior_wmh_2', 'la_wh_wa'],
     ['ior_rc_25', 'ior_rah_225', 'ior_wmc_2', 'la_wh_wa'],
 
@@ -87,19 +87,145 @@ module.exports = {
     ['ior_rh_225', 'ior_rac_175', 'ior_wmh_2', 'lh_lh_wa'],
     ['ior_rc_225', 'ior_rah_175', 'ior_wmc_2', 'lh_lh_wa'],
   ],
-  D: [
+  'A:3': [
+    ['ior_rh_35', 'ior_rac_325', 'ior_wmh_3', 'la_wh_wa'],
+    ['ior_rc_35', 'ior_rah_325', 'ior_wmc_3', 'la_wh_wa'],
+
+    ['ior_rh_35', 'ior_rac_3', 'ior_wmh_3', 'la_dr_wa'],
+    ['ior_rc_35', 'ior_rah_3', 'ior_wmc_3', 'la_dr_wa'],
+
+    ['ior_rh_35', 'ior_rac_275', 'ior_wmh_3', 'la_lh_wa'],
+    ['ior_rc_35', 'ior_rah_275', 'ior_wmc_3', 'la_lh_wa'],
+
+    ['ior_rh_35', 'ior_rac_25', 'ior_wmh_3', 'la_la_wa'],
+    ['ior_rc_35', 'ior_rah_25', 'ior_wmc_3', 'la_la_wa'],
+
+    ['ior_rah_25', 'ior_rc_275', 'ior_wmc_3', 'la_wh_wa'],
+    ['ior_rac_25', 'ior_rh_275', 'ior_wmh_3', 'la_wh_wa'],
+
+    ['ior_rah_25', 'ior_rc_3', 'ior_wmc_3', 'la_dr_wa'],
+    ['ior_rac_25', 'ior_rh_3', 'ior_wmh_3', 'la_dr_wa'],
+
+    ['ior_rah_25', 'ior_rc_325', 'ior_wmc_3', 'la_lh_wa'],
+    ['ior_rac_25', 'ior_rh_325', 'ior_wmh_3', 'la_lh_wa'],
+
+    ['ior_rah_275', 'ior_rc_3', 'ior_wmc_3', 'lh_dr_wa'],
+    ['ior_rac_275', 'ior_rh_3', 'ior_wmh_3', 'lh_dr_wa'],
+
+    ['ior_rh_325', 'ior_rac_3', 'ior_wmh_3', 'lh_dr_wa'],
+    ['ior_rc_325', 'ior_rah_3', 'ior_wmc_3', 'lh_dr_wa'],
+
+    ['ior_rh_325', 'ior_rac_275', 'ior_wmh_3', 'lh_lh_wa'],
+    ['ior_rc_325', 'ior_rah_275', 'ior_wmc_3', 'lh_lh_wa'],
+  ],
+  'A:4': [
+
+    ['ior_rh_45', 'ior_rac_425', 'ior_wmh_4', 'la_wh_wa'],
+    ['ior_rc_45', 'ior_rah_425', 'ior_wmc_4', 'la_wh_wa'],
+
+    ['ior_rh_45', 'ior_rac_4', 'ior_wmh_4', 'la_dr_wa'],
+    ['ior_rc_45', 'ior_rah_4', 'ior_wmc_4', 'la_dr_wa'],
+
+    ['ior_rh_45', 'ior_rac_375', 'ior_wmh_4', 'la_lh_wa'],
+    ['ior_rc_45', 'ior_rah_375', 'ior_wmc_4', 'la_lh_wa'],
+
+    ['ior_rh_45', 'ior_rac_35', 'ior_wmh_4', 'la_la_wa'],
+    ['ior_rc_45', 'ior_rah_35', 'ior_wmc_4', 'la_la_wa'],
+
+    ['ior_rah_35', 'ior_rc_375', 'ior_wmc_4', 'la_wh_wa'],
+    ['ior_rac_35', 'ior_rh_375', 'ior_wmh_4', 'la_wh_wa'],
+
+    ['ior_rah_35', 'ior_rc_4', 'ior_wmc_4', 'la_dr_wa'],
+    ['ior_rac_35', 'ior_rh_4', 'ior_wmh_4', 'la_dr_wa'],
+
+    ['ior_rah_35', 'ior_rc_425', 'ior_wmc_4', 'la_lh_wa'],
+    ['ior_rac_35', 'ior_rh_425', 'ior_wmh_4', 'la_lh_wa'],
+
+    ['ior_rah_375', 'ior_rc_4', 'ior_wmc_4', 'lh_dr_wa'],
+    ['ior_rac_375', 'ior_rh_4', 'ior_wmh_4', 'lh_dr_wa'],
+
+    ['ior_rh_425', 'ior_rac_4', 'ior_wmh_4', 'lh_dr_wa'],
+    ['ior_rc_425', 'ior_rah_4', 'ior_wmc_4', 'lh_dr_wa'],
+
+    ['ior_rh_425', 'ior_rac_375', 'ior_wmh_4', 'lh_lh_wa'],
+    ['ior_rc_425', 'ior_rah_375', 'ior_wmc_4', 'lh_lh_wa'],
+  ],
+  'A:5': [
+    ['ior_rh_55', 'ior_rac_525', 'ior_wmh_5', 'la_wh_wa'],
+    ['ior_rc_55', 'ior_rah_525', 'ior_wmc_5', 'la_wh_wa'],
+
+    ['ior_rh_55', 'ior_rac_5', 'ior_wmh_5', 'la_dr_wa'],
+    ['ior_rc_55', 'ior_rah_5', 'ior_wmc_5', 'la_dr_wa'],
+
+    ['ior_rh_55', 'ior_rac_475', 'ior_wmh_5', 'la_lh_wa'],
+    ['ior_rc_55', 'ior_rah_475', 'ior_wmc_5', 'la_lh_wa'],
+
+    ['ior_rh_55', 'ior_rac_45', 'ior_wmh_5', 'la_la_wa'],
+    ['ior_rc_55', 'ior_rah_45', 'ior_wmc_5', 'la_la_wa'],
+
+    ['ior_rah_45', 'ior_rc_475', 'ior_wmc_5', 'la_wh_wa'],
+    ['ior_rac_45', 'ior_rh_475', 'ior_wmh_5', 'la_wh_wa'],
+
+    ['ior_rah_45', 'ior_rc_5', 'ior_wmc_5', 'la_dr_wa'],
+    ['ior_rac_45', 'ior_rh_5', 'ior_wmh_5', 'la_dr_wa'],
+
+    ['ior_rah_45', 'ior_rc_525', 'ior_wmc_5', 'la_lh_wa'],
+    ['ior_rac_45', 'ior_rh_525', 'ior_wmh_5', 'la_lh_wa'],
+
+    ['ior_rah_475', 'ior_rc_5', 'ior_wmc_5', 'lh_dr_wa'],
+    ['ior_rac_475', 'ior_rh_5', 'ior_wmh_5', 'lh_dr_wa'],
+
+    ['ior_rh_525', 'ior_rac_5', 'ior_wmh_5', 'lh_dr_wa'],
+    ['ior_rc_525', 'ior_rah_5', 'ior_wmc_5', 'lh_dr_wa'],
+
+    ['ior_rh_525', 'ior_rac_475', 'ior_wmh_5', 'lh_lh_wa'],
+    ['ior_rc_525', 'ior_rah_475', 'ior_wmc_5', 'lh_lh_wa'],
+  ],
+
+
+  'D:1': [
+    ['ior_ouh_05', 'ior_ouc_075', 'ior_ot_1', 'la_wh_wa'],
+
+    ['ior_ouh_075', 'ior_ouc_1', 'ior_ot_1', 'lh_dr_wa'],
+
+    ['ior_ouc_125', 'ior_ouh_1', 'ior_ot_1', 'lh_dr_wa'],
+
+    ['ior_ouc_15', 'ior_ouh_125', 'ior_ot_1', 'la_wh_wa'],
+
+    ['ior_ouc_15', 'ior_ouh_1', 'ior_ot_1', 'la_dr_wa'],
+
+    ['ior_ouc_125', 'ior_ouh_075', 'ior_ot_1', 'lh_lh_wa'],
+
+    ['ior_ouh_05', 'ior_ouc_1', 'ior_ot_1', 'la_dr_wa'],
+
+    ['ior_ouh_05', 'ior_ouc_125', 'ior_ot_1', 'la_lh_wa'],
+
+    ['ior_ouc_15', 'ior_ouh_075', 'ior_ot_1', 'la_lh_wa'],
+
+    ['ior_ouc_15', 'ior_ouh_05', 'ior_ot_1', 'la_la_wa'],
+  ],
+  'D:2': [
+    ['ior_ouh_15', 'ior_ouc_175', 'ior_ot_2', 'la_wh_wa'],
+
+    ['ior_ouh_175', 'ior_ouc_2', 'ior_ot_2', 'lh_dr_wa'],
+
     ['ior_ouc_225', 'ior_ouh_2', 'ior_ot_2', 'lh_dr_wa'],
 
     ['ior_ouc_25', 'ior_ouh_225', 'ior_ot_2', 'la_wh_wa'],
 
     ['ior_ouc_25', 'ior_ouh_2', 'ior_ot_2', 'la_dr_wa'],
 
-    ['ior_os_0-1', 'ior_ouc_225', 'ior_ot_2', 'la_lh_wa'],
+    ['ior_ouc_225', 'ior_ouh_175', 'ior_ot_2', 'lh_lh_wa'],
+
+    ['ior_ouh_15', 'ior_ouc_2', 'ior_ot_2', 'la_dr_wa'],
 
-    ['ior_os_0-1', 'ior_ouc_25', 'ior_ot_2', 'la_la_wa'],
+    ['ior_ouh_15', 'ior_ouc_225', 'ior_ot_2', 'la_lh_wa'],
 
-    ['ior_os_0-1', 'ior_ouc_2', 'ior_ot_2', 'la_dr_wa'],
+    ['ior_ouc_25', 'ior_ouh_175', 'ior_ot_2', 'la_lh_wa'],
 
+    ['ior_ouc_25', 'ior_ouh_15', 'ior_ot_2', 'la_la_wa'],
+  ],
+  'D:3': [
     ['ior_ouh_25', 'ior_ouc_275', 'ior_ot_3', 'la_wh_wa'],
 
     ['ior_ouh_275', 'ior_ouc_3', 'ior_ot_3', 'lh_dr_wa'],
@@ -119,11 +245,101 @@ module.exports = {
     ['ior_ouc_35', 'ior_ouh_275', 'ior_ot_3', 'la_lh_wa'],
 
     ['ior_ouc_35', 'ior_ouh_25', 'ior_ot_3', 'la_la_wa'],
+  ],
+  'D:4': [
+    ['ior_ouh_35', 'ior_ouc_375', 'ior_ot_4', 'la_wh_wa'],
+
+    ['ior_ouh_375', 'ior_ouc_4', 'ior_ot_4', 'lh_dr_wa'],
+
+    ['ior_ouc_425', 'ior_ouh_4', 'ior_ot_4', 'lh_dr_wa'],
+
+    ['ior_ouc_45', 'ior_ouh_425', 'ior_ot_4', 'la_wh_wa'],
+
+    ['ior_ouh_35', 'ior_ouc_4', 'ior_ot_4', 'la_dr_wa'],
+
+    ['ior_ouc_425', 'ior_ouh_375', 'ior_ot_4', 'lh_lh_wa'],
+
+    ['ior_ouc_45', 'ior_ouh_4', 'ior_ot_4', 'la_dr_wa'],
+
+    ['ior_ouh_35', 'ior_ouc_425', 'ior_ot_4', 'la_lh_wa'],
+
+    ['ior_ouc_45', 'ior_ouh_375', 'ior_ot_4', 'la_lh_wa'],
+
+    ['ior_ouc_45', 'ior_ouh_35', 'ior_ot_4', 'la_la_wa'],
+  ],
+  'D:5': [
+    ['ior_ouh_45', 'ior_ouc_475', 'ior_ot_5', 'la_wh_wa'],
+
+    ['ior_ouh_475', 'ior_ouc_5', 'ior_ot_5', 'lh_dr_wa'],
+
+    ['ior_ouc_525', 'ior_ouh_5', 'ior_ot_5', 'lh_dr_wa'],
+
+    ['ior_ouc_55', 'ior_ouh_525', 'ior_ot_5', 'la_wh_wa'],
+
+    ['ior_ouh_45', 'ior_ouc_5', 'ior_ot_5', 'la_dr_wa'],
+
+    ['ior_ouc_525', 'ior_ouh_475', 'ior_ot_5', 'lh_lh_wa'],
+
+    ['ior_ouc_55', 'ior_ouh_5', 'ior_ot_5', 'la_dr_wa'],
+
+    ['ior_ouh_45', 'ior_ouc_525', 'ior_ot_5', 'la_lh_wa'],
+
+    ['ior_ouc_55', 'ior_ouh_475', 'ior_ot_5', 'la_lh_wa'],
 
-    ['ior_os_0-1', 'ior_ouc_35', 'ior_os_2-3', 'la_la_wa'],
+    ['ior_ouc_55', 'ior_ouh_45', 'ior_ot_5', 'la_la_wa'],
+  ],
+  'D:6': [
+    ['ior_ouh_55', 'ior_ouc_575', 'ior_ot_6', 'la_wh_wa'],
+
+    ['ior_ouh_575', 'ior_ouc_6', 'ior_ot_6', 'lh_dr_wa'],
+
+    ['ior_ouc_625', 'ior_ouh_6', 'ior_ot_6', 'lh_dr_wa'],
+
+    ['ior_ouc_65', 'ior_ouh_625', 'ior_ot_6', 'la_wh_wa'],
+
+    ['ior_ouh_55', 'ior_ouc_6', 'ior_ot_6', 'la_dr_wa'],
+
+    ['ior_ouc_625', 'ior_ouh_575', 'ior_ot_6', 'lh_lh_wa'],
+
+    ['ior_ouc_65', 'ior_ouh_6', 'ior_ot_6', 'la_dr_wa'],
+
+    ['ior_ouh_55', 'ior_ouc_625', 'ior_ot_6', 'la_lh_wa'],
+
+    ['ior_ouc_65', 'ior_ouh_575', 'ior_ot_6', 'la_lh_wa'],
+
+    ['ior_ouc_65', 'ior_ouh_55', 'ior_ot_6', 'la_la_wa'],
+  ],
+  'D:7': [
+    ['ior_ouh_65', 'ior_ouc_675', 'ior_ot_7', 'la_wh_wa'],
 
+    ['ior_ouh_675', 'ior_ouc_7', 'ior_ot_7', 'lh_dr_wa'],
+
+    ['ior_ouc_725', 'ior_ouh_7', 'ior_ot_7', 'lh_dr_wa'],
+
+    ['ior_ouc_75', 'ior_ouh_725', 'ior_ot_7', 'la_wh_wa'],
+
+    ['ior_ouh_65', 'ior_ouc_7', 'ior_ot_7', 'la_dr_wa'],
+
+    ['ior_ouc_725', 'ior_ouh_675', 'ior_ot_7', 'lh_lh_wa'],
+
+    ['ior_ouc_75', 'ior_ouh_7', 'ior_ot_7', 'la_dr_wa'],
+
+    ['ior_ouh_65', 'ior_ouc_725', 'ior_ot_7', 'la_lh_wa'],
+
+    ['ior_ouc_75', 'ior_ouh_675', 'ior_ot_7', 'la_lh_wa'],
+
+    ['ior_ouc_75', 'ior_ouh_65', 'ior_ot_7', 'la_la_wa'],
   ],
-  E: [
+  // 'G:0': [
+  //   ['ior_os_0-1', 'ior_ouc_35', 'ior_os_2-3', 'la_la_wa'],
+
+  //   ['ior_os_0-1', 'ior_ouc_225', 'ior_ot_2', 'la_lh_wa'],
+
+  //   ['ior_os_0-1', 'ior_ouc_25', 'ior_ot_2', 'la_la_wa'],
+
+  //   ['ior_os_0-1', 'ior_ouc_2', 'ior_ot_2', 'la_dr_wa'],
+  // ],
+  'R:0': [
     ['ior_rh_05', 'ior_rac_05', '-', 'la_wa_rv'],
     ['ior_rc_05', 'ior_rah_05', '-', 'la_wa_rv'],
     ['ior_mh', 'ior_rac_05', '-', 'la_wa_rv'],

+ 19 - 2
server/triangle/settings.js

@@ -2,12 +2,29 @@ const Logs = require('../libs/logs');
 
 const SETTING = {
   innerDefaultAmount: 10000,
-  minProfitAmount: 0,
+  // minProfitAmount: 0,
   minShowAmount: 0,
   innerRebateRatio: 0,
   obRebateRatio: 0,
+  obRebateType: 0,
+  obMaxDiff: 0,
+  imRebateRatio: 0,
+  imRebateType: 0,
+  imMaxDiff: 0,
   hgRebateRatio: 0,
-  runWorkerEnabled: false
+  hgRebateType: 0,
+  hgRebateLower: 0,
+  hgMaxDiff: 0,
+  pcRebateRatio: 0,
+  pcRebateType: 0,
+  subsidyTime: 0,
+  subsidyAmount: 0,
+  subsidyRbWmAmount: 0,
+  subsidyRbOtAmount: 0,
+  expireTimeEvents: 45000,
+  expireTimeSpecial: 60000,
+  syncSettingEnabled: false,
+  runWorkerEnabled: false,
 }
 
 const getSetting = (key) => {

+ 83 - 41
server/triangle/totalProfitCalc.js

@@ -19,7 +19,11 @@ const fixFloat = (number, x = 2) => {
  */
 const CROSS_TYPE_MAP = { w: -1, l: 1, a: 1, h: 0.5, d: 0, r: 0, v: 0 };
 const lossProportion = (sol) => {
-  const { cross_type, odds_side_a, odds_side_b, rebate_side_a, rebate_side_b } = sol;
+  const {
+    cross_type, odds_side_a, odds_side_b,
+    rebate_side_a, rebate_side_b,
+    rebate_type_side_a, rebate_type_side_b,
+  } = sol;
   const typeList = cross_type.split('_').map(part => {
     return part.split('').map(key => CROSS_TYPE_MAP[key]);
   }).map(([a, b])=> a * b);
@@ -29,14 +33,24 @@ const lossProportion = (sol) => {
     loss_proportion_a = typeList[0] * (1 - rebate_side_a);
   }
   else {
-    loss_proportion_a = typeList[0] * odds_side_a * (1 + rebate_side_a);
+    if (rebate_type_side_a == 1) {
+      loss_proportion_a = typeList[0] * (odds_side_a + rebate_side_a);
+    }
+    else {
+      loss_proportion_a = typeList[0] * odds_side_a * (1 + rebate_side_a);
+    }
   }
 
   if (typeList[1] >= 0) {
     loss_proportion_b = typeList[1] * (1 - rebate_side_b);
   }
   else {
-    loss_proportion_b = typeList[1] * odds_side_b * (1 + rebate_side_b);
+    if (rebate_type_side_b == 1) {
+      loss_proportion_b = typeList[1] * (odds_side_b + rebate_side_b);
+    }
+    else {
+      loss_proportion_b = typeList[1] * odds_side_b * (1 + rebate_side_b);
+    }
   }
 
   return { loss_proportion_a, loss_proportion_b };
@@ -46,13 +60,13 @@ const lossProportion = (sol) => {
  * 不同组合的金额计算
  */
 const HandicapCalc = function (data) {
-  const { i, g, a, b, c, A, B, C, w, l } = data;
+  const { i, g, a, b, c, A, B, C, TA, TB, TC, w, l } = data;
   const t = w + l;
-  const k1 = a * (1 + A);
+  const k1 = TA == 1 ? a + A : a * (1 + A);
   const k2 = 1 - A;
-  const k3 = b * (1 + B);
+  const k3 = TB == 1 ? b + B : b * (1 + B);
   const k4 = 1 - B;
-  const k5 = c * (1 + C);
+  const k5 = TC == 1 ? c + C : c * (1 + C);
   const k6 = 1 - C;
 
   const calcTemplate = (handlers) => {
@@ -210,12 +224,15 @@ const calcGoldsWithTarget = (data) => {
     rebate_side_a: A,
     rebate_side_b: B,
     rebate_side_c: C,
+    rebate_type_side_a: TA,
+    rebate_type_side_b: TB,
+    rebate_type_side_c: TC,
     inner_index: i,
     cross_type: t,
     win_target: w,
     loss_out: l = 0,
   } = data;
-  const calc = new HandicapCalc({ i, g, a, b, c, A, B, C, w, l });
+  const calc = new HandicapCalc({ i, g, a, b, c, A, B, C, TA, TB, TC, w, l });
   const { x, y, z } = calc?.[t]() ?? {};
   return {
     gold_side_a: fixFloat(x),
@@ -237,6 +254,9 @@ const calcWinResultWithTarget = (data) => {
     rebate_side_a: rebateA1,
     rebate_side_b: rebateB1,
     rebate_side_c: rebateC1,
+    rebate_type_side_a: rebateTypeA1,
+    rebate_type_side_b: rebateTypeB1,
+    rebate_type_side_c: rebateTypeC1,
     inner_index: inner_index_1,
   } = sol1;
   const {
@@ -272,6 +292,9 @@ const calcWinResultWithTarget = (data) => {
     rebate_side_a: rebateA2,
     rebate_side_b: rebateB2,
     rebate_side_c: rebateC2,
+    rebate_type_side_a: rebateTypeA2,
+    rebate_type_side_b: rebateTypeB2,
+    rebate_type_side_c: rebateTypeC2,
     inner_index: inner_index_2,
   } = sol2;
   const {
@@ -321,6 +344,9 @@ const calcWinResultWithTarget = (data) => {
         rebate_side_a: rebateA1,
         rebate_side_b: rebateB1,
         rebate_side_c: rebateC1,
+        rebate_type_side_a: rebateTypeA1,
+        rebate_type_side_b: rebateTypeB1,
+        rebate_type_side_c: rebateTypeC1,
         inner_index: inner_index_1,
       },
       {
@@ -334,6 +360,9 @@ const calcWinResultWithTarget = (data) => {
         rebate_side_a: rebateA2,
         rebate_side_b: rebateB2,
         rebate_side_c: rebateC2,
+        rebate_type_side_a: rebateTypeA2,
+        rebate_type_side_b: rebateTypeB2,
+        rebate_type_side_c: rebateTypeC2,
         inner_index: inner_index_2,
       }
     ],
@@ -424,56 +453,69 @@ const calcSecondProfit = (betInfo) => {
 }
 
 /**
- * 结合第一关亏损计算第二关新利润
+ * 获取第一关内盘信息及外盘损失
  */
-const calcTotalProfitWithFixedFirst = (betInfo1, betInfo2, inner_base, inner_rebate) => {
+const getFirstInfo = (betInfo) => {
   const {
-    cross_type: crossType1,
-    inner_index: inner_index_1,
-    gold_side_a: goldA1,
-    gold_side_b: goldB1,
-    gold_side_c: goldC1,
-    odds_side_a: oddsA1,
-    odds_side_b: oddsB1,
-    odds_side_c: oddsC1,
-    rebate_side_a: rebateA1,
-    rebate_side_b: rebateB1,
-    rebate_side_c: rebateC1,
-  } = betInfo1;
+    inner_index,
+    gold_side_a, gold_side_b, gold_side_c,
+    odds_side_a, odds_side_b, odds_side_c,
+    rebate_side_a, rebate_side_b, rebate_side_c,
+  } = betInfo;
 
-  let loss_out_1 = 0, inner_ref_value = 0, inner_odds_1 = 0;
-  switch (inner_index_1) {
+  let loss_out = 0, inner_ref_value = 0, inner_odds = 0;
+  switch (inner_index) {
     case 0:
-      loss_out_1 = goldB1 * (1 - rebateB1) + goldC1 * (1 - rebateC1);
-      inner_ref_value = goldA1;
-      inner_odds_1 = oddsA1;
+      loss_out = gold_side_b * (1 - rebate_side_b) + gold_side_c * (1 - rebate_side_c);
+      inner_ref_value = gold_side_a;
+      inner_odds = odds_side_a;
       break;
     case 1:
-      loss_out_1 = goldA1 * (1 - rebateA1) + goldC1 * (1 - rebateC1);
-      inner_ref_value = goldB1;
-      inner_odds_1 = oddsB1;
+      loss_out = gold_side_a * (1 - rebate_side_a) + gold_side_c * (1 - rebate_side_c);
+      inner_ref_value = gold_side_b;
+      inner_odds = odds_side_b;
       break;
     case 2:
-      const { loss_proportion_a: lpA1, loss_proportion_b: lpB1 } = lossProportion(betInfo1);
-      loss_out_1 = goldA1 * lpA1 + goldB1 * lpB1;
-      inner_ref_value = goldC1;
-      inner_odds_1 = oddsC1;
+      const { loss_proportion_a, loss_proportion_b } = lossProportion(betInfo);
+      loss_out = gold_side_a * loss_proportion_a + gold_side_b * loss_proportion_b;
+      inner_ref_value = gold_side_c;
+      inner_odds = odds_side_c;
       break;
   }
+  return { loss_out, inner_ref_value, inner_odds };
+}
+
+/**
+ * 结合第一关亏损计算第二关新利润
+ */
+const calcTotalProfitWithFixedFirst = (betInfo1, betInfo2, inner_base, inner_rebate) => {
+
+  const { loss_out, inner_ref_value, inner_odds } = getFirstInfo(betInfo1);
 
   if (inner_base && inner_base != inner_ref_value) {
     Logs.out('inner_base is not equal to inner_ref_value', inner_base, inner_ref_value);
     throw new Error('内盘基准额度和内盘索引额度不一致');
   }
+  else if (!inner_base) {
+    inner_base = inner_ref_value;
+  }
 
-  const profitInfo = calcSecondProfit({ ...betInfo2, inner_base, inner_odds_first: inner_odds_1, inner_rebate });
+  const profitInfo = calcSecondProfit({ ...betInfo2, inner_base, inner_odds_first: inner_odds, inner_rebate });
+  const { cross_type } = profitInfo;
 
-  // profitInfo.win_side_a = fixFloat(profitInfo.win_side_a - loss_out_1);
-  // profitInfo.win_side_b = fixFloat(profitInfo.win_side_b - loss_out_1);
-  // profitInfo.win_side_c = fixFloat(profitInfo.win_side_c - loss_out_1);
-  profitInfo.win_average = fixFloat(profitInfo.win_average - loss_out_1);
+  profitInfo.win_side_a = typeof(profitInfo.win_side_a) !== 'number' ? undefined : fixFloat(profitInfo.win_side_a - loss_out);
+  profitInfo.win_side_b = typeof(profitInfo.win_side_b) !== 'number' ? undefined : fixFloat(profitInfo.win_side_b - loss_out);
+  profitInfo.win_side_c = cross_type == 'la_wa_rv' ? 0 : (typeof(profitInfo.win_side_c) !== 'number' ? undefined : fixFloat(profitInfo.win_side_c - loss_out));
+  profitInfo.win_average = fixFloat(profitInfo.win_average - loss_out);
 
-  return profitInfo;
+  const { win_average_rate, win_profit_rate, ...profitInfoRest } = profitInfo;
+
+  return profitInfoRest;
 }
 
-module.exports = { calcTotalProfit, calcTotalProfitWithFixedFirst };
+
+module.exports = {
+  calcTotalProfit,
+  calcTotalProfitWithFixedFirst,
+  getFirstInfo,
+};

+ 158 - 58
server/triangle/trangleCalc.js

@@ -1,19 +1,15 @@
 const crypto = require('crypto');
-const Logs = require('../libs/logs');
 const IOR_KEYS_MAP = require('./iorKeys');
 const { getSetting } = require('./settings');
 const { eventSolutions } = require('./eventSolutions');
 
-/**
- * 筛选最优赔率
- */
-function getOptimalSelections(odds, rules) {
+const cartesianOdds = (selection) => {
+  const [a, b, c] = selection;
+  return a.flatMap(itemA => b.flatMap(itemB => c.map(itemC => [itemA, itemB, itemC])));
+}
+
+const getOptimalSelections = (odds, rules) => {
   const results = [];
-  const { obRebateRatio, hgRebateRatio } = getSetting();
-  const rebateMap = {
-    ob: 1 + obRebateRatio / 100,
-    hg: 1 + hgRebateRatio / 100,
-  };
 
   rules.forEach((rule, ruleIndex) => {
     let validOptions = [];
@@ -26,7 +22,7 @@ function getOptimalSelections(odds, rules) {
         const key = rule[j];
         let item = odds[key];
         if (key == '-') {
-          item = { no: 1};
+          item = { no: { v: 1 } };
         }
         if (!item) {
           isValid = false;
@@ -37,53 +33,45 @@ function getOptimalSelections(odds, rules) {
             isValid = false;
             break;
           }
-          selection.push({
+          selection.push([{
             k: key,
             p: 'ps',
-            v: item.ps,
-            o: item,
-          });
+            v: item.ps.v,
+            r: item.ps.r,
+            s: item.ps.s,
+            o: item
+          }]);
         }
         else {
-          const candidates = ['ob', 'hg', 'no'].filter((k) => k in item);
+          const { ps, ...itemRest } = item;
+          const candidates = Object.keys(itemRest);
           if (candidates.length === 0) {
             isValid = false;
             break;
           }
-          // Logs.out('candidates', candidates)
-          const best = candidates.reduce((a, b) => {
-            const aValue = (item[a]-1)*rebateMap[a];
-            const bValue = (item[b]-1)*rebateMap[b];
-            const seletcted = aValue > bValue ? a : b;
-            return seletcted;
-          });
-          // Logs.out('best', item, best)
-          selection.push({
+          selection.push(candidates.map(k => ({
             k: key,
-            p: best,
-            v: item[best],
-            o: item,
-          });
+            p: k,
+            v: item[k].v,
+            r: item[k].r,
+            s: item[k].s,
+            q: item[k].q,
+            o: item
+          })));
         }
       }
 
       if (isValid) {
-        validOptions.push(selection);
+        const cartesian = cartesianOdds(selection);
+        cartesian.forEach(item => {
+          validOptions.push(item);
+        });
       }
     }
 
     validOptions.forEach(iors => {
       results.push({ rule, iors, ruleIndex });
     });
-
-    // if (validOptions.length > 0) {
-    //   const iors = validOptions.reduce((a, b) => {
-    //     const sumA = a.reduce((sum, x) => sum + x.v, 0);
-    //     const sumB = b.reduce((sum, x) => sum + x.v, 0);
-    //     return sumA > sumB ? a : b;
-    //   });
-    //   results.push({ rule, iors, ruleIndex });
-    // }
   });
 
   return results;
@@ -102,34 +90,142 @@ const fixFloat = (number, x=2) => {
 /**
  * 盘口排序
  */
-const priority = { ps: 1, ob: 2, hg: 3 };
+const priority = { ps: 1, ob: 2, hg: 3, im: 4, pc: 5 };
 const sortCpr = (cpr) => {
   const temp = [...cpr];
   temp.sort((a, b) => priority[a.p] - priority[b.p]);
   return temp;
 }
 
+/**
+ * 获取平台类型
+ */
+const getPlatformKey = (cpr) => {
+  const platforms = sortCpr(cpr).map(item => item.p);
+  return [...new Set(platforms)].join('_');
+}
+
 /**
  * 添加返佣
  */
 const attachRebate = (ior) => {
-  const { obRebateRatio, hgRebateRatio } = getSetting();
-  const { p } = ior;
-  let rebate = 0;
+  const { obRebateRatio, obRebateType, hgRebateRatio, hgRebateLower, hgRebateType, pcRebateRatio, pcRebateType } = getSetting();
+  const { p, k } = ior;
+  let rebate = 0, rebateType = 0;
   if (p == 'ps') {
     rebate = 0;
+    rebateType = -1;
+  }
+  else if (p == 'pc') {
+    rebate = pcRebateRatio;
+    rebateType = pcRebateType;
   }
   else if (p == 'ob') {
     rebate = obRebateRatio;
+    rebateType = obRebateType;
   }
   else if (p == 'hg') {
     rebate = hgRebateRatio;
+    rebateType = hgRebateType;
+    if (k.startsWith('ior_m')) {
+      rebate = hgRebateLower;
+    }
+  }
+  return { ...ior, b: rebate, t: rebateType };
+}
+
+/**
+ * 提取盘口数据
+ */
+const extractOdds = ({ evtime, events, sptime, special }) => {
+  const { expireTimeEvents, expireTimeSpecial } = getSetting();
+  const nowTime = Date.now();
+  const expireTimeEv = nowTime - expireTimeEvents;
+  const expireTimeSP = nowTime - expireTimeSpecial;
+  const extractData = {
+    odds: null,
+    evExpire: false,
+    spExpire: false,
+    removeCount: 0,
+  }
+  let odds = {};
+  if (evtime > expireTimeEv) {
+    odds = { ...odds, ...events };
+  }
+  else if (events) {
+    extractData.evExpire = true;
+  }
+  if (sptime > expireTimeSP) {
+    odds = { ...odds, ...special };
   }
-  return { ...ior, r: rebate };
+  else if (special) {
+    extractData.spExpire = true;
+  }
+  Object.keys(odds).forEach(ior => {
+    if (!odds[ior]?.v || odds[ior].v <= 0) {
+      delete odds[ior];
+      extractData.removeCount++;
+    }
+  });
+  extractData.odds = odds;
+  return extractData;
 }
 
-const eventsCombination = (passableEvents) => {
-  const { minProfitAmount, innerDefaultAmount, innerRebateRatio } = getSetting();
+/**
+ * 筛选有效盘口
+ */
+const getPassableEvents = (relations, eventsLogsMap) => {
+  return relations.map(({ id, rel, mk }) => {
+    const eventsMap = {};
+    const oddsMap = {};
+
+    Object.keys(rel).forEach(platform => {
+      const { leagueName, teamHomeName, teamAwayName, timestamp, stage, score, retime, evtime, events, sptime, special } = rel[platform] ?? {};
+      if (!events && !special) {
+        return;
+      }
+      if (platform == 'ps') {
+        eventsMap.info = { leagueName, teamHomeName, teamAwayName, id, timestamp, stage, score, retime };
+      }
+      const { odds, evExpire, spExpire, removeCount } = extractOdds({ evtime, events, sptime, special });
+
+      /** 日志 盘口过期 */
+      if (eventsLogsMap && (evExpire || spExpire)) {
+        eventsLogsMap.expireEvents?.push({
+          mk, platform,
+          info: rel[platform],
+          evExpire, spExpire,
+          evtime, sptime,
+        });
+      }
+      /** 日志 盘口移除 */
+      if (eventsLogsMap && removeCount) {
+        eventsLogsMap.removeEvents?.push({
+          mk, platform,
+          info: rel[platform],
+          removeCount,
+        });
+      }
+      /** 日志 End */
+
+      Object.keys(odds).forEach(ior => {
+        if (!oddsMap[ior]) {
+          oddsMap[ior] = {};
+        }
+        oddsMap[ior][platform] = odds[ior];
+      });
+    });
+    eventsMap.odds = oddsMap;
+    return eventsMap;
+  })
+  .filter(item => item?.info);
+}
+
+/**
+ * 盘口组合计算
+ */
+const eventsCombination = (passableEvents, innerBase, innerRebate) => {
+  const { innerDefaultAmount, innerRebateRatio } = getSetting();
   const solutions = [];
   passableEvents.forEach(events => {
     const { odds, info } = events;
@@ -150,27 +246,31 @@ const eventsCombination = (passableEvents) => {
         const betInfo = {
           cross_type: crossType,
           inner_index: innerIndex,
-          inner_base: innerDefaultAmount,
-          inner_rebate: fixFloat(innerRebateRatio / 100, 3),
+          inner_base: innerBase ?? innerDefaultAmount,
+          inner_rebate: innerRebate ?? fixFloat(innerRebateRatio / 100, 3),
           odds_side_a: fixFloat(oddsSideA.v - 1),
           odds_side_b: fixFloat(oddsSideB.v - 1),
           odds_side_c: fixFloat(oddsSideC.v - 1),
-          rebate_side_a: parseFloat((oddsSideA.r / 100).toFixed(4)),
-          rebate_side_b: parseFloat((oddsSideB.r / 100).toFixed(4)),
-          rebate_side_c: parseFloat((oddsSideC.r / 100).toFixed(4)),
+          rebate_side_a: parseFloat((oddsSideA.b / 100).toFixed(4)),
+          rebate_type_side_a: oddsSideA.t,
+          rebate_side_b: parseFloat((oddsSideB.b / 100).toFixed(4)),
+          rebate_type_side_b: oddsSideB.t,
+          rebate_side_c: parseFloat((oddsSideC.b / 100).toFixed(4)),
+          rebate_type_side_c: oddsSideC.t,
         };
         const sol = eventSolutions(betInfo, true);
         if (cpr[2].k == '-') {
           cpr.pop();
         }
-        if (sol?.win_average > minProfitAmount) {
+        if (!isNaN(sol?.win_average)) {
           const id = info.id;
-          const sortedCpr = sortCpr(cpr);
-          const keys = sortedCpr.map(item => `${item.k}`).join('_');
-          const sid = crypto.createHash('sha1').update(`${id}_${keys}`).digest('hex');
-          const crpGroup = `${id}_${sortedCpr[0].k}`;
+          const keys = cpr.map(item => `${item.k}-${item.p}`).join('##');
+          const sid = crypto.createHash('sha1').update(`${id}-${keys}`).digest('hex');
+          const crpGroup = `${id}_${cpr.find(item => item.p == 'ps').k}`;
+          const hasLower = cpr.some(item => item.q === 0);
+          const platformKey = getPlatformKey(cpr);
           const timestamp = Date.now();
-          solutions.push({sid, sol, cpr, info, group: crpGroup, rule: `${iorGroup}:${ruleIndex}`, timestamp});
+          solutions.push({sid, sol, cpr, cross: platformKey, info, group: crpGroup, lower: hasLower, rule: `${iorGroup}:${ruleIndex}`, timestamp});
         }
       });
     });
@@ -180,4 +280,4 @@ const eventsCombination = (passableEvents) => {
   });
 }
 
-module.exports = { eventsCombination };
+module.exports = { eventsCombination, getPassableEvents };

+ 1 - 0
web/apps/web-antd/src/api/request.ts

@@ -108,6 +108,7 @@ function createRequestClient(baseURL: string, options?: RequestClientOptions) {
 
 export const requestClient = createRequestClient(apiURL, {
   responseReturn: 'data',
+  timeout: 30_000,
 });
 
 export const baseRequestClient = new RequestClient({ baseURL: apiURL });

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

@@ -19,6 +19,7 @@
   "system": {
     "title": "System",
     "user": "User",
-    "parameter": "Parameter"
+    "parameter": "Parameter",
+    "control": "Control Panel"
   }
 }

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

@@ -19,6 +19,7 @@
   "system": {
     "title": "系统设置",
     "user": "用户管理",
-    "parameter": "参数设置"
+    "parameter": "参数设置",
+    "control": "控制面板"
   }
 }

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

@@ -13,17 +13,6 @@ const routes: RouteRecordRaw[] = [
     path: '/match',
     redirect: '/match/solutions',
     children: [
-      {
-        name: 'RelatedMatch',
-        path: 'related',
-        component: () => import('#/views/match/related/index.vue'),
-        meta: {
-          icon: 'ion:git-compare-outline',
-          title: $t('page.match.related'),
-          roles: ['admin'], // Only users with admin role can access this page
-          hideInMenu: true, // 隐藏关联比赛入口
-        },
-      },
       {
         name: 'CenterOrder',
         path: 'solutions',

+ 11 - 0
web/apps/web-antd/src/router/routes/modules/system.ts

@@ -11,6 +11,7 @@ const routes: RouteRecordRaw[] = [
     },
     name: 'System',
     path: '/system',
+    redirect: '/system/parameter',
     children: [
       {
         name: 'UserManagement',
@@ -19,6 +20,7 @@ const routes: RouteRecordRaw[] = [
         meta: {
           icon: 'ion:people-outline',
           title: $t('page.system.user'),
+          hideInMenu: true,
         },
       },
       {
@@ -30,6 +32,15 @@ const routes: RouteRecordRaw[] = [
           title: $t('page.system.parameter'),
         },
       },
+      {
+        name: 'BackendControl',
+        path: 'control',
+        component: () => import('#/views/system/control/index.vue'),
+        meta: {
+          icon: 'ion:server-outline',
+          title: $t('page.system.control'),
+        },
+      },
     ],
   },
 ];

+ 7 - 7
web/apps/web-antd/src/views/_core/authentication/login.vue

@@ -32,13 +32,13 @@ const formSchema = computed((): VbenFormSchema[] => {
       label: $t('authentication.password'),
       rules: z.string().min(1, { message: $t('authentication.passwordTip') }),
     },
-    {
-      component: markRaw(SliderCaptcha),
-      fieldName: 'captcha',
-      rules: z.boolean().refine((value) => value, {
-        message: $t('authentication.verifyRequiredTip'),
-      }),
-    },
+    // {
+    //   component: markRaw(SliderCaptcha),
+    //   fieldName: 'captcha',
+    //   rules: z.boolean().refine((value) => value, {
+    //     message: $t('authentication.verifyRequiredTip'),
+    //   }),
+    // },
   ];
 });
 </script>

+ 44 - 47
web/apps/web-antd/src/views/match/components/match_card.vue

@@ -11,6 +11,10 @@ const parseEventKey = (key) => {
     const ratio = key.split('_')[1];
     return `大/小 ${ratio}`;
   }
+  else if (key?.startsWith('ot')) {
+    const ratio = key.split('_')[1];
+    return `进球数 ${ratio}`;
+  }
   return key;
 }
 defineProps({
@@ -18,14 +22,6 @@ defineProps({
     type: Number,
     required: true
   },
-  platform: {
-    type: String,
-    required: true
-  },
-  leagueName: {
-    type: String,
-    required: true
-  },
   teamHomeName: {
     type: String,
     required: true
@@ -34,10 +30,6 @@ defineProps({
     type: String,
     required: true
   },
-  dateTime: {
-    type: String,
-    required: true
-  },
   events: {
     type: Array,
     required: true
@@ -49,31 +41,37 @@ defineProps({
   selected: {
     type: Array,
     required: false
-  }
+  },
 })
 </script>
 
 <template>
   <div class="match-card">
-    <div class="card-header">
-      <div class="league-name"><strong v-if="matchNumStr">[{{ matchNumStr }}]</strong>{{ leagueName }}</div>
-      <div class="date-time">{{ dateTime }}</div>
-    </div>
     <div class="team-name">
       <span class="home-name">{{ teamHomeName }}</span>
       <em>VS</em>
       <span class="away-name">{{ teamAwayName }}</span>
     </div>
-    <div class="events-list" :class="{'list-row2': events.length <= 2}">
+    <div class="events-list" v-if="events.length">
       <table>
         <tr v-for="item in events">
           <th>{{ parseEventKey(item[0]) }}</th>
-          <td><span :class="{'selected': selected.includes(item[1]?.key)}">{{ item[1]?.value ? item[1].value : '-' }}</span></td>
-          <td><span :class="{'selected': selected.includes(item[2]?.key)}">{{ item[2]?.value ? item[2].value : '-' }}</span></td>
-          <td><span :class="{'selected': selected.includes(item[3]?.key)}">{{ item[3]?.value ? item[3].value : '-' }}</span></td>
+          <td>
+            <span :class="{'selected': selected.includes(item[1]?.key), 'strikethrough': item[1]?.qualified === 0}">{{ item[1]?.value ? item[1].value : '-' }}</span>
+            <em v-if="item[1]?.origin">{{ item[1].origin }}</em>
+          </td>
+          <td>
+            <span :class="{'selected': selected.includes(item[2]?.key), 'strikethrough': item[2]?.qualified === 0}">{{ item[2]?.value ? item[2].value : '-' }}</span>
+            <em v-if="item[2]?.origin">{{ item[2].origin }}</em>
+          </td>
+          <td>
+            <span :class="{'selected': selected.includes(item[3]?.key), 'strikethrough': item[3]?.qualified === 0}">{{ item[3]?.value ? item[3].value : '-' }}</span>
+            <em v-if="item[3]?.origin">{{ item[3].origin }}</em>
+          </td>
         </tr>
       </table>
     </div>
+    <div class="events-empty" v-else>暂无数据</div>
   </div>
 </template>
 
@@ -83,24 +81,6 @@ defineProps({
   flex-direction: column;
   padding: 20px;
 }
-.card-header {
-  display: flex;
-  height: 30px;
-  align-items: center;
-  justify-content: space-between;
-  .league-name {
-    font-size: 16px;
-    strong {
-      margin-right: 4px;
-      font-weight: normal;
-      color: hsl(var(--destructive));
-    }
-  }
-  .date-time {
-    font-size: 12px;
-    color: hsl(var(--foreground) / 0.7);
-  }
-}
 .team-name {
   display: flex;
   align-items: center;
@@ -146,7 +126,7 @@ defineProps({
     border-spacing: 0;
     table-layout: fixed;
     th, td {
-      height: 30px;
+      padding: 5px;
       border: 1px solid hsl(var(--border));
       text-align: center;
     }
@@ -156,26 +136,43 @@ defineProps({
     }
     td {
       width: calc((100% - 64px) / 2);
+      font-size: 0;
     }
     span {
       display: inline-block;
       height: 20px;
+      padding: 0 5px;
       line-height: 20px;
       vertical-align: middle;
-      padding: 0 5px;
+      font-size: 14px;
+      &.strikethrough {
+        text-decoration: line-through;
+        color: hsl(var(--foreground) / 0.35);
+      }
       &.selected {
         border-radius: 4px;
         background-color: hsl(var(--primary));
         color: hsl(var(--primary-foreground));
       }
     }
-  }
-  &.list-row2 {
-    table {
-      th, td {
-        height: 45px;
-      }
+    em {
+      display: block;
+      height: 18px;
+      margin-top: -3px;
+      line-height: 18px;
+      font-style: normal;
+      font-size: 12px;
+      color: hsl(var(--foreground) / 0.5);
     }
   }
 }
+.events-empty {
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  height: 34px;
+  margin-top: 10px;
+  font-size: 14px;
+  border: 1px solid hsl(var(--border));
+}
 </style>

+ 379 - 0
web/apps/web-antd/src/views/match/components/solution_item.vue

@@ -0,0 +1,379 @@
+<script setup>
+import { Tooltip } from 'ant-design-vue';
+import { ref, computed } from 'vue';
+import dayjs from 'dayjs';
+
+import MatchCard from './match_card.vue';
+
+const props = defineProps({
+  serial: {
+    type: Number,
+  },
+  id: {
+    type: Number,
+    required: true
+  },
+  mk: {
+    type: Number,
+    required: true
+  },
+  rel: {
+    type: Object,
+    required: true
+  },
+  solutions: {
+    type: Array,
+    required: true
+  },
+  selected: {
+    type: Boolean,
+    default: false
+  }
+});
+
+const emit = defineEmits(['toggle']);
+
+const selectedIndex = ref(0);
+
+const parseIorKey = (iorKey) => {
+  const [, type, accept, side, , ratioString] = iorKey.match(/^ior_(r|ou|m|wm|ot|os)(a?)(h|c|n)?(_([\d-]+))?$/);
+  let ratio = 0;
+  if (type === 'ot' || type === 'os') {
+    ratio = ratioString;
+  }
+  else if (ratioString) {
+    ratio = `${ratioString[0]}.${ratioString.slice(1)}` * (accept ? 1 : -1);
+  }
+  return { type, side, ratio };
+}
+
+const PS_IOR_KEYS = [
+  ['0', 'ior_mh', 'ior_mn', 'ior_mc'],
+  ['-1', 'ior_rh_15', 'ior_wmh_1', 'ior_rac_05'],
+  ['-2', 'ior_rh_25', 'ior_wmh_2', 'ior_rac_15'],
+  ['-3', 'ior_rh_35', 'ior_wmh_3', 'ior_rac_25'],
+  ['-4', 'ior_rh_45', 'ior_wmh_4', 'ior_rac_35'],
+  ['-5', 'ior_rh_55', 'ior_wmh_5', 'ior_rac_45'],
+  ['+1', 'ior_rah_05', 'ior_wmc_1', 'ior_rc_15'],
+  ['+2', 'ior_rah_15', 'ior_wmc_2', 'ior_rc_25'],
+  ['+3', 'ior_rah_25', 'ior_wmc_3', 'ior_rc_35'],
+  ['+4', 'ior_rah_35', 'ior_wmc_4', 'ior_rc_45'],
+  ['+5', 'ior_rah_45', 'ior_wmc_5', 'ior_rc_55'],
+  ['ot_1', '-', 'ior_ot_1', '-'],
+  ['ot_2', '-', 'ior_ot_2', '-'],
+  ['ot_3', '-', 'ior_ot_3', '-'],
+  ['ot_4', '-', 'ior_ot_4', '-'],
+  ['ot_5', '-', 'ior_ot_5', '-'],
+  ['ot_6', '-', 'ior_ot_6', '-'],
+  ['ot_7', '-', 'ior_ot_7', '-'],
+];
+
+const fixFloat = (number, x = 2) => {
+  return parseFloat(number.toFixed(x));
+}
+
+const formatPsEvents = (events) => {
+  return PS_IOR_KEYS.map(([label, ...keys]) => {
+    const match = keys.map(key => ({
+      key,
+      value: events[key]?.v ?? 0,
+      origin: events[key]?.r
+    }));
+    return {
+      label,
+      match
+    };
+  })
+  // .filter(item => item.match.every(entry => entry.value !== 0))
+  .map(({label, match}) => [label, ...match]);
+}
+
+const formatEvents = (events) => {
+  const eventsMap = {};
+  Object.keys(events).forEach(key => {
+    const { type, side, ratio } = parseIorKey(key);
+    let ratioKey, index;
+    if (type === 'r') {
+      if (side === 'h') {
+        ratioKey = ratio;
+        index = 0;
+      }
+      else if (side === 'c') {
+        ratioKey = -ratio;
+        index = 2;
+      }
+    }
+    else if (type === 'm') {
+      ratioKey = 'm';
+      if (side == 'h') {
+        index = 0;
+      }
+      else if (side == 'c') {
+        index = 2;
+      }
+      else {
+        index = 1;
+      }
+    }
+    else if (type === 'wm') {
+      ratioKey = `wm_${Math.abs(ratio)}`;
+      if (side === 'h') {
+        index = 0;
+      }
+      else if (side === 'c') {
+        index = 2;
+      }
+    }
+    else if (type === 'ou') {
+      ratioKey = `ou_${Math.abs(ratio)}`;
+      if (side === 'c') {
+        index = 0;
+      }
+      else if (side === 'h') {
+        index = 2;
+      }
+    }
+    else if (type === 'ot') {
+      ratioKey = `ot_${ratio}`;
+      index = 1;
+    }
+    if (typeof (ratioKey) == 'number') {
+      if (ratioKey > 0) {
+        ratioKey = `+${ratioKey}`;
+      }
+      else {
+        ratioKey = `${ratioKey}`;
+      }
+    }
+
+    if (!ratioKey) {
+      return;
+    }
+
+    if (!eventsMap[ratioKey]) {
+      eventsMap[ratioKey] = new Array(3).fill(undefined);
+    }
+
+    const value = events[key]?.v ?? 0;
+    const origin = events[key]?.r;
+    const qualified = events[key]?.q ?? 1;
+    eventsMap[ratioKey][index] = { key, value, origin, qualified };
+  });
+
+  return Object.keys(eventsMap).sort((a, b) => a.localeCompare(b)).map(key => {
+    return [key, ...eventsMap[key]];
+  });
+}
+
+const toggleSolution = () => {
+  const id = props.id;
+  const sid = currentSolution.value.sid;
+  emit('toggle', { id, sid });
+};
+
+const switchSolution = (index) => {
+  if (index == selectedIndex.value) {
+    return;
+  }
+  selectedIndex.value = index;
+  if (!props.selected) {
+    return;
+  }
+  toggleSolution();
+};
+
+const currentIndex = computed(() => {
+  const index = selectedIndex.value;
+  if (props.solutions[index]) {
+    return index;
+  }
+  return 0;
+});
+
+const currentSolution = computed(() => {
+  return props.solutions[currentIndex.value];
+});
+
+const currentRelation = computed(() => {
+  const cpr = currentSolution.value.cpr;
+  const rel = props.rel;
+  const { ps: { leagueName, timestamp, stage, retime, score } } = rel;
+  const dateTime = dayjs(timestamp).format('YYYY-MM-DD HH:mm:ss');
+  const relation = { leagueName, timestamp, dateTime, stage, retime, score };
+  Object.keys(rel).forEach(platform => {
+    const { eventId, teamHomeName, teamAwayName, events, special } = rel[platform] ?? {};
+    if (!relation.rel) {
+      relation.rel = {};
+    }
+    const mergedEvents = { ...events, ...special };
+    const formattedEvents = platform === 'ps' ? formatPsEvents(mergedEvents) : formatEvents(mergedEvents);
+    relation.rel[platform] = { eventId, teamHomeName, teamAwayName, events: formattedEvents };
+  });
+  cpr.forEach(item => {
+    const { k, p } = item;
+    if (!relation.rel[p]['selected']) {
+      relation.rel[p]['selected'] = [];
+    }
+    relation.rel[p]['selected'].push(k);
+  });
+  return relation;
+});
+
+const ps = computed(() => {
+  return currentRelation.value.rel.ps;
+});
+
+const hg = computed(() => {
+  return currentRelation.value.rel.hg;
+});
+
+const ob = computed(() => {
+  return currentRelation.value.rel.ob;
+});
+
+const im = computed(() => {
+  return currentRelation.value.rel.im;
+});
+
+</script>
+
+<template>
+<div class="solution-item" :class="{ 'selected': selected }">
+  <div class="solution-header">
+    <div class="serial-number" v-if="serial">{{ serial }}.</div>
+    <div class="stage" v-if="currentRelation.stage">[{{ currentRelation.stage }}{{ currentRelation.retime ? ` ${currentRelation.retime}` : '' }}]</div>
+    <div class="score" v-if="currentRelation.stage">[{{ currentRelation.score }}]</div>
+    <div class="league-name">{{ currentRelation.leagueName }}</div>
+    <div class="date-time">{{ currentRelation.dateTime }}</div>
+    <div class="switch-btns" v-if="solutions.length">
+      <Tooltip v-for="({sol}, index) in solutions" :key="index"
+      class="switch-btn-item"
+      :class="{ 'selected': index === currentIndex }"
+      :title="`${sol.win_profit_rate}% (${sol.cross_type})`"
+      @click="switchSolution(index)">{{ sol.win_average_rate }}</Tooltip>
+    </div>
+  </div>
+  <div class="solution-content">
+    <MatchCard platform="ps" :eventId="ps.eventId" :teamHomeName="ps.teamHomeName"
+    :teamAwayName="ps.teamAwayName" :dateTime="ps.dateTime" :events="ps.events ?? []"
+    :selected="ps.selected ?? []" />
+
+    <MatchCard platform="ob" :eventId="ob.eventId" :teamHomeName="ob.teamHomeName"
+      :teamAwayName="ob.teamAwayName" :dateTime="ob.dateTime" :events="ob.events ?? []"
+      :selected="ob.selected ?? []" />
+
+    <MatchCard platform="hg" :eventId="hg.eventId" :teamHomeName="hg.teamHomeName"
+      :teamAwayName="hg.teamAwayName" :dateTime="hg.dateTime" :events="hg.events ?? []"
+      :selected="hg.selected ?? []" />
+
+    <MatchCard platform="im" :eventId="im.eventId" :teamHomeName="im.teamHomeName"
+      :teamAwayName="im.teamAwayName" :dateTime="im.dateTime" :events="im.events ?? []"
+      :selected="im.selected ?? []" />
+
+    <!-- <div class="solution-profit" @click="toggleSolution()">
+      <p>{{ currentSolution.sol.win_average_rate }}%</p>
+      <p>{{ currentSolution.sol.win_profit_rate }}%</p>
+      <p>{{ currentSolution.sol.cross_type }}</p>
+    </div> -->
+  </div>
+</div>
+</template>
+
+<style lang="scss" scoped>
+.solution-item {
+  display: flex;
+  flex-direction: column;
+  border-radius: 10px;
+  background-color: hsl(var(--card));
+
+  &.selected {
+    background-color: hsl(var(--primary) / 0.15);
+  }
+
+  &:not(:last-child) {
+    margin-bottom: 20px;
+  }
+}
+
+.solution-header {
+  display: flex;
+  align-items: center;
+  height: 40px;
+  padding: 0 15px;
+  border-bottom: 1px solid hsl(var(--border));
+  .serial-number {
+    margin-right: 5px;
+    font-size: 16px;
+    font-weight: 400;
+    color: hsl(var(--foreground) / 0.7);
+  }
+  .score, .stage {
+    text-align: center;
+    font-size: 16px;
+    font-weight: 400;
+  }
+  .score {
+    color: hsl(var(--primary));
+  }
+  .stage {
+    color: hsl(var(--destructive));
+  }
+  .league-name {
+    margin-right: 10px;
+    font-size: 16px;
+    font-weight: 400;
+  }
+  .date-time {
+    text-align: right;
+    font-size: 14px;
+    color: hsl(var(--foreground) / 0.7);
+  }
+}
+
+.switch-btns {
+  display: flex;
+  align-items: center;
+  justify-content: flex-end;
+  flex: 1;
+  :deep(.switch-btn-item) {
+    display: block;
+    height: 20px;
+    line-height: 20px;
+    padding: 0 5px;
+    border-radius: 4px;
+    cursor: pointer;
+    &:hover {
+      color: hsl(var(--primary));
+    }
+    &.selected {
+      color: hsl(var(--primary-foreground));
+      background-color: hsl(var(--primary));
+      cursor: default;
+    }
+    &:not(:last-child) {
+      margin-right: 5px;
+    }
+  }
+}
+
+.solution-content {
+  display: flex;
+  .match-card {
+    flex: 1;
+  }
+  .match-card:not(:last-child) {
+    border-right: 1px solid hsl(var(--border));
+  }
+}
+
+/*
+.solution-profit {
+  display: flex;
+  flex-direction: column;
+  width: 80px;
+  align-items: center;
+  justify-content: center;
+}
+  */
+
+</style>

+ 154 - 234
web/apps/web-antd/src/views/match/datatest/index.vue

@@ -1,279 +1,199 @@
-<template>
-  <div class="data-test-container">
-    <div class="page-header">
-      <h1>数据测试</h1>
-      <p>用于测试和验证比赛数据的页面</p>
-    </div>
-
-    <div class="content-area">
-      <div class="test-panel">
-        <h2>测试数据展示</h2>
-        <div class="data-display">
-          <div class="data-item">
-            <label>当前时间:</label>
-            <span>{{ currentTime }}</span>
-          </div>
-          <!-- <div class="data-item">
-            <label>测试状态:</label>
-            <span class="status-success">正常运行</span>
-          </div> -->
-          <div class="data-item">
-            <label>数据源:</label>
-            <span>比赛管理系统</span>
-          </div>
-        </div>
-
-        <div class="radio-options">
-          <div class="radio-group">
-            <label class="radio-label">
-              <input
-                type="radio"
-                name="testMode"
-                value="1"
-                v-model="selectedTestMode"
-                class="radio-input"
-              >
-              <span class="radio-text">今日</span>
-            </label>
-            <label class="radio-label">
-              <input
-                type="radio"
-                name="testMode"
-                value="0"
-                v-model="selectedTestMode"
-                class="radio-input"
-              >
-              <span class="radio-text">早盘</span>
-            </label>
-            <label class="radio-label">
-              <input
-                type="radio"
-                name="testMode"
-                value=""
-                v-model="selectedTestMode"
-                class="radio-input"
-              >
-              <span class="radio-text">全部</span>
-            </label>
-          </div>
-        </div>
-
-        <div class="action-buttons">
-          <button class="btn btn-primary" @click="refreshData">
-            刷新数据
-          </button>
-          <button class="btn btn-secondary" @click="runTest">
-            运行测试
-          </button>
-          <button class="btn btn-secondary" @click="verifyData">
-            验证数据
-          </button>
-        </div>
-      </div>
-    </div>
-  </div>
-</template>
-
 <script setup>
-import { ref, onMounted } from 'vue';
+import { ref, watch, onMounted } from 'vue';
 import { requestClient } from '#/api/request';
+import { Page } from '@vben/common-ui';
+import { Form, Input, RadioGroup, Radio, Button, message } from 'ant-design-vue';
+
+import dayjs from 'dayjs';
+import VueJsonPretty from 'vue-json-pretty';
+import 'vue-json-pretty/lib/styles.css';
 
 const currentTime = ref('');
 const gamesRelation = ref([]);
 const gamesSolution = ref({});
-const selectedTestMode = ref('1');
+const selectedTestMode = ref(-1);
+const idsText = ref('');
+const idsList = ref([]);
+const buttonDisabled = ref(false);
+
+const prettyData = ref(null);
+const prettyKey = ref(Date.now());
+const dataCount = ref('');
+
+watch(selectedTestMode, () => {
+  refreshData();
+});
 
 const updateTime = () => {
-  currentTime.value = new Date().toLocaleString('zh-CN')
+  currentTime.value = new Date().toLocaleString('zh-CN');
+}
+
+const idsInput = (value) => {
+  idsList.value = idsText.value.split(',').map(item => +item.trim()).filter(item => !!item);
 }
 
 const refreshData = () => {
+  buttonDisabled.value = true;
   updateTime();
+  const win_min = -99999;
+  const mk = selectedTestMode.value;
+  const show_lower = true;
   Promise.all([
-    requestClient.get('/pstery/get_games_relation', { params: { mk: selectedTestMode.value } }),
-    requestClient.get('/pstery/get_solutions')
+    requestClient.get('/pstery/get_games_relation', { params: { mk } }),
+    requestClient.get('/pstery/get_solutions', { params: { win_min, mk, show_lower } })
   ])
   .then(([relations, solutions]) => {
     gamesRelation.value = relations;
     gamesSolution.value = solutions;
+    prettyData.value = null;
+    dataCount.value = '';
+    message.success('数据已刷新');
     console.log('数据已刷新');
+  })
+  .catch(err => {
+    message.error('数据刷新失败');
+    console.error('数据刷新失败', err);
+  })
+  .finally(() => {
+    buttonDisabled.value = false;
   });
 }
 
-const runTest = () => {
+const showDataPretty = (data, deep) => {
+  prettyData.value = data;
+  prettyKey.value = Date.now();
+}
+
+const runTest = (hasSolutions=true) => {
+  const gamesList = gamesRelation.value.map(item => {
+    const { mk } = item ?? {};
+    const { eventId, leagueName, teamHomeName, teamAwayName, timestamp } = item?.rel?.ps ?? {};
+    const datetime = dayjs(timestamp).format('YYYY-MM-DD HH:mm:ss');
+    return { eventId, leagueName, teamHomeName, teamAwayName, datetime, mk };
+  });
   gamesSolution.value.solutions.forEach((solution) => {
-    const { info: { id }} = solution;
-    const relation = gamesRelation.value.find((relation) => relation.id === id);
-    if (!relation) {
-      console.log('relation not found', id);
+    const { sid, info: { id }} = solution;
+    const currentGame = gamesList.find(game => game.eventId === id);
+    if (!currentGame) {
+      console.log('game not found', id);
     }
     else {
-      if (!relation.solutions) {
-        relation.solutions = [];
+      if (!currentGame.solutions) {
+        currentGame.solutions = [];
       }
-      relation.solutions.push(solution);
+      currentGame.solutions.push(sid);
     }
   });
-  console.log('gamesRelation', gamesRelation);
-}
-
-const verifyData = () => {
-  const solutions = gamesSolution.value.solutions;
-  const validSolutions = []
-  const invalidSolutions = [];
-  solutions.forEach((solution) => {
-    const { sol: { win_average, win_profit_rate } } = solution;
-    if (win_average * win_profit_rate >= 0) {
-      validSolutions.push(solution);
+  const dataList = gamesList.filter(item => {
+    if (hasSolutions) {
+      return !!item.solutions?.length;
     }
     else {
-      invalidSolutions.push(solution);
+      return !item.solutions?.length;
     }
+  }).map((item, index) => {
+    const serial = index + 1;
+    return { serial, ...item };
   });
-  console.log('validSolutions', validSolutions);
-  console.log('invalidSolutions', invalidSolutions);
+  dataCount.value = `${dataList.length} / ${gamesList.length}`;
+  showDataPretty(dataList, 2);
+  console.log('gamesRelation', dataList);
 }
 
-// const currentTime = ref('')
-
-// const updateTime = () => {
-//   currentTime.value = new Date().toLocaleString('zh-CN')
-// }
+const filterLive = () => {
+  const solutions = gamesSolution.value.solutions;
+  const liveSolutions = solutions.filter((solution) => {
+    const { info: { ob, hg, ps } } = solution;
+    return ps.stage;
+  });
+  dataCount.value = `${liveSolutions.length}`;
+  showDataPretty(liveSolutions, 2);
+  console.log('liveSolutions', liveSolutions);
+}
 
-// const refreshData = () => {
-//   updateTime()
-//   console.log('数据已刷新')
-// }
+const filterHalf = () => {
+  const solutions = gamesSolution.value.solutions;
+  const halfSolutions = solutions.filter((solution) => {
+    const { info: { ob, hg, ps } } = solution;
+    return ps.stage === 'HT';
+  });
+  dataCount.value = `${halfSolutions.length}`;
+  showDataPretty(halfSolutions, 2);
+  console.log('halfSolutions', halfSolutions);
+}
 
-// const runTest = () => {
-//   console.log('开始运行测试...')
-//   // 这里可以添加具体的测试逻辑
-// }
+const filterTarget = () => {
+  requestClient.get('/pstery/get_games_relation', { params: { mk: selectedTestMode.value, le: true, ids: idsText.value } })
+  .then(data => {
+    dataCount.value = `${data.length}`;
+    showDataPretty(data, 2);
+    console.log('data', data);
+  })
+  .catch(err => {
+    message.error('数据获取失败');
+    console.error('数据获取失败', err);
+  })
+}
 
 onMounted(() => {
   refreshData();
 })
 </script>
 
+<template>
+  <Page title="数据测试" description="用于测试和验证比赛数据的页面">
+
+    <Form :label-col="{ span: 2 }" :wrapper-col="{ span: 14 }">
+      <Form.Item label="数据源:">
+        <span>比赛管理系统</span>
+      </Form.Item>
+      <Form.Item label="更新时间:">
+        <span>{{ currentTime }}</span>
+      </Form.Item>
+      <Form.Item label="测试模式:">
+        <RadioGroup v-model:value="selectedTestMode">
+          <Radio :value="-1">全部</Radio>
+          <Radio :value="2">滚球</Radio>
+          <Radio :value="1">今日</Radio>
+          <Radio :value="0">早盘</Radio>
+        </RadioGroup>
+      </Form.Item>
+      <Form.Item label="目标赛事ID:">
+        <Input v-model:value="idsText" placeholder="请输入赛事ID,多个ID用逗号分隔" @input="idsInput" />
+      </Form.Item>
+      <Form.Item :wrapper-col="{ span: 14, offset: 2 }">
+        <Button @click="refreshData" :disabled="buttonDisabled">
+          刷新数据
+        </Button>
+        <Button @click="runTest(true)" :disabled="buttonDisabled">
+          有欢乐值
+        </Button>
+        <Button @click="runTest(false)" :disabled="buttonDisabled">
+          无欢乐值
+        </Button>
+        <Button @click="filterLive" :disabled="buttonDisabled">
+          筛选滚球
+        </Button>
+        <Button @click="filterHalf" :disabled="buttonDisabled">
+          筛选中场
+        </Button>
+        <Button @click="filterTarget" :disabled="!idsList.length">
+          目标赛事
+        </Button>
+      </Form.Item>
+    </Form>
+
+    <div class="data-table" v-if="prettyData">
+      <div class="data-count" v-if="dataCount">
+        <span>数据总数:{{ dataCount }}</span>
+      </div>
+      <vue-json-pretty :data="prettyData" :indent="2" :deep="2" :key="prettyKey" :showDoubleQuotes="false"></vue-json-pretty>
+    </div>
+  </Page>
+</template>
+
 <style lang="scss" scoped>
-.data-test-container {
+.data-table {
   padding: 20px;
-
-  .page-header {
-    margin-bottom: 30px;
-
-    h1 {
-      font-size: 24px;
-      font-weight: bold;
-      margin-bottom: 8px;
-      color: #333;
-    }
-
-    p {
-      color: #666;
-      font-size: 14px;
-    }
-  }
-
-  .content-area {
-    .test-panel {
-      background: #fff;
-      border-radius: 8px;
-      padding: 24px;
-      box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
-
-      h2 {
-        font-size: 18px;
-        font-weight: 600;
-        margin-bottom: 20px;
-        color: #333;
-      }
-
-      .data-display {
-        margin-bottom: 24px;
-
-        .data-item {
-          display: flex;
-          align-items: center;
-          margin-bottom: 12px;
-
-          label {
-            font-weight: 500;
-            color: #555;
-            min-width: 100px;
-          }
-
-          span {
-            color: #333;
-
-            &.status-success {
-              color: #52c41a;
-              font-weight: 500;
-            }
-          }
-        }
-      }
-
-      .radio-options {
-        margin-bottom: 20px;
-
-        .radio-group {
-          display: flex;
-          gap: 20px;
-
-          .radio-label {
-            display: flex;
-            align-items: center;
-            cursor: pointer;
-            user-select: none;
-
-            .radio-input {
-              margin-right: 8px;
-              cursor: pointer;
-            }
-
-            .radio-text {
-              font-size: 14px;
-              color: #333;
-            }
-          }
-        }
-      }
-
-      .action-buttons {
-        display: flex;
-        gap: 12px;
-
-        .btn {
-          padding: 8px 16px;
-          border-radius: 6px;
-          border: none;
-          cursor: pointer;
-          font-size: 14px;
-          transition: all 0.2s;
-
-          &.btn-primary {
-            background: #1890ff;
-            color: white;
-
-            &:hover {
-              background: #40a9ff;
-            }
-          }
-
-          &.btn-secondary {
-            background: #f5f5f5;
-            color: #333;
-            border: 1px solid #d9d9d9;
-
-            &:hover {
-              background: #e6f7ff;
-              border-color: #1890ff;
-            }
-          }
-        }
-      }
-    }
-  }
 }
 </style>

+ 0 - 414
web/apps/web-antd/src/views/match/related/index.vue

@@ -1,414 +0,0 @@
-<script setup>
-import { requestClient } from '#/api/request';
-import { Button, message } from 'ant-design-vue';
-import { ref, reactive, computed, onMounted } from 'vue';
-import dayjs from 'dayjs';
-import MatchItem from '../components/match_item.vue';
-
-const gamesList = reactive({});
-const gamesRelations = reactive({});
-const currentRelation = reactive({});
-const selectedInfo = ref(null);
-
-const formatDate = (timestamp) => {
-  return dayjs(timestamp).format('YYYY-MM-DD HH:mm:ss');
-}
-
-const formatGameItem = (game, platform) => {
-  const selected = currentRelation[platform]?.eventId == game.eventId;
-  const { timestamp } = game;
-  const dateTime = formatDate(timestamp);
-  return { ...game, dateTime, selected };
-}
-
-const setGameOrderWeight= (game) => {
-  const { t, l, h, a } = selectedInfo.value ?? {};
-  game.orderWeight = 0;
-  const { leagueName, teamHomeName, teamAwayName, timestamp } = game;
-  if (timestamp != t) {
-    game.orderWeight = -99;
-  }
-  else  {
-    game.orderWeight += 1;
-  }
-  if (leagueName.startsWith(l)) {
-    game.orderWeight += 1;
-  }
-  if (teamHomeName.startsWith(h)) {
-    game.orderWeight += 1;
-  }
-  if (teamAwayName.startsWith(a)) {
-    game.orderWeight += 1;
-  }
-  return game;
-}
-
-const getGameOrderList = (platform) => {
-  let games = gamesList[platform]?.games ?? [];
-  const relatedGames = new Set(Object.values(gamesRelations).map(item => item[platform]?.eventId));
-  games = games.map(game => formatGameItem(game, platform));
-  if (platform == 'jc') {
-    return games.filter(game => !relatedGames.has(game.eventId))
-    .sort((a, b) => {
-      if (a.selected) {
-        return -1;
-      }
-      return 1;
-    });
-  }
-  return games.map(setGameOrderWeight)
-  .sort((a, b) => b.orderWeight - a.orderWeight)
-  .filter(game => {
-    if (game.orderWeight > 0 && !relatedGames.has(game.eventId)) {
-      return true;
-    }
-    return false;
-  });
-}
-
-const showRelationButton = computed(() => {
-  return currentRelation.ps || currentRelation.ob;
-});
-
-const relationsList = computed(() => {
-  return Object.keys(gamesRelations).map(id => {
-    const rel = gamesRelations[id];
-    Object.values(rel).forEach(item => {
-      item.dateTime = formatDate(item.timestamp);
-    });
-    return { id, rel };
-  });
-});
-
-const jcGamesList = computed(() => {
-  return getGameOrderList('jc');
-});
-
-const psGamesList = computed(() => {
-  return getGameOrderList('ps');
-});
-
-const obGamesList = computed(() => {
-  return getGameOrderList('ob');
-});
-
-const getGamesList = async () => {
-  try {
-    const data = await requestClient.get('/triangle/get_games_list');
-    return data;
-  }
-  catch (error) {
-    console.error('Failed to fetch games info:', error);
-    message.error('获取比赛信息失败');
-    return [];
-  }
-}
-
-const getGamesRelations = async () => {
-  try {
-    const data = await requestClient.get('/triangle/get_games_relation');
-    return data;
-  }
-  catch (error) {
-    console.error('Failed to fetch game relations:', error);
-    message.error('获取比赛关系失败');
-    return [];
-  }
-}
-
-const setGamesRelation = () => {
-  const rel = currentRelation;
-  Object.keys(rel).forEach(key => {
-    if (!rel[key]) {
-      delete rel[key];
-    }
-  });
-  Object.values(rel).forEach(item => {
-    delete item.orderWeight;
-  });
-  const id = rel['jc']?.eventId;
-  if (!id) {
-    console.log('没有选择竞彩的比赛');
-    message.warn('设置比赛关系失败');
-  }
-  requestClient.post('/triangle/update_games_relation', { id, rel })
-  .then(res => {
-    console.log('设置比赛关系成功', res);
-    message.success('设置比赛关系成功');
-    selectedInfo.value = null;
-    currentRelation.jc = currentRelation.ps = currentRelation.ob = null;
-    updateGamesRelations();
-  })
-  .catch(error => {
-    console.error('Failed to set game relation:', error);
-    message.error('设置比赛关系失败');
-  });
-}
-
-const removeGamesRelation = (id) => {
-  requestClient.post('/triangle/remove_games_relation', { id })
-  .then(res => {
-    console.log('删除比赛关系成功', res);
-    message.success('删除比赛关系成功');
-    updateGamesRelations();
-  })
-  .catch(error => {
-    console.error('Failed to remove game relation:', error);
-    message.error('删除比赛关系失败');
-  });
-}
-
-const openRelationModal = () => {
-  relationModalVisible.value = true;
-}
-
-const updateGamesList = async () => {
-  const data = await getGamesList();
-  Object.keys(data).forEach(key => {
-    gamesList[key] = data[key];
-  });
-}
-
-const updateGamesRelations = async () => {
-  const data = await getGamesRelations();
-  data.forEach(item => {
-    const { id, rel } = item;
-    gamesRelations[id] = rel;
-  });
-  const newIds = new Set(data.map(item => item.id));
-  Object.keys(gamesRelations).forEach(id => {
-    if (!newIds.has(id)) {
-      delete gamesRelations[id];
-    }
-  });
-}
-
-const selectGame = (platform, game) => {
-  const { leagueId, eventId, timestamp, leagueName, teamHomeName, teamAwayName, selected, matchNumStr } = game;
-  if (selected) {
-    currentRelation[platform] = null;
-  }
-  else {
-    currentRelation[platform] = { leagueId, eventId, timestamp, leagueName, teamHomeName, teamAwayName, matchNumStr };
-  }
-  if (platform == 'jc') {
-    currentRelation.ps = null;
-    currentRelation.ob = null;
-    if (selected) {
-      selectedInfo.value = null;
-    }
-    else {
-      selectedInfo.value = {
-        t: timestamp,
-        l: leagueName,
-        h: teamHomeName,
-        a: teamAwayName,
-      }
-    }
-  }
-}
-
-onMounted(() => {
-  updateGamesList();
-  updateGamesRelations();
-});
-</script>
-
-<template>
-
-  <div class="relation-container">
-    <div class="top-panel" ref="topPanel">
-      <span>竞彩</span>
-      <span>平博</span>
-      <span>OB</span>
-      <i>{{ relationsList.length }}</i>
-    </div>
-    <div class="match-list" v-if="relationsList.length">
-      <div class="match-row" v-for="({ id, rel }) in relationsList" :key="id">
-        <MatchItem
-          :eventId="rel.jc.eventId"
-          :leagueName="rel.jc.leagueName"
-          :teamHomeName="rel.jc.teamHomeName"
-          :teamAwayName="rel.jc.teamAwayName"
-          :dateTime="rel.jc.dateTime"
-          :matchNumStr="rel.jc.matchNumStr" />
-        <MatchItem  v-if="rel.ps"
-          :eventId="rel.ps.eventId"
-          :leagueName="rel.ps.leagueName"
-          :teamHomeName="rel.ps.teamHomeName"
-          :teamAwayName="rel.ps.teamAwayName"
-          :dateTime="rel.ps.dateTime" />
-        <div class="match-item match-item-holder" v-else></div>
-        <MatchItem v-if="rel.ob"
-          :eventId="rel.ob.eventId"
-          :leagueName="rel.ob.leagueName"
-          :teamHomeName="rel.ob.teamHomeName"
-          :teamAwayName="rel.ob.teamAwayName"
-          :dateTime="rel.ob.dateTime" />
-        <div class="match-item match-item-holder" v-else></div>
-        <div class="match-action">
-          <Button type="link" class="action-btn" @click="removeGamesRelation(id)">
-            <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="3 6 5 6 21 6"></polyline><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"></path></svg>
-          </Button>
-        </div>
-      </div>
-    </div>
-    <div class="match-list col-list" v-if="jcGamesList.length">
-      <div class="match-col">
-        <MatchItem v-for="game in jcGamesList" :key="game.id"
-          :eventId="game.eventId"
-          :leagueName="game.leagueName"
-          :teamHomeName="game.teamHomeName"
-          :teamAwayName="game.teamAwayName"
-          :dateTime="game.dateTime"
-          :matchNumStr="game.matchNumStr"
-          :selected="game.selected"
-          @click="selectGame('jc', game)" />
-      </div>
-      <div class="match-col">
-        <MatchItem v-for="game in psGamesList" :key="game.id"
-          :eventId="game.eventId"
-          :leagueName="game.leagueName"
-          :teamHomeName="game.teamHomeName"
-          :teamAwayName="game.teamAwayName"
-          :dateTime="game.dateTime"
-          :selected="game.selected"
-          @click="selectGame('ps', game)" />
-      </div>
-      <div class="match-col">
-        <MatchItem v-for="game in obGamesList" :key="game.id"
-          :eventId="game.eventId"
-          :leagueName="game.leagueName"
-          :teamHomeName="game.teamHomeName"
-          :teamAwayName="game.teamAwayName"
-          :dateTime="game.dateTime"
-          :selected="game.selected"
-          @click="selectGame('ob', game)" />
-      </div>
-      <div class="match-action">
-        <Button type="link" class="action-btn" v-if="showRelationButton" @click="setGamesRelation">
-          <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"></path><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"></path></svg>
-        </Button>
-      </div>
-    </div>
-    <div class="list-empty" v-if="!relationsList.length && !jcGamesList.length">暂无数据</div>
-  </div>
-
-</template>
-
-<style lang="scss" scoped>
-.relation-container {
-  padding: 10px;
-}
-
-.top-panel {
-  display: flex;
-  margin-bottom: 5px;
-  border: 1px solid hsl(var(--border));
-  border-radius: 6px;
-  background-color: hsl(var(--card));
-  span, i {
-    display: block;
-  }
-  span {
-    flex: 1;
-    text-align: center;
-    &:not(:last-child) {
-      border-right: 1px solid hsl(var(--border));
-    }
-  }
-  i {
-    width: 50px;
-    text-align: center;
-    font-style: normal;
-  }
-}
-
-.match-list {
-  margin-bottom: 5px;
-  border: 1px solid hsl(var(--border));
-  border-radius: 6px;
-  background-color: hsl(var(--card));
-  overflow: hidden;
-  &.col-list {
-    display: flex;
-  }
-}
-
-.match-row {
-  display: flex;
-  flex-wrap: wrap;
-  &:not(:last-child) {
-    border-bottom: 1px solid hsl(var(--border));
-  }
-  .match-item {
-    &:not(:last-child) {
-      border-right: 1px solid hsl(var(--border));
-    }
-  }
-}
-
-.match-col {
-  flex: 1;
-  &:not(:last-child) {
-    border-right: 1px solid hsl(var(--border));
-  }
-  .match-item {
-    border-bottom: 1px solid hsl(var(--border));
-    &:last-child {
-      margin-bottom: -1px;
-    }
-  }
-}
-
-.match-item-holder {
-  flex: 1;
-  padding: 10px 15px;
-}
-
-.match-action {
-  display: flex;
-  align-items: center;
-  justify-content: center;
-  width: 50px;
-}
-
-.action-btn {
-  padding: 4px;
-  display: flex;
-  align-items: center;
-  justify-content: center;
-  color: hsl(var(--foreground) / 0.7);
-  &:hover {
-    color: hsl(var(--foreground));
-  }
-  .col-list & {
-    position: relative;
-    top: 40px;
-    align-self: flex-start;
-  }
-  svg {
-    width: 16px;
-    height: 16px;
-  }
-}
-
-.list-empty {
-  text-align: center;
-  padding: 10px;
-  font-size: 18px;
-  color: hsl(var(--foreground) / 0.7);
-}
-
-@media (max-width: 768px) {
-  .match-item {
-    min-width: calc(50% - 16px);
-  }
-}
-
-@media (max-width: 480px) {
-  .match-item {
-    min-width: calc(100% - 16px);
-  }
-}
-</style>

+ 208 - 442
web/apps/web-antd/src/views/match/solutions/index.vue

@@ -1,23 +1,28 @@
 <script setup>
 import { requestClient } from '#/api/request';
-import { Button, message, Form, InputNumber, Drawer } from 'ant-design-vue';
-import { ref, reactive, computed, onMounted, onUnmounted } from 'vue';
+import { Button, message, Form, InputNumber, RadioGroup, Radio, Checkbox, Drawer, Input, Switch } from 'ant-design-vue';
+import { ref, reactive, computed, watch, onMounted, onUnmounted } from 'vue';
 import dayjs from 'dayjs';
 
-import MatchCard from '../components/match_card.vue';
+// import MatchCard from '../components/match_card.vue';
+import SolutionItem from '../components/solution_item.vue';
 
 import { useContentsPositionStore } from '@vben/stores';
 const contentsPositionStore = useContentsPositionStore();
 
 const solutions = ref([]);
+const markCount = ref({ all: 0, rollball: 0, today: 0, early: 0 });
 const selectedSolutions = reactive([]);
 const totalProfit = ref({});
 const loopActive = ref(false);
-
-const psOptions = reactive({
-  bet: 10000,
-  rebate: 0,
-});
+const loopTimer = ref(null);
+const updateTimer = ref(null);
+const minProfitRate = ref(2);
+const marketType = ref(-1);
+const dataType = ref(0);
+const showLower = ref(false);
+const searchValue = ref('');
+const updateLoaderHide = ref(null);
 
 const totalProfitVisible = ref(false);
 
@@ -35,99 +40,24 @@ const headerStyle = computed(() => {
   }
 });
 
-const totalProfitValue = computed(() => {
-  const { profit = {}, preSolution = {}, subSolution = {}, gamesEvents = {} } = totalProfit.value;
-  const sol1 = formatSolution(preSolution, gamesEvents);
-  const sol2 = formatSolution(subSolution, gamesEvents);
-
-  const psScale = psOptions.bet / profit.ps_base ?? 10000;
-  const psRebate = psOptions.bet * psOptions.rebate / 100;
-  const profitInfo = {};
-  Object.keys(profit).forEach(key => {
-    if (key == 'win_diff') {
-      return;
-    }
-    if (key.startsWith('gold')) {
-      profitInfo[key] = fixFloat(profit[key] * psScale);
-    }
-    else if (key.startsWith('win_')) {
-      profitInfo[key] = fixFloat(profit[key] * psScale + psRebate);
-    }
-  });
-
-  const psInfo = [];
-  const outPreSol = [];
-  const outSubSol = [];
-
-  const solutions = [sol1, sol2].filter(item => item);
-  solutions.forEach((item, index) => {
-    const { sol: { ps_index }, cpr } = item;
-    const newCpr = [...cpr];
-    const ps_info = newCpr.splice(ps_index, 1);
-    psInfo.push({ ...ps_info[0] });
-    newCpr.forEach((c, i) => {
-      let side = '';
-      if (ps_index == 0) {
-        if (i == 0) {
-          side = "B"
-        }
-        else {
-          side = "M";
-        }
-      }
-      else if (ps_index == 1) {
-        if (i == 0) {
-          side = "A";
-        }
-        else {
-          side = "M";
-        }
-      }
-      else {
-        if (i == 0) {
-          side = "A";
-        }
-        else {
-          side = "B";
-        }
-      }
-      if (index == 0) {
-        outPreSol.push({ ...c, g: profitInfo[`gold${side}${index+1}`] });
-      }
-      else {
-        outSubSol.push({ ...c, g: profitInfo[`gold${side}${index+1}`] });
-      }
-    })
-  });
-
-  return { solutions, profit: profitInfo, psInfo, outPreSol, outSubSol };
-});
-
 const solutionsList = computed(() => {
-  const startTimestamp = selectedSolutions[0]?.timestamp ?? 0;
   return solutions.value.map(item => {
-    const selected = selectedSolutions.findIndex(sol => sol.sid === item.sid) >= 0;
-    const disabled = false && !selected && (item.info.ps.timestamp < startTimestamp + 1000 * 60 * 60 * 2);
-    const currentSol = { ...item.sol };
-
-    const psScale = psOptions.bet / currentSol.inner_base;
-    const psRebate = psOptions.bet * psOptions.rebate / 100;
-
-    Object.keys(currentSol).forEach(key => {
-      if (key.startsWith('gold_')) {
-        currentSol[key] = fixFloat(currentSol[key] * psScale);
-      }
-      else if (key.startsWith('win_')) {
-        currentSol[key] = fixFloat(currentSol[key] * psScale + psRebate);
-      }
-    });
-    return { ...item, sol: currentSol, selected, disabled };
+    const selected = selectedSolutions.findIndex(sol => sol.id == item.id) >= 0;
+    const topSolutions = item.solutions.slice(0, 10);
+    return { ...item, solutions: topSolutions, selected };
   });
 });
 
+
 const getSolutions = async () => {
   try {
-    const data = await requestClient.get('/pstery/get_solutions');
+    const mk = marketType.value;
+    const tp = dataType.value;
+    const sk = searchValue.value.trim();
+    const win_min = !!sk ? -99999 : minProfitRate.value * 100;
+    const with_events = true;
+    const show_lower = showLower.value;
+    const data = await requestClient.get('/pstery/get_games_solutions', { params: { win_min, mk, tp, sk, with_events, show_lower } });
     return data;
   }
   catch (error) {
@@ -140,7 +70,7 @@ const getSolutions = async () => {
 const calcTotalProfit = async () => {
   const sids = selectedSolutions.map(item => item.sid);
   try {
-    const totalProfit = await requestClient.post('/pstery/calc_total_profit', [...sids, psOptions.bet]);
+    const totalProfit = await requestClient.post('/pstery/calc_total_profit', {sids});
     return totalProfit;
   }
   catch (error) {
@@ -150,203 +80,25 @@ const calcTotalProfit = async () => {
   }
 }
 
-const parseIorKey = (iorKey) => {
-  const [, type, accept, side, , ratioString] = iorKey.match(/^ior_(r|ou|m|wm|ot|os)(a?)(h|c|n)?(_([\d-]+))?$/);
-  let ratio = 0;
-  if (type === 'ot' || type === 'os') {
-    ratio = ratioString;
-  }
-  else if (ratioString) {
-    ratio = `${ratioString[0]}.${ratioString.slice(1)}` * (accept ? 1 : -1);
-  }
-  return { type, side, ratio };
-}
-
-const PS_IOR_KEYS = [
-  ['0', 'ior_mh', 'ior_mn', 'ior_mc'],
-  ['-1', 'ior_rh_15', 'ior_wmh_1', 'ior_rac_05'],
-  ['-2', 'ior_rh_25', 'ior_wmh_2', 'ior_rac_15'],
-  ['+1', 'ior_rah_05', 'ior_wmc_1', 'ior_rc_15'],
-  ['+2', 'ior_rah_15', 'ior_wmc_2', 'ior_rc_25'],
-  ['0-1', 'ior_ot_0', 'ior_os_0-1', 'ior_ot_1'],
-  ['2-3', 'ior_ot_2', 'ior_os_2-3', 'ior_ot_3'],
-];
-
-const formatPsEvents = (events) => {
-  return PS_IOR_KEYS.map(([label, ...keys]) => {
-    const match = keys.map(key => ({
-      key,
-      value: events[key] ?? 0
-    }));
-    return {
-      label,
-      match
-    };
-  })
-  // .filter(item => item.match.every(entry => entry.value !== 0))
-  .map(({label, match}) => [label, ...match]);
-}
-
-// const rivalIor = (ior) => {
-//   const map = {
-//     "ior_rh": "ior_rac",
-//     "ior_rc": "ior_rah",
-//     "ior_rac": "ior_rh",
-//     "ior_rah": "ior_rc",
-//     "ior_wmh": "ior_wmc",
-//     "ior_wmc": "ior_wmh",
-//     "ior_wmh_2": "ior_wmc_2",
-//     "ior_wmc_2": "ior_wmh_2"
-//   };
-//   const iorInfos = ior.split('_');
-//   const iorStart = iorInfos.slice(0, 2).join('_');
-//   if (!map[iorStart]) {
-//     return ior;
-//   }
-//   return `${map[iorStart]}_${iorInfos[2]}`;
-// }
-
-const formatEvents = (events, cprKeys) => {
-  const eventsMap = {};
-  Object.keys(events).forEach(key => {
-    const { type, side, ratio } = parseIorKey(key);
-    let ratioKey, index;
-    if (type === 'r') {
-      if (side === 'h') {
-        ratioKey = ratio;
-        index = 0;
-      }
-      else if (side === 'c') {
-        ratioKey = -ratio;
-        index = 2;
-      }
-    }
-    else if (type === 'm') {
-      ratioKey = 'm';
-      if (side == 'h') {
-        index = 0;
-      }
-      else if (side == 'c') {
-        index = 2;
-      }
-      else {
-        index = 1;
-      }
-    }
-    else if (type === 'wm') {
-      ratioKey = `wm_${Math.abs(ratio)}`;
-      if (side === 'h') {
-        index = 0;
-      }
-      else if (side === 'c') {
-        index = 2;
-      }
-    }
-    else if (type === 'ou') {
-      ratioKey = `ou_${Math.abs(ratio)}`;
-      if (side === 'c') {
-        index = 0;
-      }
-      else if (side === 'h') {
-        index = 2;
-      }
-    }
-    else if (type === 'os') {
-      ratioKey = ratio;
-      index = 1;
-    }
-    else if (type === 'ot') {
-      switch (ratio) {
-        case '0':
-          ratioKey = '0-1';
-          index = 0;
-          break;
-        case '1':
-          ratioKey = '0-1';
-          index = 2;
-          break;
-        case '2':
-          ratioKey = '2-3';
-          index = 0;
-          break;
-        case '3':
-          ratioKey = '2-3';
-          index = 2;
-          break;
-      }
-    }
-    if (typeof (ratioKey) == 'number') {
-      if (ratioKey > 0) {
-        ratioKey = `+${ratioKey}`;
-      }
-      // else if (ratioKey === 0) {
-      //   ratioKey = '-0';
-      // }
-      else {
-        ratioKey = `${ratioKey}`;
-      }
-    }
-
-    if (!ratioKey) {
-      return;
-    }
-
-    if (!eventsMap[ratioKey]) {
-      eventsMap[ratioKey] = new Array(3).fill(undefined);
-    }
-
-    const value = events[key] ?? 0;
-    eventsMap[ratioKey][index] = { key, value };
-  });
-
-  return Object.keys(eventsMap).sort((a, b) => a.localeCompare(b)).map(key => {
-    return [key, ...eventsMap[key]];
-  });
-}
-
-const formatSolution = (solution, eventsList) => {
-  const { cpr, info } = solution;
-  if (!cpr || !info) {
-    return null;
+const updateSolutions = async (showLoading=false) => {
+  clearTimeout(loopTimer.value);
+  if (showLoading && !updateLoaderHide.value) {
+    updateLoaderHide.value = message.loading('数据加载中...', 0);
   }
-
-  const cprKeys = cpr.map(item => item.k);
-
-  const psEvents = eventsList.ps?.[info.ps.eventId] ?? {};
-  const obEvents = eventsList.ob?.[info.ob.eventId] ?? {};
-  const hgEvents = eventsList.hg?.[info.hg.eventId] ?? {};
-
-  info.ps.events = formatPsEvents(psEvents);
-  info.ob.events = formatEvents(obEvents, cprKeys);
-  info.hg.events = formatEvents(hgEvents, cprKeys);
-
-  info.ps.dateTime = dayjs(info.ps.timestamp).format('YYYY-MM-DD HH:mm:ss');
-  info.ob.dateTime = dayjs(info.ob.timestamp).format('YYYY-MM-DD HH:mm:ss');
-  info.hg.dateTime = dayjs(info.hg.timestamp).format('YYYY-MM-DD HH:mm:ss');
-
-  cpr.forEach(item => {
-    const { k, p } = item;
-    if (!info[p]['selected']) {
-      info[p]['selected'] = [];
-    }
-    info[p]['selected'].push(k);
-  });
-
-  return solution;
-}
-
-const updateSolutions = async () => {
   getSolutions()
-  .then(({ solutions: solutionsList, gamesEvents: eventsList }) => {
-    solutions.value = solutionsList?.map(solution => formatSolution(solution, eventsList)) ?? [];
+  .then(({ gamesSolutions, mkCount }) => {
+    solutions.value = gamesSolutions ?? [];
+    markCount.value = mkCount;
   })
   .catch(error => {
     console.error('Failed to update solutions:', error);
     message.error('获取中单方案失败');
   })
   .finally(() => {
+    updateLoaderHide.value?.();
+    updateLoaderHide.value = null;
     if (loopActive.value) {
-      setTimeout(() => {
+      loopTimer.value = setTimeout(() => {
         updateSolutions();
       }, 1000 * 10);
     }
@@ -360,31 +112,111 @@ const showTotalProfit = async () => {
   console.log('profit', profit);
 };
 
-const closeTotalProfit = () => {
-  totalProfitVisible.value = false;
-  selectedSolutions.length = 0;
-  totalProfit.value = {};
-};
 
-const toggleSolution = (sid, timestamp) => {
-  const findIndex = selectedSolutions.findIndex(item => item.sid === sid);
+const toggleSolution = (data) => {
+  // console.log('toggleSolution', data);
+  const { id, sid } = data;
+  const findIndex = selectedSolutions.findIndex(item => item.id == id);
   if (findIndex >= 0) {
-    selectedSolutions.splice(findIndex, 1);
+    if (selectedSolutions[findIndex].sid == sid) {
+      selectedSolutions.splice(findIndex, 1);
+    }
+    else {
+      selectedSolutions.splice(findIndex, 1, data);
+    }
   }
   else if (selectedSolutions.length < 2) {
-    selectedSolutions.push({ sid, timestamp });
+    selectedSolutions.push(data);
   }
   else {
-    selectedSolutions.splice(1, 1, { sid, timestamp });
+    selectedSolutions.splice(1, 1, data);
   }
   if (selectedSolutions.length == 2) {
     showTotalProfit();
   }
 }
 
+const setLocalStorage = (key, value) => {
+  localStorage.setItem(key, JSON.stringify(value));
+}
+
+const getLocalStorage = (key) => {
+  const value = localStorage.getItem(key);
+  return value ? JSON.parse(value) : null;
+}
+
+watch(searchValue, (newVal, oldVal) => {
+  if (newVal.trim() == oldVal.trim()) {
+    return;
+  }
+  clearTimeout(updateTimer.value);
+  updateTimer.value = setTimeout(() => {
+    updateSolutions();
+  }, 1000);
+});
+
+watch(minProfitRate, (newVal) => {
+  clearTimeout(updateTimer.value);
+  updateTimer.value = setTimeout(() => {
+    setLocalStorage('minProfitRate', newVal);
+    updateSolutions();
+  }, 1000);
+});
+
+watch(marketType, (newVal) => {
+  if (!updateLoaderHide.value) {
+    updateLoaderHide.value = message.loading('数据更新中...', 0);
+  }
+  clearTimeout(updateTimer.value);
+  updateTimer.value = setTimeout(() => {
+    setLocalStorage('marketType', newVal);
+    updateSolutions();
+  }, 1000);
+});
+
+watch(dataType, (newVal) => {
+  if (!updateLoaderHide.value) {
+    updateLoaderHide.value = message.loading('数据更新中...', 0);
+  }
+  clearTimeout(updateTimer.value);
+  updateTimer.value = setTimeout(() => {
+    setLocalStorage('dataType', newVal);
+    updateSolutions();
+  }, 1000);
+});
+
+watch(showLower, (newVal) => {
+  if (!updateLoaderHide.value) {
+    updateLoaderHide.value = message.loading('数据更新中...', 0);
+  }
+  clearTimeout(updateTimer.value);
+  updateTimer.value = setTimeout(() => {
+    setLocalStorage('showLower', newVal);
+    updateSolutions();
+  }, 1000);
+});
+
 onMounted(() => {
   loopActive.value = true;
-  updateSolutions();
+  const min_win_rate = getLocalStorage('minProfitRate');
+  const mk = getLocalStorage('marketType');
+  const tp = getLocalStorage('dataType');
+  const show_lower = getLocalStorage('showLower');
+  if (min_win_rate !== null) {
+    minProfitRate.value = min_win_rate;
+  }
+  if (mk !== null) {
+    marketType.value = mk;
+  }
+  if (tp !== null) {
+    dataType.value = tp;
+  }
+  if (show_lower !== null) {
+    showLower.value = show_lower;
+  }
+  setTimeout(() => {
+    updateSolutions(true);
+  }, 100);
 });
 
 onUnmounted(() => {
@@ -399,104 +231,55 @@ onUnmounted(() => {
 
     <div class="contents-header transition-all duration-200" :style="headerStyle">
       <div class="solution-options">
-        <Form layout="inline">
-          <Form.Item label="PS 投注">
-            <InputNumber size="small" placeholder="PS投注" min="1000" v-model:value="psOptions.bet" />
+        <Form layout="inline" class="sol-opt-container">
+          <Form.Item label="比赛类型" class="sol-opt-item">
+            <RadioGroup v-model:value="marketType">
+              <Radio :value="-1">全部({{ markCount?.all ?? 0 }})</Radio>
+              <Radio :value="2">滚球({{ markCount?.rollball ?? 0 }})</Radio>
+              <Radio :value="1">今日({{ markCount?.today ?? 0 }})</Radio>
+              <Radio :value="0">早盘({{ markCount?.early ?? 0 }})</Radio>
+            </RadioGroup>
+          </Form.Item>
+          <Form.Item label="盘口类型" class="sol-opt-item">
+            <RadioGroup v-model:value="dataType">
+              <Radio :value="0">全部</Radio>
+              <Radio :value="1">让球</Radio>
+              <Radio :value="2">大小</Radio>
+            </RadioGroup>
+          </Form.Item>
+          <Form.Item label="最小利润率(%)" class="sol-opt-item input-item" :class="{ 'disabled': !!searchValue.trim() }">
+            <InputNumber class="number-input" size="small" max="100" min="-100" step="0.1" placeholder="最小利润率(%)" v-model:value="minProfitRate"/>
+          </Form.Item>
+          <Form.Item label="显示低赔盘" class="sol-opt-item input-item">
+            <Switch v-model:checked="showLower" />
+          </Form.Item>
+          <Form.Item class="sol-opt-item input-item">
+            <Input class="search-input" placeholder="搜索联赛/球队" :allowClear="true" v-model:value="searchValue"/>
           </Form.Item>
-          <!-- <Form.Item label="PS 返点">
-            <InputNumber size="small" placeholder="PS返点" min="0" v-model:value="psOptions.rebate" />
-          </Form.Item> -->
         </Form>
       </div>
       <div class="solution-header">
         <span>PS</span>
-        <span>OB</span>
-        <span>HG</span>
-        <em>利润</em>
+        <span><Checkbox>OB</Checkbox></span>
+        <span><Checkbox>HG</Checkbox></span>
+        <span><Checkbox>IM</Checkbox></span>
+        <!-- <em>利润</em> -->
       </div>
     </div>
 
-    <div class="solution-list">
-      <div class="solution-item"
-        v-for="{ sid, sol: { win_average, win_profit_rate, cross_type }, info: { ps, ob, hg }, selected, disabled } in solutionsList" :key="sid"
-        :class="{ 'selected': selected, 'disabled': disabled }">
-        <MatchCard platform="ps" :eventId="ps.eventId" :leagueName="ps.leagueName" :teamHomeName="ps.teamHomeName"
-          :teamAwayName="ps.teamAwayName" :dateTime="ps.dateTime" :eventInfo="ps.eventInfo" :events="ps.events ?? []"
-          :matchNumStr="ps.matchNumStr" :selected="ps.selected ?? []" />
-
-        <MatchCard platform="ob" :eventId="ob.eventId" :leagueName="ob.leagueName" :teamHomeName="ob.teamHomeName"
-          :teamAwayName="ob.teamAwayName" :dateTime="ob.dateTime" :events="ob.events ?? []"
-          :selected="ob.selected ?? []" />
-
-        <MatchCard platform="hg" :eventId="hg.eventId" :leagueName="hg.leagueName" :teamHomeName="hg.teamHomeName"
-          :teamAwayName="hg.teamAwayName" :dateTime="hg.dateTime" :events="hg.events ?? []"
-          :selected="hg.selected ?? []" />
-
-        <div class="solution-profit" @click="!disabled && toggleSolution(sid, ps.timestamp)">
-          <p>{{ win_average }}</p>
-          <p>{{ win_profit_rate }}%</p>
-          <p>{{ cross_type }}</p>
-        </div>
-      </div>
+    <div class="solution-list" v-if="solutionsList.length">
+      <SolutionItem v-for="(solution, index) in solutionsList"
+        :key="solution.id"
+        :serial="index+1"
+        :id="solution.id"
+        :mk="solution.mk"
+        :rel="solution.rel"
+        :selected="solution.selected"
+        :solutions="solution.solutions"
+        @toggle="toggleSolution"
+      />
     </div>
-    <div class="list-empty" v-if="!solutionsList.length">暂无数据</div>
-
-    <!-- <Drawer
-      title="综合利润方案"
-      placement="bottom"
-      height="600"
-      :visible="totalProfitVisible"
-      @close="closeTotalProfit"
-    >
-      <div class="solution-total-profit" v-if="totalProfitValue.solutions.length">
-        <div class="solution-item"
-          v-for="{ sid, info: { ps, ob, hg } } in totalProfitValue.solutions" :key="sid">
-          <MatchCard platform="ps" :eventId="ps.eventId" :leagueName="ps.leagueName" :teamHomeName="ps.teamHomeName"
-            :teamAwayName="ps.teamAwayName" :dateTime="ps.dateTime" :eventInfo="ps.eventInfo" :events="ps.events ?? []"
-            :matchNumStr="ps.matchNumStr" :selected="ps.selected ?? []" />
-
-          <MatchCard platform="ob" :eventId="ob.eventId" :leagueName="ob.leagueName" :teamHomeName="ob.teamHomeName"
-            :teamAwayName="ob.teamAwayName" :dateTime="ob.dateTime" :events="ob.events ?? []"
-            :selected="ob.selected ?? []" />
-
-          <MatchCard platform="hg" :eventId="hg.eventId" :leagueName="hg.leagueName" :teamHomeName="hg.teamHomeName"
-            :teamAwayName="hg.teamAwayName" :dateTime="hg.dateTime" :events="hg.events ?? []"
-            :selected="hg.selected ?? []" />
-        </div>
-      </div>
-      <div class="profit-info">
-        <table>
-          <tr>
-            <th></th>
-            <td>PS</td>
-            <td colspan="2">第一场</td>
-            <td colspan="2">第二场</td>
-          </tr>
-          <tr>
-            <th>赔率</th>
-            <td>{{ totalProfitValue.psInfo[0]?.v }}: {{ totalProfitValue.psInfo[1]?.v }}</td>
-            <td>{{ totalProfitValue.outPreSol[0]?.p }}: {{ totalProfitValue.outPreSol[0]?.v }}</td>
-            <td>{{ totalProfitValue.outPreSol[1]?.p }}: {{ totalProfitValue.outPreSol[1]?.v }}</td>
-            <td>{{ totalProfitValue.outSubSol[0]?.p }}: {{ totalProfitValue.outSubSol[0]?.v }}</td>
-            <td>{{ totalProfitValue.outSubSol[1]?.p }}: {{ totalProfitValue.outSubSol[1]?.v }}</td>
-          </tr>
-          <tr>
-            <th>下注</th>
-            <td>{{ psOptions.bet }}</td>
-            <td>{{ totalProfitValue.outPreSol[0]?.g }}</td>
-            <td>{{ totalProfitValue.outPreSol[1]?.g }}</td>
-            <td>{{ totalProfitValue.outSubSol[0]?.g }}</td>
-            <td>{{ totalProfitValue.outSubSol[1]?.g }}</td>
-          </tr>
-          <tr>
-            <th>利润</th>
-            <td>{{ totalProfitValue.profit.win_ps }}</td>
-            <td colspan="2">{{ totalProfitValue.profit.win_target }}</td>
-            <td colspan="2">{{ totalProfitValue.profit.win_target }}</td>
-          </tr>
-        </table>
-      </div>
-    </Drawer> -->
+    <div class="list-empty" v-else>暂无数据</div>
 
   </div>
 </template>
@@ -511,6 +294,7 @@ onUnmounted(() => {
   border-bottom: 1px solid hsl(var(--border));
   background-color: hsl(var(--background));
 }
+
 .solution-options {
   position: relative;
   display: flex;
@@ -518,6 +302,43 @@ onUnmounted(() => {
   padding: 5px 20px;
   border-bottom: 1px solid hsl(var(--border));
 }
+
+.sol-opt-container {
+  flex-grow: 1;
+  // justify-content: flex-end;
+}
+
+.sol-opt-item {
+  margin-inline-end: 0 !important;
+  &:nth-child(2) {
+    margin-inline-start: auto;
+  }
+  &:nth-child(n+3) {
+    padding-inline-start: 15px;
+    border-left: 1px solid hsl(var(--border));
+  }
+  &.input-item:not(:last-child) {
+    padding-inline-end: 15px;
+  }
+  &.disabled {
+    opacity: 0.5;
+    * {
+      text-decoration: line-through;
+    }
+  }
+  .search-input, .number-input {
+    height: 28px;
+  }
+  .search-input {
+    width: 150px;
+  }
+  .number-input {
+    display: inline-flex;
+    width: 60px;
+    align-items: center;
+  }
+}
+
 .solution-header {
   position: relative;
   display: flex;
@@ -534,74 +355,19 @@ onUnmounted(() => {
     flex: 1;
   }
 
-  em {
-    width: 80px;
-    font-style: normal;
-  }
+  // em {
+  //   width: 80px;
+  //   font-style: normal;
+  // }
 }
 
 .solution-container {
   padding-top: 74px;
 }
 
-.solution-item {
-  display: flex;
-  .match-card {
-    flex: 1;
-  }
-}
-
 .solution-list {
   padding: 20px;
   overflow: hidden;
-
-  .solution-item {
-    border-radius: 10px;
-    background-color: hsl(var(--card));
-
-    &.selected {
-      background-color: hsl(var(--primary) / 0.15);
-    }
-
-    &.disabled {
-      opacity: 0.5;
-      cursor: not-allowed;
-    }
-
-    &:not(:last-child) {
-      margin-bottom: 20px;
-    }
-
-    .match-card {
-      border-right: 1px solid hsl(var(--border));
-    }
-
-    .solution-profit {
-      display: flex;
-      flex-direction: column;
-      width: 80px;
-      align-items: center;
-      justify-content: center;
-    }
-  }
-}
-
-.profit-info {
-  table {
-    width: 100%;
-    border-collapse: collapse;
-    border-spacing: 0;
-    table-layout: fixed;
-    th, td {
-      height: 30px;
-      border: 1px solid hsl(var(--border));
-      text-align: center;
-    }
-    th {
-      width: 64px;
-      font-weight: normal;
-    }
-  }
 }
 
 .list-empty {

+ 128 - 0
web/apps/web-antd/src/views/system/control/index.vue

@@ -0,0 +1,128 @@
+<script setup>
+import { Page } from '@vben/common-ui';
+import { requestClient } from '#/api/request';
+import { Card, Button, Space, message } from 'ant-design-vue';
+import { ref } from 'vue';
+
+const buttonDisabled = ref(false);
+const updateLoaderHide = ref(null);
+
+const ControlAction = {
+  async updateCode() {
+    return requestClient.get('/control/update_code')
+    .then(result => {
+      message.success('更新代码成功');
+      console.log('更新代码成功');
+      console.log(result);
+    });
+  },
+
+  async restartSportteryHot() {
+    return requestClient.get('/control/restart_sporttery', { params: { hot: true } })
+    .then(result => {
+      message.success('重启 sporttery 服务成功');
+      console.log('重启 sporttery 服务成功');
+      console.log(result);
+    });
+  },
+
+  async restartSportteryCold() {
+    return requestClient.get('/control/restart_sporttery', { params: { hot: false } })
+    .then(result => {
+      message.success('重启 sporttery 服务成功');
+      console.log('重启 sporttery 服务成功');
+      console.log(result);
+    });
+  },
+
+  async restartPinnacleHot() {
+    return requestClient.get('/control/restart_pinnacle', { params: { hot: true } })
+    .then(result => {
+      message.success('重启 pinnacle 服务成功');
+      console.log('重启 pinnacle 服务成功');
+      console.log(result);
+    });
+  },
+
+  async restartPinnacleCold() {
+    return requestClient.get('/control/restart_pinnacle', { params: { hot: false } })
+    .then(result => {
+      message.success('重启 pinnacle 服务成功');
+      console.log('重启 pinnacle 服务成功');
+      console.log(result);
+    });
+  },
+
+  async releaseWeb() {
+    return requestClient.get('/control/release_web', { timeout: 90_000 })
+    .then(result => {
+      message.success('发布 web 服务成功');
+      console.log('发布 web 服务成功');
+      console.log(result);
+    });
+  },
+}
+
+const handleAction = (action) => {
+  buttonDisabled.value = true;
+  updateLoaderHide.value = message.loading('执行中...', 0);
+  ControlAction[action]?.()
+  .finally(() => {
+    buttonDisabled.value = false;
+    updateLoaderHide.value?.();
+    updateLoaderHide.value = null;
+  });
+}
+
+</script>
+
+<template>
+  <Page title="控制面板">
+    <Space direction="vertical" size="large" style="width: 100%">
+      <Card title="全局控制">
+        <Space>
+          <Button type="primary" @click="handleAction('updateCode')" ghost :disabled="buttonDisabled">
+            更新代码
+          </Button>
+        </Space>
+      </Card>
+
+      <Card title="服务管理">
+        <Space>
+          <Button @click="handleAction('restartSportteryHot')" type="primary" ghost :disabled="buttonDisabled">
+            热重启服务
+          </Button>
+
+          <Button @click="handleAction('restartSportteryCold')" ghost danger :disabled="buttonDisabled">
+            冷重启服务
+          </Button>
+        </Space>
+      </Card>
+
+      <Card title="Web管理">
+        <Space>
+          <Button @click="handleAction('releaseWeb')" type="primary" ghost :disabled="buttonDisabled">
+            发布Web服务
+          </Button>
+        </Space>
+      </Card>
+
+
+      <Card title="PS采集管理">
+        <Space>
+          <Button @click="handleAction('restartPinnacleHot')" type="primary" ghost :disabled="buttonDisabled">
+            热重启服务
+          </Button>
+
+          <Button @click="handleAction('restartPinnacleCold')" ghost danger :disabled="buttonDisabled">
+            冷重启服务
+          </Button>
+        </Space>
+      </Card>
+    </Space>
+  </Page>
+</template>
+
+<style scoped>
+</style>
+

+ 137 - 17
web/apps/web-antd/src/views/system/parameter/index.vue

@@ -1,17 +1,34 @@
 <script setup>
 import { Page } from '@vben/common-ui';
 import { requestClient } from '#/api/request';
-import { Button, message, Form, InputNumber, Drawer, Switch } from 'ant-design-vue';
+import { Button, message, Form, InputNumber, RadioGroup, Radio, Drawer, Switch } from 'ant-design-vue';
 import { ref, reactive, computed, onMounted, onUnmounted, watch } from 'vue';
 
 const initialFormState = {
   innerDefaultAmount: 10000,
-  minProfitAmount: 0,
+  // minProfitAmount: 0,
   minShowAmount: 0,
   innerRebateRatio: 0,
   obRebateRatio: 0,
+  obRebateType: 0,
+  obMaxDiff: 0,
+  imRebateRatio: 0,
+  imRebateType: 0,
+  imMaxDiff: 0,
   hgRebateRatio: 0,
-  runWorkerEnabled: false
+  hgRebateType: 0,
+  hgRebateLower: 0,
+  hgMaxDiff: 0,
+  pcRebateRatio: 0,
+  pcRebateType: 0,
+  subsidyTime: 0,
+  subsidyAmount: 0,
+  subsidyRbWmAmount: 0,
+  subsidyRbOtAmount: 0,
+  expireTimeEvents: 0,
+  expireTimeSpecial: 0,
+  syncSettingEnabled: false,
+  runWorkerEnabled: false,
 };
 
 const formState = reactive({ ...initialFormState });
@@ -102,18 +119,6 @@ onUnmounted(() => {
         />
       </Form.Item>
 
-      <Form.Item
-        label="最小单关利润额"
-        name="minProfitAmount"
-      >
-        <InputNumber
-          v-model:value="formState.minProfitAmount"
-          :min="-99999"
-          :step="1"
-          style="width: 200px"
-        />
-      </Form.Item>
-
       <Form.Item
         label="最小展示利润额"
         name="minShowAmount"
@@ -131,6 +136,7 @@ onUnmounted(() => {
         name="innerRebateRatio"
       >
         <InputNumber
+          :disabled="formState.syncSettingEnabled"
           v-model:value="formState.innerRebateRatio"
           :min="0"
           :max="100"
@@ -144,12 +150,42 @@ onUnmounted(() => {
         name="obRebateRatio"
       >
         <InputNumber
+          :disabled="formState.syncSettingEnabled"
           v-model:value="formState.obRebateRatio"
           :min="0"
           :max="100"
           :step="0.1"
-          style="width: 200px"
+          style="width: 200px; vertical-align: middle;"
         />
+        <RadioGroup :disabled="formState.syncSettingEnabled" style="margin-left: 10px; vertical-align: middle;" v-model:value="formState.obRebateType">
+          <Radio :value="0">结算</Radio>
+          <Radio :value="1">本金</Radio>
+        </RadioGroup>
+      </Form.Item>
+
+      <Form.Item
+        label="OB赔率差值阈值"
+        name="obMaxDiff"
+      >
+        <InputNumber :disabled="formState.syncSettingEnabled" v-model:value="formState.obMaxDiff" :step="0.01" style="width: 200px" />
+      </Form.Item>
+
+      <Form.Item
+        label="IM返点比例(%)"
+        name="imRebateRatio"
+      >
+        <InputNumber :disabled="formState.syncSettingEnabled" v-model:value="formState.imRebateRatio" :min="0" :step="0.1" style="width: 200px" />
+        <RadioGroup :disabled="formState.syncSettingEnabled" style="margin-left: 10px; vertical-align: middle;" v-model:value="formState.imRebateType">
+          <Radio :value="0">结算</Radio>
+          <Radio :value="1">本金</Radio>
+        </RadioGroup>
+      </Form.Item>
+
+      <Form.Item
+        label="IM赔率差值阈值"
+        name="imMaxDiff"
+      >
+        <InputNumber :disabled="formState.syncSettingEnabled" v-model:value="formState.imMaxDiff" :step="0.01" style="width: 200px" />
       </Form.Item>
 
       <Form.Item
@@ -157,12 +193,96 @@ onUnmounted(() => {
         name="hgRebateRatio"
       >
         <InputNumber
+          :disabled="formState.syncSettingEnabled"
           v-model:value="formState.hgRebateRatio"
           :min="0"
           :max="100"
           :step="0.1"
-          style="width: 200px"
+          style="width: 200px; vertical-align: middle;"
+        />
+        <RadioGroup :disabled="formState.syncSettingEnabled" style="margin-left: 10px; vertical-align: middle;" v-model:value="formState.hgRebateType">
+          <Radio :value="0">结算</Radio>
+          <Radio :value="1">本金</Radio>
+        </RadioGroup>
+      </Form.Item>
+
+      <Form.Item
+        label="HG低返点比例(%)"
+        name="hgRebateLower"
+      >
+        <InputNumber :disabled="formState.syncSettingEnabled" v-model:value="formState.hgRebateLower" :min="0" :step="0.01" style="width: 200px" />
+      </Form.Item>
+
+      <Form.Item
+        label="HG赔率差值阈值"
+        name="hgMaxDiff"
+      >
+        <InputNumber :disabled="formState.syncSettingEnabled" v-model:value="formState.hgMaxDiff" :step="0.01" style="width: 200px" />
+      </Form.Item>
+
+      <Form.Item
+        label="PC返点比例(%)"
+        name="pcRebateRatio"
+      >
+        <InputNumber :disabled="formState.syncSettingEnabled"
+          v-model:value="formState.pcRebateRatio"
+          :min="0"
+          :step="0.1"
+          style="width: 200px; vertical-align: middle;"
         />
+        <RadioGroup :disabled="formState.syncSettingEnabled" style="margin-left: 10px; vertical-align: middle;" v-model:value="formState.pcRebateType">
+          <Radio :value="0">结算</Radio>
+          <Radio :value="1">本金</Radio>
+        </RadioGroup>
+      </Form.Item>
+
+      <Form.Item
+        label="赛前补水时间(-h)"
+        name="subsidyTime"
+      >
+        <InputNumber :disabled="formState.syncSettingEnabled" v-model:value="formState.subsidyTime" :min="0" :step="48" style="width: 200px" />
+      </Form.Item>
+
+      <Form.Item
+        label="赛前补水比例"
+        name="subsidyAmount"
+      >
+        <InputNumber :disabled="formState.syncSettingEnabled" v-model:value="formState.subsidyAmount" :min="0" :step="0.01" style="width: 200px" />
+      </Form.Item>
+
+      <Form.Item
+        label="滚球补水(净胜)"
+        name="subsidyRbWmAmount"
+      >
+        <InputNumber :disabled="formState.syncSettingEnabled" v-model:value="formState.subsidyRbWmAmount" :min="0" :step="0.01" style="width: 200px" />
+      </Form.Item>
+
+      <Form.Item
+        label="滚球补水(进球)"
+        name="subsidyRbOtAmount"
+      >
+        <InputNumber :disabled="formState.syncSettingEnabled" v-model:value="formState.subsidyRbOtAmount" :min="0" :step="0.01" style="width: 200px" />
+      </Form.Item>
+
+      <Form.Item
+        label="普通盘过期(ms)"
+        name="expireTimeEvents"
+      >
+        <InputNumber v-model:value="formState.expireTimeEvents" :min="0" :step="1000" style="width: 200px" />
+      </Form.Item>
+
+      <Form.Item
+        label="特殊盘过期(ms)"
+        name="expireTimeSpecial"
+      >
+        <InputNumber v-model:value="formState.expireTimeSpecial" :min="0" :step="1000" style="width: 200px" />
+      </Form.Item>
+
+      <Form.Item
+        label="同步 Qboss 配置"
+        name="syncSettingEnabled"
+      >
+        <Switch v-model:checked="formState.syncSettingEnabled" />
       </Form.Item>
 
       <Form.Item

+ 6 - 0
web/apps/web-antd/vite.config.mts

@@ -12,6 +12,12 @@ export default defineConfig(async () => {
       },
       server: {
         proxy: {
+          '/api/control': {
+            changeOrigin: true,
+            rewrite: (path) => path.replace(/^\/api\/control/, ''),
+            target: 'http://127.0.0.1:9056/api/control',
+            ws: true,
+          },
           '/api': {
             changeOrigin: true,
             rewrite: (path) => path.replace(/^\/api/, ''),

+ 4 - 0
web/package.json

@@ -117,5 +117,9 @@
       "canvas",
       "node-gyp"
     ]
+  },
+  "dependencies": {
+    "dayjs": "catalog:",
+    "vue-json-pretty": "^2.5.0"
   }
 }

+ 2 - 2
web/packages/locales/src/langs/en-US/authentication.json

@@ -1,7 +1,7 @@
 {
   "welcomeBack": "Welcome Back",
-  "pageTitle": "Plug-and-play Admin system",
-  "pageDesc": "Efficient, versatile frontend template",
+  "pageTitle": "Q Boss",
+  "pageDesc": "Focus on multi-bet, only for stable red single",
   "loginSuccess": "Login Successful",
   "loginSuccessDesc": "Welcome Back",
   "loginSubtitle": "Enter your account details to manage your projects",

+ 1 - 1
web/packages/locales/src/langs/zh-CN/authentication.json

@@ -1,6 +1,6 @@
 {
   "welcomeBack": "兄弟,欢迎回来",
-  "pageTitle": "串子兄弟",
+  "pageTitle": "Q博士",
   "pageDesc": "专注串关,只为稳稳的红单",
   "loginSuccess": "登录成功",
   "loginSuccessDesc": "兄弟,欢迎回来",

Разница между файлами не показана из-за своего большого размера
+ 174 - 430
web/pnpm-lock.yaml


Некоторые файлы не были показаны из-за большого количества измененных файлов