1.新增显示分红说明文档菜单
2.文档新增英文版
This commit is contained in:
@@ -5,6 +5,7 @@ declare(strict_types=1);
|
|||||||
namespace app\admin\controller\docs;
|
namespace app\admin\controller\docs;
|
||||||
|
|
||||||
use app\common\controller\Backend;
|
use app\common\controller\Backend;
|
||||||
|
use app\common\library\docs\MarkdownDocReader;
|
||||||
use support\Response;
|
use support\Response;
|
||||||
use Webman\Http\Request;
|
use Webman\Http\Request;
|
||||||
|
|
||||||
@@ -13,7 +14,17 @@ use Webman\Http\Request;
|
|||||||
*/
|
*/
|
||||||
class Doc36ZiHuaMobileApi extends Backend
|
class Doc36ZiHuaMobileApi extends Backend
|
||||||
{
|
{
|
||||||
private const DOC_RELATIVE = 'docs' . DIRECTORY_SEPARATOR . '36字花-移动端接口设计草案.md';
|
private const DOC_ZH = [
|
||||||
|
'relative' => 'docs' . DIRECTORY_SEPARATOR . '36字花-移动端接口设计草案.md',
|
||||||
|
'filename' => '36字花-移动端接口设计草案.md',
|
||||||
|
'ascii_fallback' => '36zihua-mobile-api-design-draft.md',
|
||||||
|
];
|
||||||
|
|
||||||
|
private const DOC_EN = [
|
||||||
|
'relative' => 'docs' . DIRECTORY_SEPARATOR . 'en' . DIRECTORY_SEPARATOR . '36zihua-mobile-api-design-draft.md',
|
||||||
|
'filename' => '36zihua-mobile-api-design-draft.md',
|
||||||
|
'ascii_fallback' => '36zihua-mobile-api-design-draft.md',
|
||||||
|
];
|
||||||
|
|
||||||
public function content(Request $request): Response
|
public function content(Request $request): Response
|
||||||
{
|
{
|
||||||
@@ -22,19 +33,20 @@ class Doc36ZiHuaMobileApi extends Backend
|
|||||||
return $response;
|
return $response;
|
||||||
}
|
}
|
||||||
|
|
||||||
$path = $this->docAbsolutePath();
|
$doc = MarkdownDocReader::resolve($request, self::DOC_ZH, self::DOC_EN);
|
||||||
if (!is_file($path)) {
|
if (!is_file($doc['path'])) {
|
||||||
return $this->error(__('Document file not found'));
|
return $this->error(__('Document file not found'));
|
||||||
}
|
}
|
||||||
|
|
||||||
$raw = file_get_contents($path);
|
$raw = file_get_contents($doc['path']);
|
||||||
if ($raw === false) {
|
if ($raw === false) {
|
||||||
return $this->error(__('Failed to read document'));
|
return $this->error(__('Failed to read document'));
|
||||||
}
|
}
|
||||||
|
|
||||||
return $this->success('', [
|
return $this->success('', [
|
||||||
'markdown' => $raw,
|
'markdown' => $raw,
|
||||||
'filename' => '36字花-移动端接口设计草案.md',
|
'filename' => $doc['filename'],
|
||||||
|
'lang' => $doc['lang'],
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -45,22 +57,20 @@ class Doc36ZiHuaMobileApi extends Backend
|
|||||||
return $response;
|
return $response;
|
||||||
}
|
}
|
||||||
|
|
||||||
$path = $this->docAbsolutePath();
|
$doc = MarkdownDocReader::resolve($request, self::DOC_ZH, self::DOC_EN);
|
||||||
if (!is_file($path)) {
|
if (!is_file($doc['path'])) {
|
||||||
return $this->error(__('Document file not found'));
|
return $this->error(__('Document file not found'));
|
||||||
}
|
}
|
||||||
|
|
||||||
$body = file_get_contents($path);
|
$body = file_get_contents($doc['path']);
|
||||||
if ($body === false) {
|
if ($body === false) {
|
||||||
return $this->error(__('Failed to read document'));
|
return $this->error(__('Failed to read document'));
|
||||||
}
|
}
|
||||||
|
|
||||||
$utf8Name = '36字花-移动端接口设计草案.md';
|
|
||||||
$asciiFallback = '36zihua-mobile-api-design-draft.md';
|
|
||||||
$disposition = sprintf(
|
$disposition = sprintf(
|
||||||
'attachment; filename="%s"; filename*=UTF-8\'\'%s',
|
'attachment; filename="%s"; filename*=UTF-8\'\'%s',
|
||||||
$asciiFallback,
|
$doc['ascii_fallback'],
|
||||||
rawurlencode($utf8Name)
|
rawurlencode($doc['filename'])
|
||||||
);
|
);
|
||||||
|
|
||||||
return new Response(200, [
|
return new Response(200, [
|
||||||
@@ -69,9 +79,4 @@ class Doc36ZiHuaMobileApi extends Backend
|
|||||||
'Cache-Control' => 'private, max-age=0, must-revalidate',
|
'Cache-Control' => 'private, max-age=0, must-revalidate',
|
||||||
], $body);
|
], $body);
|
||||||
}
|
}
|
||||||
|
|
||||||
private function docAbsolutePath(): string
|
|
||||||
{
|
|
||||||
return rtrim(base_path(), DIRECTORY_SEPARATOR . '/') . DIRECTORY_SEPARATOR . self::DOC_RELATIVE;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
82
app/admin/controller/docs/DocCommissionShare.php
Normal file
82
app/admin/controller/docs/DocCommissionShare.php
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace app\admin\controller\docs;
|
||||||
|
|
||||||
|
use app\common\controller\Backend;
|
||||||
|
use app\common\library\docs\MarkdownDocReader;
|
||||||
|
use support\Response;
|
||||||
|
use Webman\Http\Request;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 后台只读展示《分红说明文档》Markdown,并提供下载。
|
||||||
|
*/
|
||||||
|
class DocCommissionShare extends Backend
|
||||||
|
{
|
||||||
|
private const DOC_ZH = [
|
||||||
|
'relative' => 'docs' . DIRECTORY_SEPARATOR . '分红说明文档.md',
|
||||||
|
'filename' => '分红说明文档.md',
|
||||||
|
'ascii_fallback' => 'commission-share-guide.md',
|
||||||
|
];
|
||||||
|
|
||||||
|
private const DOC_EN = [
|
||||||
|
'relative' => 'docs' . DIRECTORY_SEPARATOR . 'en' . DIRECTORY_SEPARATOR . 'commission-share-guide.md',
|
||||||
|
'filename' => 'commission-share-guide.md',
|
||||||
|
'ascii_fallback' => 'commission-share-guide.md',
|
||||||
|
];
|
||||||
|
|
||||||
|
public function content(Request $request): Response
|
||||||
|
{
|
||||||
|
$response = $this->initializeBackend($request);
|
||||||
|
if ($response !== null) {
|
||||||
|
return $response;
|
||||||
|
}
|
||||||
|
|
||||||
|
$doc = MarkdownDocReader::resolve($request, self::DOC_ZH, self::DOC_EN);
|
||||||
|
if (!is_file($doc['path'])) {
|
||||||
|
return $this->error(__('Document file not found'));
|
||||||
|
}
|
||||||
|
|
||||||
|
$raw = file_get_contents($doc['path']);
|
||||||
|
if ($raw === false) {
|
||||||
|
return $this->error(__('Failed to read document'));
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->success('', [
|
||||||
|
'markdown' => $raw,
|
||||||
|
'filename' => $doc['filename'],
|
||||||
|
'lang' => $doc['lang'],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function download(Request $request): Response
|
||||||
|
{
|
||||||
|
$response = $this->initializeBackend($request);
|
||||||
|
if ($response !== null) {
|
||||||
|
return $response;
|
||||||
|
}
|
||||||
|
|
||||||
|
$doc = MarkdownDocReader::resolve($request, self::DOC_ZH, self::DOC_EN);
|
||||||
|
if (!is_file($doc['path'])) {
|
||||||
|
return $this->error(__('Document file not found'));
|
||||||
|
}
|
||||||
|
|
||||||
|
$body = file_get_contents($doc['path']);
|
||||||
|
if ($body === false) {
|
||||||
|
return $this->error(__('Failed to read document'));
|
||||||
|
}
|
||||||
|
|
||||||
|
$disposition = sprintf(
|
||||||
|
'attachment; filename="%s"; filename*=UTF-8\'\'%s',
|
||||||
|
$doc['ascii_fallback'],
|
||||||
|
rawurlencode($doc['filename'])
|
||||||
|
);
|
||||||
|
|
||||||
|
return new Response(200, [
|
||||||
|
'Content-Type' => 'text/markdown; charset=UTF-8',
|
||||||
|
'Content-Disposition' => $disposition,
|
||||||
|
'Cache-Control' => 'private, max-age=0, must-revalidate',
|
||||||
|
], $body);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -132,4 +132,7 @@ return [
|
|||||||
'36字花移动端接口文档' => '36 Zihua mobile API design draft',
|
'36字花移动端接口文档' => '36 Zihua mobile API design draft',
|
||||||
'拉取正文' => 'Load document body',
|
'拉取正文' => 'Load document body',
|
||||||
'下载 Markdown' => 'Download Markdown',
|
'下载 Markdown' => 'Download Markdown',
|
||||||
|
|
||||||
|
// 文档:分红说明
|
||||||
|
'分红说明文档' => 'Commission share guide',
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -54,5 +54,9 @@ return [
|
|||||||
'manualSettle' => '手动结算',
|
'manualSettle' => '手动结算',
|
||||||
'batchSettlePending' => '批量结算待结算渠道',
|
'batchSettlePending' => '批量结算待结算渠道',
|
||||||
'walletAdjust' => '钱包加减点',
|
'walletAdjust' => '钱包加减点',
|
||||||
|
'Markdown文档' => 'Markdown文档',
|
||||||
|
'分红说明文档' => '分红说明文档',
|
||||||
|
'拉取正文' => '拉取正文',
|
||||||
|
'下载 Markdown' => '下载 Markdown',
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
82
app/common/library/docs/MarkdownDocReader.php
Normal file
82
app/common/library/docs/MarkdownDocReader.php
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace app\common\library\docs;
|
||||||
|
|
||||||
|
use Webman\Http\Request;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 按请求语言读取 docs 目录下的 Markdown 文件(英文缺失时回退中文)。
|
||||||
|
*/
|
||||||
|
final class MarkdownDocReader
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @param array{relative:string,filename:string,ascii_fallback?:string} $zh
|
||||||
|
* @param array{relative:string,filename:string,ascii_fallback?:string} $en
|
||||||
|
* @return array{path:string,filename:string,ascii_fallback:string,lang:string}
|
||||||
|
*/
|
||||||
|
public static function resolve(Request $request, array $zh, array $en): array
|
||||||
|
{
|
||||||
|
$lang = self::resolveLang($request);
|
||||||
|
$useEn = $lang === 'en';
|
||||||
|
$spec = $useEn ? $en : $zh;
|
||||||
|
$base = rtrim(base_path(), DIRECTORY_SEPARATOR . '/');
|
||||||
|
$path = $base . DIRECTORY_SEPARATOR . str_replace(['/', '\\'], DIRECTORY_SEPARATOR, $spec['relative']);
|
||||||
|
if ($useEn && !is_file($path)) {
|
||||||
|
$spec = $zh;
|
||||||
|
$path = $base . DIRECTORY_SEPARATOR . str_replace(['/', '\\'], DIRECTORY_SEPARATOR, $zh['relative']);
|
||||||
|
$lang = 'zh-cn';
|
||||||
|
}
|
||||||
|
$filename = strval($spec['filename'] ?? 'document.md');
|
||||||
|
$asciiFallback = strval($spec['ascii_fallback'] ?? $filename);
|
||||||
|
|
||||||
|
return [
|
||||||
|
'path' => $path,
|
||||||
|
'filename' => $filename,
|
||||||
|
'ascii_fallback' => $asciiFallback,
|
||||||
|
'lang' => $lang,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function resolveLang(Request $request): string
|
||||||
|
{
|
||||||
|
$thinkRaw = $request->header('think-lang', '');
|
||||||
|
$thinkLang = is_string($thinkRaw) ? trim($thinkRaw) : '';
|
||||||
|
if ($thinkLang === '' && is_array($thinkRaw) && isset($thinkRaw[0]) && is_string($thinkRaw[0])) {
|
||||||
|
$thinkLang = trim($thinkRaw[0]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$queryRaw = $request->get('lang');
|
||||||
|
if ($queryRaw === null || $queryRaw === '') {
|
||||||
|
$queryRaw = $request->post('lang');
|
||||||
|
}
|
||||||
|
$queryLang = is_string($queryRaw) ? trim($queryRaw) : (is_scalar($queryRaw) ? trim(strval($queryRaw)) : '');
|
||||||
|
|
||||||
|
$headerRaw = $request->header('lang', '');
|
||||||
|
$headerLang = is_string($headerRaw) ? trim($headerRaw) : '';
|
||||||
|
|
||||||
|
$normalize = static function (string $raw): string {
|
||||||
|
$s = str_replace('_', '-', strtolower(trim($raw)));
|
||||||
|
if ($s === 'zh') {
|
||||||
|
return 'zh-cn';
|
||||||
|
}
|
||||||
|
return $s;
|
||||||
|
};
|
||||||
|
|
||||||
|
foreach ([$thinkLang, $headerLang, $queryLang] as $candidate) {
|
||||||
|
if ($candidate === '') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$normalized = $normalize($candidate);
|
||||||
|
if ($normalized === 'en') {
|
||||||
|
return 'en';
|
||||||
|
}
|
||||||
|
if ($normalized === 'zh-cn') {
|
||||||
|
return 'zh-cn';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'zh-cn';
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,135 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use Phinx\Migration\AbstractMigration;
|
||||||
|
use support\think\Db;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Markdown 文档:分红说明文档(归入 docs/mdManual 目录)
|
||||||
|
*/
|
||||||
|
final class DocsCommissionShareMenu extends AbstractMigration
|
||||||
|
{
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
$ruleTable = $this->hasTable('admin_rule') ? 'admin_rule' : ($this->hasTable('menu_rule') ? 'menu_rule' : null);
|
||||||
|
if ($ruleTable === null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
$ct = $this->table($ruleTable)->hasColumn('create_time') ? 'create_time' : 'createtime';
|
||||||
|
$ut = $this->table($ruleTable)->hasColumn('update_time') ? 'update_time' : 'updatetime';
|
||||||
|
$now = time();
|
||||||
|
|
||||||
|
$dirId = $this->resolveDocsDirId($ruleTable, $ct, $ut, $now);
|
||||||
|
$menuName = 'docs/docCommissionShare';
|
||||||
|
$menuId = $this->ensureMenu($ruleTable, $dirId, $menuName, $ct, $ut, $now);
|
||||||
|
$this->ensureButtons($ruleTable, $menuId, $menuName, $ct, $ut, $now);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function resolveDocsDirId(string $table, string $ct, string $ut, int $now): int
|
||||||
|
{
|
||||||
|
$row = Db::name($table)->where('name', 'docs/mdManual')->where('type', 'menu_dir')->find();
|
||||||
|
if ($row) {
|
||||||
|
return (int) $row['id'];
|
||||||
|
}
|
||||||
|
return (int) Db::name($table)->insertGetId([
|
||||||
|
'pid' => 0,
|
||||||
|
'type' => 'menu_dir',
|
||||||
|
'title' => 'Markdown文档',
|
||||||
|
'name' => 'docs/mdManual',
|
||||||
|
'path' => 'docs/mdManual',
|
||||||
|
'icon' => 'fa fa-book',
|
||||||
|
'menu_type' => 'tab',
|
||||||
|
'component' => '',
|
||||||
|
'url' => '',
|
||||||
|
'extend' => 'none',
|
||||||
|
'remark' => '',
|
||||||
|
'weigh' => 53,
|
||||||
|
'status' => 1,
|
||||||
|
'keepalive' => 0,
|
||||||
|
$ct => $now,
|
||||||
|
$ut => $now,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function ensureMenu(string $table, int $pid, string $name, string $ct, string $ut, int $now): int
|
||||||
|
{
|
||||||
|
$row = Db::name($table)->where('name', $name)->where('type', 'menu')->find();
|
||||||
|
if ($row) {
|
||||||
|
Db::name($table)->where('id', $row['id'])->update([
|
||||||
|
'pid' => $pid,
|
||||||
|
'title' => '分红说明文档',
|
||||||
|
'path' => $name,
|
||||||
|
'component' => '/src/views/backend/docs/docCommissionShare/index.vue',
|
||||||
|
'icon' => 'fa fa-percent',
|
||||||
|
'menu_type' => 'tab',
|
||||||
|
'weigh' => 51,
|
||||||
|
'status' => 1,
|
||||||
|
'keepalive' => 1,
|
||||||
|
$ut => $now,
|
||||||
|
]);
|
||||||
|
return (int) $row['id'];
|
||||||
|
}
|
||||||
|
return (int) Db::name($table)->insertGetId([
|
||||||
|
'pid' => $pid,
|
||||||
|
'type' => 'menu',
|
||||||
|
'title' => '分红说明文档',
|
||||||
|
'name' => $name,
|
||||||
|
'path' => $name,
|
||||||
|
'icon' => 'fa fa-percent',
|
||||||
|
'menu_type' => 'tab',
|
||||||
|
'component' => '/src/views/backend/docs/docCommissionShare/index.vue',
|
||||||
|
'url' => '',
|
||||||
|
'extend' => 'none',
|
||||||
|
'remark' => 'commission share guide markdown',
|
||||||
|
'weigh' => 51,
|
||||||
|
'status' => 1,
|
||||||
|
'keepalive' => 1,
|
||||||
|
$ct => $now,
|
||||||
|
$ut => $now,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function ensureButtons(string $table, int $pid, string $menuName, string $ct, string $ut, int $now): void
|
||||||
|
{
|
||||||
|
$titleMap = [
|
||||||
|
'content' => '拉取正文',
|
||||||
|
'download' => '下载 Markdown',
|
||||||
|
];
|
||||||
|
foreach ($titleMap as $action => $title) {
|
||||||
|
$btnName = $menuName . '/' . $action;
|
||||||
|
$exists = Db::name($table)->where('name', $btnName)->where('type', 'button')->find();
|
||||||
|
if ($exists) {
|
||||||
|
Db::name($table)->where('id', $exists['id'])->update([
|
||||||
|
'pid' => $pid,
|
||||||
|
'title' => $title,
|
||||||
|
'status' => 1,
|
||||||
|
$ut => $now,
|
||||||
|
]);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
Db::name($table)->insert([
|
||||||
|
'pid' => $pid,
|
||||||
|
'type' => 'button',
|
||||||
|
'title' => $title,
|
||||||
|
'name' => $btnName,
|
||||||
|
'path' => '',
|
||||||
|
'icon' => '',
|
||||||
|
'menu_type' => 'tab',
|
||||||
|
'component' => '',
|
||||||
|
'url' => '',
|
||||||
|
'extend' => 'none',
|
||||||
|
'remark' => '',
|
||||||
|
'weigh' => 0,
|
||||||
|
'status' => 1,
|
||||||
|
'keepalive' => 0,
|
||||||
|
$ct => $now,
|
||||||
|
$ut => $now,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
}
|
||||||
|
}
|
||||||
905
docs/en/36zihua-mobile-api-design-draft.md
Normal file
905
docs/en/36zihua-mobile-api-design-draft.md
Normal file
@@ -0,0 +1,905 @@
|
|||||||
|
# 36 Zihua Mobile API Design Draft (V1)
|
||||||
|
|
||||||
|
This document is based on `docs/36字花-数据库与实施计划.md` and the PRD. It provides an initial mobile-facing API inventory and field definitions.
|
||||||
|
|
||||||
|
Scope: **platform-wide single period number and single draw result**; channels are used only for attribution, commission sharing, and risk control—not for splitting game rounds.
|
||||||
|
|
||||||
|
**Addendum (2026-04)**: §**1.5** describes server-side **Redis hot-spot caching** (`GameHotDataRedis`). It does **not** change any interface URL, parameter, or response field contracts; it is for integration testing and operations reference only.
|
||||||
|
|
||||||
|
## 1. Design Conventions
|
||||||
|
|
||||||
|
### 1.1 Base Conventions
|
||||||
|
- Protocol: HTTPS + JSON
|
||||||
|
- Path naming: `/api/{module}/{action}`, must match regex `^/api/[a-z]+/[a-z]+[A-Z][a-zA-Z]*$`
|
||||||
|
- **HTTP methods**: All mobile business APIs (`/api/*`, excluding `/api/v1/authToken`) use `POST`. Query-style endpoints also accept `GET` (for browser/debug tools); clients should uniformly use `POST`.
|
||||||
|
- For `POST`, header `Content-Type: application/json`; parameters in JSON body
|
||||||
|
- In `GET` compatibility mode, parameters go in the URL query string
|
||||||
|
- **Exception**: Notice module `/api/notice/noticeList`, `/api/notice/noticeConfirm` support **`GET` only**; parameters always use URL query string
|
||||||
|
- **Notice list without auth**: `GET /api/notice/noticeList` does **not** require `auth-token` or `user-token`; if `user-token` is still sent, popout items can return the user’s real `is_read`
|
||||||
|
- Auth endpoint `/api/v1/authToken` remains `GET`
|
||||||
|
- Time: UTC timestamp (seconds) + server timezone configuration
|
||||||
|
- Amounts: numeric transport (e.g. `"100.00"`); client display uses two decimal places (storage remains `decimal(18,2)`)
|
||||||
|
- Idempotency: critical write APIs require `idempotency_key`
|
||||||
|
- Required headers:
|
||||||
|
- `auth-token`: API auth token from `GET /api/v1/authToken` (signature-based API access credential)
|
||||||
|
- `user-token`: user session token; required on login-protected APIs
|
||||||
|
- Language header:
|
||||||
|
- `lang=zh`: Chinese (default)
|
||||||
|
- `lang=en`: English
|
||||||
|
|
||||||
|
### 1.2 Common Response Shape
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"code": 1,
|
||||||
|
"message": "ok",
|
||||||
|
"data": {}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- `code=1` means success; any other value is a business error
|
||||||
|
- All `/api/*` response messages support Chinese and English: default Chinese; `lang=en` returns English, `lang=zh` returns Chinese
|
||||||
|
- Suggested error code ranges (by nature):
|
||||||
|
- `1000-1099`: parameter errors (missing fields, type errors, format errors, out of range)
|
||||||
|
- `1100-1199`: auth errors (not logged in, token expired, insufficient permission)
|
||||||
|
- `2000-2999`: business errors (insufficient balance, round not found, order not found, notice not found)
|
||||||
|
- `3000-3099`: flow errors (illegal flow/state, e.g. bet after lock, duplicate confirm, illegal state transition)
|
||||||
|
- `5000-5999`: system errors (service exception, dependency timeout, unknown error)
|
||||||
|
|
||||||
|
- Recommended base error codes (first release):
|
||||||
|
- `1`: success
|
||||||
|
- `1001`: missing parameter
|
||||||
|
- `1002`: invalid parameter format
|
||||||
|
- `1003`: illegal parameter value
|
||||||
|
- `1101`: not logged in or session expired
|
||||||
|
- `1103`: no permission
|
||||||
|
- `2001`: insufficient balance
|
||||||
|
- `2002`: round not found
|
||||||
|
- `2003`: order not found
|
||||||
|
- `2004`: notice not found
|
||||||
|
- `3001`: operation not allowed in current flow
|
||||||
|
- `3002`: betting closed, bets not allowed
|
||||||
|
- `3003`: duplicate request (idempotency conflict)
|
||||||
|
- `5000`: system busy, try again later
|
||||||
|
|
||||||
|
### 1.3 Authentication
|
||||||
|
- **API auth (`auth-token`)**: all mobile business APIs must send header `auth-token` (issued by `/api/v1/authToken`)
|
||||||
|
- **User session (`user-token`)**: login-protected APIs send header `user-token`; on expiry, refresh or log in again
|
||||||
|
|
||||||
|
### 1.4 Obtain API Auth Token (`auth-token`)
|
||||||
|
- **GET** `/api/v1/authToken`
|
||||||
|
- Purpose: obtain `auth-token` (required on all API request headers)
|
||||||
|
|
||||||
|
Request example:
|
||||||
|
`/api/v1/authToken?secret=564d14asdasd113e46542asd6das1a2a×tamp=1776331077&device_id=1&signature=AD84C49880896DBC16C59C7B122D1FF7`
|
||||||
|
|
||||||
|
Request parameters:
|
||||||
|
- `secret`: string (client secret; server validates against env `AUTH_TOKEN_SECRET`)
|
||||||
|
- `timestamp`: int (request timestamp; server allows ±300 seconds from server time)
|
||||||
|
- `device_id`: string (device id)
|
||||||
|
- `signature`: string (signature value)
|
||||||
|
|
||||||
|
Signature algorithm:
|
||||||
|
- Parameters in signature (excluding `signature`): `device_id`, `secret`, `timestamp`
|
||||||
|
- Sort parameter names **a-z**, concatenate as `key=value&key=value...`
|
||||||
|
- Compute: `signature = strtoupper(md5(concatenated string))`
|
||||||
|
|
||||||
|
Response parameters:
|
||||||
|
- `auth_token`: string (API auth token; put in header `auth-token`)
|
||||||
|
- `expires_in`: int (TTL in seconds)
|
||||||
|
- `server_time`: int (server timestamp for clock sync)
|
||||||
|
|
||||||
|
Possible error codes:
|
||||||
|
- `1001` missing parameter
|
||||||
|
- `1002` invalid parameter format
|
||||||
|
- `1103` invalid secret / signature error
|
||||||
|
- `3001` invalid timestamp
|
||||||
|
|
||||||
|
### 1.5 Server Performance & Redis Hot-Spot Cache (Implementation Notes)
|
||||||
|
|
||||||
|
> **No client contract change**: request paths, parameters, response JSON shape, and error codes are unchanged by caching; this section only explains how the server reduces latency, read paths, and consistency notes.
|
||||||
|
|
||||||
|
**Difference from “framework file cache”**
|
||||||
|
|
||||||
|
| Config | Scope |
|
||||||
|
|--------|--------|
|
||||||
|
| `CACHE_DRIVER` (`config/cache.php`, e.g. `file`) | Think-ORM / `get_sys_config()` etc. **system `config` table** model cache under `runtime/cache`; **not** used on this game hot-path. |
|
||||||
|
| `GAME_HOT_CACHE_*` (`config/game_hot_cache.php`) | Game **`user` / `game_config` / `game_record`** row-level JSON cache via **`support\Redis`** (`config/redis.php`), key prefix `dfw:v1:`. |
|
||||||
|
|
||||||
|
**Server cache coverage (read paths relevant to mobile)**
|
||||||
|
|
||||||
|
- **User**: member auth prefers Redis `user` row snapshot; on miss, DB then backfill. After **balance, streak, bet-flow**, etc. change and commit, **`GameHotDataCoordinator::afterUserCommitted($userId)`**: **`GameHotDataRedis::userReplaceCacheFromDb`** aligns with DB, then enqueues idempotent refresh tasks (`GameHotDataWriteQueue` / `GameHotDataQueueConsumer`) for peak shaving—not a substitute for synchronous reload.
|
||||||
|
- **Game config**: `game_config` cached by `config_key`. Direct `Db` updates in admin must call **`GameHotDataCoordinator::afterGameConfigKeyCommitted($key)`** (model `GameConfig` events and standalone form controllers wired); standalone save uses **`GameHotDataLock` (`TYPE_GAME_CONFIG`)** per `config_key`. Do not only delete cache keys without reload—max inconsistency window is TTL.
|
||||||
|
- **Round**: active round, round by `id`, latest `game_record`, etc.; after write, **`GameHotDataCoordinator::afterGameRecordCommitted`** refreshes Redis keys and enqueues. Draw/lock paths may use **`GameHotDataLock` (`TYPE_GAME_RECORD`)** per record id.
|
||||||
|
|
||||||
|
**Environment variables (see repo root `.env-example`)**
|
||||||
|
|
||||||
|
- `GAME_HOT_CACHE_ENABLED`: enable Redis hot cache (`false` = always DB).
|
||||||
|
- `GAME_HOT_CACHE_TTL_GAME_CONFIG` / `GAME_HOT_CACHE_TTL_GAME_RECORD` / `GAME_HOT_CACHE_TTL_USER`: TTL seconds; **write-then-sync reload is primary**, TTL is fallback only.
|
||||||
|
- `GAME_HOT_CACHE_ENABLE_WRITE_QUEUE` and queue length, consumer interval, etc.: idempotent refresh after write (`config/game_hot_cache.php`).
|
||||||
|
|
||||||
|
**Consistency notes (integration / testing)**
|
||||||
|
|
||||||
|
- Scripts that bypass coordinator and only change DB without **`GameHotDataCoordinator`** may be briefly inconsistent with Redis; avoid in production.
|
||||||
|
- **`POST /api/game/betPlace`** debit path uses the same **per-user Redis lock** as admin wallet adjust (`GameHotDataRedis::userAdminMutationLockTry`) and **`WHERE coin = ?` conditional update**, mutually exclusive with concurrent payout/admin adjust; on failure returns Chinese messages listed in **§4.2**.
|
||||||
|
- Clients may still use **§3.2 `dictionaryList` `version`** for local cache; server dictionary also has Redis acceleration—both can coexist.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Auth & Account Module (`user`)
|
||||||
|
|
||||||
|
### 2.1 Register
|
||||||
|
- **POST** `/api/user/register`
|
||||||
|
- Purpose: phone-only registration with invite attribution (admin/channel)
|
||||||
|
|
||||||
|
Request parameters:
|
||||||
|
- `username`: string, phone number (registration account; mainland China mobile only)
|
||||||
|
- `password`: string, plaintext over HTTPS (login password; server stores salted hash)
|
||||||
|
- `invite_code`: string, required (sub-agent invite code; binds `channel_id` and ownership)
|
||||||
|
- `device_id`: string, optional (device id for risk control and login logs)
|
||||||
|
|
||||||
|
Response parameters:
|
||||||
|
- `user-token`: string (session token for login-protected APIs)
|
||||||
|
- `refresh_token`: string, optional (refresh access token)
|
||||||
|
- `expires_in`: int (seconds, token TTL)
|
||||||
|
- `user`: object (non-sensitive fields only; no `id`)
|
||||||
|
- `uuid`: string (public user id, 10 chars)
|
||||||
|
- `username`: string (nickname/display name)
|
||||||
|
- `coin`: string (current balance)
|
||||||
|
- `channel_id`: int (attribution channel id)
|
||||||
|
- `risk_flags`: int (risk status bitmask)
|
||||||
|
|
||||||
|
### 2.2 Login
|
||||||
|
- **POST** `/api/user/login`
|
||||||
|
|
||||||
|
Request parameters:
|
||||||
|
- `username`: string (login account; currently phone)
|
||||||
|
- `password`: string (login password)
|
||||||
|
- `device_id`: string, optional (risk assist)
|
||||||
|
|
||||||
|
Response parameters:
|
||||||
|
- `user-token`: string (access token for login-protected APIs)
|
||||||
|
- `refresh_token`: string, optional
|
||||||
|
- `expires_in`: int (access token remaining seconds)
|
||||||
|
- `user`: object (non-sensitive fields only; no `id`)
|
||||||
|
- `uuid`: string (public user id, 10 chars)
|
||||||
|
- `username`: string (nickname/display name)
|
||||||
|
- `coin`: string (current balance)
|
||||||
|
- `channel_id`: int (attribution channel id)
|
||||||
|
- `risk_flags`: int (risk status bitmask)
|
||||||
|
|
||||||
|
### 2.3 Current User Profile
|
||||||
|
- **POST** `/api/user/profile`
|
||||||
|
|
||||||
|
Response parameters (amount fields as 2-decimal strings, aligned with wallet display):
|
||||||
|
|
||||||
|
**Basic profile**
|
||||||
|
- `uuid`: string (public user id, 10 chars)
|
||||||
|
- `username`: string (nickname)
|
||||||
|
- `head_image`: string (avatar URL)
|
||||||
|
- `phone`: string (mobile)
|
||||||
|
- `email`: string (email)
|
||||||
|
- `register_invite_code`: string (invite code snapshot at registration)
|
||||||
|
- `channel_id`: int (attribution channel id)
|
||||||
|
- `risk_flags`: int (risk status bitmask)
|
||||||
|
- `current_streak`: int (current win streak count)
|
||||||
|
- `last_bet_period_no`: string (period no of last valid bet)
|
||||||
|
- `create_time`: int (registration timestamp)
|
||||||
|
|
||||||
|
**Funds & withdraw quota**
|
||||||
|
- `coin` / `coin_balance`: string (current balance; same value)
|
||||||
|
- `frozen_balance`: string (frozen balance; fixed `0.00` when none)
|
||||||
|
- `total_deposit_coin`: string (lifetime deposits)
|
||||||
|
- `total_withdraw_coin`: string (lifetime withdraws; incremented after acceptance)
|
||||||
|
- `bet_flow_coin`: string (bet flow / turnover; after settlement, +`total_amount` per bet 1:1)
|
||||||
|
- `max_withdrawable`: string (**max single withdraw allowed now** = `min(coin_balance, max_withdraw_by_flow)`)
|
||||||
|
- `withdraw_flow`: object (bet-flow / withdraw quota diagnostic snapshot; includes `pending_withdraw`)
|
||||||
|
- `ratio`: string (bet-flow multiplier; `0` = unlimited)
|
||||||
|
- `net_deposit`: string (net deposit = max(0, total deposit − total withdraw))
|
||||||
|
- `required_bet_flow`: string (required bet flow by threshold rule; display only)
|
||||||
|
- `remaining_bet_flow`: string (remaining bet flow by threshold; display only)
|
||||||
|
- `eligible`: bool (meets overall threshold; display only; actual gate is `max_withdrawable`)
|
||||||
|
- `max_withdraw_by_flow`: string/null (cap from bet flow only; `null` when `ratio=0`)
|
||||||
|
- `flow_unlimited`: bool (unlimited bet-flow mode)
|
||||||
|
- `pending_withdraw`: object
|
||||||
|
- `count`: int (pending-review withdraw orders)
|
||||||
|
- `max`: int (max pending-review withdraws per user, currently `3`; exceeds → `withdrawCreate` returns `code=2004`)
|
||||||
|
|
||||||
|
### 2.4 Refresh Token (Optional)
|
||||||
|
- **POST** `/api/user/refreshToken`
|
||||||
|
|
||||||
|
Request parameters:
|
||||||
|
- `refresh_token`: string (credential to renew access token)
|
||||||
|
|
||||||
|
Response parameters:
|
||||||
|
- `user-token`: string (new access token)
|
||||||
|
- `expires_in`: int (new token TTL)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Game Lobby & Dictionary Module (`game`/`lobby`)
|
||||||
|
|
||||||
|
### 3.1 Lobby Init
|
||||||
|
- **POST** `/api/game/lobbyInit`
|
||||||
|
- Purpose: one-shot current round, config, 36 zihua dictionary, user quick display
|
||||||
|
|
||||||
|
Response parameters:
|
||||||
|
- `server_time`: int (server time for client clock sync)
|
||||||
|
- `runtime_enabled`: bool (**game runtime switch**; `false` = admin maintenance—**no new bets**, idle won’t auto-create periods, post-payout won’t auto-create next; **current open round still draws, pays out, and settles**. Mobile should disable bet entry and show maintenance copy)
|
||||||
|
- `period`: object
|
||||||
|
- `period_no`: string (global period number)
|
||||||
|
- `status`: string (`betting`/`locked`/`settling`/`finished`/`void`; `void` = period voided)
|
||||||
|
- `countdown`: int (countdown seconds)
|
||||||
|
- `lock_at`: int (lock timestamp)
|
||||||
|
- `open_at`: int (expected draw timestamp)
|
||||||
|
- `bet_config`: object
|
||||||
|
- `pick_max_number_count`: int (max numbers per bet; from `game_config.config_key = pick_max_number_count`; default matches seed, usually 10; range 1–36)
|
||||||
|
- `chips`: object (quick chip map; fixed keys `"1"`…`"6"`, values are per-number stake strings, 2 decimals; same as admin `game_config.bet_chips`)
|
||||||
|
- `default_bet_chip_id`: int (default chip id from `game_config.default_bet_chip_id`; invalid → first valid chip)
|
||||||
|
- `min_bet_per_number`: string (min per number; ≤ selected chip and admin limits)
|
||||||
|
- `max_bet_per_number`: string (max per number)
|
||||||
|
- `dictionary`: array<object>
|
||||||
|
- `number`: int (1-36, zihua number)
|
||||||
|
- `name`: string (zihua name)
|
||||||
|
- `category`: string (category)
|
||||||
|
- `icon`: string (icon URL)
|
||||||
|
- `user_snapshot`: object (user snapshot + **odds for current player this round**; no full 1–10 table)
|
||||||
|
- `coin`: string (balance)
|
||||||
|
- `current_streak`: int (current win streak)
|
||||||
|
- `streak_level`: int (streak tier 1–10 if win this round: `min(current_streak+1, 10)`)
|
||||||
|
- `odds_factor`: int (odds multiplier; payout = bet `total_amount` × `odds_factor`)
|
||||||
|
- `is_jackpot`: bool (jackpot tier)
|
||||||
|
|
||||||
|
### 3.2 36 Zihua Dictionary (Cacheable)
|
||||||
|
- **POST** `/api/game/dictionaryList`
|
||||||
|
|
||||||
|
Response parameters:
|
||||||
|
- `version`: string (dictionary version for client cache compare)
|
||||||
|
- `items`: same as `dictionary` (36 zihua entries)
|
||||||
|
|
||||||
|
## 4. Betting & Round Module (`game`/`bet`)
|
||||||
|
|
||||||
|
### 4.1 Current Period Detail
|
||||||
|
- **POST** `/api/game/periodCurrent`
|
||||||
|
|
||||||
|
Response parameters:
|
||||||
|
- `runtime_enabled`: bool (same as `lobbyInit.runtime_enabled`)
|
||||||
|
- `period_id`: int (current period primary key)
|
||||||
|
- `period_no`: string (current period number)
|
||||||
|
- `status`: string (includes `void`)
|
||||||
|
- `countdown`: int (remaining seconds)
|
||||||
|
- `bet_close_in`: int (seconds until lock)
|
||||||
|
- `result_number`: int/null (null before draw)
|
||||||
|
|
||||||
|
### 4.2 Place Bet
|
||||||
|
- **POST** `/api/game/placeBet` (legacy path `/api/game/betPlace` supported)
|
||||||
|
- Purpose: manual bet per period; player sends **picked numbers** and **chip id `bet_id` (1–6)**. Stake per number from `game_config.bet_chips`; server debits `per-number amount × count(numbers)` as `total_amount`. One winning number per round; win if that number ∈ picked set.
|
||||||
|
|
||||||
|
Request parameters:
|
||||||
|
- `period_no`: string (target period)
|
||||||
|
- `numbers`: string (comma-separated picks, e.g. `1,8,16`; each 1–36; count ≤ `pick_max_number_count`; duplicates deduped)
|
||||||
|
- `bet_id`: int (quick chip 1–6; must exist in `lobbyInit.bet_config.chips`; **`single_bet_amount` / `bet_amount` not used**)
|
||||||
|
- `idempotency_key`: string, required
|
||||||
|
|
||||||
|
Response parameters:
|
||||||
|
- `order_no`: string (bet order no)
|
||||||
|
- `period_no`: string (actual period)
|
||||||
|
- `status`: string (`accepted`/`rejected`)
|
||||||
|
- `bet_id`: int (chip used)
|
||||||
|
- `single_bet_amount`: string (from `bet_id`)
|
||||||
|
- `numbers_count`: int (number count)
|
||||||
|
- `locked_balance`: string, optional (frozen amount)
|
||||||
|
- `balance_after`: string (balance after order)
|
||||||
|
- `current_streak`: int (streak snapshot after order)
|
||||||
|
|
||||||
|
**Additional possible error codes** (see header ranges; debit/cache consistency):
|
||||||
|
|
||||||
|
- `3001`: game paused (`runtime_enabled=false`, admin live switch off or void without restart; same band as flow errors)
|
||||||
|
- `5000`: system busy; or **user Redis lock** not acquired (same serial as admin wallet/concurrent writes: 「该用户正在被其他管理员操作(钱包/并发保存),请稍后再试」); or **`coin` conditional update** miss (concurrent bet/payout/admin: 「扣款失败:该用户余额已被其他请求修改(如下注、派彩或其他管理员已保存),请刷新后重试」).
|
||||||
|
|
||||||
|
### 4.3 Auto Spin
|
||||||
|
- **POST** `/api/game/autoSpin`
|
||||||
|
|
||||||
|
Request parameters:
|
||||||
|
- `action`: string (`start`/`stop`)
|
||||||
|
- `period_no`: string (required when `action=start`)
|
||||||
|
- `numbers`: string (required when `action=start`, comma-separated)
|
||||||
|
- `bet_id`: int (required when `action=start`; same as `placeBet`, chips 1–6)
|
||||||
|
- `rounds`: int (required when `action=start`, >=1)
|
||||||
|
|
||||||
|
Response parameters:
|
||||||
|
- `status`: string (`scheduled`/`stopped`)
|
||||||
|
- `auto_mode`: bool
|
||||||
|
- `bet_id`: int (`start` only)
|
||||||
|
- `single_bet_amount`: string (`start` only, from `bet_id`)
|
||||||
|
- `remaining_rounds`: int (`start` only)
|
||||||
|
|
||||||
|
### 4.4 My Bet Orders (Last 1 Month)
|
||||||
|
- **POST** `/api/game/betMyOrders`
|
||||||
|
|
||||||
|
Request parameters:
|
||||||
|
- `page`: int, optional, default 1
|
||||||
|
- `page_size`: int, optional, default 20
|
||||||
|
|
||||||
|
Response parameters:
|
||||||
|
- `list`: array<object>
|
||||||
|
- `order_no`: string
|
||||||
|
- `period_no`: string
|
||||||
|
- `numbers`: array[int]
|
||||||
|
- `bet_amount`: string (same as `total_amount`)
|
||||||
|
- `total_amount`: string
|
||||||
|
- `result_number`: int/null
|
||||||
|
- `win_amount`: string
|
||||||
|
- `status`: string
|
||||||
|
- `create_time`: int
|
||||||
|
- `pagination`: object (`page`, `page_size`, `total`)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Wallet & Finance Module (`wallet`/`finance`)
|
||||||
|
|
||||||
|
### 5.1 Balance Sync (Standalone Summary Removed)
|
||||||
|
- Removed `/api/wallet/balanceSummary`.
|
||||||
|
- Balance sync sources:
|
||||||
|
- `placeBet.balance_after`
|
||||||
|
- WebSocket `wallet.changed`
|
||||||
|
- Deposit/withdraw detail APIs (`depositDetail` / `withdrawDetail`) for order-level reconciliation
|
||||||
|
- Multiplier `ratio` from admin **Game Config → `withdraw_bet_flow_ratio`**; effective immediately on new requests.
|
||||||
|
- Lifetime fields (`total_deposit_coin` / `total_withdraw_coin` / `bet_flow_coin`) are cumulative; rejections reversed by admin review flow.
|
||||||
|
|
||||||
|
### 5.2 Wallet Records
|
||||||
|
- **POST** `/api/wallet/recordList`
|
||||||
|
|
||||||
|
Request parameters:
|
||||||
|
- `page`: int, optional, default 1
|
||||||
|
- `page_size`: int, optional, default 20
|
||||||
|
- `type`: string, optional (filter; omit = all):
|
||||||
|
- `deposit`: deposit credit
|
||||||
|
- `withdraw`: withdraw debit/freeze
|
||||||
|
- `bet`: bet debit
|
||||||
|
- `payout`: draw payout credit
|
||||||
|
- `adjust`: manual adjust (`biz_type=admin_credit/admin_deduct`)
|
||||||
|
- `bet_void`: period void refund (admin live void pending bets)
|
||||||
|
|
||||||
|
Response parameters:
|
||||||
|
- `list`: array<object>
|
||||||
|
- `record_id`: int
|
||||||
|
- `biz_type`: string
|
||||||
|
- `direction`: int (1 in, 2 out)
|
||||||
|
- `amount`: string
|
||||||
|
- `balance_before`: string
|
||||||
|
- `balance_after`: string
|
||||||
|
- `ref_type`: string
|
||||||
|
- `ref_id`: string
|
||||||
|
- `create_time`: int
|
||||||
|
|
||||||
|
Notes:
|
||||||
|
- Amount fields display with two decimals on client.
|
||||||
|
- Admin credit/deduct creates `biz_type=admin_credit/admin_deduct` records; default remark template: `后台管理员(操作管理员)加点/扣点100(值)` (example).
|
||||||
|
|
||||||
|
### 5.3 Deposit Tier List
|
||||||
|
- **POST** `/api/finance/depositTierList`
|
||||||
|
|
||||||
|
Notes:
|
||||||
|
- Maintained in admin **Config → Deposit Tiers**, stored in `game_config.deposit_tier` (JSON array).
|
||||||
|
- Admin “pay currency” dropdown from `game_config.finance_cashier.currencies` (not hardcoded).
|
||||||
|
- Init/rebuild: **6 tiers per currency** from `finance_cashier` (ops may edit).
|
||||||
|
- Only `status=1` tiers, sorted by `sort`.
|
||||||
|
- Tiers describe recharge spec only; collection via third-party `pay_url`.
|
||||||
|
- **i18n**: admin stores `title`, `title_en`, `desc`, `desc_en`. API `title` / `desc` follow `lang`:
|
||||||
|
- `lang=zh` (default): `title` / `desc`, fallback to English if empty
|
||||||
|
- `lang=en`: `title_en` / `desc_en`, fallback to Chinese if empty
|
||||||
|
- Mobile sees single `title` / `desc` only
|
||||||
|
|
||||||
|
Request parameters: none
|
||||||
|
|
||||||
|
Response parameters:
|
||||||
|
- `list`: tier list; each item:
|
||||||
|
- `id`: string (stable tier id; pass as `tier_id`; same as `tier_key`)
|
||||||
|
- `tier_key`: string (same as `id`, legacy)
|
||||||
|
- `title`: string (localized by `lang`)
|
||||||
|
- `currency`: string (e.g. `CNY`)
|
||||||
|
- `pay_amount`: string (2 decimals, list price)
|
||||||
|
- `amount`: string (2 decimals, amount user pays)
|
||||||
|
- `bonus_amount`: string (2 decimals, bonus; `0.00` if none)
|
||||||
|
- `total_amount`: string (2 decimals, `amount + bonus_amount`)
|
||||||
|
- `desc`: string (localized; may be empty)
|
||||||
|
- `channels`: array (for `depositCreate` `channel_code`; all enabled channels compatible with all tiers)
|
||||||
|
- each: `code`, `name`, `sort`
|
||||||
|
|
||||||
|
### 5.3A Deposit / Withdraw Config
|
||||||
|
- **POST** `/api/finance/depositWithdrawConfig`
|
||||||
|
- Legacy: `POST /api/finance/cashierConfig` (same shape; prefer `depositWithdrawConfig`)
|
||||||
|
|
||||||
|
Purpose:
|
||||||
|
- One-shot recharge/withdraw page config: currencies, rates, deposit channels, withdraw banks, limits, copy.
|
||||||
|
|
||||||
|
Response parameters:
|
||||||
|
- `platform_coin_label`: string (localized)
|
||||||
|
- `currencies`: array
|
||||||
|
- `code`, `label`, `deposit_coins_per_fiat`, `withdraw_coins_per_fiat`
|
||||||
|
- `rates`: array (compat)
|
||||||
|
- `currency`, `diamonds_per_fiat_unit`
|
||||||
|
- `pay_channels`: array (deposit)
|
||||||
|
- `code`, `name`, `sort`, `status` (1=enabled), `tier_ids` (compat; empty = all tiers)
|
||||||
|
- `withdraw`: object
|
||||||
|
- `pay_channels`: array (for `withdrawCreate` `channel_code`; enabled + server-integrated only)
|
||||||
|
- `banks`: array
|
||||||
|
- `min_ewallet`, `min_bank`, `rate_hint`, `processing_note`, `fee_note`
|
||||||
|
- `rate_mode`: `fixed` / `live`
|
||||||
|
- `fields`: object (**aligned with DDPay / `withdrawCreate`**; not admin cashier toggles)
|
||||||
|
- `receive_type_bank_only`: bool (fixed `true`, bank only)
|
||||||
|
- `require_channel_code`: bool (`true`, `channel_code`)
|
||||||
|
- `require_receiver_name`: bool (`true`, `receiver_name`)
|
||||||
|
- `require_receive_account`: bool (`true`, `receive_account`)
|
||||||
|
- `require_receiver_email`: bool (`true`, `receiver_email`)
|
||||||
|
- `require_receiver_mobile`: bool (`true`, `receiver_mobile`)
|
||||||
|
- `require_bank_code`: bool (`true`, `bank_code`)
|
||||||
|
- `require_bank_branch`: bool (`false`, optional; server sends `N/A` to DDPay if omitted)
|
||||||
|
|
||||||
|
### 5.4 Create Deposit Order
|
||||||
|
- **POST** `/api/finance/depositCreate`
|
||||||
|
- `Content-Type: application/json` (recommended), `application/x-www-form-urlencoded`, or **`multipart/form-data`**; same field names; server reads unified param names.
|
||||||
|
|
||||||
|
Notes:
|
||||||
|
- **Mock pay (integration)**: `channel_code=mock`, no DDPay merchant; returns `pay_url` (**mock cashier** `GET /api/finance/mockDepositPage`, **3 min** TTL). User confirms → **`pending_review`**; credit after admin approves in **Deposit Orders**.
|
||||||
|
- Optional **`GET/POST /api/finance/mockDepositPay`** (login + same `auth-token`): in-app confirm (either cashier or this; idempotent).
|
||||||
|
- **DDPay**: `channel_code=ddpay`; pending order then DDPay deposit API; on success `pay_url=payment_url`.
|
||||||
|
- Switch: `FINANCE_MOCK_PAY_ENABLED` (default on in dev); off → `mock` unavailable.
|
||||||
|
- Settlement: **`POST /api/finance/ddpayDepositNotify`** verified, or sync `transaction_status=completed`; **`mock`** credits only after admin approval.
|
||||||
|
- Tiers/channels from `depositTierList`.
|
||||||
|
- Max 3 pending deposit orders per user; unpaid orders fail after `DEPOSIT_PENDING_EXPIRE_SECONDS` (default **60s**, `.env`); pay link countdown matches.
|
||||||
|
|
||||||
|
Request parameters:
|
||||||
|
|
||||||
|
**A. Common required**
|
||||||
|
- `tier_id`: string, required (synonym `tier_key`)
|
||||||
|
- `channel_code`: string, required, `mock` or `ddpay`
|
||||||
|
- `idempotency_key`: string, required, ≤64
|
||||||
|
|
||||||
|
**A.1 Mock (`channel_code=mock`)**
|
||||||
|
- No `payment_type` / `payer_name` / `payer_bank_name`
|
||||||
|
|
||||||
|
**B. DDPay required (`channel_code=ddpay`)**
|
||||||
|
|
||||||
|
> Per DDPay official *Payment Gateway* (`docs/DDPay Payment Gateway_v1.1.3_zh.md` / PDF).
|
||||||
|
|
||||||
|
- `payment_type`: string, required (**official enum §3.1**): **`01`** FPX, **`02`** duitnow, **`03`** ewallet; confirm other values with DDPay support.
|
||||||
|
- Alias: `paymentType`
|
||||||
|
- **Note**: values like `FPX`, `duitnow` may be rejected; send **`01` / `02` / `03`**.
|
||||||
|
- `payer_name`: string, required (account holder name)
|
||||||
|
- Alias: `payerName`
|
||||||
|
- `payer_bank_name`: string, required (**`payer_bank[name]`**; full name from DDPay bank list)
|
||||||
|
- **MYR**: English full name from deposit bank list (e.g. `Public Bank`, `Maybank2U`); **THB**: THB list English full name. Mismatches may be rejected.
|
||||||
|
- Aliases: `payer_bank[name]`, `payerBankName`
|
||||||
|
|
||||||
|
**B.1 Server-only DDPay fields (integration reference)**
|
||||||
|
|
||||||
|
Mobile must not send; server fills on deposit API:
|
||||||
|
|
||||||
|
- `client_id`, `identifier` (if `DDPAY_IDENTIFIER` set), `order_id` (= `order_no`)
|
||||||
|
- `transaction_amount`: tier **`pay_amount`** (Decimal); currency per DDPay onboarding (docs often MYR; use your merchant currency).
|
||||||
|
- `callback_url`, `redirect_url`: from **`DDPAY_PUBLIC_BASE_URL`** (`.env-example`) or request `Host`; production **HTTPS**.
|
||||||
|
|
||||||
|
> **Common `1001` (DDPay)**: missing `payment_type` / `payer_name` / `payer_bank_name`; or `payment_type` not `01/02/03`.
|
||||||
|
|
||||||
|
Example (DDPay, MYR + FPX):
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"tier_id": "t_xxxxxxxx",
|
||||||
|
"channel_code": "ddpay",
|
||||||
|
"idempotency_key": "dp_20260429_xxx",
|
||||||
|
"payment_type": "01",
|
||||||
|
"payer_name": "ZHANG SAN",
|
||||||
|
"payer_bank_name": "Public Bank"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Response parameters:
|
||||||
|
- `order_no`, `amount`, `bonus_amount`, `total_amount`, `pay_channel`, `paid`, `pay_url`, `review_required`, `reject_reason`, `expire_at`, `expire_seconds`, `status` (`pending` / `pending_review` / `paid` / `failed`), `create_time`, `pay_time`
|
||||||
|
|
||||||
|
#### 5.4.1 DDPay Callback & Status (Current Implementation)
|
||||||
|
|
||||||
|
- Callback: `POST /api/finance/ddpayDepositNotify` (**`callback_url`** to DDPay; **HTTPS**).
|
||||||
|
- Official webhook fields (§3.5, **verify signature first**):
|
||||||
|
|
||||||
|
| Param | Description |
|
||||||
|
|-------|-------------|
|
||||||
|
| `client_id` | Merchant id |
|
||||||
|
| `order_id` | Reference (= deposit `order_no`) |
|
||||||
|
| `transaction_status` | `pending` / `completed` / `failed` (§4.1) |
|
||||||
|
| `timestamp` | Notify time |
|
||||||
|
| `transaction_amount` | Paid amount |
|
||||||
|
| `signature` | Signature |
|
||||||
|
|
||||||
|
- After verify:
|
||||||
|
- `completed` → settle, `paid`
|
||||||
|
- `failed` → `failed`
|
||||||
|
- Respond HTTP **200**, body **`{"status":"ok"}`**; up to **6** retries per docs.
|
||||||
|
- Without callback, `pending` persists; server may use deposit status query (`query_time` `YYYY-MM-DD HH:MM:SS`); not exposed to mobile yet.
|
||||||
|
|
||||||
|
Error codes:
|
||||||
|
- `1001`: missing `tier_id`/`tier_key`, `channel_code`, `idempotency_key`
|
||||||
|
- `1001` (DDPay): missing/invalid DDPay fields
|
||||||
|
- `1002`: `idempotency_key` too long or cross-user conflict
|
||||||
|
- `2000`: generic DB/settle failure; DDPay API failure → 「DDPay 充值发起失败」, see `deposit_order.remark` (`[ddpay]`) or logs
|
||||||
|
- `2003`: invalid/disabled tier
|
||||||
|
- `2004`: bad `channel_code`; mock off; ddpay off; currency/channel mismatch
|
||||||
|
- `2005`: too many pending (`data.max_pending`, `data.pending_count`, `data.expire_seconds`)
|
||||||
|
|
||||||
|
### 5.4A Mock Pay (Browser Cashier + Optional App Confirm)
|
||||||
|
|
||||||
|
- **GET/POST** `/api/finance/mockDepositPage`: HTML cashier (no login; `order_no` required; `pay_channel=mock`, `status=pending`, not expired)
|
||||||
|
- **GET/POST** `/api/finance/mockDepositConfirm`: cashier confirm (no login; → **`pending_review`**, no credit)
|
||||||
|
- **GET/POST** `/api/finance/mockDepositPay`: app confirm (login; idempotent with above)
|
||||||
|
|
||||||
|
After admin approves/rejects in **Deposit Orders**, mock order succeeds or fails.
|
||||||
|
|
||||||
|
### 5.5 Deposit Order Detail
|
||||||
|
- **POST** `/api/finance/depositDetail`
|
||||||
|
|
||||||
|
Request parameters:
|
||||||
|
- `order_no`: string, required
|
||||||
|
|
||||||
|
Response parameters (same as `depositCreate`):
|
||||||
|
- `order_no`, `amount`, `bonus_amount`, `total_amount`, `pay_channel`, `paid`, `pay_url`, `review_required`, `reject_reason`, `expire_at`, `expire_seconds`, `status`, `create_time`, `pay_time`
|
||||||
|
|
||||||
|
### 5.6 Deposit Order List
|
||||||
|
- **POST** `/api/finance/depositList`
|
||||||
|
|
||||||
|
Paged recharge history; use `depositDetail` for full fields.
|
||||||
|
|
||||||
|
Request parameters:
|
||||||
|
- `page`, `page_size` (max `100`, over → `20`)
|
||||||
|
|
||||||
|
Response parameters:
|
||||||
|
- `list`: `order_no`, `amount`, `bonus_amount`, `status`
|
||||||
|
- `pagination`: `page`, `page_size`, `total`
|
||||||
|
|
||||||
|
### 5.7 Withdraw Request
|
||||||
|
- **POST** `/api/finance/withdrawCreate`
|
||||||
|
|
||||||
|
Notes:
|
||||||
|
- **DDPay payout only**: `channel_code` must be **`ddpay`** (alias `pay_channel`); else `code=2004`.
|
||||||
|
- Channels: `depositWithdrawConfig` → `withdraw.pay_channels`.
|
||||||
|
|
||||||
|
Request parameters:
|
||||||
|
- `channel_code`: string, required (`ddpay`; alias `pay_channel`)
|
||||||
|
- `withdraw_coin`: string (> 0)
|
||||||
|
- `receive_account`: string (DDPay **`receiver_account`**)
|
||||||
|
- `receive_type`: string (`bank` only currently)
|
||||||
|
- `idempotency_key`: string
|
||||||
|
- `receiver_name`: string (required for `bank`; DDPay **`receiver_name`**)
|
||||||
|
- `receiver_email`: string (required, valid email, max 255)
|
||||||
|
- `receiver_mobile`: string (required, 5–32, digits and `+` `-` space, ≥5 digits)
|
||||||
|
- `bank_code`: string (required for `bank`; from `withdraw_banks[].code` → DDPay **`bank[name]`**)
|
||||||
|
- `bank_branch`: string (optional; server sends **`N/A`** if omitted per DDPay)
|
||||||
|
|
||||||
|
**DDPay Payout field map (§3.2, server assembles after approval)**
|
||||||
|
|
||||||
|
| Official param | Description |
|
||||||
|
|----------------|-------------|
|
||||||
|
| `client_id` | Merchant id |
|
||||||
|
| `bill_number` | Unique ref (= `order_no`) |
|
||||||
|
| `amount` | Payout amount |
|
||||||
|
| `receiver_name` | Account holder |
|
||||||
|
| `receiver_account` | Account / mobile |
|
||||||
|
| `bank[name]` | Full bank name (appendix) |
|
||||||
|
| `bank_branch` | Branch; default `N/A` |
|
||||||
|
| `callback_url` | HTTPS notify URL |
|
||||||
|
| `signature` | MD5 lowercase |
|
||||||
|
|
||||||
|
Response may include `transaction_fee`, `transaction_total`, `transaction_status`, `remark`, etc.
|
||||||
|
|
||||||
|
Return parameters:
|
||||||
|
- `order_no`, `status` (`pending_review`/`approved`/`rejected`; paid merged into `approved`), `fee_coin`, `actual_arrival_coin`, `risk_review_required`
|
||||||
|
|
||||||
|
Validation order (first failure wins):
|
||||||
|
1. Params & amount (`1001`)
|
||||||
|
2. Channel: not `ddpay` → `2004 Withdraw only supports DDPay`; disabled → `2004 Pay channel not available`
|
||||||
|
3. Pending limit: `status=0` withdraw orders ≤ 3 → `2004 Too many pending withdraw orders` + `max_pending`, `pending_count`
|
||||||
|
4. `coin_balance >= withdraw_coin` → `2001 Insufficient balance`
|
||||||
|
5. `withdraw_coin <= max_withdrawable` → `2002 Withdraw exceeds available bet flow` + diagnostic `data`
|
||||||
|
6. On success in one transaction:
|
||||||
|
- `withdraw_order` with `pay_channel`, amounts, `status=0`, user `channel_id` snapshot, receiver fields
|
||||||
|
- `user`: `coin -= withdraw_coin`, `total_withdraw_coin += withdraw_coin` with `WHERE coin >= withdraw_coin`
|
||||||
|
- `user_wallet_record`: `biz_type=withdraw`, `direction=2`, `idempotency_key=wd_apply_{order_no}` (freeze semantics)
|
||||||
|
|
||||||
|
Notes (bet-flow withdraw quota):
|
||||||
|
- `max_withdrawable = min(coin_balance, max_withdraw_by_flow)`; each withdraw consumes `withdraw_coin × ratio` quota via `total_withdraw_coin`.
|
||||||
|
- `ratio = 0`: unlimited bet-flow; cap = `coin_balance` only.
|
||||||
|
- Apply-and-freeze: mobile submit debits `user.coin` immediately; admin **reject** refunds in one transaction.
|
||||||
|
- Admin **approve** triggers DDPay payout; payout **fail** auto-refund (`withdraw_refund`), `rejected` (`status=2`).
|
||||||
|
- After payout, internal `status=3`; mobile shows `approved`.
|
||||||
|
- `withdraw_bet_flow_ratio` in game config; default `1.00`.
|
||||||
|
|
||||||
|
### 5.8 Withdraw Order Detail
|
||||||
|
- **POST** `/api/finance/withdrawDetail`
|
||||||
|
|
||||||
|
Request parameters:
|
||||||
|
- `order_no`: string, required
|
||||||
|
|
||||||
|
Response parameters:
|
||||||
|
- `order_no`, `status`, `withdraw_coin`, `fee_coin`, `actual_arrival_coin`, `receive_type`, `receive_account`, `pay_channel`, `receiver_email`, `receiver_mobile`, `reject_reason`, `create_time`, `review_time`
|
||||||
|
|
||||||
|
### 5.9 Withdraw Order List
|
||||||
|
- **POST** `/api/finance/withdrawList`
|
||||||
|
|
||||||
|
Paged list; use `withdrawDetail` for fees, actual amount, reject reason.
|
||||||
|
|
||||||
|
Request parameters:
|
||||||
|
- `page`, `page_size` (max `100`)
|
||||||
|
|
||||||
|
Response parameters:
|
||||||
|
- `list`: `order_no`, `amount`, `status`
|
||||||
|
- `pagination`: `page`, `page_size`, `total`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Notice Module (`operation`/`notice`)
|
||||||
|
|
||||||
|
### 6.1 Notice List
|
||||||
|
- **GET** `/api/notice/noticeList`
|
||||||
|
- **Auth**: no `auth-token` or `user-token` (public)
|
||||||
|
|
||||||
|
Request parameters (query):
|
||||||
|
- `page`, `page_size`
|
||||||
|
|
||||||
|
Response parameters:
|
||||||
|
- `list`: array<object>
|
||||||
|
- `notice_id`, `title`, `content`, `notice_type` (`silent`/`popout`), `must_confirm`, `is_read` (**popout only**; `silent` always `false`, no read record), `publish_time`
|
||||||
|
|
||||||
|
> **Read records**: `user_notice_read` only for popout confirm; silent mailbox never writes/reads records.
|
||||||
|
|
||||||
|
### 6.2 Popout Confirm Read
|
||||||
|
- **GET** `/api/notice/noticeConfirm`
|
||||||
|
|
||||||
|
Request parameters (query):
|
||||||
|
- `notice_id`: int
|
||||||
|
|
||||||
|
Behavior:
|
||||||
|
- **Popout only**; silent returns business error
|
||||||
|
- Existing record: update `read_at`; if unconfirmed, set `confirmed=1`
|
||||||
|
- No record: create confirmed record
|
||||||
|
|
||||||
|
Response parameters:
|
||||||
|
- `notice_id`, `confirmed`, `confirm_time`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. WebSocket (H5) & State Sync
|
||||||
|
|
||||||
|
> This version removed webman/push channel mode; H5 uses native WebSocket; HTTP polling is weak-network fallback only.
|
||||||
|
|
||||||
|
### 7.0 Deployment & Connection Prerequisites (Current Implementation)
|
||||||
|
|
||||||
|
Aligned with `app/process/GameWebSocketServer.php`, `config/process.php`, `app/common/library/admin/WebSocketConfigHelper.php`:
|
||||||
|
|
||||||
|
- **Dedicated process**: run Webman process **`gameWebSocketServer`** (`config/process.php`), default **`H5_WEBSOCKET_LISTEN`** (`websocket://0.0.0.0:3131`). Without it, clients cannot connect.
|
||||||
|
- **URL**: prefer **`H5_WEBSOCKET_URL`** (full `ws://` / `wss://`, with path per `.env-example`). **Do not use `127.0.0.1` / `localhost` in production** for browser clients. Loopback in `.env` with external HTTP Host → `WebSocketConfigHelper` ignores config and derives `ws(s)://{host}/ws/`. Admin **`GET /admin/test.GameCurrentStatus/wsConfig`**, **`GET /admin/game.Live/wsConfig`** same; local fallback **`ws://127.0.0.1:3131/ws/`**. HTTPS sites need reverse proxy **`/ws/`** → port **3131**.
|
||||||
|
- **Mobile gap**: **`POST /api/game/lobbyInit` does not return WebSocket URL**; H5 must use ops/bundle `H5_WEBSOCKET_URL` or remote config (may differ from HTTP API host).
|
||||||
|
- **Mixed content**: HTTPS pages require **`wss://`**.
|
||||||
|
- **Redis event bus**: HTTP uses **`GameWebSocketEventBus`** (Redis list); if Redis down, broadcast pushes may fail except **`admin.live.snapshot`** has per-second direct snapshot fallback.
|
||||||
|
- **Handshake auth (2026-05, mandatory)**: `GameWebSocketServer::onWebSocketConnect` via `GameWebSocketAuthHelper::authorize` on URL query:
|
||||||
|
- **mobile**: query **`auth-token`** + **`user-token`** (hyphenated; legacy `auth_token`/`user_token` parsed but **use hyphens for new clients**). Binds `user_id`; user topics only to owner.
|
||||||
|
- **admin**: query **`admin-ws-token`** (from admin `wsConfig`, Redis, TTL 7200s). `user_id=0`, full observability.
|
||||||
|
- Failure → `{"event":"ws.error","code":1101,"message":"Authentication failed: ..."}` then `close`.
|
||||||
|
- **Server filter (user topics)**: `bet.win`, `user.streak`, `wallet.changed`, `bet.accepted`, `auto.spin.progress` only if `data.user_id` equals connection `user_id`. Others broadcast by subscription. Admin mode not filtered.
|
||||||
|
- **Idle timeout**: no uplink (incl. `ping`/`subscribe`) for 60s → server `close`.
|
||||||
|
- **Log channel `ws`**: `runtime/logs/ws.log` (7 days): enqueue, dispatch, handshake, subscribe, pong, idle close, send failures.
|
||||||
|
- **Subscribe required**: after connect, only handshake + subscribed topics; no `subscribe` → no `period.tick` etc.
|
||||||
|
|
||||||
|
### 7.1 WebSocket Connection & Messages
|
||||||
|
|
||||||
|
- **URL**: §7.0 (`H5_WEBSOCKET_URL` or admin `ws_url`)
|
||||||
|
- **Client**: browser `WebSocket` (`ws://` / `wss://`)
|
||||||
|
- **Required query (2026-05+)**:
|
||||||
|
- **H5/mobile**: `auth-token` + `user-token`. Optional: `device_id`, `lang`.
|
||||||
|
- **Admin**: `admin-ws-token` (in `ws_url`).
|
||||||
|
- Examples:
|
||||||
|
- H5: `wss://ws.example.com/ws/?auth-token=xxx&user-token=yyy&device_id=ios_001&lang=zh`
|
||||||
|
- Admin: `wss://ws.example.com/ws/?admin-ws-token=zzz`
|
||||||
|
- Missing/invalid → `ws.error` `1101` then close.
|
||||||
|
- **First frame on connect**:
|
||||||
|
- `event`: `ws.connected`
|
||||||
|
- `message`: `WebSocket connected`
|
||||||
|
- `connection_id`, `mode` (`mobile` | `admin`; **no** `user_id`)
|
||||||
|
- `server_time` (seconds, int)
|
||||||
|
- `heartbeat_interval` (30)
|
||||||
|
- `idle_timeout` (60; send `ping` within `idle_timeout - heartbeat_interval`)
|
||||||
|
- **Error frames** (not HTTP `code` band):
|
||||||
|
- Bad JSON: `ws.error`, `Invalid JSON payload`
|
||||||
|
- Unknown `action`: `ws.error`, `Unsupported action`, maybe `received_action`
|
||||||
|
- Process error: `ws.error`, `Server internal error`, Workerman `code`/`detail`
|
||||||
|
- **Suggested messages**:
|
||||||
|
- Heartbeat: `{"action":"ping"}`
|
||||||
|
- Pong: `{"event":"pong","server_time":"YYYY-mm-dd HH:ii:ss"}` (**string** local time; business pushes often use int seconds—branch in client)
|
||||||
|
- Subscribe: `{"action":"subscribe","topics":["period.tick"]}`
|
||||||
|
- Streak/odds: `["user.streak","wallet.changed","bet.accepted"]`
|
||||||
|
- Funds: `["bet.accepted","wallet.changed"]`
|
||||||
|
- Auto spin: `["auto.spin.progress","wallet.changed"]`
|
||||||
|
- Recommended mobile set: `period.tick`, `bet.win`, `user.streak`, `wallet.changed`, `bet.accepted`, `period.opened`, `period.payout`, `jackpot.hit`
|
||||||
|
|
||||||
|
#### 7.1.1 Message Protocol Fields
|
||||||
|
|
||||||
|
- Client → server:
|
||||||
|
- `action`: `ping` / `subscribe`
|
||||||
|
- `topics`: required for `subscribe` (array)
|
||||||
|
- Server → client:
|
||||||
|
- `event`, `topic`, `data`, `server_time` (seconds)
|
||||||
|
|
||||||
|
#### 7.1.2 Subscribe Behavior
|
||||||
|
|
||||||
|
- **Connection alone does not stream all business events**; send `subscribe`.
|
||||||
|
- Success: `{"event":"ws.subscribed","topics":[...]}` (deduped, sorted).
|
||||||
|
- **`subscribe` replaces** the whole subscription set (not append).
|
||||||
|
- Without subscription: handshake + `pong` only.
|
||||||
|
- **Server filters by bound user** on mobile; outbound **`data` has no `user_id`** (and other sensitive fields).
|
||||||
|
- **Outbound redaction (2026-05)**: removes `user_id`, `uuid`, `phone`, `balance_before`, `channel_id`; `jackpot.hit` `hits[]` keeps `nickname`, `period_no`, `total_win`, `result_number`, etc.
|
||||||
|
- **No** full `streak_win_reward` table; odds via `user.streak` / `wallet.changed` / `bet.accepted` and `lobbyInit.user_snapshot`.
|
||||||
|
|
||||||
|
#### 7.1.2A Streak Odds & Streak Count (WebSocket)
|
||||||
|
|
||||||
|
- **`user.streak`** (after settlement; current player odds):
|
||||||
|
- `data.current_streak`, `streak_level`, `odds_factor`, `is_jackpot`
|
||||||
|
- **`wallet.changed` / `bet.accepted` / `bet.win`**: merge **`current_streak`**, **`streak_level`**, **`odds_factor`**, **`is_jackpot`** (no `user_id`).
|
||||||
|
- **`bet.accepted` vs `bet.win` `is_jackpot`**: `bet.accepted` = tier at bet time; **`bet.win` authoritative** for settlement jackpot tier (`streak_at_bet` → `streak_win_reward.is_jackpot=true`, usually tier 10).
|
||||||
|
|
||||||
|
#### 7.1.3 Push Frequency & Triggers (Current)
|
||||||
|
|
||||||
|
- `period.tick`: **only when `status ∈ {betting, locked}`**, once per second (no full odds table).
|
||||||
|
- **Payout silence**: no `period.tick` during `status=payouting`; instead **`period.payout.tick`** per second with `payout_remaining_seconds` / `payout_until`. Wins via `period.opened` / `period.payout` / **`bet.win`** / `jackpot.hit` / `wallet.changed(biz_type=payout)`.
|
||||||
|
- **Boundary (once per period)**:
|
||||||
|
- `status=finished`: one frame when payout grace ends.
|
||||||
|
- `status=void`: one frame on void.
|
||||||
|
- **Resume**: new period `betting` restarts ticks.
|
||||||
|
- Dedup: Redis `dfw:v1:ws:tick:boundary:{period_no}:{status}` (TTL 300s).
|
||||||
|
- `user.streak`: per user after settlement (may reset to 0).
|
||||||
|
- `admin.live.snapshot`: every second (admin live page; not affected by payout silence).
|
||||||
|
- `period.opened` / `period.payout` / `admin.live.opened`: event-driven.
|
||||||
|
- `wallet.changed`: on balance change; payout includes `amount`, `period_no`, `period_id`, `result_number`.
|
||||||
|
- **`bet.win` (wins this period, small + jackpot)**:
|
||||||
|
- After settlement, aggregated per winning user (same batch as `wallet.changed(payout)`); **personal win UI listens here**; style via `data.is_jackpot`. Jackpot users still get `bet.win`; don’t rely only on `jackpot.hit`.
|
||||||
|
- Fields: `period_id`, `period_no`, `result_number`, `total_win`, `balance_after`, `bets[]` `{ bet_id, win_amount }`, **`is_jackpot`**, **`is_win`** (`true`), **`payout_pending_review`** (jackpot admin review pending), merged streak fields, `server_time`.
|
||||||
|
- Dedup: `dfw:v1:ws:betwin:{period_id}:{user_id}` (86400s), separate from `dfw:v1:settle:notify:{period_id}`. Payload rebuilt from DB settled orders if memory aggregate empty.
|
||||||
|
- **Compensation**: `buildBetWinPayloadsFromSettledOrders` if needed.
|
||||||
|
- After **`approveJackpot`**: pushes `bet.win` again post-credit.
|
||||||
|
- **`jackpot.hit` (public jackpot broadcast)**:
|
||||||
|
- After **`bet.win`** in same batch, if jackpot-tier hits exist, one public frame for marquee; else skip. **Personal UI still `bet.win`**.
|
||||||
|
- Order: `bet.win` per user → `jackpot.hit` if applicable.
|
||||||
|
- `hits[]`: `nickname`, `period_no`, `total_win`, `result_number` (no `user_id`).
|
||||||
|
|
||||||
|
#### 7.1.4 Settlement Push Dedup & Ops Republish (2026-05)
|
||||||
|
|
||||||
|
| Redis Key | Purpose |
|
||||||
|
|-----------|---------|
|
||||||
|
| `dfw:v1:settle:notify:{period_id}` | Once per period: `user.streak` / settlement-batch `wallet.changed` / `jackpot.hit` |
|
||||||
|
| `dfw:v1:ws:betwin:{period_id}:{user_id}` | Once per user per period: `bet.win` |
|
||||||
|
|
||||||
|
**Ops republish** (settled win but client missed `bet.win`):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
php scripts/republish_bet_win.php --play-record-id=1370
|
||||||
|
php scripts/republish_bet_win.php --period-id=123
|
||||||
|
php scripts/republish_bet_win.php --period-no=20260526-183418-c9c90ef1
|
||||||
|
# force ignore dedup: add --force
|
||||||
|
```
|
||||||
|
|
||||||
|
Integration: `php scripts/verify_ws_topic_subscribe.php`.
|
||||||
|
|
||||||
|
### 7.1A Admin Connection (Ops Integration)
|
||||||
|
|
||||||
|
- Menus: `连接服务器websocket` (test), **Game Management → Live Game** (`/admin/game/live`)
|
||||||
|
- WS config:
|
||||||
|
- `/admin/test.GameCurrentStatus/wsConfig`
|
||||||
|
- `/admin/game.Live/wsConfig` (auto subscribe incl. `admin.live.snapshot`, `bet.win`)
|
||||||
|
- **HTTP snapshot**: `GET /admin/game.Live/snapshot` read-only (`buildSnapshot`); **no** `recoverLiveRoundState` / auto-draw on this endpoint; progression via **`gameLiveTicker`** and draw flow.
|
||||||
|
- Admin page: `ws_url`, `connect_tip`, `sample_messages`; connect/disconnect; manual subscribe/ping; live frame log.
|
||||||
|
|
||||||
|
### 7.2 HTTP Fallback APIs
|
||||||
|
|
||||||
|
- Removed: `/api/game/currentStatus`, `/api/game/periodHistory`, `/api/wallet/balanceSummary`.
|
||||||
|
- State/balance primarily WebSocket; HTTP for actions/details (`placeBet`, `depositDetail`, `withdrawDetail`).
|
||||||
|
|
||||||
|
### 7.3 Consistency Rules
|
||||||
|
|
||||||
|
- Countdown from server time, not local clock drift.
|
||||||
|
- After bet, trust `placeBet.balance_after` and `wallet.changed`.
|
||||||
|
- On disconnect, reconnect and resubscribe; no `currentStatus`/`periodHistory`/`balanceSummary` backfill.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Mobile End-to-End Call Flows
|
||||||
|
|
||||||
|
## 8.1 First Game Entry
|
||||||
|
1. `GET /api/v1/authToken?secret=xxx×tamp=xxx&device_id=xxx&signature=xxx` → `auth-token`
|
||||||
|
2. `POST /api/user/login` (header `auth-token`)
|
||||||
|
3. `POST /api/game/lobbyInit` (header `auth-token`)
|
||||||
|
4. WebSocket base URL (**not in lobbyInit today**: ops/bundle `H5_WEBSOCKET_URL` or custom config) → connect, **send `subscribe` immediately** (§7.0/7.1; **include `bet.win`**)
|
||||||
|
5. `POST /api/game/placeBet`
|
||||||
|
6. Balance: `placeBet.balance_after` + `wallet.changed`; after draw, **`bet.win`** (`is_win=true`), jackpot style via `data.is_jackpot` (no `user_id` in payload)
|
||||||
|
7. On disconnect/foreground, reconnect and resubscribe
|
||||||
|
|
||||||
|
## 8.2 Deposit → Bet → Withdraw
|
||||||
|
1. `POST /api/finance/depositTierList` (pick tier + `channels[].code`)
|
||||||
|
2. `POST /api/finance/depositCreate` (`tier_id` + **`channel_code=ddpay`** + `idempotency_key` + DDPay fields; JSON / form-data / urlencoded)
|
||||||
|
- `paid=false`, `status=pending`, non-empty `pay_url`: open **`pay_url`** (DDPay `payment_url`); credit via **`ddpayDepositNotify`**; poll `depositDetail` or `wallet.changed`
|
||||||
|
3. Optional `POST /api/finance/depositDetail`
|
||||||
|
4. `POST /api/game/placeBet`
|
||||||
|
5. `wallet.changed` or order detail
|
||||||
|
6. `POST /api/wallet/recordList`
|
||||||
|
7. `POST /api/finance/withdrawCreate` (immediate freeze) → `POST /api/finance/withdrawDetail`
|
||||||
|
|
||||||
|
## 8.3 Popout Notice Flow
|
||||||
|
1. Client listens `notice.popout`
|
||||||
|
2. `GET /api/notice/noticeList` (includes `content`, `must_confirm`)
|
||||||
|
3. User confirms `GET /api/notice/noticeConfirm?notice_id=...`
|
||||||
|
4. Frontend may block bet until confirmed
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Game Sequence (WebSocket + HTTP)
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart TD
|
||||||
|
A[User login /api/user/login] --> B[Lobby init /api/game/lobbyInit]
|
||||||
|
B --> C[Connect WebSocket and subscribe]
|
||||||
|
C --> D{0-20s betting window?}
|
||||||
|
D -- yes --> E[Place bet /api/game/placeBet]
|
||||||
|
E --> F[Wait wallet.changed for balance]
|
||||||
|
D -- no --> G[Lock and draw phase]
|
||||||
|
G --> H[Server tally and draw]
|
||||||
|
H --> I[WebSocket: period.opened / bet.win / wallet.changed etc.]
|
||||||
|
I --> J[Reconnect and resubscribe on disconnect]
|
||||||
|
J --> C
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. Admin Agent Commission Config (Admin Supplement, 2026-05-29)
|
||||||
|
|
||||||
|
> Commission rates are maintained in **Administrator Management** `/admin/auth/admin`, not via the channel page “share ratio” dialog.
|
||||||
|
|
||||||
|
### 10.1 Page & Display
|
||||||
|
|
||||||
|
- **Tree table** by `parent_admin_id` (role-group style); expand/collapse
|
||||||
|
- Columns: username, nickname, **channel**, **parent agent**, **commission share (%)**, role group, invite code, status, etc.
|
||||||
|
- Super-admin public search: **channel dropdown filter**
|
||||||
|
- Non-super-admin: **self and all downline agents only**; no peers under other channel roots
|
||||||
|
|
||||||
|
### 10.2 Form Fields
|
||||||
|
|
||||||
|
| Field | Description |
|
||||||
|
|-------|-------------|
|
||||||
|
| `channel_id` | Channel (super-admin selectable; others follow role/account) |
|
||||||
|
| `parent_admin_id` | Parent agent; empty = channel **root agent** |
|
||||||
|
| `commission_share_rate` | Share taken from parent’s period commission (0–100); required when parent set |
|
||||||
|
| `group_arr` | Role group (single select, permissions only) |
|
||||||
|
|
||||||
|
### 10.3 Validation & Hints
|
||||||
|
|
||||||
|
- Under same `parent_admin_id`, sum of enabled children’s `commission_share_rate` **≤ 100%**
|
||||||
|
- Form calls **GET** `/admin/auth.Admin/commissionShareRemainder?parent_admin_id=&exclude_id=` for remaining allocatable %
|
||||||
|
- At 100% total: hint that parent retains no share at this level
|
||||||
|
|
||||||
|
### 10.4 Settlement Split (Channel Settlement Integration)
|
||||||
|
|
||||||
|
- After channel settlement total commission, **`AdminCommissionDistributionService`** splits recursively from root agent
|
||||||
|
- Each admin’s net amount → `agent_commission_record` and **immediate credit** to `admin_wallet`
|
||||||
|
- At least one channel root agent required; else settlement fails
|
||||||
|
|
||||||
|
### 10.5 Legacy APIs (Deprecated in UI; Do Not Integrate)
|
||||||
|
|
||||||
|
- ~~GET `/admin/channel/channelAdminShareList`~~
|
||||||
|
- ~~POST `/admin/channel/saveChannelAdminShare`~~
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11. Open Implementation Decisions (Before API Development)
|
||||||
|
|
||||||
|
1. **Login**: password only, or SMS/email OTP?
|
||||||
|
2. **Withdraw receive types**: bank only v1, or e-wallet/crypto too?
|
||||||
|
3. **Auto spin**: ship in v1 or hide `auto-bet` APIs?
|
||||||
|
4. **WebSocket topics**: fix topic names and payloads per this doc?
|
||||||
|
5. **Error codes**: company-wide table to align with this draft?
|
||||||
|
|
||||||
|
After confirmation: implement controllers + validate + service + routes per this document.
|
||||||
126
docs/en/commission-share-guide.md
Normal file
126
docs/en/commission-share-guide.md
Normal file
@@ -0,0 +1,126 @@
|
|||||||
|
# Commission Share Guide
|
||||||
|
|
||||||
|
## 1. Purpose
|
||||||
|
|
||||||
|
This document describes the end-to-end flow **channel commission → agent tree split → admin wallet credit**, so operations, finance, and engineering share the same configuration and settlement rules.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Overall Structure (Two Layers + Tree)
|
||||||
|
|
||||||
|
Commission is calculated in two steps:
|
||||||
|
|
||||||
|
### A. Layer 1: Channel settlement (platform → channel)
|
||||||
|
|
||||||
|
- Compute **channel total commission** from `channel.agent_mode`:
|
||||||
|
- **turnover**: base = sum of settled bet amounts; commission = total bet × `turnover_share_rate`
|
||||||
|
- **affiliate**: base = platform profit after cost deduction; commission = base × ladder share rate
|
||||||
|
- Data scope: `bet_order.status = 2` (settled), period = **last settlement end ~ current settlement time**
|
||||||
|
- Output: `agent_settlement_period` snapshot
|
||||||
|
|
||||||
|
### B. Layer 2: Agent tree split (channel total → each admin’s net amount)
|
||||||
|
|
||||||
|
- **Do not** configure flat channel-wide shares on the channel page (`channel_admin_share` is deprecated in UI; table may remain for history)
|
||||||
|
- Maintain the agent tree in **Administrator Management** (`/admin/auth/admin`):
|
||||||
|
- `parent_admin_id`: parent agent (empty for top-level)
|
||||||
|
- `commission_share_rate`: percentage taken from **parent’s commission for this period** (sub-agents only)
|
||||||
|
- At settlement, `AdminCommissionDistributionService` splits recursively:
|
||||||
|
1. Channel total commission goes to **top-level agent(s)** (`parent_admin_id` is null)
|
||||||
|
2. Each agent allocates to direct children by `commission_share_rate` from **their own received amount**
|
||||||
|
3. **Parent keeps** = received amount − sum allocated to children
|
||||||
|
|
||||||
|
**Example** (parent receives 3000 this period):
|
||||||
|
|
||||||
|
| Sub-agent | Rate | Amount |
|
||||||
|
|-----------|------|--------|
|
||||||
|
| Sub-agent A | 20% | 600 |
|
||||||
|
| Sub-agent B | 40% | 1200 |
|
||||||
|
| Parent | — | 1200 (3000 − 600 − 1200) |
|
||||||
|
|
||||||
|
If a sub-agent has further downline, the same rules apply on **their received amount**.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Configuration & Permissions
|
||||||
|
|
||||||
|
| Capability | Entry | Notes |
|
||||||
|
|------------|-------|-------|
|
||||||
|
| Channel commission params | `/admin/channel` | `agent_mode`, turnover/affiliate rates, settlement cycle, etc. |
|
||||||
|
| Agent tree & share rates | `/admin/auth/admin` | Tree list; parent agent, share rate, channel |
|
||||||
|
| Channel filter | Admin list common search | Super admin can filter by channel |
|
||||||
|
| Visibility | Admin list | Non–super admin sees **self + all downline** only |
|
||||||
|
| Settlement | `/admin/channel` manual / cron | **Super admin only**; credits `admin_wallet` on settle |
|
||||||
|
|
||||||
|
### 3.1 Sub-agent share validation
|
||||||
|
|
||||||
|
- Under the same `parent_admin_id`, enabled sub-agents’ `commission_share_rate` **must not exceed 100% in total**
|
||||||
|
- Form shows **remaining allocatable rate** when creating/editing sub-agents
|
||||||
|
- If total is 100%, parent keeps **no commission** at this level
|
||||||
|
- Top-level agents (no parent) **do not** set `commission_share_rate`
|
||||||
|
|
||||||
|
### 3.2 Role groups
|
||||||
|
|
||||||
|
- `admin_group` is for **menu/data permissions only**
|
||||||
|
- **Not** used in amount calculation
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Settlement Flow
|
||||||
|
|
||||||
|
1. Super admin triggers channel settlement (manual or `ChannelAutoSettleTicker`)
|
||||||
|
2. `ChannelSettlementService::buildSettlePayload` aggregates bets and computes channel total commission
|
||||||
|
3. `AdminCommissionDistributionService::distributeChannelCommission` splits by agent tree
|
||||||
|
4. In one transaction:
|
||||||
|
- Insert `agent_settlement_period` (`status = 2` completed)
|
||||||
|
- Insert `agent_commission_record` per admin with amount > 0 (`status = 1` paid)
|
||||||
|
- `AdminWalletService::creditCommission` → `admin_wallet` + `admin_wallet_record` (`biz_type = commission_income`)
|
||||||
|
5. Reset `channel.carryover_balance` to 0 (settle-and-pay; no channel pending pool)
|
||||||
|
|
||||||
|
**Prerequisite**: at least one **top-level agent** for the channel (`channel_id` match, `parent_admin_id` empty); otherwise settlement fails.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Recommended wording (operations / stakeholders)
|
||||||
|
|
||||||
|
1. **Channel commission**: computed from settled bets for the period (**not** from deposit volume).
|
||||||
|
2. **Distribution**: total commission enters the channel top agent, then splits down by **parent/child and share rates** in Administrator Management; parent keeps the remainder after paying children.
|
||||||
|
3. **Role groups**: permissions only, not money.
|
||||||
|
4. **Traceability**: each `agent_commission_record` links to `agent_settlement_period`; wallet `commission_income` traces back to commission records.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Core fields
|
||||||
|
|
||||||
|
| Table / field | Role |
|
||||||
|
|---------------|------|
|
||||||
|
| `channel.agent_mode` / `turnover_share_rate` / `affiliate_*` | Channel commission calculation |
|
||||||
|
| `admin.parent_admin_id` | Parent agent |
|
||||||
|
| `admin.channel_id` | Channel |
|
||||||
|
| `admin.commission_share_rate` | Share from parent (%); null for top-level |
|
||||||
|
| `agent_settlement_period` | Settlement period snapshot |
|
||||||
|
| `agent_commission_record` | Paid commission per admin |
|
||||||
|
| `admin_wallet` / `admin_wallet_record` | Admin wallet & ledger |
|
||||||
|
|
||||||
|
> **Legacy** `channel_admin_share`: flat 100% split (2026-04-18); tree split since 2026-05-29. Do not configure via this table.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Related code
|
||||||
|
|
||||||
|
| Module | Path |
|
||||||
|
|--------|------|
|
||||||
|
| Channel settlement | `app/common/service/ChannelSettlementService.php` |
|
||||||
|
| Tree split | `app/common/service/AdminCommissionDistributionService.php` |
|
||||||
|
| Admin CRUD / validation | `app/admin/controller/auth/Admin.php` |
|
||||||
|
| Admin UI | `web/src/views/backend/auth/admin/` |
|
||||||
|
| Auto settlement | `app/process/ChannelAutoSettleTicker.php` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Changelog
|
||||||
|
|
||||||
|
| Date | Change |
|
||||||
|
|------|--------|
|
||||||
|
| 2026-04-18 | `channel_admin_share` flat split; removed `admin`/`admin_group.commission_rate` |
|
||||||
|
| 2026-04-23 | Settle-and-pay to admin wallet; `admin_wallet` system |
|
||||||
|
| 2026-05-29 | **Agent tree commission** in Administrator Management; removed channel share UI; tree list & downline visibility |
|
||||||
@@ -124,3 +124,4 @@
|
|||||||
| 2026-04-18 | 落地 `channel_admin_share` 渠道内 flat 拆分;移除 `admin`/`admin_group.commission_rate` |
|
| 2026-04-18 | 落地 `channel_admin_share` 渠道内 flat 拆分;移除 `admin`/`admin_group.commission_rate` |
|
||||||
| 2026-04-23 | 超管结算即发放至管理员钱包;新增 `admin_wallet` 体系 |
|
| 2026-04-23 | 超管结算即发放至管理员钱包;新增 `admin_wallet` 体系 |
|
||||||
| 2026-05-29 | **改为代理树形分红**:配置迁移至管理员管理 `commission_share_rate` + `parent_admin_id`;移除渠道页分配比例入口;管理员列表树形展示与下级可见范围 |
|
| 2026-05-29 | **改为代理树形分红**:配置迁移至管理员管理 `commission_share_rate` + `parent_admin_id`;移除渠道页分配比例入口;管理员列表树形展示与下级可见范围 |
|
||||||
|
| 2026-05-29 | 新增英文文档 `docs/en/commission-share-guide.md`;后台切换 `lang=en` 时文档页自动加载英文版 |
|
||||||
|
|||||||
@@ -91,6 +91,12 @@ export default {
|
|||||||
doc_title: '36 Zihua Mobile API Design Draft',
|
doc_title: '36 Zihua Mobile API Design Draft',
|
||||||
nav_outline: 'Outline',
|
nav_outline: 'Outline',
|
||||||
},
|
},
|
||||||
|
docsCommissionShare: {
|
||||||
|
download: 'Download Markdown',
|
||||||
|
load_fail: 'Failed to load document',
|
||||||
|
doc_title: 'Commission Share Guide',
|
||||||
|
nav_outline: 'Outline',
|
||||||
|
},
|
||||||
vite: {
|
vite: {
|
||||||
Later: '稍后',
|
Later: '稍后',
|
||||||
'Restart hot update': '重启热更新',
|
'Restart hot update': '重启热更新',
|
||||||
|
|||||||
@@ -91,6 +91,12 @@ export default {
|
|||||||
doc_title: '36字花移动端接口设计草案',
|
doc_title: '36字花移动端接口设计草案',
|
||||||
nav_outline: '目录',
|
nav_outline: '目录',
|
||||||
},
|
},
|
||||||
|
docsCommissionShare: {
|
||||||
|
download: '下载 Markdown',
|
||||||
|
load_fail: '文档加载失败',
|
||||||
|
doc_title: '分红说明文档',
|
||||||
|
nav_outline: '目录',
|
||||||
|
},
|
||||||
vite: {
|
vite: {
|
||||||
Later: '稍后',
|
Later: '稍后',
|
||||||
'Restart hot update': '重启热更新',
|
'Restart hot update': '重启热更新',
|
||||||
|
|||||||
@@ -204,6 +204,13 @@ async function onDownload() {
|
|||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
void loadContent()
|
void loadContent()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => config.lang.defaultLang,
|
||||||
|
() => {
|
||||||
|
void loadContent()
|
||||||
|
}
|
||||||
|
)
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped lang="scss">
|
<style scoped lang="scss">
|
||||||
|
|||||||
290
web/src/views/backend/docs/docCommissionShare/index.vue
Normal file
290
web/src/views/backend/docs/docCommissionShare/index.vue
Normal file
@@ -0,0 +1,290 @@
|
|||||||
|
<template>
|
||||||
|
<div class="default-main doc-md-page" v-loading="loading">
|
||||||
|
<el-card shadow="never">
|
||||||
|
<template #header>
|
||||||
|
<div class="doc-md-header">
|
||||||
|
<span class="doc-md-title">{{ pageTitle }}</span>
|
||||||
|
<el-button type="primary" :loading="downloading" @click="onDownload">{{ t('docsCommissionShare.download') }}</el-button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<div v-if="loadError" class="color-danger">{{ loadError }}</div>
|
||||||
|
<el-row v-else :gutter="16" class="doc-md-row">
|
||||||
|
<el-col v-if="toc.length" :xs="24" :sm="8" :md="7" :lg="6" class="doc-md-nav-col">
|
||||||
|
<el-affix :offset="88">
|
||||||
|
<div class="doc-nav-panel">
|
||||||
|
<div class="doc-nav-title">{{ t('docsCommissionShare.nav_outline') }}</div>
|
||||||
|
<el-scrollbar max-height="calc(100vh - 200px)">
|
||||||
|
<ul class="doc-nav-list">
|
||||||
|
<li
|
||||||
|
v-for="item in toc"
|
||||||
|
:key="item.id"
|
||||||
|
class="doc-nav-item"
|
||||||
|
:class="'doc-nav-level-' + item.level"
|
||||||
|
>
|
||||||
|
<a href="#" class="doc-nav-link" @click.prevent="scrollToHeading(item.id)">{{
|
||||||
|
item.text
|
||||||
|
}}</a>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</el-scrollbar>
|
||||||
|
</div>
|
||||||
|
</el-affix>
|
||||||
|
</el-col>
|
||||||
|
<el-col :xs="24" :sm="toc.length ? 16 : 24" :md="toc.length ? 17 : 24" :lg="toc.length ? 18 : 24">
|
||||||
|
<div ref="bodyRef" class="ba-markdown doc-md-body" v-html="html"></div>
|
||||||
|
</el-col>
|
||||||
|
</el-row>
|
||||||
|
</el-card>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { marked } from 'marked'
|
||||||
|
import { computed, nextTick, onMounted, ref, watch } from 'vue'
|
||||||
|
import { useI18n } from 'vue-i18n'
|
||||||
|
import { getUrl } from '/@/utils/axios'
|
||||||
|
import createAxios from '/@/utils/axios'
|
||||||
|
import { useAdminInfo } from '/@/stores/adminInfo'
|
||||||
|
import { useConfig } from '/@/stores/config'
|
||||||
|
|
||||||
|
defineOptions({
|
||||||
|
name: 'docs/docCommissionShare',
|
||||||
|
})
|
||||||
|
|
||||||
|
const { t } = useI18n()
|
||||||
|
const adminInfo = useAdminInfo()
|
||||||
|
const config = useConfig()
|
||||||
|
|
||||||
|
const bodyRef = ref<HTMLElement | null>(null)
|
||||||
|
const loading = ref(true)
|
||||||
|
const downloading = ref(false)
|
||||||
|
const markdown = ref('')
|
||||||
|
const loadError = ref('')
|
||||||
|
const downloadFilename = ref('分红说明文档.md')
|
||||||
|
|
||||||
|
marked.use({ breaks: true })
|
||||||
|
|
||||||
|
interface TocItem {
|
||||||
|
level: number
|
||||||
|
text: string
|
||||||
|
id: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const toc = computed((): TocItem[] => {
|
||||||
|
const md = markdown.value
|
||||||
|
if (!md) {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
const lines = md.split(/\r?\n/)
|
||||||
|
const out: TocItem[] = []
|
||||||
|
let idx = 0
|
||||||
|
for (const line of lines) {
|
||||||
|
const trimmed = line.trim()
|
||||||
|
const m = /^(#{1,4})\s+(.+)$/.exec(trimmed)
|
||||||
|
if (!m) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
const level = m[1].length
|
||||||
|
let text = m[2].trim()
|
||||||
|
text = text.replace(/\s+#+\s*$/, '').trim()
|
||||||
|
out.push({ level, text, id: 'md-nav-' + String(idx) })
|
||||||
|
idx += 1
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
})
|
||||||
|
|
||||||
|
const html = computed(() => {
|
||||||
|
if (!markdown.value) {
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
return String(marked.parse(markdown.value))
|
||||||
|
})
|
||||||
|
|
||||||
|
const pageTitle = computed(() => t('docsCommissionShare.doc_title'))
|
||||||
|
|
||||||
|
function applyHeadingIds() {
|
||||||
|
const root = bodyRef.value
|
||||||
|
const items = toc.value
|
||||||
|
if (!root || !items.length) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const hs = root.querySelectorAll('h1, h2, h3, h4')
|
||||||
|
const n = Math.min(items.length, hs.length)
|
||||||
|
for (let i = 0; i < n; i++) {
|
||||||
|
const el = hs.item(i)
|
||||||
|
el.id = items[i].id
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => [html.value, markdown.value],
|
||||||
|
() => {
|
||||||
|
void nextTick(() => {
|
||||||
|
applyHeadingIds()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
async function loadContent() {
|
||||||
|
loading.value = true
|
||||||
|
loadError.value = ''
|
||||||
|
try {
|
||||||
|
const res = await createAxios(
|
||||||
|
{
|
||||||
|
url: '/admin/docs.DocCommissionShare/content',
|
||||||
|
method: 'get',
|
||||||
|
},
|
||||||
|
{ loading: false }
|
||||||
|
)
|
||||||
|
if (res.code === 1 && res.data && typeof res.data.markdown === 'string') {
|
||||||
|
markdown.value = res.data.markdown
|
||||||
|
if (typeof res.data.filename === 'string' && res.data.filename) {
|
||||||
|
downloadFilename.value = res.data.filename
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
loadError.value = res.msg || t('docsCommissionShare.load_fail')
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
loadError.value = t('docsCommissionShare.load_fail')
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
void nextTick(() => {
|
||||||
|
applyHeadingIds()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function scrollToHeading(id: string) {
|
||||||
|
const el = document.getElementById(id)
|
||||||
|
if (el) {
|
||||||
|
el.scrollIntoView({ behavior: 'smooth', block: 'start' })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function triggerBlobDownload(blob: Blob, filename: string) {
|
||||||
|
const url = URL.createObjectURL(blob)
|
||||||
|
const a = document.createElement('a')
|
||||||
|
a.href = url
|
||||||
|
a.download = filename
|
||||||
|
a.style.display = 'none'
|
||||||
|
document.body.appendChild(a)
|
||||||
|
a.click()
|
||||||
|
document.body.removeChild(a)
|
||||||
|
URL.revokeObjectURL(url)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onDownload() {
|
||||||
|
downloading.value = true
|
||||||
|
try {
|
||||||
|
const token = adminInfo.getToken()
|
||||||
|
const fullUrl = getUrl() + '/admin/docs.DocCommissionShare/download'
|
||||||
|
const response = await fetch(fullUrl, {
|
||||||
|
method: 'GET',
|
||||||
|
headers: {
|
||||||
|
batoken: token,
|
||||||
|
server: 'true',
|
||||||
|
'think-lang': config.lang.defaultLang,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if (!response.ok) {
|
||||||
|
loadError.value = t('docsCommissionShare.load_fail')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const ctype = response.headers.get('content-type') || ''
|
||||||
|
if (ctype.includes('application/json')) {
|
||||||
|
const j = await response.json()
|
||||||
|
loadError.value = typeof j.msg === 'string' && j.msg ? j.msg : t('docsCommissionShare.load_fail')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const blob = await response.blob()
|
||||||
|
triggerBlobDownload(blob, downloadFilename.value)
|
||||||
|
} catch {
|
||||||
|
loadError.value = t('docsCommissionShare.load_fail')
|
||||||
|
} finally {
|
||||||
|
downloading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
void loadContent()
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => config.lang.defaultLang,
|
||||||
|
() => {
|
||||||
|
void loadContent()
|
||||||
|
}
|
||||||
|
)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
.doc-md-page {
|
||||||
|
.doc-md-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.doc-md-title {
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.doc-md-row {
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
.doc-md-nav-col {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
@media (min-width: 768px) {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.doc-nav-panel {
|
||||||
|
padding: 12px 12px 8px;
|
||||||
|
border: 1px solid var(--el-border-color-lighter);
|
||||||
|
border-radius: var(--el-border-radius-base);
|
||||||
|
background: var(--ba-bg-color-overlay);
|
||||||
|
}
|
||||||
|
.doc-nav-title {
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 14px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
padding-bottom: 8px;
|
||||||
|
border-bottom: 1px solid var(--el-border-color-lighter);
|
||||||
|
}
|
||||||
|
.doc-nav-list {
|
||||||
|
list-style: none;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0 0 8px;
|
||||||
|
}
|
||||||
|
.doc-nav-item {
|
||||||
|
margin: 0;
|
||||||
|
padding: 4px 0;
|
||||||
|
line-height: 1.35;
|
||||||
|
}
|
||||||
|
.doc-nav-level-1 {
|
||||||
|
padding-left: 0;
|
||||||
|
}
|
||||||
|
.doc-nav-level-2 {
|
||||||
|
padding-left: 10px;
|
||||||
|
}
|
||||||
|
.doc-nav-level-3 {
|
||||||
|
padding-left: 20px;
|
||||||
|
}
|
||||||
|
.doc-nav-level-4 {
|
||||||
|
padding-left: 30px;
|
||||||
|
}
|
||||||
|
.doc-nav-link {
|
||||||
|
color: var(--el-text-color-regular);
|
||||||
|
text-decoration: none;
|
||||||
|
font-size: 13px;
|
||||||
|
word-break: break-word;
|
||||||
|
cursor: pointer;
|
||||||
|
&:hover {
|
||||||
|
color: var(--el-color-primary);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.doc-md-body {
|
||||||
|
max-width: 1100px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
Reference in New Issue
Block a user