diff --git a/.agents/skills/receiving-code-review/SKILL.md b/.agents/skills/receiving-code-review/SKILL.md new file mode 100644 index 0000000..4c77a10 --- /dev/null +++ b/.agents/skills/receiving-code-review/SKILL.md @@ -0,0 +1,213 @@ +--- +name: receiving-code-review +description: Use when receiving code review feedback, before implementing suggestions, especially if feedback seems unclear or technically questionable - requires technical rigor and verification, not performative agreement or blind implementation +--- + +# Code Review Reception + +## Overview + +Code review requires technical evaluation, not emotional performance. + +**Core principle:** Verify before implementing. Ask before assuming. Technical correctness over social comfort. + +## The Response Pattern + +``` +WHEN receiving code review feedback: + +1. READ: Complete feedback without reacting +2. UNDERSTAND: Restate requirement in own words (or ask) +3. VERIFY: Check against codebase reality +4. EVALUATE: Technically sound for THIS codebase? +5. RESPOND: Technical acknowledgment or reasoned pushback +6. IMPLEMENT: One item at a time, test each +``` + +## Forbidden Responses + +**NEVER:** +- "You're absolutely right!" (explicit instruction-file violation) +- "Great point!" / "Excellent feedback!" (performative) +- "Let me implement that now" (before verification) + +**INSTEAD:** +- Restate the technical requirement +- Ask clarifying questions +- Push back with technical reasoning if wrong +- Just start working (actions > words) + +## Handling Unclear Feedback + +``` +IF any item is unclear: + STOP - do not implement anything yet + ASK for clarification on unclear items + +WHY: Items may be related. Partial understanding = wrong implementation. +``` + +**Example:** +``` +your human partner: "Fix 1-6" +You understand 1,2,3,6. Unclear on 4,5. + +❌ WRONG: Implement 1,2,3,6 now, ask about 4,5 later +✅ RIGHT: "I understand items 1,2,3,6. Need clarification on 4 and 5 before proceeding." +``` + +## Source-Specific Handling + +### From your human partner +- **Trusted** - implement after understanding +- **Still ask** if scope unclear +- **No performative agreement** +- **Skip to action** or technical acknowledgment + +### From External Reviewers +``` +BEFORE implementing: + 1. Check: Technically correct for THIS codebase? + 2. Check: Breaks existing functionality? + 3. Check: Reason for current implementation? + 4. Check: Works on all platforms/versions? + 5. Check: Does reviewer understand full context? + +IF suggestion seems wrong: + Push back with technical reasoning + +IF can't easily verify: + Say so: "I can't verify this without [X]. Should I [investigate/ask/proceed]?" + +IF conflicts with your human partner's prior decisions: + Stop and discuss with your human partner first +``` + +**your human partner's rule:** "External feedback - be skeptical, but check carefully" + +## YAGNI Check for "Professional" Features + +``` +IF reviewer suggests "implementing properly": + grep codebase for actual usage + + IF unused: "This endpoint isn't called. Remove it (YAGNI)?" + IF used: Then implement properly +``` + +**your human partner's rule:** "You and reviewer both report to me. If we don't need this feature, don't add it." + +## Implementation Order + +``` +FOR multi-item feedback: + 1. Clarify anything unclear FIRST + 2. Then implement in this order: + - Blocking issues (breaks, security) + - Simple fixes (typos, imports) + - Complex fixes (refactoring, logic) + 3. Test each fix individually + 4. Verify no regressions +``` + +## When To Push Back + +Push back when: +- Suggestion breaks existing functionality +- Reviewer lacks full context +- Violates YAGNI (unused feature) +- Technically incorrect for this stack +- Legacy/compatibility reasons exist +- Conflicts with your human partner's architectural decisions + +**How to push back:** +- Use technical reasoning, not defensiveness +- Ask specific questions +- Reference working tests/code +- Involve your human partner if architectural + +**If you're uncomfortable pushing back out loud:** Name that tension, then tell your partner about the issue you've seen. They'll appreciate your honesty. + +## Acknowledging Correct Feedback + +When feedback IS correct: +``` +✅ "Fixed. [Brief description of what changed]" +✅ "Good catch - [specific issue]. Fixed in [location]." +✅ [Just fix it and show in the code] + +❌ "You're absolutely right!" +❌ "Great point!" +❌ "Thanks for catching that!" +❌ "Thanks for [anything]" +❌ ANY gratitude expression +``` + +**Why no thanks:** Actions speak. Just fix it. The code itself shows you heard the feedback. + +**If you catch yourself about to write "Thanks":** DELETE IT. State the fix instead. + +## Gracefully Correcting Your Pushback + +If you pushed back and were wrong: +``` +✅ "You were right - I checked [X] and it does [Y]. Implementing now." +✅ "Verified this and you're correct. My initial understanding was wrong because [reason]. Fixing." + +❌ Long apology +❌ Defending why you pushed back +❌ Over-explaining +``` + +State the correction factually and move on. + +## Common Mistakes + +| Mistake | Fix | +|---------|-----| +| Performative agreement | State requirement or just act | +| Blind implementation | Verify against codebase first | +| Batch without testing | One at a time, test each | +| Assuming reviewer is right | Check if breaks things | +| Avoiding pushback | Technical correctness > comfort | +| Partial implementation | Clarify all items first | +| Can't verify, proceed anyway | State limitation, ask for direction | + +## Real Examples + +**Performative Agreement (Bad):** +``` +Reviewer: "Remove legacy code" +❌ "You're absolutely right! Let me remove that..." +``` + +**Technical Verification (Good):** +``` +Reviewer: "Remove legacy code" +✅ "Checking... build target is 10.15+, this API needs 13+. Need legacy for backward compat. Current impl has wrong bundle ID - fix it or drop pre-13 support?" +``` + +**YAGNI (Good):** +``` +Reviewer: "Implement proper metrics tracking with database, date filters, CSV export" +✅ "Grepped codebase - nothing calls this endpoint. Remove it (YAGNI)? Or is there usage I'm missing?" +``` + +**Unclear Item (Good):** +``` +your human partner: "Fix items 1-6" +You understand 1,2,3,6. Unclear on 4,5. +✅ "Understand 1,2,3,6. Need clarification on 4 and 5 before implementing." +``` + +## GitHub Thread Replies + +When replying to inline review comments on GitHub, reply in the comment thread (`gh api repos/{owner}/{repo}/pulls/{pr}/comments/{id}/replies`), not as a top-level PR comment. + +## The Bottom Line + +**External feedback = suggestions to evaluate, not orders to follow.** + +Verify. Question. Then implement. + +No performative agreement. Technical rigor always. diff --git a/.agents/skills/requesting-code-review/SKILL.md b/.agents/skills/requesting-code-review/SKILL.md new file mode 100644 index 0000000..4b8aa60 --- /dev/null +++ b/.agents/skills/requesting-code-review/SKILL.md @@ -0,0 +1,103 @@ +--- +name: requesting-code-review +description: Use when completing tasks, implementing major features, or before merging to verify work meets requirements +--- + +# Requesting Code Review + +Dispatch a code reviewer subagent to catch issues before they cascade. The reviewer gets precisely crafted context for evaluation — never your session's history. This keeps the reviewer focused on the work product, not your thought process, and preserves your own context for continued work. + +**Core principle:** Review early, review often. + +## When to Request Review + +**Mandatory:** +- After each task in subagent-driven development +- After completing major feature +- Before merge to main + +**Optional but valuable:** +- When stuck (fresh perspective) +- Before refactoring (baseline check) +- After fixing complex bug + +## How to Request + +**1. Get git SHAs:** +```bash +BASE_SHA=$(git rev-parse HEAD~1) # or origin/main +HEAD_SHA=$(git rev-parse HEAD) +``` + +**2. Dispatch code reviewer subagent:** + +Dispatch a `general-purpose` subagent, filling the template at [code-reviewer.md](code-reviewer.md) + +**Placeholders:** +- `{DESCRIPTION}` - Brief summary of what you built +- `{PLAN_OR_REQUIREMENTS}` - What it should do +- `{BASE_SHA}` - Starting commit +- `{HEAD_SHA}` - Ending commit + +**3. Act on feedback:** +- Fix Critical issues immediately +- Fix Important issues before proceeding +- Note Minor issues for later +- Push back if reviewer is wrong (with reasoning) + +## Example + +``` +[Just completed Task 2: Add verification function] + +You: Let me request code review before proceeding. + +BASE_SHA=$(git log --oneline | grep "Task 1" | head -1 | awk '{print $1}') +HEAD_SHA=$(git rev-parse HEAD) + +[Dispatch code reviewer subagent] + DESCRIPTION: Added verifyIndex() and repairIndex() with 4 issue types + PLAN_OR_REQUIREMENTS: Task 2 from docs/superpowers/plans/deployment-plan.md + BASE_SHA: a7981ec + HEAD_SHA: 3df7661 + +[Subagent returns]: + Strengths: Clean architecture, real tests + Issues: + Important: Missing progress indicators + Minor: Magic number (100) for reporting interval + Assessment: Ready to proceed + +You: [Fix progress indicators] +[Continue to Task 3] +``` + +## Integration with Workflows + +**Subagent-Driven Development:** +- Review after EACH task +- Catch issues before they compound +- Fix before moving to next task + +**Executing Plans:** +- Review after each task or at natural checkpoints +- Get feedback, apply, continue + +**Ad-Hoc Development:** +- Review before merge +- Review when stuck + +## Red Flags + +**Never:** +- Skip review because "it's simple" +- Ignore Critical issues +- Proceed with unfixed Important issues +- Argue with valid technical feedback + +**If reviewer wrong:** +- Push back with technical reasoning +- Show code/tests that prove it works +- Request clarification + +See template at: [code-reviewer.md](code-reviewer.md) diff --git a/.agents/skills/requesting-code-review/code-reviewer.md b/.agents/skills/requesting-code-review/code-reviewer.md new file mode 100644 index 0000000..db84ae2 --- /dev/null +++ b/.agents/skills/requesting-code-review/code-reviewer.md @@ -0,0 +1,172 @@ +# Code Reviewer Prompt Template + +Use this template when dispatching a code reviewer subagent. + +**Purpose:** Review completed work against requirements and code quality standards before it cascades into more work. + +``` +Subagent (general-purpose): + description: "Review code changes" + prompt: | + You are a Senior Code Reviewer with expertise in software architecture, + design patterns, and best practices. Your job is to review completed work + against its plan or requirements and identify issues before they cascade. + + ## What Was Implemented + + [DESCRIPTION] + + ## Requirements / Plan + + [PLAN_OR_REQUIREMENTS] + + ## Git Range to Review + + **Base:** [BASE_SHA] + **Head:** [HEAD_SHA] + + ```bash + git diff --stat [BASE_SHA]..[HEAD_SHA] + git diff [BASE_SHA]..[HEAD_SHA] + ``` + + ## Read-Only Review + + Your review is read-only on this checkout. Do not mutate the working tree, the index, HEAD, or branch state in any way. Use tools like `git show`, `git diff`, and `git log` to inspect history. If you need a working copy of a different revision, check it out into a separate temporary directory (e.g. `git worktree add /tmp/review-[SHA] [SHA]`) — never move HEAD on this checkout. + + ## What to Check + + **Plan alignment:** + - Does the implementation match the plan / requirements? + - Are deviations justified improvements, or problematic departures? + - Is all planned functionality present? + + **Code quality:** + - Clean separation of concerns? + - Proper error handling? + - Type safety where applicable? + - DRY without premature abstraction? + - Edge cases handled? + + **Architecture:** + - Sound design decisions? + - Reasonable scalability and performance? + - Security concerns? + - Integrates cleanly with surrounding code? + + **Testing:** + - Tests verify real behavior, not mocks? + - Edge cases covered? + - Integration tests where they matter? + - All tests passing? + + **Production readiness:** + - Migration strategy if schema changed? + - Backward compatibility considered? + - Documentation complete? + - No obvious bugs? + + ## Calibration + + Categorize issues by actual severity. Not everything is Critical. + Acknowledge what was done well before listing issues — accurate praise + helps the implementer trust the rest of the feedback. + + If you find significant deviations from the plan, flag them specifically + so the implementer can confirm whether the deviation was intentional. + If you find issues with the plan itself rather than the implementation, + say so. + + ## Output Format + + ### Strengths + [What's well done? Be specific.] + + ### Issues + + #### Critical (Must Fix) + [Bugs, security issues, data loss risks, broken functionality] + + #### Important (Should Fix) + [Architecture problems, missing features, poor error handling, test gaps] + + #### Minor (Nice to Have) + [Code style, optimization opportunities, documentation polish] + + For each issue: + - File:line reference + - What's wrong + - Why it matters + - How to fix (if not obvious) + + ### Recommendations + [Improvements for code quality, architecture, or process] + + ### Assessment + + **Ready to merge?** [Yes | No | With fixes] + + **Reasoning:** [1-2 sentence technical assessment] + + ## Critical Rules + + **DO:** + - Categorize by actual severity + - Be specific (file:line, not vague) + - Explain WHY each issue matters + - Acknowledge strengths + - Give a clear verdict + + **DON'T:** + - Say "looks good" without checking + - Mark nitpicks as Critical + - Give feedback on code you didn't actually read + - Be vague ("improve error handling") + - Avoid giving a clear verdict +``` + +**Placeholders:** +- `[DESCRIPTION]` — brief summary of what was built +- `[PLAN_OR_REQUIREMENTS]` — what it should do (plan file path, task text, or requirements) +- `[BASE_SHA]` — starting commit +- `[HEAD_SHA]` — ending commit + +**Reviewer returns:** Strengths, Issues (Critical / Important / Minor), Recommendations, Assessment + +## Example Output + +``` +### Strengths +- Clean database schema with proper migrations (db.ts:15-42) +- Comprehensive test coverage (18 tests, all edge cases) +- Good error handling with fallbacks (summarizer.ts:85-92) + +### Issues + +#### Important +1. **Missing help text in CLI wrapper** + - File: index-conversations:1-31 + - Issue: No --help flag, users won't discover --concurrency + - Fix: Add --help case with usage examples + +2. **Date validation missing** + - File: search.ts:25-27 + - Issue: Invalid dates silently return no results + - Fix: Validate ISO format, throw error with example + +#### Minor +1. **Progress indicators** + - File: indexer.ts:130 + - Issue: No "X of Y" counter for long operations + - Impact: Users don't know how long to wait + +### Recommendations +- Add progress reporting for user experience +- Consider config file for excluded projects (portability) + +### Assessment + +**Ready to merge: With fixes** + +**Reasoning:** Core implementation is solid with good architecture and tests. Important issues (help text, date validation) are easily fixed and don't affect core functionality. +``` diff --git a/.env.docker.example b/.env.docker.example index 977d457..6656ac6 100644 --- a/.env.docker.example +++ b/.env.docker.example @@ -22,9 +22,28 @@ RUN_MIGRATIONS_ON_START=false # 对外端口(宝塔/Nginx 反代推荐只暴露前端,API 经反向代理访问) BIND_ADDR=127.0.0.1 -PLAYER_PORT=8082 ADMIN_PORT=8081 +# ── 四套 player 镜像 tag 与宿主机端口 ────────────────────────── +# main(暗金主站) +PLAYER_IMAGE_TAG=main +PLAYER_PORT=8082 + +# theme-2(Pinnacle 蓝白) +PLAYER2_IMAGE_TAG=theme-2 +PLAYER2_PORT=8083 + +# theme-3(统一移动端视觉) +PLAYER3_IMAGE_TAG=theme-3 +PLAYER3_PORT=8084 + +# theme-4(海军蓝暗色极简) +PLAYER4_IMAGE_TAG=theme-4 +PLAYER4_PORT=8085 + +# CORS 白名单:四个玩家站域名 + 管理后台(逗号分隔,勿含空格,勿带末尾 /) +# CORS_ORIGINS=https://www.example.com,https://theme2.example.com,https://theme3.example.com,https://theme4.example.com,https://admin.example.com + # 管理端构建时注入:生成邀请注册链接的玩家端公网地址(勿带末尾 /) VITE_PLAYER_URL=https://www.thebet365.net diff --git a/.env.example b/.env.example index 41a4867..c557630 100644 --- a/.env.example +++ b/.env.example @@ -4,8 +4,8 @@ JWT_SECRET=change-me-in-production-use-long-random-string JWT_PLAYER_EXPIRES=24h JWT_ADMIN_EXPIRES=2h JWT_AGENT_EXPIRES=8h -# 本地开发建议 3100:Windows Hyper-V 常保留 2960–3059,3000 会 EACCES -PORT=3100 +PORT=3000 +# Windows + Hyper-V/WSL 若 3000 报 EACCES,可改为 3100(apps/api/.env 本地配置,勿提交) NODE_ENV=development UPLOAD_DIR= diff --git a/.gitignore b/.gitignore index 10fe39b..6bfddea 100644 --- a/.gitignore +++ b/.gitignore @@ -8,6 +8,12 @@ docker-build.log thebet365-images.tar thebet365-images-*.tar thebet365-images-*.manifest.txt +thebet365-full-themes-*.tar +thebet365-full-themes-*.manifest.txt +thebet365-player-*.tar +thebet365-player-*.manifest.txt +thebet365-admin-latest.tar +thebet365-admin-latest.manifest.txt .claude/ *.log .DS_Store diff --git a/AGENTS.md b/AGENTS.md index 1295739..c6a7f31 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,12 +1,5 @@ # AGENTS.md -> 给 AI 编程助手(Cursor 等)看的项目速查手册,浓缩开发约定与易错点。 -> 人类日常开发请优先看 `README.md` 与 `docs/`。 - -如果你是AI请每次读了这个文件就在已有的次数上+1 - -当前被读次数: 3 - ## 目录 @@ -60,7 +53,7 @@ ## API 领域结构 -业务规则放在 `apps/api/src/domains/*`,应用层只做编排: +业务规则放在 `apps/api/src/domains/`*,应用层只做编排: | 领域 | 路径 | 职责 | @@ -136,7 +129,7 @@ **改文案步骤:** -1. 确定 key 命名:导航 `nav.`*、通用 `common.*`、页面 `user.*` / `match.*` / `deposit.*` 等,与现有前缀保持一致。 +1. 确定 key 命名:导航 `nav.`*、通用 `common.`*、页面 `user.*` / `match.*` / `deposit.*` 等,与现有前缀保持一致。 2. 在 `admin-messages.ts` 的 **zh / en / ms 三个对象**里各加同名 key(核心短文案)。 3. 若属于某列表页/弹窗长文案,优先加到 `admin-pages.ts`(中/英)与 `admin-pages-ms.ts`(马来)的对应 export,它们会 spread 进 `admin-messages`。 4. 表单校验错误:throw `FormValidationError('err.xxx')` 并在三语里定义 `err.xxx`。 @@ -197,15 +190,9 @@ | `docs/短信调试与日志说明.md` | 创蓝短信排错 | | `docs/UAT_CHECKLIST.md` | 上线前回归清单 | | `docs/player-mobile-performance.md` | 玩家端性能验收 | -| `docs/admin-page-switch-performance.md` | 管理端切页优化任务 | +| | | -## 测试与冒烟 - -- Jest 用例在 `apps/api/src/**/*.spec.ts`,`rootDir: src`;文档中的单元/规则测试一般不依赖真实数据库。 -- 管理端「冒烟测试」页调用 DB 相关检查;`SmokeTestService` 非生产默认可用,生产需 `ALLOW_SMOKE_TESTS=true`。 -- UAT 回归流程见 `docs/UAT_CHECKLIST.md`(含管理端 UI 冒烟与钱包/代理额度手工检查)。 - ## 部署注意 - 生产 compose 使用 `.env.docker`:`docker compose -f docker-compose.prod.yml --env-file .env.docker up -d --build`,或配置好后 `pnpm docker:up`。 diff --git a/apps/admin/index.html b/apps/admin/index.html index 5df508f..6ca70e8 100644 --- a/apps/admin/index.html +++ b/apps/admin/index.html @@ -2,7 +2,7 @@ - + diff --git a/apps/admin/src/App.vue b/apps/admin/src/App.vue index 2d0df54..d805102 100644 --- a/apps/admin/src/App.vue +++ b/apps/admin/src/App.vue @@ -50,6 +50,22 @@ button { cursor: pointer; font-family: inherit; } html, body { scrollbar-width: none; -ms-overflow-style: none; + touch-action: manipulation; +} + +html { + -webkit-text-size-adjust: 100%; + text-size-adjust: 100%; +} + +@media (max-width: 1023px) { + input, + select, + textarea, + .el-input__inner, + .el-textarea__inner { + font-size: 16px !important; + } } html::-webkit-scrollbar, body::-webkit-scrollbar { diff --git a/apps/admin/src/views/AgentManager.vue b/apps/admin/src/views/AgentManager.vue index b8d2c38..18e4944 100644 --- a/apps/admin/src/views/AgentManager.vue +++ b/apps/admin/src/views/AgentManager.vue @@ -112,7 +112,11 @@ type SubAgentLevelState = { const subAgentLevelState = reactive>({}); const agentLevelCounts = ref>({}); -const hierarchySettings = ref({ maxAgentLevel: 0 }); +const hierarchySettings = ref({ maxAgentLevel: 0, defaultSubAgentCreditRatio: 50 }); +const agentSuspendDefaults = ref({ + suspendFreezeDirectPlayers: false, + suspendBlockPlayerLogin: false, +}); function ensureSubAgentState(level: number): SubAgentLevelState { if (!subAgentLevelState[level]) { @@ -220,7 +224,9 @@ const creditContextLoading = ref(false); /* ─── Init ─── */ let pageInitPromise: Promise | null = null; const pageInitLoaded = ref(false); -const DEFAULT_SUB_AGENT_CREDIT_RATIO = 50; +const DEFAULT_SUB_AGENT_CREDIT_RATIO = computed( + () => hierarchySettings.value.defaultSubAgentCreditRatio || 50, +); const freezeAgentVisible = ref(false); const freezeAgentLoading = ref(false); const freezeAgentTarget = ref(null); @@ -323,7 +329,7 @@ function computeSubAgentCreditByRatio(available: number, ratioPercent: number): const creditQuickRatios = [10, 15, 20, 30] as const; function computeDefaultSubAgentCreditLimit(available: number): number { - return computeSubAgentCreditByRatio(available, DEFAULT_SUB_AGENT_CREDIT_RATIO); + return computeSubAgentCreditByRatio(available, DEFAULT_SUB_AGENT_CREDIT_RATIO.value); } function applyCreateSubAgentCreditRatio(ratioPercent: number) { @@ -439,16 +445,33 @@ onActivated(() => { } }); +async function loadAgentSuspendDefaults() { + try { + const { data } = await api.get('/admin/agents/settings/suspend'); + agentSuspendDefaults.value = { + suspendFreezeDirectPlayers: Boolean(data.data?.suspendFreezeDirectPlayers), + suspendBlockPlayerLogin: Boolean(data.data?.suspendBlockPlayerLogin), + }; + } catch { + /* keep defaults */ + } +} + async function loadUsersPageInit() { try { - const { data } = await api.get('/admin/users/page-init'); + const [pageInitRes] = await Promise.all([ + api.get('/admin/users/page-init'), + loadAgentSuspendDefaults(), + ]); + const { data } = pageInitRes; const payload = data.data as { - hierarchySettings?: { maxAgentLevel: number }; + hierarchySettings?: { maxAgentLevel: number; defaultSubAgentCreditRatio?: number }; agentLevelCounts?: Record; }; if (payload.hierarchySettings) { hierarchySettings.value = { maxAgentLevel: payload.hierarchySettings.maxAgentLevel ?? 0, + defaultSubAgentCreditRatio: payload.hierarchySettings.defaultSubAgentCreditRatio ?? 50, }; } if (payload.agentLevelCounts) { @@ -1108,8 +1131,8 @@ const freezeAgentIsSuspend = computed(() => { function toggleFreezeAgent(row: AgentRow) { freezeAgentTarget.value = row; freezeAgentForm.value = { - freezeDirectPlayers: false, - blockDirectPlayerLogin: false, + freezeDirectPlayers: agentSuspendDefaults.value.suspendFreezeDirectPlayers, + blockDirectPlayerLogin: agentSuspendDefaults.value.suspendBlockPlayerLogin, unfreezeDirectPlayers: false, }; freezeAgentVisible.value = true; diff --git a/apps/admin/src/views/Contents.vue b/apps/admin/src/views/Contents.vue index 6dd1a14..8ba97aa 100644 --- a/apps/admin/src/views/Contents.vue +++ b/apps/admin/src/views/Contents.vue @@ -75,6 +75,8 @@ const notifyInbox = ref(false); interface InboxNotifySettings { inboxEnabled: boolean; deposit: boolean; + banner: boolean; + announcement: boolean; } interface MessageBroadcastItem { @@ -100,6 +102,8 @@ interface BroadcastTranslationForm { const inboxNotifySettings = ref({ inboxEnabled: true, deposit: true, + banner: true, + announcement: true, }); const inboxNotifySaving = ref(false); @@ -218,6 +222,8 @@ async function loadInboxNotifySettings() { inboxNotifySettings.value = { inboxEnabled: data.data?.inboxEnabled !== false, deposit: Boolean(data.data?.deposit), + banner: data.data?.banner !== false, + announcement: data.data?.announcement !== false, }; } catch (e: unknown) { const err = e as { response?: { data?: { error?: string } } }; @@ -232,10 +238,14 @@ async function saveInboxNotifySettings() { const { data } = await api.put('/admin/contents/inbox-notify-settings', { inboxEnabled: inboxNotifySettings.value.inboxEnabled, deposit: inboxNotifySettings.value.deposit, + banner: inboxNotifySettings.value.banner, + announcement: inboxNotifySettings.value.announcement, }); inboxNotifySettings.value = { inboxEnabled: data.data?.inboxEnabled !== false, deposit: Boolean(data.data?.deposit), + banner: data.data?.banner !== false, + announcement: data.data?.announcement !== false, }; ElMessage.success(t('msg.saved')); } catch (e: unknown) { @@ -785,6 +795,30 @@ void load(); /> +
+
+ {{ t('content.inbox_notify.banner') }} + {{ t('content.inbox_notify.banner_hint') }} +
+ +
+ +
+
+ {{ t('content.inbox_notify.announcement') }} + {{ t('content.inbox_notify.announcement_hint') }} +
+ +
+

{{ t('content.inbox_notify.manual_title') }}

  • {{ t('content.inbox_notify.banner_note') }}
  • diff --git a/apps/admin/src/views/agent/GlobalSettingsView.vue b/apps/admin/src/views/agent/GlobalSettingsView.vue index a9ec386..ac7e0d8 100644 --- a/apps/admin/src/views/agent/GlobalSettingsView.vue +++ b/apps/admin/src/views/agent/GlobalSettingsView.vue @@ -27,7 +27,11 @@ const bettingLimits = ref({ maxPayoutParlay: 1000000, dailyStakeLimit: 200000, }); -const hierarchySettings = ref({ maxAgentLevel: 0 }); +const hierarchySettings = ref({ maxAgentLevel: 0, defaultSubAgentCreditRatio: 50 }); +const agentSuspendSettings = ref({ + suspendFreezeDirectPlayers: false, + suspendBlockPlayerLogin: false, +}); const platformDirectRate = ref(0); const adminInviteRate = ref(0); const resetAllowed = ref(false); @@ -36,6 +40,7 @@ const resetConfirmPhrase = ref(''); const settingsSaving = ref(false); const limitsSaving = ref(false); const hierarchySaving = ref(false); +const suspendSaving = ref(false); const platformDirectSaving = ref(false); const resetLoading = ref(false); const loading = ref(false); @@ -47,7 +52,7 @@ async function loadSettings() { const payload = data.data as { playerSettings?: typeof playerSettings.value; bettingLimits?: typeof bettingLimits.value; - hierarchySettings?: { maxAgentLevel: number }; + hierarchySettings?: { maxAgentLevel: number; defaultSubAgentCreditRatio?: number }; platformDirect?: { platformDirectRate?: number | string; adminInviteRate?: number | string }; }; if (payload.playerSettings) playerSettings.value = payload.playerSettings; @@ -55,6 +60,7 @@ async function loadSettings() { if (payload.hierarchySettings) { hierarchySettings.value = { maxAgentLevel: payload.hierarchySettings.maxAgentLevel ?? 0, + defaultSubAgentCreditRatio: payload.hierarchySettings.defaultSubAgentCreditRatio ?? 50, }; } if (payload.platformDirect) { @@ -93,11 +99,27 @@ async function savePlayerSettings() { } } +async function loadAgentSuspendSettings() { + try { + const { data } = await api.get('/admin/agents/settings/suspend'); + agentSuspendSettings.value = { + suspendFreezeDirectPlayers: Boolean(data.data?.suspendFreezeDirectPlayers), + suspendBlockPlayerLogin: Boolean(data.data?.suspendBlockPlayerLogin), + }; + } catch { + /* keep defaults */ + } +} + async function saveHierarchySettings() { hierarchySaving.value = true; try { const { data } = await api.put('/admin/agents/settings/hierarchy', hierarchySettings.value); - hierarchySettings.value = { maxAgentLevel: data.data?.maxAgentLevel ?? hierarchySettings.value.maxAgentLevel }; + hierarchySettings.value = { + maxAgentLevel: data.data?.maxAgentLevel ?? hierarchySettings.value.maxAgentLevel, + defaultSubAgentCreditRatio: + data.data?.defaultSubAgentCreditRatio ?? hierarchySettings.value.defaultSubAgentCreditRatio, + }; ElMessage.success(t('msg.saved')); } catch (e: unknown) { const err = e as { response?: { data?: { error?: string } } }; @@ -107,6 +129,23 @@ async function saveHierarchySettings() { } } +async function saveAgentSuspendSettings() { + suspendSaving.value = true; + try { + const { data } = await api.put('/admin/agents/settings/suspend', agentSuspendSettings.value); + agentSuspendSettings.value = { + suspendFreezeDirectPlayers: Boolean(data.data?.suspendFreezeDirectPlayers), + suspendBlockPlayerLogin: Boolean(data.data?.suspendBlockPlayerLogin), + }; + ElMessage.success(t('msg.saved')); + } catch (e: unknown) { + const err = e as { response?: { data?: { error?: string } } }; + ElMessage.error(err.response?.data?.error ?? t('msg.save_failed')); + } finally { + suspendSaving.value = false; + } +} + async function savePlatformDirectSettings() { platformDirectSaving.value = true; try { @@ -177,7 +216,7 @@ onMounted(() => { void router.replace('/users'); return; } - void Promise.all([loadSettings(), loadResetDatabaseStatus()]); + void Promise.all([loadSettings(), loadAgentSuspendSettings(), loadResetDatabaseStatus()]); }); @@ -211,6 +250,17 @@ onMounted(() => { :disabled="hierarchySaving" /> + + + +

    {{ t('agent.hierarchy.default_sub_credit_ratio_hint') }}

    {{ t('common.save') }} @@ -219,6 +269,27 @@ onMounted(() => { +
    +

    {{ t('agent.suspend.settings_title') }}

    +

    {{ t('agent.suspend.settings_hint') }}

    + + + + + + + + +
    +

    {{ t('cashback.settings_title') }}

    diff --git a/apps/admin/vite.config.ts b/apps/admin/vite.config.ts index 3118525..db42949 100644 --- a/apps/admin/vite.config.ts +++ b/apps/admin/vite.config.ts @@ -1,15 +1,16 @@ import { defineConfig } from 'vite'; import vue from '@vitejs/plugin-vue'; import { resolve } from 'path'; -import { resolveApiDevTarget } from '../../scripts/dev-api-target.mjs'; import { visualizer } from 'rollup-plugin-visualizer'; import AutoImport from 'unplugin-auto-import/vite'; import Components from 'unplugin-vue-components/vite'; import { ElementPlusResolver } from 'unplugin-vue-components/resolvers'; +import { resolveDevApiTarget } from '../../scripts/resolve-dev-api-target.mjs'; + +const devApiTarget = resolveDevApiTarget(); export default defineConfig(({ mode }) => { const analyze = process.env.ANALYZE === '1' || mode === 'analyze'; - const apiTarget = resolveApiDevTarget(); return { plugins: [ @@ -75,10 +76,11 @@ export default defineConfig(({ mode }) => { }, publicDir: resolve(__dirname, '../../packages/shared/public'), server: { + host: true, port: 5174, proxy: { - '/api': { target: apiTarget, changeOrigin: true }, - '/uploads': { target: apiTarget, changeOrigin: true }, + '/api': { target: devApiTarget, changeOrigin: true }, + '/uploads': { target: devApiTarget, changeOrigin: true }, }, }, }; diff --git a/apps/api/src/applications/admin/admin-rbac.spec.ts b/apps/api/src/applications/admin/admin-rbac.spec.ts index c403daf..0a3b5f1 100644 --- a/apps/api/src/applications/admin/admin-rbac.spec.ts +++ b/apps/api/src/applications/admin/admin-rbac.spec.ts @@ -85,6 +85,28 @@ describe('Admin RBAC (SEC010–SEC012)', () => { expect(guardAllows(user, 'settlement.resettle')).toBe(false); }); + it('FINANCE_ADMIN has deposit and cashback permissions but not reset database', () => { + const user = userWithRole('FINANCE_ADMIN'); + expect(guardAllows(user, 'deposit.manage')).toBe(true); + expect(guardAllows(user, 'deposit.review')).toBe(true); + expect(guardAllows(user, 'cashback.confirm')).toBe(true); + expect(guardAllows(user, 'settings.reset_database')).toBe(false); + }); + + it('MATCH_ADMIN cannot manage deposits or confirm cashback', () => { + const user = userWithRole('MATCH_ADMIN'); + expect(guardAllows(user, 'deposit.manage')).toBe(false); + expect(guardAllows(user, 'deposit.review')).toBe(false); + expect(guardAllows(user, 'cashback.confirm')).toBe(false); + }); + + it('SUPPORT cannot confirm cashback or manage deposits', () => { + const user = userWithRole('SUPPORT'); + expect(guardAllows(user, 'cashback.confirm')).toBe(false); + expect(guardAllows(user, 'deposit.manage')).toBe(false); + expect(guardAllows(user, 'deposit.review')).toBe(false); + }); + it('SUPER_ADMIN bypasses permission checks', () => { const user = userWithRole('SUPER_ADMIN'); expect(guardAllows(user, 'wallet.deposit')).toBe(true); diff --git a/apps/api/src/applications/admin/admin.controller.spec.ts b/apps/api/src/applications/admin/admin.controller.spec.ts new file mode 100644 index 0000000..0cf03f5 --- /dev/null +++ b/apps/api/src/applications/admin/admin.controller.spec.ts @@ -0,0 +1,255 @@ +import { Decimal } from '@prisma/client/runtime/library'; +import { AdminController } from './admin.controller'; +import { AgentsService } from '../../domains/agent/agents.service'; +import { createPrismaMock } from '../../testing/prisma-mock'; + +function stubDeps() { + return { + users: {}, + agents: { updateAgentAdmin: jest.fn() } as AgentsService | { updateAgentAdmin: jest.Mock }, + wallet: {}, + matches: {}, + catalogArchive: {}, + outright: {}, + markets: {}, + settlement: {}, + cashback: {}, + content: { create: jest.fn() }, + i18n: {}, + audit: { log: jest.fn().mockResolvedValue(undefined) }, + bets: {}, + prisma: {}, + dashboardService: {}, + systemConfig: { getInboxNotifySettings: jest.fn() }, + bettingLimits: {}, + databaseReset: {}, + smokeTests: {}, + depositService: {}, + playerMessages: { + broadcastBannerPromotion: jest.fn(), + broadcastAnnouncementPromotion: jest.fn(), + }, + staff: {}, + presence: {}, + depositCleanup: {}, + }; +} + +function buildController(deps: ReturnType) { + return new AdminController( + deps.users as never, + deps.agents as never, + deps.wallet as never, + deps.matches as never, + deps.catalogArchive as never, + deps.outright as never, + deps.markets as never, + deps.settlement as never, + deps.cashback as never, + deps.content as never, + deps.i18n as never, + deps.audit as never, + deps.bets as never, + deps.prisma as never, + deps.dashboardService as never, + deps.systemConfig as never, + deps.bettingLimits as never, + deps.databaseReset as never, + deps.smokeTests as never, + deps.depositService as never, + deps.playerMessages as never, + deps.staff as never, + deps.presence as never, + deps.depositCleanup as never, + ); +} + +describe('AdminController createContent inbox notify gating', () => { + const deps = stubDeps(); + const controller = buildController(deps); + + const baseDto = { + contentType: 'BANNER' as const, + status: 'ACTIVE' as const, + notifyInbox: true, + translations: [{ locale: 'zh-CN', title: 'Promo', body: 'Body' }], + }; + + beforeEach(() => { + jest.clearAllMocks(); + deps.content.create.mockResolvedValue({ id: '88' }); + deps.playerMessages.broadcastBannerPromotion.mockResolvedValue(3); + deps.playerMessages.broadcastAnnouncementPromotion.mockResolvedValue(5); + }); + + it('does not broadcast banner when inbox.notify.banner is false', async () => { + deps.systemConfig.getInboxNotifySettings.mockResolvedValue({ + inboxEnabled: true, + deposit: true, + banner: false, + announcement: true, + }); + + const res = await controller.createContent(baseDto); + + expect(deps.playerMessages.broadcastBannerPromotion).not.toHaveBeenCalled(); + expect((res.data as { notifiedCount?: number }).notifiedCount).toBeUndefined(); + }); + + it('broadcasts banner when inbox.notify.banner is true', async () => { + deps.systemConfig.getInboxNotifySettings.mockResolvedValue({ + inboxEnabled: true, + deposit: true, + banner: true, + announcement: false, + }); + + const res = await controller.createContent(baseDto); + + expect(deps.playerMessages.broadcastBannerPromotion).toHaveBeenCalledWith({ + contentId: 88n, + translations: [{ locale: 'zh-CN', title: 'Promo', body: 'Body' }], + }); + expect((res.data as { notifiedCount?: number }).notifiedCount).toBe(3); + }); + + it('does not broadcast announcement when inbox.notify.announcement is false', async () => { + deps.systemConfig.getInboxNotifySettings.mockResolvedValue({ + inboxEnabled: true, + deposit: true, + banner: true, + announcement: false, + }); + + await controller.createContent({ + ...baseDto, + contentType: 'NOTICE', + }); + + expect(deps.playerMessages.broadcastAnnouncementPromotion).not.toHaveBeenCalled(); + }); + + it('skips notify when inbox feature is disabled even if notifyInbox is checked', async () => { + deps.systemConfig.getInboxNotifySettings.mockResolvedValue({ + inboxEnabled: false, + deposit: true, + banner: true, + announcement: true, + }); + + await controller.createContent(baseDto); + + expect(deps.playerMessages.broadcastBannerPromotion).not.toHaveBeenCalled(); + }); +}); + +describe('AdminController updateAgent suspend wiring', () => { + const agentId = 10n; + const operatorId = 1n; + + const tx = { + user: { + create: jest.fn(), + findUnique: jest.fn(), + update: jest.fn(), + }, + userAuth: { create: jest.fn() }, + userPreference: { create: jest.fn() }, + userInvite: { create: jest.fn(), findUnique: jest.fn() }, + agentProfile: { create: jest.fn(), findUnique: jest.fn() }, + agentClosure: { create: jest.fn(), findMany: jest.fn() }, + }; + + const prismaBase = { + ...tx, + agentProfile: { ...tx.agentProfile, update: jest.fn() }, + user: { ...tx.user, updateMany: jest.fn() }, + }; + const prisma = createPrismaMock(prismaBase) as typeof prismaBase & { $transaction: jest.Mock }; + + const systemConfig = { + getAgentHierarchySettings: jest.fn(), + getAgentSuspendSettings: jest.fn(), + }; + const audit = { log: jest.fn().mockResolvedValue(undefined) }; + + let agentsService: AgentsService; + let controller: AdminController; + + beforeEach(() => { + jest.clearAllMocks(); + prisma.$transaction.mockImplementation(async (arg: unknown) => { + if (Array.isArray(arg)) return Promise.all(arg); + return (arg as (client: typeof tx) => Promise)(tx); + }); + + systemConfig.getAgentSuspendSettings.mockResolvedValue({ + suspendFreezeDirectPlayers: true, + suspendBlockPlayerLogin: true, + }); + + agentsService = new AgentsService( + prisma as never, + { hashPassword: jest.fn() } as never, + systemConfig as never, + {} as never, + { recalculateUsedCredit: jest.fn() } as never, + ); + jest.spyOn(agentsService, 'getAgentAdminDetail').mockResolvedValue({ userId: '10' } as never); + + prisma.agentProfile.findUnique.mockResolvedValue({ + userId: agentId, + user: { username: 'agent-a', locale: 'zh-CN' }, + parentAgentId: null, + }); + prisma.user.updateMany.mockResolvedValue({ count: 2 }); + prisma.agentProfile.update.mockResolvedValue({}); + prisma.user.update.mockResolvedValue({}); + + const deps = stubDeps(); + deps.agents = agentsService as never; + deps.audit = audit; + controller = buildController(deps); + }); + + it('forwards bare SUSPENDED body to AgentsService and applies global defaults', async () => { + await controller.updateAgent(operatorId, agentId.toString(), { status: 'SUSPENDED' }); + + expect(systemConfig.getAgentSuspendSettings).toHaveBeenCalled(); + expect(prisma.user.updateMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: expect.objectContaining({ parentId: agentId, userType: 'PLAYER' }), + data: { status: 'SUSPENDED' }, + }), + ); + expect(prisma.agentProfile.update).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ blockDirectPlayerLogin: true }), + }), + ); + expect(audit.log).toHaveBeenCalledWith( + expect.objectContaining({ + operatorId, + action: 'UPDATE_AGENT', + module: 'AGENTS', + targetId: agentId.toString(), + }), + ); + }); + + it('does not freeze players when global default is false and body omits flag', async () => { + systemConfig.getAgentSuspendSettings.mockResolvedValue({ + suspendFreezeDirectPlayers: false, + suspendBlockPlayerLogin: false, + }); + + await controller.updateAgent(operatorId, agentId.toString(), { status: 'SUSPENDED' }); + + expect(prisma.user.updateMany).not.toHaveBeenCalled(); + expect(prisma.agentProfile.update).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ blockDirectPlayerLogin: false }), + }), + ); + }); +}); diff --git a/apps/api/src/applications/admin/admin.controller.ts b/apps/api/src/applications/admin/admin.controller.ts index 4897d78..a0a4128 100644 --- a/apps/api/src/applications/admin/admin.controller.ts +++ b/apps/api/src/applications/admin/admin.controller.ts @@ -1110,6 +1110,14 @@ class InboxNotifySettingsDto { @IsOptional() @IsBoolean() deposit?: boolean; + + @IsOptional() + @IsBoolean() + banner?: boolean; + + @IsOptional() + @IsBoolean() + announcement?: boolean; } class BroadcastTranslationDto { @@ -3444,21 +3452,21 @@ export class AdminController { const item = await this.content.create(createDto); let notifiedCount: number | undefined; if (notifyInbox && (createDto.status ?? 'DRAFT') === 'ACTIVE') { - const inboxEnabled = await this.systemConfig.getInboxFeatureEnabled(); - if (inboxEnabled) { + const inboxNotify = await this.systemConfig.getInboxNotifySettings(); + if (inboxNotify.inboxEnabled) { const translations = createDto.translations.map((tr) => ({ locale: tr.locale, title: tr.title, body: tr.body, })); - if (createDto.contentType === 'BANNER') { + if (createDto.contentType === 'BANNER' && inboxNotify.banner) { notifiedCount = await this.playerMessages.broadcastBannerPromotion({ contentId: BigInt(item.id), translations, }); } else if ( - createDto.contentType === 'NOTICE' || - createDto.contentType === 'TICKER' + (createDto.contentType === 'NOTICE' || createDto.contentType === 'TICKER') && + inboxNotify.announcement ) { notifiedCount = await this.playerMessages.broadcastAnnouncementPromotion({ contentId: BigInt(item.id), diff --git a/apps/api/src/applications/player/player.controller.ts b/apps/api/src/applications/player/player.controller.ts index f2f52c4..ec16982 100644 --- a/apps/api/src/applications/player/player.controller.ts +++ b/apps/api/src/applications/player/player.controller.ts @@ -306,13 +306,14 @@ export class PlayerController { @CurrentUser('locale') locale: string, @Query('status') status?: string, @Query('page') page?: string, + @Query('pageSize') pageSize?: string, @Query('matchId') matchId?: string, ) { const result = await this.bets.getUserBets( userId, status, page ? parseInt(page, 10) : 1, - 20, + pageSize ? parseInt(pageSize, 10) : 20, matchId ? BigInt(matchId) : undefined, ); const items = await this.matches.enrichBetsForHistory(result.items, locale); @@ -347,9 +348,10 @@ export class PlayerController { async transactions( @CurrentUser('id') userId: bigint, @Query('page') page?: string, + @Query('pageSize') pageSize?: string, @Query('type') type?: string, ) { - const result = await this.wallet.getTransactions(userId, page ? parseInt(page) : 1, 20, type); + const result = await this.wallet.getTransactions(userId, page ? parseInt(page) : 1, pageSize ? parseInt(pageSize) : 20, type); return jsonResponse(result); } @@ -424,10 +426,12 @@ export class PlayerController { async myDepositOrders( @CurrentUser('id') userId: bigint, @Query('page') page?: string, + @Query('pageSize') pageSize?: string, ) { const result = await this.deposit.getPlayerDepositOrders( userId, page ? parseInt(page, 10) : 1, + pageSize ? parseInt(pageSize, 10) : 20, ); return jsonResponse(result); } diff --git a/apps/api/src/domains/agent/agents.service.spec.ts b/apps/api/src/domains/agent/agents.service.spec.ts index a32a949..9141b06 100644 --- a/apps/api/src/domains/agent/agents.service.spec.ts +++ b/apps/api/src/domains/agent/agents.service.spec.ts @@ -32,12 +32,30 @@ describe('AgentsService', () => { }, }; - const prisma = createPrismaMock(tx); + const prismaBase = { + ...tx, + agentProfile: { + ...tx.agentProfile, + update: jest.fn(), + }, + user: { + ...tx.user, + updateMany: jest.fn(), + }, + }; + const prisma = createPrismaMock(prismaBase) as typeof prismaBase & { $transaction: jest.Mock }; + prisma.$transaction.mockImplementation(async (arg: unknown) => { + if (Array.isArray(arg)) { + return Promise.all(arg); + } + return (arg as (client: typeof tx) => Promise)(tx); + }); const auth = { hashPassword: jest.fn(), }; const systemConfig = { getAgentHierarchySettings: jest.fn(), + getAgentSuspendSettings: jest.fn(), }; const network = {}; const credit = { @@ -56,13 +74,20 @@ describe('AgentsService', () => { credit as never, ); - systemConfig.getAgentHierarchySettings.mockResolvedValue({ maxAgentLevel: 3 }); + systemConfig.getAgentHierarchySettings.mockResolvedValue({ + maxAgentLevel: 3, + defaultSubAgentCreditRatio: 50, + }); + systemConfig.getAgentSuspendSettings.mockResolvedValue({ + suspendFreezeDirectPlayers: true, + suspendBlockPlayerLogin: true, + }); auth.hashPassword.mockResolvedValue('hashed-password'); - tx.agentProfile.findUnique.mockResolvedValue({ + prisma.agentProfile.findUnique.mockResolvedValue({ userId: parentAgentId, level: 1, - creditLimit: new Decimal(1000), - usedCredit: new Decimal(0), + creditLimit: new Decimal(10000), + usedCredit: new Decimal(2000), cashbackRate: new Decimal(10), maxSingleDeposit: null, maxDailyDeposit: null, @@ -91,10 +116,41 @@ describe('AgentsService', () => { password: 'secret', level: 2, parentAgentId, - creditLimit: 300, }); + expect(tx.agentProfile.create).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ creditLimit: 4000 }), + }), + ); expect(credit.recalculateUsedCredit).toHaveBeenCalledWith(parentAgentId); expect(credit.recalculateUsedCredit).not.toHaveBeenCalledWith(createdAgentId); }); + + it('applies global suspend defaults when suspending without explicit flags', async () => { + jest.spyOn(service, 'getAgentAdminDetail').mockResolvedValue({ userId: '10' } as never); + prisma.agentProfile.findUnique.mockResolvedValue({ + userId: 10n, + user: { username: 'agent-a', locale: 'zh-CN' }, + parentAgentId: null, + }); + prisma.user.updateMany.mockResolvedValue({ count: 2 }); + prisma.agentProfile.update.mockResolvedValue({}); + prisma.user.update.mockResolvedValue({}); + + await service.updateAgentAdmin(10n, { status: 'SUSPENDED' }); + + expect(systemConfig.getAgentSuspendSettings).toHaveBeenCalled(); + expect(prisma.user.updateMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: expect.objectContaining({ parentId: 10n, userType: 'PLAYER' }), + data: { status: 'SUSPENDED' }, + }), + ); + expect(prisma.agentProfile.update).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ blockDirectPlayerLogin: true }), + }), + ); + }); }); diff --git a/apps/api/src/domains/agent/agents.service.ts b/apps/api/src/domains/agent/agents.service.ts index 225407a..98d93bb 100644 --- a/apps/api/src/domains/agent/agents.service.ts +++ b/apps/api/src/domains/agent/agents.service.ts @@ -40,6 +40,15 @@ export class AgentsService { return agentLevel < maxLevel; } + /** 与 Admin 创建下级代理预填逻辑一致:可用授信 × 比例(%),非 100% 时向下取整到百位 */ + computeDefaultSubAgentCredit(availableCredit: number, ratioPercent: number): number { + if (availableCredit <= 0) return 0; + const pct = Math.min(100, Math.max(1, ratioPercent)) / 100; + const raw = availableCredit * pct; + const rounded = pct >= 1 ? availableCredit : Math.floor(raw / 100) * 100; + return Math.min(availableCredit, Math.max(0, rounded)); + } + private async buildAgentAncestorChainMap(parentAgentIds: (bigint | null | undefined)[]) { return this.network.buildAgentAncestorChainMap(parentAgentIds); } @@ -923,11 +932,17 @@ export class AgentsService { }); } - // Handle status change (per-action cascade freeze / login block) + // Handle status change (per-action cascade freeze / login block; 未传参时用全局默认) if (data.status) { const profilePatch: Prisma.AgentProfileUpdateInput = { status: data.status }; + let freezeDirectPlayers = false; if (data.status === 'SUSPENDED') { - profilePatch.blockDirectPlayerLogin = data.blockDirectPlayerLogin === true; + const suspendDefaults = await this.systemConfig.getAgentSuspendSettings(); + const blockDirectPlayerLogin = + data.blockDirectPlayerLogin ?? suspendDefaults.suspendBlockPlayerLogin; + freezeDirectPlayers = + data.freezeDirectPlayers ?? suspendDefaults.suspendFreezeDirectPlayers; + profilePatch.blockDirectPlayerLogin = blockDirectPlayerLogin === true; } else if (data.status === 'ACTIVE') { profilePatch.blockDirectPlayerLogin = false; } @@ -943,7 +958,7 @@ export class AgentsService { }), ]); - if (data.status === 'SUSPENDED' && data.freezeDirectPlayers) { + if (data.status === 'SUSPENDED' && freezeDirectPlayers) { await this.prisma.user.updateMany({ where: { parentId: agentId, userType: 'PLAYER', deletedAt: null }, data: { status: 'SUSPENDED' }, @@ -1156,20 +1171,31 @@ export class AgentsService { ) { await this.validateAgentLevel(data.level, data.parentAgentId); + let resolvedCreditLimit = data.creditLimit; let resolvedCashbackRate = data.cashbackRate ?? 0; if (data.parentAgentId) { const parentProfile = await this.prisma.agentProfile.findUnique({ where: { userId: data.parentAgentId }, - select: { cashbackRate: true }, + select: { cashbackRate: true, creditLimit: true, usedCredit: true }, }); resolvedCashbackRate = data.cashbackRate ?? (parentProfile ? Number(parentProfile.cashbackRate) : 0); + if (resolvedCreditLimit === undefined && parentProfile) { + const hierarchy = await this.systemConfig.getAgentHierarchySettings(); + const available = new Decimal(parentProfile.creditLimit).sub(parentProfile.usedCredit); + resolvedCreditLimit = this.computeDefaultSubAgentCredit( + available.toNumber(), + hierarchy.defaultSubAgentCreditRatio, + ); + } await this.assertChildAgentWithinParent(data.parentAgentId, { - creditLimit: data.creditLimit ?? 0, + creditLimit: resolvedCreditLimit ?? 0, cashbackRate: resolvedCashbackRate, maxSingleDeposit: data.maxSingleDeposit, maxDailyDeposit: data.maxDailyDeposit, }); + } else if (resolvedCreditLimit === undefined) { + resolvedCreditLimit = 0; } const maxSingleDeposit = this.normalizeOptionalLimit(data.maxSingleDeposit); @@ -1208,7 +1234,7 @@ export class AgentsService { userId: user.id, level: data.level, parentAgentId: data.parentAgentId, - creditLimit: data.creditLimit ?? 0, + creditLimit: resolvedCreditLimit ?? 0, cashbackRate: resolvedCashbackRate, maxSingleDeposit, maxDailyDeposit, diff --git a/apps/api/src/domains/deposit/deposit.service.spec.ts b/apps/api/src/domains/deposit/deposit.service.spec.ts index f498280..7f6e5fb 100644 --- a/apps/api/src/domains/deposit/deposit.service.spec.ts +++ b/apps/api/src/domains/deposit/deposit.service.spec.ts @@ -47,6 +47,8 @@ describe('DepositService', () => { getInboxNotifySettings: jest.fn().mockResolvedValue({ inboxEnabled: true, deposit: true, + banner: true, + announcement: true, }), }; diff --git a/apps/api/src/domains/identity/auth.service.spec.ts b/apps/api/src/domains/identity/auth.service.spec.ts new file mode 100644 index 0000000..3bb1ce9 --- /dev/null +++ b/apps/api/src/domains/identity/auth.service.spec.ts @@ -0,0 +1,83 @@ +import * as bcrypt from 'bcryptjs'; +import { AuthService } from './auth.service'; +import { expectAppError } from '../../testing/prisma-mock'; + +describe('AuthService player login', () => { + const prisma = { + user: { + findUnique: jest.fn(), + }, + userAuth: { + update: jest.fn(), + }, + }; + const jwt = { sign: jest.fn().mockReturnValue('token') }; + const config = { get: jest.fn().mockReturnValue('1h') }; + const systemConfig = {}; + const invites = {}; + const sms = {}; + const audit = { log: jest.fn() }; + + let service: AuthService; + + beforeEach(() => { + jest.clearAllMocks(); + service = new AuthService( + prisma as never, + jwt as never, + config as never, + systemConfig as never, + invites as never, + sms as never, + audit as never, + ); + }); + + it('blocks player login when parent agent is suspended with blockDirectPlayerLogin', async () => { + const passwordHash = await bcrypt.hash('Player@123', 4); + prisma.user.findUnique + .mockResolvedValueOnce({ + id: 10n, + username: 'player1', + userType: 'PLAYER', + status: 'ACTIVE', + parentId: 20n, + auth: { passwordHash, loginFailCount: 0, lockedUntil: null }, + adminRole: null, + }) + .mockResolvedValueOnce({ + userType: 'AGENT', + status: 'SUSPENDED', + agentProfile: { blockDirectPlayerLogin: true }, + }); + + await expect(service.login('player1', 'Player@123', 'player')).rejects.toMatchObject( + expectAppError('PARENT_AGENT_SUSPENDED'), + ); + expect(jwt.sign).not.toHaveBeenCalled(); + }); + + it('allows player login when parent agent suspended without blockDirectPlayerLogin', async () => { + const passwordHash = await bcrypt.hash('Player@123', 4); + prisma.user.findUnique + .mockResolvedValueOnce({ + id: 10n, + username: 'player1', + userType: 'PLAYER', + status: 'ACTIVE', + parentId: 20n, + locale: 'zh-CN', + auth: { passwordHash, loginFailCount: 0, lockedUntil: null }, + adminRole: null, + }) + .mockResolvedValueOnce({ + userType: 'AGENT', + status: 'SUSPENDED', + agentProfile: { blockDirectPlayerLogin: false }, + }); + prisma.userAuth.update.mockResolvedValue({}); + + const result = await service.login('player1', 'Player@123', 'player'); + expect(result.token).toBe('token'); + }); +}); diff --git a/apps/api/src/domains/operations/cashback/cashback.service.spec.ts b/apps/api/src/domains/operations/cashback/cashback.service.spec.ts new file mode 100644 index 0000000..583de91 --- /dev/null +++ b/apps/api/src/domains/operations/cashback/cashback.service.spec.ts @@ -0,0 +1,126 @@ +import { Decimal } from '@prisma/client/runtime/library'; +import { CashbackService } from './cashback.service'; + +describe('CashbackService previewBatch', () => { + const tx = { + cashbackBatch: { + findMany: jest.fn(), + create: jest.fn(), + }, + cashbackItem: { + create: jest.fn(), + }, + cashbackBet: { + create: jest.fn(), + }, + }; + const prisma = { + cashbackBatch: { + findFirst: jest.fn(), + }, + cashbackBet: { + findMany: jest.fn(), + }, + bet: { + findMany: jest.fn(), + }, + cashbackRule: { + findMany: jest.fn(), + }, + agentProfile: { + findMany: jest.fn(), + }, + user: { + findMany: jest.fn(), + }, + wallet: { + findMany: jest.fn(), + }, + $transaction: jest.fn(async (fn: (client: typeof tx) => Promise) => fn(tx)), + }; + const funds = {}; + const systemConfig = { + getPlatformDirectCashbackSettings: jest.fn(), + }; + + let service: CashbackService; + + const platformPlayerId = 100n; + const adminInvitePlayerId = 101n; + const adminSponsorId = 200n; + const periodStart = new Date('2026-06-01T00:00:00.000Z'); + const periodEnd = new Date('2026-06-01T23:59:59.999Z'); + + beforeEach(() => { + jest.clearAllMocks(); + service = new CashbackService(prisma as never, funds as never, systemConfig as never); + systemConfig.getPlatformDirectCashbackSettings.mockResolvedValue({ + platformDirectRate: 0.02, + adminInviteRate: 0.05, + }); + prisma.cashbackBatch.findFirst.mockResolvedValue(null); + prisma.cashbackBet.findMany.mockResolvedValue([]); + prisma.cashbackRule.findMany.mockResolvedValue([]); + prisma.agentProfile.findMany.mockResolvedValue([]); + tx.cashbackBatch.findMany.mockResolvedValue([]); + tx.cashbackItem.create.mockResolvedValue({}); + tx.cashbackBet.create.mockResolvedValue({}); + tx.cashbackBatch.create.mockImplementation(({ data }: { data: Record }) => + Promise.resolve({ id: 1n, batchNo: 'CB-TEST', ...data }), + ); + }); + + it('uses platformDirectRate for platform-direct players', async () => { + prisma.bet.findMany.mockResolvedValue([ + { + id: 1n, + userId: platformPlayerId, + stake: new Decimal(1000), + status: 'WON', + settledAt: new Date('2026-06-01T12:00:00.000Z'), + user: { id: platformPlayerId, parentId: null, inviteSponsorId: null }, + selections: [{ marketType: 'FT_1X2' }], + }, + ]); + prisma.user.findMany.mockResolvedValue([ + { id: platformPlayerId, username: 'pd1', parent: null }, + ]); + prisma.wallet.findMany.mockResolvedValue([ + { userId: platformPlayerId, availableBalance: new Decimal(0) }, + ]); + + const result = await service.previewBatch(periodStart, periodEnd); + expect(result.items).toHaveLength(1); + expect(result.items[0].amount.toString()).toBe('20'); + expect(result.totalAmount.toString()).toBe('20'); + }); + + it('uses adminInviteRate for admin-invited platform-direct players', async () => { + prisma.bet.findMany.mockResolvedValue([ + { + id: 2n, + userId: adminInvitePlayerId, + stake: new Decimal(1000), + status: 'LOST', + settledAt: new Date('2026-06-01T12:00:00.000Z'), + user: { + id: adminInvitePlayerId, + parentId: null, + inviteSponsorId: adminSponsorId, + }, + selections: [{ marketType: 'FT_1X2' }], + }, + ]); + prisma.user.findMany + .mockResolvedValueOnce([{ id: adminSponsorId, userType: 'ADMIN' }]) + .mockResolvedValueOnce([{ id: adminInvitePlayerId, username: 'inv1', parent: null }]); + prisma.wallet.findMany.mockResolvedValue([ + { userId: adminInvitePlayerId, availableBalance: new Decimal(0) }, + ]); + + const result = await service.previewBatch(periodStart, periodEnd); + expect(result.items).toHaveLength(1); + expect(result.items[0].amount.toString()).toBe('50'); + expect(result.totalAmount.toString()).toBe('50'); + }); +}); diff --git a/apps/api/src/domains/operations/smoke-tests/smoke-test.bet-flow-probes.ts b/apps/api/src/domains/operations/smoke-tests/smoke-test.bet-flow-probes.ts index 6b9bb43..cabc9e0 100644 --- a/apps/api/src/domains/operations/smoke-tests/smoke-test.bet-flow-probes.ts +++ b/apps/api/src/domains/operations/smoke-tests/smoke-test.bet-flow-probes.ts @@ -1,4 +1,5 @@ import { Decimal } from '@prisma/client/runtime/library'; +import { BET_LIMIT_KEYS } from '../../betting/betting-limits.service'; import type { AgentsService } from '../../agent/agents.service'; import type { BetsService } from '../../betting/bets.service'; import type { SettlementService } from '../../settlement/settlement.service'; @@ -12,7 +13,7 @@ import { teardownBetFlowFixture, } from './smoke-test.bet-flow.fixture'; -export const BET_FLOW_PROBE_COUNT = 5; +export const BET_FLOW_PROBE_COUNT = 13; export type BetFlowProbeDeps = { prisma: PrismaService; @@ -22,6 +23,38 @@ export type BetFlowProbeDeps = { agents: AgentsService; }; +async function upsertSystemConfig( + prisma: PrismaService, + key: string, + value: string, + snapshots: Array<{ key: string; value: string | null }>, +) { + const row = await prisma.systemConfig.findUnique({ where: { configKey: key } }); + snapshots.push({ key, value: row?.configValue ?? null }); + await prisma.systemConfig.upsert({ + where: { configKey: key }, + create: { configKey: key, configValue: value, description: 'smoke test' }, + update: { configValue: value }, + }); +} + +async function restoreSystemConfigs( + prisma: PrismaService, + snapshots: Array<{ key: string; value: string | null }>, +) { + for (const snap of snapshots) { + if (snap.value === null) { + await prisma.systemConfig.deleteMany({ where: { configKey: snap.key } }); + } else { + await prisma.systemConfig.upsert({ + where: { configKey: snap.key }, + create: { configKey: snap.key, configValue: snap.value, description: 'smoke test restore' }, + update: { configValue: snap.value }, + }); + } + } +} + async function confirmMatchSettlement( deps: BetFlowProbeDeps, fx: BetFlowFixtureIds, @@ -201,6 +234,401 @@ export function createBetFlowProbes(deps: BetFlowProbeDeps): SmokeTestCaseDef[] } }, }, + { + id: 'BF006', + suite: 'bet-flow', + name: '录分校验:半场比分不能大于全场', + description: 'HT>FT 时 recordScore 返回 SETTLEMENT_SCORE_INVALID', + run: async () => { + const fx = await createBetFlowFixture(deps.prisma, deps.wallet); + try { + await deps.prisma.match.update({ + where: { id: fx.matchId }, + data: { status: 'CLOSED', closeTime: new Date() }, + }); + await expectAppErrorThrows( + 'recordScore invalid HT>FT', + async () => { + await deps.settlement.recordScore(fx.matchId, 2, 0, 1, 0, fx.operatorId); + }, + 'SETTLEMENT_SCORE_INVALID', + ); + } finally { + await teardownBetFlowFixture(deps.prisma, fx); + } + }, + }, + { + id: 'BF007', + suite: 'bet-flow', + name: '确认结算:拒绝已作废的旧 PREVIEW 批次', + description: '新 preview 后旧 batch 变为 CANCELLED,confirm 旧 batchId 失败', + run: async () => { + const fx = await createBetFlowFixture(deps.prisma, deps.wallet); + try { + await deps.prisma.match.update({ + where: { id: fx.matchId }, + data: { status: 'CLOSED', closeTime: new Date() }, + }); + const previewA = await deps.settlement.previewSettlement(fx.matchId, fx.operatorId, { + htHome: 0, + htAway: 0, + ftHome: 1, + ftAway: 0, + }); + await deps.settlement.previewSettlement(fx.matchId, fx.operatorId, { + htHome: 0, + htAway: 0, + ftHome: 2, + ftAway: 1, + }); + const stale = await deps.prisma.settlementBatch.findUnique({ + where: { id: previewA.batch.id }, + }); + expectEqual('old batch cancelled', stale?.status, 'CANCELLED'); + await expectAppErrorThrows( + 'confirm cancelled batch', + async () => { + await deps.settlement.confirmSettlement(previewA.batch.id, fx.operatorId); + }, + 'SETTLEMENT_BATCH_ALREADY_CONFIRMED', + ); + } finally { + await teardownBetFlowFixture(deps.prisma, fx); + } + }, + }, + { + id: 'BF008', + suite: 'bet-flow', + name: '串动作废:VOID 腿续算,已有 LOSE 腿不退本', + description: '跨场串关一场 LOSE 后另一场取消,payout=0 而非退 stake', + run: async () => { + const fx = await createBetFlowFixture(deps.prisma, deps.wallet, { initialBalance: 1000 }); + const matchB = await deps.prisma.match.create({ + data: { + sportType: 'FOOTBALL', + leagueId: fx.leagueId, + homeTeamId: fx.homeTeamId, + awayTeamId: fx.awayTeamId, + startTime: new Date(Date.now() + 48 * 60 * 60 * 1000), + status: 'PUBLISHED', + publishTime: new Date(), + }, + }); + const marketB = await deps.prisma.market.create({ + data: { + matchId: matchB.id, + marketType: 'FT_1X2', + period: 'FT', + status: 'OPEN', + selections: { + create: [ + { + selectionCode: 'HOME', + selectionName: 'Home B', + odds: new Decimal('2.00'), + oddsVersion: BigInt(1), + status: 'OPEN', + sortOrder: 0, + }, + { + selectionCode: 'AWAY', + selectionName: 'Away B', + odds: new Decimal('2.00'), + oddsVersion: BigInt(1), + status: 'OPEN', + sortOrder: 1, + }, + ], + }, + }, + include: { selections: true }, + }); + const awayB = marketB.selections.find((s) => s.selectionCode === 'AWAY')!; + + try { + const parlay = await deps.bets.placeParlayBet( + fx.playerId, + null, + [ + { selectionId: fx.homeSelectionId, oddsVersion: fx.homeOddsVersion }, + { selectionId: awayB.id, oddsVersion: awayB.oddsVersion }, + ], + 100, + `smoke-parlay-void-${fx.runId}`, + ); + + await deps.prisma.match.update({ + where: { id: matchB.id }, + data: { status: 'CLOSED', closeTime: new Date() }, + }); + const previewB = await deps.settlement.previewSettlement(matchB.id, fx.operatorId, { + htHome: 1, + htAway: 0, + ftHome: 2, + ftAway: 1, + }); + await deps.settlement.confirmSettlement(previewB.batch.id, fx.operatorId); + + let w = await deps.wallet.getWallet(fx.playerId); + expectEqual('frozen while pending', w.frozenBalance.toString(), '100'); + + await deps.settlement.voidMatchBets(fx.matchId); + + const settled = await deps.prisma.bet.findUnique({ where: { id: parlay.id } }); + expectEqual('settled status', settled?.status, 'LOST'); + expectEqual('actualReturn', settled?.actualReturn.toString(), '0'); + + w = await deps.wallet.getWallet(fx.playerId); + expectEqual('available after void parlay', w.availableBalance.toString(), '900'); + expectEqual('frozen after void parlay', w.frozenBalance.toString(), '0'); + } finally { + const bets = await deps.prisma.bet.findMany({ + where: { userId: fx.playerId }, + select: { id: true }, + }); + const betIds = bets.map((b) => b.id); + if (betIds.length) { + await deps.prisma.betSelection.deleteMany({ where: { betId: { in: betIds } } }); + await deps.prisma.bet.deleteMany({ where: { id: { in: betIds } } }); + } + const batchIds = ( + await deps.prisma.settlementBatch.findMany({ + where: { matchId: { in: [fx.matchId, matchB.id] } }, + select: { id: true }, + }) + ).map((b) => b.id); + if (batchIds.length) { + await deps.prisma.settlementItem.deleteMany({ where: { batchId: { in: batchIds } } }); + await deps.prisma.settlementBatch.deleteMany({ where: { id: { in: batchIds } } }); + } + await deps.prisma.matchScore.deleteMany({ + where: { matchId: { in: [fx.matchId, matchB.id] } }, + }); + const marketIds = ( + await deps.prisma.market.findMany({ + where: { matchId: matchB.id }, + select: { id: true }, + }) + ).map((m) => m.id); + if (marketIds.length) { + await deps.prisma.marketSelection.deleteMany({ where: { marketId: { in: marketIds } } }); + await deps.prisma.market.deleteMany({ where: { id: { in: marketIds } } }); + } + await deps.prisma.match.deleteMany({ where: { id: matchB.id } }); + await teardownBetFlowFixture(deps.prisma, fx); + } + }, + }, + { + id: 'BF009', + suite: 'bet-flow', + name: '赔率版本不一致:BetsService 拒绝下注', + uatRef: 'B003', + description: '提交旧 oddsVersion 应返回 ODDS_CHANGED', + run: async () => { + const fx = await createBetFlowFixture(deps.prisma, deps.wallet, { initialBalance: 500 }); + try { + await deps.prisma.marketSelection.update({ + where: { id: fx.homeSelectionId }, + data: { oddsVersion: { increment: 1 } }, + }); + await expectAppErrorThrows( + 'placeSingleBet odds changed', + async () => { + await deps.bets.placeSingleBet( + fx.playerId, + null, + fx.homeSelectionId, + fx.homeOddsVersion, + 50, + `smoke-odds-${fx.runId}`, + ); + }, + 'ODDS_CHANGED', + ); + const count = await deps.prisma.bet.count({ where: { userId: fx.playerId } }); + expectEqual('bet count', count, 0); + } finally { + await teardownBetFlowFixture(deps.prisma, fx); + } + }, + }, + { + id: 'BF010', + suite: 'bet-flow', + name: '赛前限制:开赛后拒绝下注', + description: 'startTime 已过后 placeSingleBet 返回 PRE_MATCH_ONLY', + run: async () => { + const fx = await createBetFlowFixture(deps.prisma, deps.wallet, { initialBalance: 500 }); + try { + await deps.prisma.match.update({ + where: { id: fx.matchId }, + data: { startTime: new Date(Date.now() - 60_000) }, + }); + await expectAppErrorThrows( + 'placeSingleBet after kickoff', + async () => { + await deps.bets.placeSingleBet( + fx.playerId, + null, + fx.homeSelectionId, + fx.homeOddsVersion, + 50, + `smoke-prematch-${fx.runId}`, + ); + }, + 'PRE_MATCH_ONLY', + ); + } finally { + await teardownBetFlowFixture(deps.prisma, fx); + } + }, + }, + { + id: 'BF011', + suite: 'bet-flow', + name: '每日投注上限:超额拒单', + description: 'dailyStakeLimit=150 时第二笔 100 注单失败', + run: async () => { + const fx = await createBetFlowFixture(deps.prisma, deps.wallet, { initialBalance: 1000 }); + const snapshots: Array<{ key: string; value: string | null }> = []; + try { + await upsertSystemConfig(deps.prisma, BET_LIMIT_KEYS.dailyStakeLimit, '150', snapshots); + await deps.bets.placeSingleBet( + fx.playerId, + null, + fx.homeSelectionId, + fx.homeOddsVersion, + 100, + `smoke-daily-1-${fx.runId}`, + ); + await expectAppErrorThrows( + 'placeSingleBet daily limit', + async () => { + await deps.bets.placeSingleBet( + fx.playerId, + null, + fx.drawSelectionId, + fx.drawOddsVersion, + 100, + `smoke-daily-2-${fx.runId}`, + ); + }, + 'DAILY_STAKE_LIMIT', + ); + } finally { + await restoreSystemConfigs(deps.prisma, snapshots); + await teardownBetFlowFixture(deps.prisma, fx); + } + }, + }, + { + id: 'BF012', + suite: 'bet-flow', + name: '串关限额:超 maxStakeParlay 拒单', + description: 'maxStakeParlay=80 时下 100 串关失败', + run: async () => { + const fx = await createBetFlowFixture(deps.prisma, deps.wallet, { initialBalance: 1000 }); + const matchB = await deps.prisma.match.create({ + data: { + sportType: 'FOOTBALL', + leagueId: fx.leagueId, + homeTeamId: fx.homeTeamId, + awayTeamId: fx.awayTeamId, + startTime: new Date(Date.now() + 48 * 60 * 60 * 1000), + status: 'PUBLISHED', + publishTime: new Date(), + }, + }); + const marketB = await deps.prisma.market.create({ + data: { + matchId: matchB.id, + marketType: 'FT_1X2', + period: 'FT', + status: 'OPEN', + selections: { + create: [ + { + selectionCode: 'AWAY', + selectionName: 'Away B', + odds: new Decimal('2.00'), + oddsVersion: BigInt(1), + status: 'OPEN', + sortOrder: 0, + }, + ], + }, + }, + include: { selections: true }, + }); + const awayB = marketB.selections[0]!; + const snapshots: Array<{ key: string; value: string | null }> = []; + try { + await upsertSystemConfig(deps.prisma, BET_LIMIT_KEYS.maxStakeParlay, '80', snapshots); + await expectAppErrorThrows( + 'placeParlayBet max stake', + async () => { + await deps.bets.placeParlayBet( + fx.playerId, + null, + [ + { selectionId: fx.homeSelectionId, oddsVersion: fx.homeOddsVersion }, + { selectionId: awayB.id, oddsVersion: awayB.oddsVersion }, + ], + 100, + `smoke-parlay-max-${fx.runId}`, + ); + }, + 'MAX_STAKE', + ); + } finally { + await restoreSystemConfigs(deps.prisma, snapshots); + const markets = await deps.prisma.market.findMany({ + where: { matchId: matchB.id }, + select: { id: true }, + }); + const marketIds = markets.map((m) => m.id); + if (marketIds.length) { + await deps.prisma.marketSelection.deleteMany({ where: { marketId: { in: marketIds } } }); + await deps.prisma.market.deleteMany({ where: { id: { in: marketIds } } }); + } + await deps.prisma.match.deleteMany({ where: { id: matchB.id } }); + await teardownBetFlowFixture(deps.prisma, fx); + } + }, + }, + { + id: 'BF013', + suite: 'bet-flow', + name: '改 SystemConfig 后拒单:minStake', + description: 'minStake=200 时下 100 单关返回 MIN_STAKE', + run: async () => { + const fx = await createBetFlowFixture(deps.prisma, deps.wallet, { initialBalance: 1000 }); + const snapshots: Array<{ key: string; value: string | null }> = []; + try { + await upsertSystemConfig(deps.prisma, BET_LIMIT_KEYS.minStake, '200', snapshots); + await expectAppErrorThrows( + 'placeSingleBet min stake', + async () => { + await deps.bets.placeSingleBet( + fx.playerId, + null, + fx.homeSelectionId, + fx.homeOddsVersion, + 100, + `smoke-min-${fx.runId}`, + ); + }, + 'MIN_STAKE', + ); + } finally { + await restoreSystemConfigs(deps.prisma, snapshots); + await teardownBetFlowFixture(deps.prisma, fx); + } + }, + }, { id: 'BF005', suite: 'bet-flow', diff --git a/apps/api/src/domains/operations/smoke-tests/smoke-test.cases.ts b/apps/api/src/domains/operations/smoke-tests/smoke-test.cases.ts index 5f87e29..3c9ee34 100644 --- a/apps/api/src/domains/operations/smoke-tests/smoke-test.cases.ts +++ b/apps/api/src/domains/operations/smoke-tests/smoke-test.cases.ts @@ -380,6 +380,36 @@ export const SMOKE_TEST_CASES: SmokeTestCaseDef[] = [ expectEqual('payout', payout.toNumber(), 100, { stake: 100, legs }); }, }, + { + id: 'S020', + suite: 'settlement', + name: '串关 VOID 腿 + LOSE 腿:整单全输', + description: '比赛取消腿按 odds=1.0 续算,已有 LOSE 腿时 payout=0', + run: () => { + const legs = [ + { odds: 2.0, result: 'VOID' as const }, + { odds: 2.0, result: 'LOSE' as const }, + ]; + const { betResult, payout } = calculateParlayPayout(100, legs); + expectEqual('betResult', betResult, 'LOST', { stake: 100, legs }); + expectEqual('payout', payout.toNumber(), 0, { stake: 100, legs }); + }, + }, + { + id: 'S021', + suite: 'settlement', + name: '串关 VOID 腿 + WIN 腿:按 WIN 腿赔率结算', + description: '作废腿不参与升赔,其余 WIN 腿正常连乘', + run: () => { + const legs = [ + { odds: 2.0, result: 'VOID' as const }, + { odds: 1.5, result: 'WIN' as const }, + ]; + const { betResult, payout } = calculateParlayPayout(100, legs); + expectEqual('betResult', betResult, 'WON', { stake: 100, legs }); + expectEqual('payout', payout.toNumber(), 150, { stake: 100, legs }); + }, + }, { id: 'OUT001', suite: 'settlement', @@ -431,17 +461,6 @@ export const SMOKE_TEST_CASES: SmokeTestCaseDef[] = [ }, // —— Betting rules —— - { - id: 'B003', - suite: 'betting', - name: '赔率版本不一致应拒绝', - uatRef: 'B003', - run: () => { - const submitted = BigInt(1); - const current = BigInt(2); - expectTrue('version mismatch', submitted !== current, { submitted: '1', current: '2' }); - }, - }, { id: 'B006', suite: 'betting', @@ -760,7 +779,10 @@ export const SMOKE_TEST_CASES: SmokeTestCaseDef[] = [ ]; export const SMOKE_SUITE_META: Record = { - settlement: { name: '结算引擎', description: '独赢、波胆、让球、大小、串关、冠军盘' }, + settlement: { + name: '结算引擎', + description: '独赢、波胆、让球、大小、串关、冠军盘、作废腿续算', + }, settlement_helpers: { name: '结算辅助', description: '选项代码与中文快照名映射' }, betting: { name: '下注规则', description: '串关限制、赔率版本、四分之一盘' }, betting_limits: { name: '投注限额', description: '最小/最大投注与派彩上限校验' }, @@ -771,4 +793,8 @@ export const SMOKE_SUITE_META: Record, +) { + const row = await prisma.systemConfig.findUnique({ where: { configKey: key } }); + snapshots.push({ key, value: row?.configValue ?? null }); + await prisma.systemConfig.upsert({ + where: { configKey: key }, + create: { + configKey: key, + configValue: value ? 'true' : 'false', + description: 'smoke config probe', + }, + update: { configValue: value ? 'true' : 'false' }, + }); +} + +async function restoreConfigs( + prisma: PrismaService, + snapshots: Array<{ key: string; value: string | null }>, +) { + for (const snap of snapshots) { + if (snap.value === null) { + await prisma.systemConfig.deleteMany({ where: { configKey: snap.key } }); + } else { + await prisma.systemConfig.upsert({ + where: { configKey: snap.key }, + create: { + configKey: snap.key, + configValue: snap.value, + description: 'smoke config restore', + }, + update: { configValue: snap.value }, + }); + } + } +} + +export function createConfigProbes(deps: ConfigProbeDeps): SmokeTestCaseDef[] { + return [ + { + id: 'CFG001', + suite: 'config', + name: '代理停用默认:全局 freeze 级联冻结直属玩家', + description: 'AgentsService.updateAgentAdmin 未传 flag 时读取 agent.suspend_freeze_direct_players', + run: async () => { + const runId = `${Date.now()}_${Math.random().toString(36).slice(2, 8)}`; + const snapshots: Array<{ key: string; value: string | null }> = []; + let agentId: bigint | undefined; + let playerId: bigint | undefined; + + try { + await upsertBooleanConfig( + deps.prisma, + AGENT_SUSPEND_FREEZE_DIRECT_PLAYERS, + true, + snapshots, + ); + await upsertBooleanConfig( + deps.prisma, + AGENT_SUSPEND_BLOCK_PLAYER_LOGIN, + false, + snapshots, + ); + + const agent = await deps.prisma.user.create({ + data: { + username: `smoke_cfg_ag_${runId}`, + userType: 'AGENT', + status: 'ACTIVE', + auth: { create: { passwordHash: 'smoke' } }, + agentProfile: { + create: { level: 1, creditLimit: new Decimal(10000) }, + }, + }, + }); + agentId = agent.id; + await deps.prisma.agentClosure.create({ + data: { ancestorId: agent.id, descendantId: agent.id, depth: 0 }, + }); + + const player = await deps.prisma.user.create({ + data: { + username: `smoke_cfg_pl_${runId}`, + userType: 'PLAYER', + status: 'ACTIVE', + parentId: agent.id, + auth: { create: { passwordHash: 'smoke' } }, + }, + }); + playerId = player.id; + + await deps.agents.updateAgentAdmin(agent.id, { status: 'SUSPENDED' }); + + const frozen = await deps.prisma.user.findUnique({ where: { id: player.id } }); + expectEqual('player status after suspend', frozen?.status, 'SUSPENDED'); + } finally { + if (playerId) { + await deps.prisma.userAuth.deleteMany({ where: { userId: playerId } }); + await deps.prisma.user.deleteMany({ where: { id: playerId } }); + } + if (agentId) { + await deps.prisma.agentClosure.deleteMany({ + where: { OR: [{ ancestorId: agentId }, { descendantId: agentId }] }, + }); + await deps.prisma.agentProfile.deleteMany({ where: { userId: agentId } }); + await deps.prisma.userAuth.deleteMany({ where: { userId: agentId } }); + await deps.prisma.user.deleteMany({ where: { id: agentId } }); + } + await restoreConfigs(deps.prisma, snapshots); + } + }, + }, + { + id: 'CFG002', + suite: 'config', + name: '代理停用默认:全局 block 禁止直属玩家登录', + description: 'AgentsService.updateAgentAdmin 未传 flag 时写入 blockDirectPlayerLogin', + run: async () => { + const runId = `${Date.now()}_${Math.random().toString(36).slice(2, 8)}`; + const snapshots: Array<{ key: string; value: string | null }> = []; + let agentId: bigint | undefined; + + try { + await upsertBooleanConfig( + deps.prisma, + AGENT_SUSPEND_FREEZE_DIRECT_PLAYERS, + false, + snapshots, + ); + await upsertBooleanConfig( + deps.prisma, + AGENT_SUSPEND_BLOCK_PLAYER_LOGIN, + true, + snapshots, + ); + + const agent = await deps.prisma.user.create({ + data: { + username: `smoke_cfg2_ag_${runId}`, + userType: 'AGENT', + status: 'ACTIVE', + auth: { create: { passwordHash: 'smoke' } }, + agentProfile: { + create: { level: 1, creditLimit: new Decimal(10000) }, + }, + }, + }); + agentId = agent.id; + await deps.prisma.agentClosure.create({ + data: { ancestorId: agent.id, descendantId: agent.id, depth: 0 }, + }); + + await deps.agents.updateAgentAdmin(agent.id, { status: 'SUSPENDED' }); + + const profile = await deps.prisma.agentProfile.findUnique({ + where: { userId: agent.id }, + }); + expectTrue( + 'blockDirectPlayerLogin persisted', + profile?.blockDirectPlayerLogin === true, + { value: profile?.blockDirectPlayerLogin }, + ); + } finally { + if (agentId) { + await deps.prisma.agentClosure.deleteMany({ + where: { OR: [{ ancestorId: agentId }, { descendantId: agentId }] }, + }); + await deps.prisma.agentProfile.deleteMany({ where: { userId: agentId } }); + await deps.prisma.userAuth.deleteMany({ where: { userId: agentId } }); + await deps.prisma.user.deleteMany({ where: { id: agentId } }); + } + await restoreConfigs(deps.prisma, snapshots); + } + }, + }, + ]; +} diff --git a/apps/api/src/domains/operations/smoke-tests/smoke-test.service.ts b/apps/api/src/domains/operations/smoke-tests/smoke-test.service.ts index 721173c..8097b37 100644 --- a/apps/api/src/domains/operations/smoke-tests/smoke-test.service.ts +++ b/apps/api/src/domains/operations/smoke-tests/smoke-test.service.ts @@ -7,6 +7,7 @@ import { SettlementService } from '../../settlement/settlement.service'; import { WalletService } from '../../ledger/wallet.service'; import { SMOKE_SUITE_META, SMOKE_TEST_CASES, type SmokeTestCaseDef } from './smoke-test.cases'; import { BET_FLOW_PROBE_COUNT, createBetFlowProbes } from './smoke-test.bet-flow-probes'; +import { CONFIG_PROBE_COUNT, createConfigProbes } from './smoke-test.config-probes'; import { createDatabaseProbes, DATABASE_PROBE_COUNT } from './smoke-test.db-probes'; import { beginSmokeSteps, @@ -49,6 +50,7 @@ export class SmokeTestService { } counts.set('database', DATABASE_PROBE_COUNT); counts.set('bet-flow', BET_FLOW_PROBE_COUNT); + counts.set('config', CONFIG_PROBE_COUNT); return [...counts.entries()].map(([id, caseCount]) => ({ id, @@ -77,6 +79,7 @@ export class SmokeTestService { const staticCases = SMOKE_TEST_CASES.filter((c) => !allow || allow.has(c.suite)); const runDb = !allow || allow.has('database'); const runBetFlow = !allow || allow.has('bet-flow'); + const runConfig = !allow || allow.has('config'); const results: SmokeTestCaseResult[] = []; @@ -102,6 +105,15 @@ export class SmokeTestService { } } + if (runConfig) { + for (const probe of createConfigProbes({ + prisma: this.prisma, + agents: this.agents, + })) { + results.push(await this.executeCase(probe)); + } + } + const finished = Date.now(); const passed = results.filter((r) => r.status === 'PASS').length; const failed = results.filter((r) => r.status === 'FAIL').length; diff --git a/apps/api/src/domains/settlement/domain/settlement-calculator.ts b/apps/api/src/domains/settlement/domain/settlement-calculator.ts index 37a9543..83fc45e 100644 --- a/apps/api/src/domains/settlement/domain/settlement-calculator.ts +++ b/apps/api/src/domains/settlement/domain/settlement-calculator.ts @@ -118,8 +118,8 @@ function settleOverUnder( if (winCount === 2) return 'WIN'; if (loseCount === 2) return 'LOSE'; - if (winCount === 1) return 'HALF_WIN'; - if (loseCount === 1) return 'HALF_LOSE'; + if (winCount === 1 && loseCount === 0) return 'HALF_WIN'; + if (loseCount === 1 && winCount === 0) return 'HALF_LOSE'; return 'PUSH'; } diff --git a/apps/api/src/domains/settlement/settlement.service.spec.ts b/apps/api/src/domains/settlement/settlement.service.spec.ts index d7322c4..f9b3281 100644 --- a/apps/api/src/domains/settlement/settlement.service.spec.ts +++ b/apps/api/src/domains/settlement/settlement.service.spec.ts @@ -148,7 +148,9 @@ describe('SettlementService outright winner flow', () => { settlementBatch: { create: settlementBatchCreate, findUnique: settlementBatchFindUnique, + findFirst: jest.fn().mockResolvedValue(null), update: jest.fn().mockResolvedValue({}), + updateMany: jest.fn().mockResolvedValue({ count: 0 }), }, bet: { findMany: betFindMany }, $transaction: transaction, @@ -247,7 +249,7 @@ describe('SettlementService outright winner flow', () => { ); }); - it('keeps a parlay pending until every match in the ticket is settled', async () => { + it('shows parlay as lost when this match leg loses even if other legs are pending', async () => { matchFindFirst.mockResolvedValue({ id: matchId, isOutright: false, @@ -304,14 +306,13 @@ describe('SettlementService outright winner flow', () => { ftAway: 1, }); - expect(preview.pendingOtherMatches).toBe(1); - expect(preview.lostOnThisMatch).toBe(0); + expect(preview.pendingOtherMatches).toBe(0); + expect(preview.lostOnThisMatch).toBe(1); expect(preview.items.items).toEqual([ expect.objectContaining({ betNo: 'PARLAY-PENDING', - result: 'PENDING_OTHER_MATCHES', + result: 'LOST', payout: '0', - note: '本场腿已出结果,待其他场次结算后统一结算', }), ]); }); @@ -404,6 +405,7 @@ describe('SettlementService outright winner flow', () => { findUnique: settlementBatchFindUnique, update: jest.fn().mockResolvedValue({}), updateMany: jest.fn().mockResolvedValue({ count: 1 }), + findFirst: jest.fn().mockResolvedValue({ id: batchId }), }, match: { update: txMatchUpdate }, }); @@ -474,3 +476,442 @@ describe('SettlementService outright winner flow', () => { expect(result).toEqual({ success: true, batchId: batchId.toString() }); }); }); + +describe('SettlementService hardening', () => { + const matchId = BigInt(200); + const operatorId = BigInt(1); + const batchId = BigInt(900); + const otherBatchId = BigInt(901); + + const fixtureMatch = { + id: matchId, + isOutright: false, + status: 'PENDING_SETTLEMENT', + deletedAt: null, + }; + + const pendingSingleBet = { + id: BigInt(3001), + betNo: 'BET-SINGLE', + betType: 'SINGLE', + status: 'PENDING', + stake: new Decimal(100), + agentId: null, + userId: BigInt(70), + user: { id: BigInt(70) }, + selections: [ + { + id: BigInt(4001), + matchId, + marketType: 'FT_1X2', + selectionId: BigInt(501), + selectionNameSnapshot: 'Home', + handicapLine: null, + totalLine: null, + odds: new Decimal(2), + resultStatus: null, + sortOrder: 0, + }, + ], + }; + + function buildService(overrides: { + prisma?: Record; + wallet?: Record; + transactionClient?: Record; + } = {}) { + const wallet = { + settleBet: jest.fn().mockResolvedValue(undefined), + voidBet: jest.fn().mockResolvedValue(undefined), + ...(overrides.wallet ?? {}), + }; + const agents = { recalculateUsedCredit: jest.fn().mockResolvedValue(undefined) }; + const transaction = jest.fn(async (fn: (client: unknown) => Promise) => { + const defaultClient = { + team: { findUnique: jest.fn().mockResolvedValue(null) }, + market: { findMany: jest.fn().mockResolvedValue([]) }, + marketSelection: { findMany: jest.fn().mockResolvedValue([{ id: BigInt(501), selectionCode: 'HOME' }]) }, + matchScore: { + upsert: jest.fn().mockResolvedValue({}), + findUnique: jest.fn().mockResolvedValue({ + matchId, + htHomeScore: 0, + htAwayScore: 0, + ftHomeScore: 1, + ftAwayScore: 0, + winnerTeamId: null, + }), + }, + bet: { + findMany: jest.fn().mockResolvedValue([pendingSingleBet]), + updateMany: jest.fn().mockResolvedValue({ count: 1 }), + }, + betSelection: { update: jest.fn().mockResolvedValue({}), findMany: jest.fn().mockResolvedValue([]) }, + settlementItem: { create: jest.fn().mockResolvedValue({}) }, + settlementBatch: { + findUnique: jest.fn().mockResolvedValue({ + id: batchId, + batchNo: 'STL-001', + matchId, + status: 'PREVIEW', + htHomeScore: 0, + htAwayScore: 0, + ftHomeScore: 1, + ftAwayScore: 0, + match: fixtureMatch, + }), + update: jest.fn().mockResolvedValue({}), + updateMany: jest.fn().mockResolvedValue({ count: 1 }), + findFirst: jest.fn().mockResolvedValue({ id: batchId }), + }, + match: { update: jest.fn().mockResolvedValue({}) }, + ...(overrides.transactionClient ?? {}), + }; + await fn(defaultClient); + }); + + const prisma = { + match: { + count: jest.fn().mockResolvedValue(0), + findFirst: jest.fn().mockResolvedValue({ ...fixtureMatch, status: 'CLOSED' }), + update: jest.fn().mockResolvedValue({}), + }, + team: { findUnique: jest.fn().mockResolvedValue(null) }, + market: { findMany: jest.fn().mockResolvedValue([]) }, + marketSelection: { + findFirst: jest.fn().mockResolvedValue(null), + findMany: jest.fn().mockResolvedValue([{ id: BigInt(501), selectionCode: 'HOME' }]), + }, + matchScore: { + findUnique: jest.fn().mockResolvedValue(null), + upsert: jest.fn().mockResolvedValue({}), + }, + settlementBatch: { + create: jest.fn().mockResolvedValue({ id: batchId, matchId, status: 'PREVIEW' }), + findUnique: jest.fn(), + findFirst: jest.fn().mockResolvedValue(null), + updateMany: jest.fn().mockResolvedValue({ count: 0 }), + }, + bet: { findMany: jest.fn().mockResolvedValue([]) }, + $transaction: transaction, + ...(overrides.prisma ?? {}), + }; + + return { + service: new SettlementService(prisma as never, wallet as never, agents as never), + prisma, + wallet, + transaction, + }; + } + + it('recordScore rejects half-time scores greater than full-time', async () => { + const { service } = buildService(); + try { + await service.recordScore(matchId, 2, 0, 1, 0, operatorId); + throw new Error('Expected recordScore to reject'); + } catch (err) { + const response = (err as { getResponse?: () => unknown }).getResponse?.(); + expect(response).toEqual( + expect.objectContaining({ code: 'SETTLEMENT_SCORE_INVALID' }), + ); + } + }); + + it('previewSettlement cancels older preview batches before creating a new one', async () => { + const updateMany = jest.fn().mockResolvedValue({ count: 1 }); + const { service } = buildService({ + prisma: { + bet: { findMany: jest.fn().mockResolvedValue([]) }, + settlementBatch: { + create: jest.fn().mockResolvedValue({ id: batchId, matchId, status: 'PREVIEW' }), + updateMany, + }, + }, + }); + + await service.previewSettlement(matchId, operatorId, { + htHome: 0, + htAway: 0, + ftHome: 1, + ftAway: 0, + }); + + expect(updateMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: { matchId, status: 'PREVIEW', isResettle: false }, + data: { status: 'CANCELLED' }, + }), + ); + }); + + it('confirmSettlement rejects stale preview batches', async () => { + const staleBatch = { + id: otherBatchId, + batchNo: 'STL-OLD', + matchId, + status: 'PREVIEW', + htHomeScore: 0, + htAwayScore: 0, + ftHomeScore: 1, + ftAwayScore: 0, + match: fixtureMatch, + }; + const { service } = buildService({ + prisma: { + settlementBatch: { + findUnique: jest.fn().mockResolvedValue(staleBatch), + }, + }, + transactionClient: { + settlementBatch: { + findUnique: jest.fn().mockResolvedValue(staleBatch), + update: jest.fn().mockResolvedValue({}), + updateMany: jest.fn().mockResolvedValue({ count: 1 }), + findFirst: jest.fn().mockResolvedValue({ id: batchId }), + }, + }, + }); + + try { + await service.confirmSettlement(otherBatchId, operatorId); + throw new Error('Expected confirmSettlement to reject'); + } catch (err) { + const response = (err as { getResponse?: () => unknown }).getResponse?.(); + expect(response).toEqual( + expect.objectContaining({ code: 'SETTLEMENT_BATCH_STALE' }), + ); + } + }); + + it('confirmSettlement throws when bet status update fails', async () => { + const { service } = buildService({ + prisma: { + settlementBatch: { + findUnique: jest.fn().mockResolvedValue({ + id: batchId, + batchNo: 'STL-001', + matchId, + status: 'PREVIEW', + htHomeScore: 0, + htAwayScore: 0, + ftHomeScore: 1, + ftAwayScore: 0, + match: fixtureMatch, + }), + }, + }, + transactionClient: { + bet: { + findMany: jest.fn().mockResolvedValue([pendingSingleBet]), + updateMany: jest.fn().mockResolvedValue({ count: 0 }), + }, + }, + }); + + try { + await service.confirmSettlement(batchId, operatorId); + throw new Error('Expected confirmSettlement to reject'); + } catch (err) { + const response = (err as { getResponse?: () => unknown }).getResponse?.(); + expect(response).toEqual( + expect.objectContaining({ + code: 'SETTLEMENT_BET_UPDATE_FAILED', + params: expect.objectContaining({ betNo: 'BET-SINGLE' }), + }), + ); + } + }); + + it('voidMatchBets settles cross-match parlay as lost when voided leg joins an existing losing leg', async () => { + const parlayBet = { + id: BigInt(5001), + betNo: 'PARLAY-VOID', + betType: 'PARLAY', + status: 'PENDING', + stake: new Decimal(100), + agentId: null, + userId: BigInt(80), + selections: [ + { + id: BigInt(6001), + matchId, + marketType: 'FT_1X2', + selectionId: BigInt(501), + selectionNameSnapshot: 'Home', + handicapLine: null, + totalLine: null, + odds: new Decimal(2), + resultStatus: null, + sortOrder: 0, + }, + { + id: BigInt(6002), + matchId: BigInt(201), + marketType: 'FT_1X2', + selectionId: BigInt(502), + selectionNameSnapshot: 'Away', + handicapLine: null, + totalLine: null, + odds: new Decimal(2), + resultStatus: 'LOSE', + sortOrder: 1, + }, + ], + }; + + const betSelectionUpdate = jest.fn().mockResolvedValue({}); + const betSelectionFindMany = jest + .fn() + .mockResolvedValue([ + { ...parlayBet.selections[0], resultStatus: 'VOID', odds: new Decimal(2) }, + { ...parlayBet.selections[1], resultStatus: 'LOSE', odds: new Decimal(2) }, + ]); + const betUpdateMany = jest.fn().mockResolvedValue({ count: 1 }); + const funds = { settleBet: jest.fn().mockResolvedValue(undefined), voidBet: jest.fn() }; + + const transaction = jest.fn(async (fn: (client: unknown) => Promise) => + fn({ + match: { update: jest.fn().mockResolvedValue({}) }, + bet: { + findMany: jest.fn().mockResolvedValue([parlayBet]), + updateMany: betUpdateMany, + }, + betSelection: { + update: betSelectionUpdate, + findMany: betSelectionFindMany, + }, + }), + ); + + const service = new SettlementService( + { $transaction: transaction } as never, + funds as never, + { recalculateUsedCredit: jest.fn() } as never, + ); + + const result = await service.voidMatchBets(matchId); + + expect(result.voidedCount).toBe(1); + expect(funds.voidBet).not.toHaveBeenCalled(); + expect(funds.settleBet).toHaveBeenCalledWith( + expect.objectContaining({ + betNo: 'PARLAY-VOID', + result: 'LOSE', + batchNo: `void:${matchId}`, + }), + ); + expect(funds.settleBet.mock.calls[0][0].payout.toString()).toBe('0'); + }); + + it('previewResettlement treats single multi-leg tickets like parlays', async () => { + const multiLegSingle = { + id: BigInt(7001), + betNo: 'SINGLE-MULTI', + betType: 'SINGLE', + status: 'WON', + stake: new Decimal(100), + actualReturn: new Decimal(360), + agentId: null, + userId: BigInt(90), + selections: [ + { + id: BigInt(8001), + matchId, + marketType: 'FT_1X2', + selectionId: BigInt(501), + selectionNameSnapshot: 'Home', + handicapLine: null, + totalLine: null, + odds: new Decimal(2), + resultStatus: 'WIN', + sortOrder: 0, + }, + { + id: BigInt(8002), + matchId, + marketType: 'FT_1X2', + selectionId: BigInt(502), + selectionNameSnapshot: 'Away', + handicapLine: null, + totalLine: null, + odds: new Decimal(2), + resultStatus: 'WIN', + sortOrder: 1, + }, + ], + }; + + const { service } = buildService({ + prisma: { + match: { + count: jest.fn().mockResolvedValue(0), + findFirst: jest.fn().mockResolvedValue({ ...fixtureMatch, status: 'SETTLED' }), + update: jest.fn().mockResolvedValue({}), + }, + bet: { findMany: jest.fn().mockResolvedValue([multiLegSingle]) }, + settlementBatch: { + create: jest.fn().mockImplementation(({ data }) => + Promise.resolve({ id: batchId, ...data }), + ), + }, + }, + }); + + const preview = await service.previewResettlement( + matchId, + { + htHome: 0, + htAway: 0, + ftHome: 0, + ftAway: 1, + }, + operatorId, + ); + + expect(preview.items).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + betNo: 'SINGLE-MULTI', + newStatus: 'LOST', + }), + ]), + ); + expect(preview.items[0].newPayout.toString()).toBe('0'); + }); + + it('previewSettlement rejects unsupported market types on pending bets', async () => { + const { service } = buildService({ + prisma: { + bet: { + findMany: jest.fn().mockResolvedValue([ + { + ...pendingSingleBet, + selections: [ + { + ...pendingSingleBet.selections[0], + marketType: 'UNKNOWN_MARKET', + }, + ], + }, + ]), + }, + }, + }); + + try { + await service.previewSettlement(matchId, operatorId, { + htHome: 0, + htAway: 0, + ftHome: 1, + ftAway: 0, + }); + throw new Error('Expected previewSettlement to reject'); + } catch (err) { + const response = (err as { getResponse?: () => unknown }).getResponse?.(); + expect(response).toEqual( + expect.objectContaining({ code: 'SETTLEMENT_MARKET_UNSUPPORTED' }), + ); + } + }); +}); diff --git a/apps/api/src/domains/settlement/settlement.service.ts b/apps/api/src/domains/settlement/settlement.service.ts index 571da2a..c62c651 100644 --- a/apps/api/src/domains/settlement/settlement.service.ts +++ b/apps/api/src/domains/settlement/settlement.service.ts @@ -18,6 +18,7 @@ import { resolveSelectionCode, templateScoresForMarket, } from './domain/settlement-helpers'; +import { isSettlementSupportedMarketType } from '@thebet365/shared'; const SETTLEMENT_ENTRY_STATUSES = new Set(['CLOSED', 'PENDING_SETTLEMENT', 'SETTLED']); const STAT_MARKET_REQUIREMENTS = { @@ -189,6 +190,95 @@ export class SettlementService { } } + private assertScoreConsistency(score: ScoreInput, isOutright: boolean) { + if (isOutright) return; + if ( + score.htHome < 0 || + score.htAway < 0 || + score.ftHome < 0 || + score.ftAway < 0 || + score.htHome > score.ftHome || + score.htAway > score.ftAway + ) { + throw appBadRequest('SETTLEMENT_SCORE_INVALID'); + } + } + + private assertSupportedMarketTypes( + bets: Array<{ selections: Array<{ marketType: string }> }>, + ) { + for (const bet of bets) { + for (const sel of bet.selections) { + if (!isSettlementSupportedMarketType(sel.marketType)) { + throw appBadRequest('SETTLEMENT_MARKET_UNSUPPORTED', { + marketType: sel.marketType, + }); + } + } + } + } + + private assertOutrightWinnerForBets( + bets: Array<{ selections: Array<{ marketType: string }> }>, + winnerTeamCode: string | null, + isOutright: boolean, + ) { + const hasOutrightLeg = + isOutright || + bets.some((bet) => + bet.selections.some((sel) => sel.marketType === 'OUTRIGHT_WINNER'), + ); + if (hasOutrightLeg && !winnerTeamCode) { + throw appBadRequest('SETTLEMENT_WINNER_REQUIRED'); + } + } + + private requireBetUpdate(updated: { count: number }, betNo: string) { + if (updated.count !== 1) { + throw appBadRequest('SETTLEMENT_BET_UPDATE_FAILED', { betNo }); + } + } + + private async assertLatestPreviewBatch( + batchId: bigint, + matchId: bigint, + tx?: TxClient, + ) { + const client: PrismaClientLike = tx ?? this.prisma; + const latest = await client.settlementBatch.findFirst({ + where: { matchId, status: 'PREVIEW', isResettle: false }, + orderBy: { createdAt: 'desc' }, + select: { id: true }, + }); + if (latest && latest.id !== batchId) { + throw appBadRequest('SETTLEMENT_BATCH_STALE', { batchId: batchId.toString() }); + } + } + + private async cancelStalePreviewBatches(matchId: bigint, tx?: TxClient) { + const client: PrismaClientLike = tx ?? this.prisma; + await client.settlementBatch.updateMany({ + where: { matchId, status: 'PREVIEW', isResettle: false }, + data: { status: 'CANCELLED' }, + }); + } + + private parlayBetStatusFromResult( + parlayResult: ReturnType, + ): 'LOST' | 'PUSH' | 'WON' { + if (parlayResult.betResult === 'LOST') return 'LOST'; + if (parlayResult.betResult === 'PUSH') return 'PUSH'; + return 'WON'; + } + + private walletResultFromParlayBetResult( + betResult: 'WON' | 'LOST' | 'PUSH', + ): 'WIN' | 'LOSE' | 'PUSH' { + if (betResult === 'LOST') return 'LOSE'; + if (betResult === 'PUSH') return 'PUSH'; + return 'WIN'; + } + async recordScore( matchId: bigint, htHome: number, @@ -226,6 +316,13 @@ export class SettlementService { } } + if (!match.isOutright) { + this.assertScoreConsistency( + { htHome, htAway, ftHome, ftAway }, + match.isOutright, + ); + } + const stats = this.statsInputFromSource(statsInput); await this.prisma.matchScore.upsert({ where: { matchId }, @@ -312,7 +409,19 @@ export class SettlementService { } const scoreSource = await this.resolvePreviewScoreSource(matchId, match.isOutright, opts); + if (!match.isOutright) { + this.assertScoreConsistency( + { + htHome: scoreSource.htHome, + htAway: scoreSource.htAway, + ftHome: scoreSource.ftHome, + ftAway: scoreSource.ftAway, + }, + match.isOutright, + ); + } const computation = await this.computePreviewComputation(matchId, scoreSource); + await this.cancelStalePreviewBatches(matchId); const batch = await this.prisma.settlementBatch.create({ data: { matchId, @@ -733,6 +842,8 @@ export class SettlementService { statsInput, pendingBets.flatMap((bet) => bet.selections.map((sel) => sel.marketType)), ); + this.assertSupportedMarketTypes(pendingBets); + this.assertOutrightWinnerForBets(pendingBets, winnerTeamCode, false); let totalPayout = new Decimal(0); let totalRefund = new Decimal(0); @@ -758,6 +869,7 @@ export class SettlementService { const result = this.settleLegResult(sel, code, scoreInput, statsInput, winnerTeamCode); const payout = calculatePayout(bet.stake, sel.odds, result); if (result === 'WIN' || result === 'HALF_WIN') wonLegsOnMatch += 1; + if (result === 'LOSE') lostOnThisMatch += 1; items.push({ betId: bet.id, betNo: bet.betNo, betType: 'SINGLE', result, payout }); if (result === 'PUSH' || result === 'VOID') { totalRefund = totalRefund.add(bet.stake); @@ -776,6 +888,7 @@ export class SettlementService { }; }); const parlay = calculateParlayPayout(bet.stake, legResults); + if (parlay.betResult === 'LOST') lostOnThisMatch += 1; items.push({ betId: bet.id, betNo: bet.betNo, @@ -809,6 +922,7 @@ export class SettlementService { selectionCodes, ); if (preview.kind === 'SETTLED') { + if (preview.betResult === 'LOST') lostOnThisMatch += 1; items.push({ betId: bet.id, betNo: bet.betNo, @@ -865,11 +979,18 @@ export class SettlementService { selectionCodes.get(sel.selectionId.toString()), sel.selectionNameSnapshot, ); + const legResult = this.settleLegResult(sel, code, scoreInput, statsInput, winnerTeamCode); + if (legResult === 'LOSE') { + return { kind: 'SETTLED', betResult: 'LOST', payout: new Decimal(0) }; + } legResults.push({ odds: sel.odds, - result: this.settleLegResult(sel, code, scoreInput, statsInput, winnerTeamCode), + result: legResult, }); } else if (sel.resultStatus) { + if (sel.resultStatus === 'LOSE') { + return { kind: 'SETTLED', betResult: 'LOST', payout: new Decimal(0) }; + } legResults.push({ odds: sel.odds, result: sel.resultStatus as SelectionResult, @@ -966,6 +1087,13 @@ export class SettlementService { pendingBets.flatMap((bet) => bet.selections.map((sel) => sel.marketType)), tx, ); + this.assertSupportedMarketTypes(pendingBets); + this.assertOutrightWinnerForBets( + pendingBets, + winnerTeamCode, + currentBatch.match.isOutright, + ); + await this.assertLatestPreviewBatch(batchId, currentBatch.matchId, tx); let settledCount = 0; await this.upsertMatchScoreRecord( @@ -1001,7 +1129,7 @@ export class SettlementService { settledAt: new Date(), }, }); - if (updatedBet.count !== 1) continue; + this.requireBetUpdate(updatedBet, bet.betNo); await tx.betSelection.update({ where: { id: sel.id }, @@ -1045,12 +1173,7 @@ export class SettlementService { }); } const parlayResult = calculateParlayPayout(bet.stake, legResults); - const betStatus = - parlayResult.betResult === 'LOST' - ? 'LOST' - : parlayResult.betResult === 'PUSH' - ? 'PUSH' - : 'WON'; + const betStatus = this.parlayBetStatusFromResult(parlayResult); const updatedBet = await tx.bet.updateMany({ where: { id: bet.id, status: 'PENDING' }, @@ -1060,7 +1183,7 @@ export class SettlementService { settledAt: new Date(), }, }); - if (updatedBet.count !== 1) continue; + this.requireBetUpdate(updatedBet, bet.betNo); await this.funds.settleBet({ userId: bet.userId, @@ -1068,12 +1191,7 @@ export class SettlementService { payout: parlayResult.payout, betNo: bet.betNo, batchNo: batch.batchNo, - result: - parlayResult.betResult === 'LOST' - ? 'LOSE' - : parlayResult.betResult === 'PUSH' - ? 'PUSH' - : 'WIN', + result: this.walletResultFromParlayBetResult(parlayResult.betResult), tx, }); @@ -1116,12 +1234,7 @@ export class SettlementService { result: s.resultStatus as SelectionResult, })); const parlayResult = calculateParlayPayout(bet.stake, legResults); - const betStatus = - parlayResult.betResult === 'LOST' - ? 'LOST' - : parlayResult.betResult === 'PUSH' - ? 'PUSH' - : 'WON'; + const betStatus = this.parlayBetStatusFromResult(parlayResult); const updatedBet = await tx.bet.updateMany({ where: { id: bet.id, status: 'PENDING' }, @@ -1131,7 +1244,7 @@ export class SettlementService { settledAt: new Date(), }, }); - if (updatedBet.count !== 1) continue; + this.requireBetUpdate(updatedBet, bet.betNo); await this.funds.settleBet({ userId: bet.userId, @@ -1139,12 +1252,7 @@ export class SettlementService { payout: parlayResult.payout, betNo: bet.betNo, batchNo: batch.batchNo, - result: - parlayResult.betResult === 'LOST' - ? 'LOSE' - : parlayResult.betResult === 'PUSH' - ? 'PUSH' - : 'WIN', + result: this.walletResultFromParlayBetResult(parlayResult.betResult), tx, }); @@ -1386,7 +1494,7 @@ export class SettlementService { winnerTeamCode: string | null, selectionCodes: Map, ) { - if (bet.betType === 'SINGLE') { + if (bet.betType === 'SINGLE' && bet.selections.length === 1) { const sel = bet.selections[0]; const code = resolveSelectionCode( selectionCodes.get(sel.selectionId.toString()), @@ -1401,6 +1509,26 @@ export class SettlementService { }; } + if (bet.betType === 'SINGLE' && bet.selections.length > 1) { + const legResults: Array<{ odds: Decimal; result: SelectionResult }> = []; + const legUpdates = new Map(); + for (const sel of bet.selections) { + const code = resolveSelectionCode( + selectionCodes.get(sel.selectionId.toString()), + sel.selectionNameSnapshot, + ); + const result = this.settleLegResult(sel, code, scoreInput, statsInput, winnerTeamCode); + legResults.push({ odds: sel.odds, result }); + legUpdates.set(sel.id.toString(), result); + } + const parlayResult = calculateParlayPayout(bet.stake, legResults); + return { + payout: parlayResult.payout, + betStatus: this.parlayBetStatusFromResult(parlayResult), + legUpdates, + }; + } + const legResults: Array<{ odds: Decimal; result: SelectionResult }> = []; const legUpdates = new Map(); @@ -1423,14 +1551,11 @@ export class SettlementService { } const parlayResult = calculateParlayPayout(bet.stake, legResults); - const betStatus = - parlayResult.betResult === 'LOST' - ? 'LOST' - : parlayResult.betResult === 'PUSH' - ? 'PUSH' - : 'WON'; - - return { payout: parlayResult.payout, betStatus, legUpdates }; + return { + payout: parlayResult.payout, + betStatus: this.parlayBetStatusFromResult(parlayResult), + legUpdates, + }; } async previewResettlement( @@ -1448,6 +1573,18 @@ export class SettlementService { throw appBadRequest('RESETTLE_SETTLED_ONLY'); } + if (!match.isOutright) { + this.assertScoreConsistency( + { + htHome: scoreInput.htHome, + htAway: scoreInput.htAway, + ftHome: scoreInput.ftHome, + ftAway: scoreInput.ftAway, + }, + match.isOutright, + ); + } + const winnerTeamCode = winnerTeamId ? await this.resolveWinnerTeamCode(winnerTeamId) : null; @@ -1467,6 +1604,8 @@ export class SettlementService { statsInput, settledBets.flatMap((bet) => bet.selections.map((sel) => sel.marketType)), ); + this.assertSupportedMarketTypes(settledBets); + this.assertOutrightWinnerForBets(settledBets, winnerTeamCode, match.isOutright); const items: Array<{ betId: bigint; betNo: string; @@ -1612,6 +1751,12 @@ export class SettlementService { settledBets.flatMap((bet) => bet.selections.map((sel) => sel.marketType)), tx, ); + this.assertSupportedMarketTypes(settledBets); + this.assertOutrightWinnerForBets( + settledBets, + winnerTeamCode, + currentBatch.match.isOutright, + ); let affectedCount = 0; await this.upsertMatchScoreRecord( @@ -1715,6 +1860,7 @@ export class SettlementService { options: { cancelMatch?: boolean } = {}, ) { const agentIds = new Set(); + const voidBatchNo = `void:${matchId}`; const voidedCount = await this.prisma.$transaction(async (tx) => { if (options.cancelMatch) { @@ -1726,21 +1872,82 @@ export class SettlementService { const bets = await tx.bet.findMany({ where: { status: 'PENDING', selections: { some: { matchId } } }, + include: { selections: { orderBy: { sortOrder: 'asc' } } }, }); let count = 0; for (const bet of bets) { - const updated = await tx.bet.updateMany({ - where: { id: bet.id, status: 'PENDING' }, - data: { status: 'VOID', actualReturn: bet.stake, settledAt: new Date() }, - }); - if (updated.count !== 1) continue; + const isSingleOneLeg = bet.betType === 'SINGLE' && bet.selections.length === 1; - await this.funds.voidBet({ + if (isSingleOneLeg) { + const sel = bet.selections[0]; + const updated = await tx.bet.updateMany({ + where: { id: bet.id, status: 'PENDING' }, + data: { status: 'VOID', actualReturn: bet.stake, settledAt: new Date() }, + }); + if (updated.count !== 1) continue; + + await tx.betSelection.update({ + where: { id: sel.id }, + data: { resultStatus: 'VOID' }, + }); + + await this.funds.voidBet({ + userId: bet.userId, + stake: bet.stake, + betNo: bet.betNo, + businessKey: `void:${matchId}:${bet.betNo}`, + tx, + }); + if (bet.agentId) agentIds.add(bet.agentId); + count += 1; + continue; + } + + const legsOnMatch = bet.selections.filter( + (sel) => sel.matchId?.toString() === matchId.toString(), + ); + if (!legsOnMatch.length) continue; + + for (const sel of legsOnMatch) { + await tx.betSelection.update({ + where: { id: sel.id }, + data: { resultStatus: 'VOID' }, + }); + } + + const updatedLegs = await tx.betSelection.findMany({ + where: { betId: bet.id }, + orderBy: { sortOrder: 'asc' }, + }); + if (!updatedLegs.every((sel) => sel.resultStatus != null)) { + continue; + } + + const legResults = updatedLegs.map((sel) => ({ + odds: sel.odds, + result: sel.resultStatus as SelectionResult, + })); + const parlayResult = calculateParlayPayout(bet.stake, legResults); + const betStatus = this.parlayBetStatusFromResult(parlayResult); + + const updatedBet = await tx.bet.updateMany({ + where: { id: bet.id, status: 'PENDING' }, + data: { + status: betStatus, + actualReturn: parlayResult.payout, + settledAt: new Date(), + }, + }); + if (updatedBet.count !== 1) continue; + + await this.funds.settleBet({ userId: bet.userId, stake: bet.stake, + payout: parlayResult.payout, betNo: bet.betNo, - businessKey: `void:${matchId}:${bet.betNo}`, + batchNo: voidBatchNo, + result: this.walletResultFromParlayBetResult(parlayResult.betResult), tx, }); if (bet.agentId) agentIds.add(bet.agentId); diff --git a/apps/api/src/shared/config/system-config.service.ts b/apps/api/src/shared/config/system-config.service.ts index 1e2f251..c2f3d94 100644 --- a/apps/api/src/shared/config/system-config.service.ts +++ b/apps/api/src/shared/config/system-config.service.ts @@ -19,6 +19,10 @@ export type InboxNotifySettings = { inboxEnabled: boolean; /** 充值审核通过/拒绝时发送站内信 */ deposit: boolean; + /** 发布 Banner 内容时发送站内信推广 */ + banner: boolean; + /** 发布公告/滚动条内容时发送站内信推广 */ + announcement: boolean; }; export type PlatformDirectCashbackSettings = { @@ -41,9 +45,9 @@ export type PlayerAccountSettings = { }; export type AgentSuspendSettings = { - /** 停用代理时是否允许级联冻结其直属玩家(需管理员显式勾选) */ + /** 停用代理时默认级联冻结直属玩家(单次操作仍可覆盖) */ suspendFreezeDirectPlayers: boolean; - /** 上级代理停用时是否禁止其直属玩家登录 */ + /** 停用代理时默认禁止直属玩家登录(单次操作仍可覆盖) */ suspendBlockPlayerLogin: boolean; }; @@ -239,11 +243,13 @@ export class SystemConfigService { } async getInboxNotifySettings(): Promise { - const [inboxEnabled, deposit] = await Promise.all([ + const [inboxEnabled, deposit, banner, announcement] = await Promise.all([ this.getBoolean(INBOX_FEATURE_ENABLED, true), this.getBoolean(INBOX_NOTIFY_DEPOSIT, true), + this.getBoolean(INBOX_NOTIFY_BANNER, true), + this.getBoolean(INBOX_NOTIFY_ANNOUNCEMENT, true), ]); - return { inboxEnabled, deposit }; + return { inboxEnabled, deposit, banner, announcement }; } async updateInboxNotifySettings(data: Partial) { @@ -261,6 +267,20 @@ export class SystemConfigService { '充值审核结果是否通过站内邮箱通知玩家', ); } + if (data.banner !== undefined) { + await this.setBoolean( + INBOX_NOTIFY_BANNER, + data.banner, + '发布 Banner 内容时是否发送站内信推广', + ); + } + if (data.announcement !== undefined) { + await this.setBoolean( + INBOX_NOTIFY_ANNOUNCEMENT, + data.announcement, + '发布公告/滚动条内容时是否发送站内信推广', + ); + } return this.getInboxNotifySettings(); } diff --git a/apps/player/index.html b/apps/player/index.html index bb878d9..237a9f2 100644 --- a/apps/player/index.html +++ b/apps/player/index.html @@ -2,8 +2,12 @@ - + + + + + diff --git a/apps/player/scripts/check-desktop-i18n.mjs b/apps/player/scripts/check-desktop-i18n.mjs new file mode 100644 index 0000000..25c1455 --- /dev/null +++ b/apps/player/scripts/check-desktop-i18n.mjs @@ -0,0 +1,44 @@ +import fs from 'fs'; +import path from 'path'; +import { fileURLToPath } from 'url'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const root = path.join(__dirname, '..'); + +function flatten(obj, prefix = '') { + const out = {}; + for (const [k, v] of Object.entries(obj)) { + const key = prefix ? `${prefix}.${k}` : k; + if (v && typeof v === 'object' && !Array.isArray(v)) Object.assign(out, flatten(v, key)); + else out[key] = v; + } + return out; +} + +function extractKeysFromDir(dir) { + const keys = new Set(); + const re = /\bt\s*\(\s*['"`]([^'"`]+)['"`]/g; + function walk(d) { + for (const ent of fs.readdirSync(d, { withFileTypes: true })) { + const p = path.join(d, ent.name); + if (ent.isDirectory()) walk(p); + else if (ent.name.endsWith('.vue')) { + const src = fs.readFileSync(p, 'utf8'); + let m; + while ((m = re.exec(src))) keys.add(m[1]); + } + } + } + walk(dir); + return keys; +} + +const zhMod = await import('../src/i18n/zh-CN.ts'); +const zh = flatten(zhMod.default); +const dirs = ['src/views/desktop', 'src/components/desktop'].map((d) => path.join(root, d)); +const used = new Set(); +for (const d of dirs) extractKeysFromDir(d).forEach((k) => used.add(k)); + +const missing = [...used].filter((k) => !(k in zh)).sort(); +console.log(`Missing keys (${missing.length}):`); +missing.forEach((k) => console.log(` ${k}`)); diff --git a/apps/player/src/App.vue b/apps/player/src/App.vue index f4ac569..4acb453 100644 --- a/apps/player/src/App.vue +++ b/apps/player/src/App.vue @@ -1,8 +1,10 @@ diff --git a/apps/player/src/assets/images/pcbg.webp b/apps/player/src/assets/images/pcbg.webp new file mode 100644 index 0000000..693abbc Binary files /dev/null and b/apps/player/src/assets/images/pcbg.webp differ diff --git a/apps/player/src/components/AppToast.vue b/apps/player/src/components/AppToast.vue new file mode 100644 index 0000000..6b68d8c --- /dev/null +++ b/apps/player/src/components/AppToast.vue @@ -0,0 +1,47 @@ + + + + + diff --git a/apps/player/src/components/BackToTopButton.vue b/apps/player/src/components/BackToTopButton.vue index 77ff460..f647a8b 100644 --- a/apps/player/src/components/BackToTopButton.vue +++ b/apps/player/src/components/BackToTopButton.vue @@ -53,7 +53,7 @@ const { visible, scrollToTop } = useBackToTop(toRef(props, 'scrollEl')); } .back-top-btn.above-nav { - bottom: calc(68px + env(safe-area-inset-bottom, 0px)); + bottom: calc(var(--player-bottom-nav-h, 54px) + var(--safe-bottom, env(safe-area-inset-bottom, 0px)) + 12px); } .back-top-btn:active { diff --git a/apps/player/src/components/BannerCarousel.vue b/apps/player/src/components/BannerCarousel.vue index 30bc943..5a9edaa 100644 --- a/apps/player/src/components/BannerCarousel.vue +++ b/apps/player/src/components/BannerCarousel.vue @@ -201,6 +201,8 @@ onUnmounted(stopAutoPlay); overflow: hidden; background: linear-gradient(135deg, #F5F7FA, #E8EDF2); cursor: pointer; + touch-action: manipulation; + -webkit-tap-highlight-color: transparent; } .slide::after { diff --git a/apps/player/src/components/BetSlipDrawer.vue b/apps/player/src/components/BetSlipDrawer.vue index 1f866a2..11d295d 100644 --- a/apps/player/src/components/BetSlipDrawer.vue +++ b/apps/player/src/components/BetSlipDrawer.vue @@ -14,6 +14,7 @@ import BetSuccessOverlay from './BetSuccessOverlay.vue'; import ConfirmDialog from './ConfirmDialog.vue'; import api from '../api'; import { usePlayerProfile } from '../composables/usePlayerProfile'; +import { buildBetPlaceConfirmMessage } from '../utils/betPlaceConfirmMessage'; const props = defineProps<{ modelValue: boolean }>(); const emit = defineEmits<{ 'update:modelValue': [boolean] }>(); @@ -34,6 +35,8 @@ const balance = ref(null); const error = ref(''); const success = ref(''); const showSuccess = ref(false); +const showPlaceConfirm = ref(false); +const placeConfirmMessage = ref(''); const MIN_STAKE = 5; const MAX_STAKE_INTEGER_LENGTH = 9; const stakeInput = ref(''); @@ -59,7 +62,6 @@ interface SelectionOddsRow { const oddsDeltas = ref>({}); let oddsPollTimer: ReturnType | null = null; -const clearConfirmVisible = ref(false); const activeItems = computed(() => { if (activeTab.value === 'parlay') return slip.parlayItems; @@ -347,46 +349,54 @@ function oddsTrendClass(delta: OddsDelta) { return delta.newOdds >= delta.oldOdds ? 'odds-up' : 'odds-down'; } -function onClearSlip() { - if (!activeItems.value.length) return; - clearConfirmVisible.value = true; -} - -function confirmClearSlip() { - if (activeTab.value === 'parlay') slip.clearParlay(); - else slip.clearSingle(); - clearConfirmVisible.value = false; - error.value = ''; -} - -async function placeBet() { - if (!activeItems.value.length) return; +function validatePlaceBet(): boolean { + if (!activeItems.value.length) return false; if (!auth.token) { auth.showLoginPrompt(); - return; + return false; } if (slip.stake < MIN_STAKE) { error.value = t('bet.slip_min_error', { amount: MIN_STAKE }); - return; + return false; } if (balance.value != null && slip.stake > balance.value) { error.value = t('bet.outright_insufficient'); - return; + return false; } if (activeTab.value === 'parlay' && !canSubmitActive.value) { error.value = slip.parlayItems.length > PARLAY_MAX_LEGS ? t('bet.parlay_max_legs') : t('bet.parlay_need_more'); - return; + return false; } if (hasSuspendedSelections.value) { error.value = t('bet.odds_suspended'); - return; + return false; } + return true; +} + +function onPlaceBetClick() { + error.value = ''; + if (!validatePlaceBet()) return; if (hasPendingOddsChanges.value) { acceptPendingOdds(); } + const items = activeTab.value === 'parlay' ? [...slip.parlayItems] : activeItems.value; + placeConfirmMessage.value = buildBetPlaceConfirmMessage(t, { + mode: activeTab.value, + items, + totalStake: Number(slip.stake) || 0, + totalReturn: activeEstimatedReturn.value, + totalOdds: activeTab.value === 'parlay' ? activeTotalOdds.value : undefined, + formatMoney: (amount) => formatMoney(amount, locale.value), + getStake: () => Number(slip.stake) || 0, + getOdds: (item) => effectiveOdds(item), + }); + showPlaceConfirm.value = true; +} +async function executePlaceBet() { loading.value = true; error.value = ''; success.value = ''; @@ -428,6 +438,11 @@ async function placeBet() { } } +async function confirmPlaceBet() { + showPlaceConfirm.value = false; + await executePlaceBet(); +} + watch( () => props.modelValue, (open) => { @@ -636,7 +651,7 @@ watch( type="button" class="btn-primary" :disabled="loading || !canSubmitWithOdds" - @click="placeBet" + @click="onPlaceBetClick" > {{ submitButtonLabel }} @@ -644,23 +659,24 @@ watch(
    - - + + diff --git a/apps/player/src/components/CashBalanceChip.vue b/apps/player/src/components/CashBalanceChip.vue index 916d31e..fe7dbea 100644 --- a/apps/player/src/components/CashBalanceChip.vue +++ b/apps/player/src/components/CashBalanceChip.vue @@ -76,6 +76,9 @@ onUnmounted(() => { +
    @@ -105,6 +108,32 @@ onUnmounted(() => { .cash-chip-wrap { position: relative; z-index: 120; + display: flex; + align-items: center; + gap: 6px; +} + +.direct-recharge-btn { + height: 36px; + padding: 0 12px; + border-radius: 6px; + background: var(--primary); + color: var(--tertiary); + font-size: 13px; + font-weight: 700; + border: none; + cursor: pointer; + white-space: nowrap; +} + +.direct-recharge-btn:active { + opacity: 0.8; +} + +@media (max-width: 1023px) { + .direct-recharge-btn { + display: none; + } } .cash-chip { diff --git a/apps/player/src/components/ConfirmDialog.vue b/apps/player/src/components/ConfirmDialog.vue index 60dc142..e2422ba 100644 --- a/apps/player/src/components/ConfirmDialog.vue +++ b/apps/player/src/components/ConfirmDialog.vue @@ -89,13 +89,13 @@ function onConfirm() { .confirm-overlay { position: fixed; inset: 0; - z-index: 1000; + z-index: 1100; display: flex; align-items: center; justify-content: center; padding: 20px; padding-bottom: calc(20px + env(safe-area-inset-bottom, 0px)); - background: rgba(0, 0, 0, 0.48); + background: rgba(0, 0, 0, 0.72); backdrop-filter: blur(4px); -webkit-backdrop-filter: blur(4px); } @@ -103,18 +103,18 @@ function onConfirm() { .confirm-modal { width: 100%; max-width: 340px; - background: var(--bg-card); - border: 1px solid var(--border); + background: linear-gradient(165deg, #1a1810 0%, #121212 45%, #0a0a0a 100%); + border: 1px solid var(--border-gold-soft, rgba(200, 168, 78, 0.25)); border-radius: 12px; padding: 22px 18px 16px; - box-shadow: 0 8px 32px rgba(0, 61, 107, 0.12); + box-shadow: 0 0 24px rgba(212, 175, 55, 0.08); } .confirm-title { margin: 0 0 10px; font-size: 17px; font-weight: 800; - color: var(--primary); + color: var(--gold, #c8a84e); text-align: center; line-height: 1.35; } @@ -123,8 +123,9 @@ function onConfirm() { margin: 0 0 20px; font-size: 14px; line-height: 1.6; - color: var(--text-muted); + color: #c8c8c8; text-align: center; + white-space: pre-line; } .confirm-actions { @@ -148,19 +149,19 @@ function onConfirm() { } .confirm-btn.cancel { - border: 1px solid var(--border); + border: 1px solid #333; background: transparent; - color: var(--text-muted); + color: #888; } .confirm-btn.confirm { border: none; - background: var(--primary); - color: #fff; + background: linear-gradient(135deg, #d4a017, #e8c84a); + color: #1a1a1a; } .confirm-btn.confirm.danger { - background: #dc2626; + background: linear-gradient(135deg, #c0392b, #e74c3c); color: #fff; } diff --git a/apps/player/src/components/CustomerServicePanel.vue b/apps/player/src/components/CustomerServicePanel.vue index 93404df..8a8a0a9 100644 --- a/apps/player/src/components/CustomerServicePanel.vue +++ b/apps/player/src/components/CustomerServicePanel.vue @@ -47,7 +47,7 @@ const iframeSrc = computed(() => { display: flex; flex-direction: column; margin: 0 -16px; - background: var(--bg-body); + background: #0d0d0d; } .cs-frame { diff --git a/apps/player/src/components/FloatingMailbox.vue b/apps/player/src/components/FloatingMailbox.vue new file mode 100644 index 0000000..331f457 --- /dev/null +++ b/apps/player/src/components/FloatingMailbox.vue @@ -0,0 +1,350 @@ + + + + + diff --git a/apps/player/src/components/HomeAnnouncementCard.vue b/apps/player/src/components/HomeAnnouncementCard.vue new file mode 100644 index 0000000..f7ef060 --- /dev/null +++ b/apps/player/src/components/HomeAnnouncementCard.vue @@ -0,0 +1,248 @@ + + + + + diff --git a/apps/player/src/components/LocaleSwitcher.vue b/apps/player/src/components/LocaleSwitcher.vue index ca97273..649fffe 100644 --- a/apps/player/src/components/LocaleSwitcher.vue +++ b/apps/player/src/components/LocaleSwitcher.vue @@ -68,6 +68,7 @@ onUnmounted(() => { diff --git a/apps/player/src/components/TeamEmblem.vue b/apps/player/src/components/TeamEmblem.vue index 937a7b3..f7dee21 100644 --- a/apps/player/src/components/TeamEmblem.vue +++ b/apps/player/src/components/TeamEmblem.vue @@ -7,7 +7,7 @@ const props = withDefaults( teamCode?: string; teamName?: string; logoUrl?: string | null; - size?: 'sm' | 'md' | 'lg'; + size?: 'sm' | 'md' | 'lg' | 'xl'; }>(), { size: 'md' }, ); @@ -80,6 +80,11 @@ watch( height: 52px; } +.team-emblem--xl { + width: 72px; + height: 72px; +} + /* 国旗:横向比例 + 铺满 */ .team-emblem:not(.team-emblem--logo) { object-fit: cover; @@ -102,6 +107,11 @@ watch( height: 36px; } +.team-emblem--xl:not(.team-emblem--logo) { + width: 80px; + height: 54px; +} + /* 队徽:正方形容器 + 完整显示 */ .team-emblem--logo { object-fit: contain; @@ -129,4 +139,8 @@ watch( .team-emblem--lg.team-emblem--placeholder { font-size: 22px; } + +.team-emblem--xl.team-emblem--placeholder { + font-size: 30px; +} diff --git a/apps/player/src/components/WalletStatsPanel.vue b/apps/player/src/components/WalletStatsPanel.vue index a4edba0..666d432 100644 --- a/apps/player/src/components/WalletStatsPanel.vue +++ b/apps/player/src/components/WalletStatsPanel.vue @@ -1,264 +1,128 @@ - - - - - diff --git a/apps/player/src/components/desktop/AccountSideNav.vue b/apps/player/src/components/desktop/AccountSideNav.vue new file mode 100644 index 0000000..748611d --- /dev/null +++ b/apps/player/src/components/desktop/AccountSideNav.vue @@ -0,0 +1,121 @@ + + + + + diff --git a/apps/player/src/components/desktop/BetSlipPanel.vue b/apps/player/src/components/desktop/BetSlipPanel.vue new file mode 100644 index 0000000..6f364ec --- /dev/null +++ b/apps/player/src/components/desktop/BetSlipPanel.vue @@ -0,0 +1,1967 @@ + + + + + diff --git a/apps/player/src/components/desktop/DataTable.vue b/apps/player/src/components/desktop/DataTable.vue new file mode 100644 index 0000000..4df4261 --- /dev/null +++ b/apps/player/src/components/desktop/DataTable.vue @@ -0,0 +1,123 @@ + + + + + diff --git a/apps/player/src/components/desktop/DesktopBanner3DCarousel.vue b/apps/player/src/components/desktop/DesktopBanner3DCarousel.vue new file mode 100644 index 0000000..c03d518 --- /dev/null +++ b/apps/player/src/components/desktop/DesktopBanner3DCarousel.vue @@ -0,0 +1,468 @@ + + + + + diff --git a/apps/player/src/components/desktop/DesktopOddsBetPopover.vue b/apps/player/src/components/desktop/DesktopOddsBetPopover.vue new file mode 100644 index 0000000..c933bae --- /dev/null +++ b/apps/player/src/components/desktop/DesktopOddsBetPopover.vue @@ -0,0 +1,400 @@ + + + + + diff --git a/apps/player/src/components/desktop/DesktopOutrightEventCard.vue b/apps/player/src/components/desktop/DesktopOutrightEventCard.vue new file mode 100644 index 0000000..9bc9501 --- /dev/null +++ b/apps/player/src/components/desktop/DesktopOutrightEventCard.vue @@ -0,0 +1,112 @@ + + + + + diff --git a/apps/player/src/components/desktop/DesktopShell.vue b/apps/player/src/components/desktop/DesktopShell.vue new file mode 100644 index 0000000..1ed6b87 --- /dev/null +++ b/apps/player/src/components/desktop/DesktopShell.vue @@ -0,0 +1,131 @@ + + + diff --git a/apps/player/src/components/desktop/DesktopTopNav.vue b/apps/player/src/components/desktop/DesktopTopNav.vue new file mode 100644 index 0000000..fb4d940 --- /dev/null +++ b/apps/player/src/components/desktop/DesktopTopNav.vue @@ -0,0 +1,243 @@ + + + + + diff --git a/apps/player/src/components/desktop/DesktopWalletSubNav.vue b/apps/player/src/components/desktop/DesktopWalletSubNav.vue new file mode 100644 index 0000000..db241ef --- /dev/null +++ b/apps/player/src/components/desktop/DesktopWalletSubNav.vue @@ -0,0 +1,46 @@ + + + diff --git a/apps/player/src/components/desktop/LeagueSidebar.vue b/apps/player/src/components/desktop/LeagueSidebar.vue new file mode 100644 index 0000000..fec7edf --- /dev/null +++ b/apps/player/src/components/desktop/LeagueSidebar.vue @@ -0,0 +1,319 @@ + + + + + diff --git a/apps/player/src/components/desktop/MatchSidebar.vue b/apps/player/src/components/desktop/MatchSidebar.vue new file mode 100644 index 0000000..1a77efd --- /dev/null +++ b/apps/player/src/components/desktop/MatchSidebar.vue @@ -0,0 +1,342 @@ + + + + + diff --git a/apps/player/src/components/desktop/Pagination.vue b/apps/player/src/components/desktop/Pagination.vue new file mode 100644 index 0000000..253f4a9 --- /dev/null +++ b/apps/player/src/components/desktop/Pagination.vue @@ -0,0 +1,172 @@ + + + + + diff --git a/apps/player/src/components/desktop/SportsCategoryBar.vue b/apps/player/src/components/desktop/SportsCategoryBar.vue new file mode 100644 index 0000000..a48786e --- /dev/null +++ b/apps/player/src/components/desktop/SportsCategoryBar.vue @@ -0,0 +1,108 @@ + + + + + diff --git a/apps/player/src/components/match-detail/CorrectScorePanel.vue b/apps/player/src/components/match-detail/CorrectScorePanel.vue index 9b47bb5..ce07e18 100644 --- a/apps/player/src/components/match-detail/CorrectScorePanel.vue +++ b/apps/player/src/components/match-detail/CorrectScorePanel.vue @@ -15,10 +15,12 @@ const props = defineProps<{ }>; isSelected: (id: string) => boolean; locked?: boolean; + /** PC 全宽紧凑:更小单元格、三列均分 */ + dense?: boolean; }>(); const emit = defineEmits<{ - pick: [id: string]; + pick: [id: string, event?: MouseEvent]; }>(); const { t } = useI18n(); @@ -34,9 +36,9 @@ const columns = computed(() => ), ); -function onPick(sel: CsSelection) { +function onPick(sel: CsSelection, event?: MouseEvent) { if (props.locked) return; - emit('pick', sel.id); + emit('pick', sel.id, event); } function formatOdds(odds: string) { @@ -46,7 +48,7 @@ function formatOdds(odds: string) {