@@ -421,8 +421,8 @@ function OverviewTab({
{t("lineUi.profileFootnote", {
defaultValue: "回水上限 {{rebate}}% · 默认回水 {{defaultRebate}}% · {{cycle}}",
- rebate: ratioToPercentUi(profile.rebate_limit ?? 0),
- defaultRebate: ratioToPercentUi(profile.default_player_rebate ?? 0),
+ rebate: percentValueToUi(profile.rebate_limit ?? 0),
+ defaultRebate: percentValueToUi(profile.default_player_rebate ?? 0),
cycle: cycleLabel,
})}
{(profile.risk_tags?.length ?? 0) > 0
diff --git a/src/modules/agents/agent-line-provision-wizard.tsx b/src/modules/agents/agent-line-provision-wizard.tsx
index 3c17243..c29ad58 100644
--- a/src/modules/agents/agent-line-provision-wizard.tsx
+++ b/src/modules/agents/agent-line-provision-wizard.tsx
@@ -20,7 +20,7 @@ import {
} from "@/components/ui/select";
import { Switch } from "@/components/ui/switch";
import { useAsyncEffect } from "@/hooks/use-async-effect";
-import { percentUiToRatio } from "@/lib/admin-rate-percent";
+import { percentValueToUi } from "@/lib/admin-rate-percent";
import { adminSiteCodeLabel } from "@/lib/admin-select-display";
import { LotteryApiBizError } from "@/types/api/errors";
import type { AdminIntegrationSiteRow } from "@/types/api/admin-integration-site";
@@ -73,8 +73,8 @@ export function AgentLineProvisionWizard(): React.ReactElement {
password: form.password,
total_share_rate: Number.parseFloat(form.total_share_rate) || 0,
credit_limit: Number.parseInt(form.credit_limit, 10) || 0,
- rebate_limit: percentUiToRatio(form.rebate_limit),
- default_player_rebate: percentUiToRatio(form.default_player_rebate),
+ rebate_limit: Number.parseFloat(form.rebate_limit) || 0,
+ default_player_rebate: Number.parseFloat(form.default_player_rebate) || 0,
settlement_cycle: form.settlement_cycle,
can_grant_extra_rebate: form.can_grant_extra_rebate,
});
diff --git a/src/modules/agents/agents-console.tsx b/src/modules/agents/agents-console.tsx
index 01844e5..7b8f7ec 100644
--- a/src/modules/agents/agents-console.tsx
+++ b/src/modules/agents/agents-console.tsx
@@ -34,10 +34,8 @@ import { useAsyncEffect } from "@/hooks/use-async-effect";
import { useConfirmAction } from "@/hooks/use-confirm-action";
import { useTranslationRef } from "@/hooks/use-translation-ref";
import {
- percentUiToRatio,
percentValueToUi,
parsePercentUi,
- ratioToPercentUi,
} from "@/lib/admin-rate-percent";
import { adminHasAnyPermission } from "@/lib/admin-permissions";
import {
@@ -168,8 +166,8 @@ export function AgentsConsole(): React.ReactElement {
setProfileShareRate(percentValueToUi(row.total_share_rate ?? 0));
}
setProfileCreditLimit(String(row.credit_limit ?? 0));
- setProfileRebateLimit(ratioToPercentUi(row.rebate_limit ?? 0));
- setProfileDefaultRebate(ratioToPercentUi(row.default_player_rebate ?? 0));
+ setProfileRebateLimit(percentValueToUi(row.rebate_limit ?? 0));
+ setProfileDefaultRebate(percentValueToUi(row.default_player_rebate ?? 0));
setProfileSettlementCycle(normalizeAgentSettlementCycle(row.settlement_cycle));
setProfileExtraRebate(Boolean(row.can_grant_extra_rebate));
setProfileCanCreateChild(Boolean(row.can_create_child_agent));
@@ -183,8 +181,8 @@ export function AgentsConsole(): React.ReactElement {
const shareRate = Number.parseFloat(profileShareRate) || 0;
const base = {
credit_limit: Number.parseInt(profileCreditLimit, 10) || 0,
- rebate_limit: percentUiToRatio(profileRebateLimit),
- default_player_rebate: percentUiToRatio(profileDefaultRebate),
+ rebate_limit: Number.parseFloat(profileRebateLimit) || 0,
+ default_player_rebate: Number.parseFloat(profileDefaultRebate) || 0,
settlement_cycle: normalizeAgentSettlementCycle(profileSettlementCycle),
can_grant_extra_rebate: profileExtraRebate,
can_create_child_agent: profileCanCreateChild,
diff --git a/src/modules/config/config-hub-screen.tsx b/src/modules/config/config-hub-screen.tsx
index 23f0bbe..838e40f 100644
--- a/src/modules/config/config-hub-screen.tsx
+++ b/src/modules/config/config-hub-screen.tsx
@@ -48,6 +48,12 @@ const HUB_CARDS: HubCard[] = [
descKey: "hub.integrationDesc",
requiredAny: PRD_INTEGRATION_ACCESS_ANY,
},
+ {
+ href: "/admin/docs/integration-guide",
+ titleKey: "hub.integrationGuideTitle",
+ descKey: "hub.integrationGuideDesc",
+ requiredAny: PRD_INTEGRATION_ACCESS_ANY,
+ },
];
export function ConfigHubScreen() {
diff --git a/src/modules/docs/integration-guide-screen.tsx b/src/modules/docs/integration-guide-screen.tsx
new file mode 100644
index 0000000..91e3921
--- /dev/null
+++ b/src/modules/docs/integration-guide-screen.tsx
@@ -0,0 +1,414 @@
+import { Card, CardContent } from "@/components/ui/card";
+
+const sections = [
+ { id: "overview", title: "1. 文档说明" },
+ { id: "preparation", title: "2. 接入前准备" },
+ { id: "sso", title: "3. SSO 登录接入" },
+ { id: "wallet", title: "4. 钱包接口" },
+ { id: "errors", title: "5. 错误码与幂等" },
+ { id: "testing", title: "6. 联调与上线" },
+ { id: "appendix", title: "7. 附录" },
+] as const;
+
+const ssoFields = [
+ ["site_code", "string", "是", "站点编码,由我方提供。"],
+ ["user_id", "string", "是", "客户侧用户唯一标识。"],
+ ["username", "string", "是", "客户侧用户名或展示名。"],
+ ["timestamp", "number", "是", "发起时间戳,单位秒。"],
+ ["nonce", "string", "是", "随机串,用于防重放。"],
+ ["currency", "string", "否", "币种,如 CNY。"],
+ ["device", "string", "否", "终端类型,如 h5、web。"],
+] as const;
+
+const walletCommonFields = [
+ ["site_code", "string", "站点编码"],
+ ["user_id", "string", "客户侧用户唯一标识"],
+ ["transaction_id", "string", "唯一交易号,幂等主键"],
+ ["timestamp", "number", "请求时间戳,单位秒"],
+ ["sign", "string", "签名结果"],
+] as const;
+
+const errorRows = [
+ ["0", "成功", "按成功结果落账或继续业务。"],
+ ["1001", "参数错误", "检查字段完整性、类型、必填项。"],
+ ["1002", "签名错误", "检查签名原串、密钥、时间戳。"],
+ ["1003", "用户不存在", "确认用户标识是否正确。"],
+ ["1004", "余额不足", "前端提示余额不足,不继续下注。"],
+ ["1006", "重复交易", "返回首次结果,不允许重复记账。"],
+ ["1099", "系统异常", "可按约定策略重试,并保留日志。"],
+] as const;
+
+function DocTable({
+ headers,
+ rows,
+}: {
+ headers: readonly string[];
+ rows: readonly (readonly string[])[];
+}): React.ReactElement {
+ return (
+
+
+
+
+ {headers.map((header) => (
+ |
+ {header}
+ |
+ ))}
+
+
+
+ {rows.map((row, rowIndex) => (
+
+ {row.map((cell, cellIndex) => (
+ |
+ {cell}
+ |
+ ))}
+
+ ))}
+
+
+
+ );
+}
+
+export function IntegrationGuideScreen(): React.ReactElement {
+ return (
+
+
+
+
+
+
+
+
+
+
+
1. 文档说明
+
+ 客户完成主站登录后,通过 SSO 进入彩票端。用户在彩票端的余额查询、投注扣款、派奖加款等资金动作,由彩票端调用客户钱包接口完成。
+
+
+
+
+
+
适用范围
+
+ H5、Web、App WebView 等主站跳转彩票端场景。
+
+
+
+
职责边界
+
+ 主站负责用户身份,彩票端负责业务编排,钱包系统负责资金真理源。
+
+
+
+
阅读对象
+
+ 客户后端、前端、测试、运维以及联调负责人。
+
+
+
+
+
+
接入链路
+
+ - 1. 用户在客户主站完成登录。
+ - 2. 客户服务端生成 SSO 参数或 token。
+ - 3. 浏览器跳转至彩票端入口地址。
+ - 4. 彩票端校验签名并建立登录态。
+ - 5. 用户在彩票端进行余额查询、投注、派奖等操作。
+ - 6. 彩票端调用客户钱包接口完成账务处理。
+
+
+
+
+
+
+
2. 接入前准备
+
+ 联调开始前,双方需要先交换环境信息、签名密钥和测试数据,避免联调阶段反复返工。
+
+
+
+
+
+
客户需提供
+
+ - • 站点名称与站点编码。
+ - • 客户主站域名与钱包接口域名。
+ - • 测试环境与生产环境联系人。
+ - • 测试账号、测试余额、测试用例。
+
+
+
+
双方需确认
+
+ - • SSO 密钥或 JWT 验签方式。
+ - • 钱包签名算法、请求头、超时设置。
+ - • 余额、扣款、加款三个接口地址。
+ - • 白名单、重试策略、错误码定义。
+
+
+
+
+
+
+
+
3. SSO 登录接入
+
+ 客户用户在主站登录后,由客户服务端生成短时有效的登录凭证,浏览器携带该凭证进入彩票端。我方校验通过后建立彩票端会话。
+
+
+
+
+
推荐流程
+
+ - 1. 客户主站用户完成登录。
+ - 2. 客户服务端生成 SSO 负载。
+ - 3. 使用共享密钥完成签名。
+ - 4. 浏览器跳转至彩票端入口地址并附带凭证。
+ - 5. 彩票端校验签名、时间戳、站点编码与用户标识。
+
+
+
+
+
+
+
+
接入约束
+
+ - • token 或签名参数必须为短时有效,建议 60 至 300 秒。
+ - •
user_id 必须稳定且唯一。
+ - • 测试环境与生产环境密钥必须分离。
+ - • 密钥只允许保存在服务端,不可下发前端。
+
+
+
+
+ SSO 负载示例
+ {`{
+ "site_code": "demo",
+ "user_id": "100001",
+ "username": "demo_user",
+ "timestamp": 1718000000,
+ "nonce": "N8F2X9Q1",
+ "currency": "CNY",
+ "device": "h5"
+}`}
+
+
+
+
+
+
+
+
4. 钱包接口
+
+ 彩票端通过客户钱包接口完成余额查询、投注扣款、派奖加款。钱包系统需要保证接口可重试、可审计、可幂等。
+
+
+
+
+
+
余额查询
+
+ 用于进入彩票端、下注前或关键账务时机同步可用余额。
+
+
POST /wallet/balance
+
+
+
扣款接口
+
+ 用于投注成功后的资金扣减,必须以交易号作为唯一幂等键。
+
+
POST /wallet/debit
+
+
+
加款接口
+
+ 用于派奖、退款或撤单返还资金,不允许重复记账。
+
+
POST /wallet/credit
+
+
+
+
+
+
+
+
+ 扣款请求示例
+ {`{
+ "site_code": "demo",
+ "user_id": "100001",
+ "transaction_id": "BET202606100001",
+ "order_id": "TICKET202606100001",
+ "amount": "20.00",
+ "timestamp": 1718000001,
+ "sign": "xxxxxx"
+}`}
+
+
+
+
+ 成功响应示例
+ {`{
+ "code": 0,
+ "message": "success",
+ "data": {
+ "transaction_id": "BET202606100001",
+ "balance": "980.00"
+ }
+}`}
+
+
+
+
+
+
+
签名要求
+
+ - 1. 请求字段按字段名升序排序。
+ - 2. 以
key=value 拼接原始串。
+ - 3. 原始串尾部追加共享密钥。
+ - 4. 使用
HMAC-SHA256 生成签名。
+
+
+
+
接口要求
+
+ - • 所有接口必须使用 HTTPS。
+ - • 金额字段统一使用字符串,如
1000.00。
+ - • 请求内容类型统一为
application/json。
+ - • 钱包超时后我方可能重试,因此接口必须按幂等方式处理。
+
+
+
+
+
+
+
+
5. 错误码与幂等
+
+ 客户钱包接口需要统一错误码语义,并保证相同交易号的重复请求返回一致结果,不得重复记账。
+
+
+
+
+
+
+
幂等要求
+
+ - • 扣款与加款必须以
transaction_id 作为唯一交易键。
+ - • 同一交易号重复请求时,不允许再次扣款或再次加款。
+ - • 已成功处理的请求,再次请求时应返回首次处理结果。
+ - • 若出现网络抖动、超时或重试,钱包系统仍需保证账务一致性。
+
+
+
+
+
+
+
6. 联调与上线
+
+ 建议双方按固定顺序完成联调,先打通登录链路,再验证钱包接口,最后确认异常场景与上线条件。
+
+
+
+
+
+
联调顺序
+
+ - 1. 域名连通、证书与白名单检查。
+ - 2. SSO 参数生成与跳转验证。
+ - 3. 余额查询接口联调。
+ - 4. 扣款与加款接口联调。
+ - 5. 超时、重复请求、余额不足场景回归。
+
+
+
+
上线前检查
+
+ - • 正式域名、正式密钥、正式白名单已配置。
+ - • 测试环境与生产环境参数已分离。
+ - • 核心错误码与日志字段已对齐。
+ - • 关键链路已完成验收回归。
+ - • 上线联系人与回滚预案已确认。
+
+
+
+
+
+
+
+
7. 附录
+
+ 以下约定用于减少不同系统之间的解析差异,建议双方保持一致。
+
+
+
+
+
+
金额格式
+
+ 统一使用字符串传输,例如 1000.00。
+
+
+
+
时间格式
+
+ 默认使用 Unix 时间戳秒级,双方也可统一为 ISO8601。
+
+
+
+
字符与报文
+
+ 字符编码统一 UTF-8,请求内容类型统一为 JSON。
+
+
+
+
+
+
+
+
+ );
+}
\ No newline at end of file