From cb9a1e870825646d8311ec00df7c09c79dadc93a Mon Sep 17 00:00:00 2001 From: Mars <3361409208a@gmail.com> Date: Fri, 12 Jun 2026 14:08:00 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E7=9F=AD=E4=BF=A1=E8=B0=83=E8=AF=95?= =?UTF-8?q?=E6=97=A5=E5=BF=97=E3=80=81Tawk=20=E5=AE=A2=E6=9C=8D=E3=80=81?= =?UTF-8?q?=E6=89=8B=E6=9C=BA=E5=8F=B7=E6=A0=A1=E9=AA=8C=E6=94=BE=E5=AE=BD?= =?UTF-8?q?=E4=B8=8E=20Docker=20=E6=96=87=E6=A1=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit API - 短信发码/验码/创蓝全链路结构化日志(手机号脱敏) - 新增 SMS_DEBUG_LOG_CODE,联调时可输出验证码与 sessionId(对应创蓝批次号) - 注册成功、短信找回密码成功写入审计相关日志 - 放宽手机号归一化:移除区号白名单与 10~15 位长度限制 Player - 公告走马灯滚动周期调整为 35 秒 - 在线客服接入 Tawk.to(tawk.html),登录用户透传昵称/头像/ID - 三语补充 support.connecting 文案 部署与文档 - docker-compose 与 .env.docker.example 增加 SMS_DEBUG_LOG_CODE - 新增 docs/短信调试与日志说明.md、docs/docker 镜像构建导出脚本与说明 - Docker 部署指南补充镜像构建文档链接 - .gitignore 忽略 thebet365-images.tar 与 docker-build.log Co-authored-by: Cursor --- .env.docker.example | 2 + .gitignore | 2 + apps/api/src/domains/identity/auth.service.ts | 11 +- .../domains/identity/sms/chuanglan/client.ts | 17 +- .../domains/identity/sms/chuanglan/config.ts | 4 + .../src/domains/identity/sms/phone.util.ts | 23 +- .../src/domains/identity/sms/sms-log.util.ts | 13 ++ .../src/domains/identity/sms/sms.service.ts | 60 ++++- .../src/components/AnnouncementMarquee.vue | 2 +- .../src/components/CustomerServiceModal.vue | 32 ++- apps/player/src/config/customerService.ts | 28 ++- apps/player/src/i18n/en-US.ts | 1 + apps/player/src/i18n/ms-MY.ts | 1 + apps/player/src/i18n/zh-CN.ts | 1 + docker-compose.prod.yml | 1 + docs/Docker部署指南.md | 2 + docs/docker/build-and-export-images.ps1 | 65 ++++++ docs/docker/build-and-export-images.sh | 95 ++++++++ docs/docker/镜像构建与导出.md | 202 +++++++++++++++++ docs/短信调试与日志说明.md | 213 ++++++++++++++++++ packages/shared/public/tawk.html | 129 +++++++++++ 21 files changed, 870 insertions(+), 34 deletions(-) create mode 100644 apps/api/src/domains/identity/sms/sms-log.util.ts create mode 100644 docs/docker/build-and-export-images.ps1 create mode 100644 docs/docker/build-and-export-images.sh create mode 100644 docs/docker/镜像构建与导出.md create mode 100644 docs/短信调试与日志说明.md create mode 100644 packages/shared/public/tawk.html diff --git a/.env.docker.example b/.env.docker.example index a0c3418..abddddb 100644 --- a/.env.docker.example +++ b/.env.docker.example @@ -26,3 +26,5 @@ CHUANGLAN_CONNECT_TIMEOUT_MS=10000 CHUANGLAN_READ_TIMEOUT_MS=10000 SMS_CODE_TTL_SECONDS=300 SMS_RATE_LIMIT_SECONDS=60 +# 联调时在 .env.docker 设为 true,日志会输出验证码与 sessionId(对应创蓝批次号);上线前改回 false +SMS_DEBUG_LOG_CODE=false diff --git a/.gitignore b/.gitignore index 9fe7cd8..fd5a73d 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,8 @@ node_modules/ dist/ .pnpm-store/ release/ +docker-build.log +thebet365-images.tar .claude/ *.log .DS_Store diff --git a/apps/api/src/domains/identity/auth.service.ts b/apps/api/src/domains/identity/auth.service.ts index 5254244..0bfb0ca 100644 --- a/apps/api/src/domains/identity/auth.service.ts +++ b/apps/api/src/domains/identity/auth.service.ts @@ -1,4 +1,4 @@ -import { Injectable } from '@nestjs/common'; +import { Injectable, Logger } from '@nestjs/common'; import { Decimal } from '@prisma/client/runtime/library'; import { appForbidden, appUnauthorized, appBadRequest, appNotFound } from '../../shared/common/app-error'; import { JwtService } from '@nestjs/jwt'; @@ -11,6 +11,7 @@ import { InvitesService } from './invites.service'; import { SmsService } from './sms/sms.service'; import { AuditService } from '../operations/audit/audit.service'; import { normalizePhone, resolvePlayerLoginCandidates, stripLocalPhoneDigits } from './sms/phone.util'; +import { maskPhoneForLog } from './sms/sms-log.util'; const MAX_LOGIN_FAILS = 5; const LOCK_DURATION_MS = 15 * 60 * 1000; @@ -24,6 +25,8 @@ export interface JwtPayload { @Injectable() export class AuthService { + private readonly logger = new Logger(AuthService.name); + constructor( private prisma: PrismaService, private jwt: JwtService, @@ -313,6 +316,8 @@ export class AuthService { return created; }); + this.logger.log(`Player registered username=${username} phone=${maskPhoneForLog(phone)}`); + return this.login(username, data.password, 'player'); } @@ -425,6 +430,10 @@ export class AuthService { ipAddress: data.ipAddress, }); + this.logger.log( + `Password reset by phone userId=${player.id.toString()} phone=${maskPhoneForLog(normalizePhone(data.countryCode, data.phone))} ip=${data.ipAddress ?? 'n/a'}`, + ); + return { success: true }; } diff --git a/apps/api/src/domains/identity/sms/chuanglan/client.ts b/apps/api/src/domains/identity/sms/chuanglan/client.ts index 9e474b6..dae9796 100644 --- a/apps/api/src/domains/identity/sms/chuanglan/client.ts +++ b/apps/api/src/domains/identity/sms/chuanglan/client.ts @@ -1,11 +1,13 @@ -import { Injectable } from '@nestjs/common'; +import { Injectable, Logger } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { generateChuanglanSign } from './sign'; import { loadChuanglanConfig } from './config'; import type { ChuanglanSendResponse, SmsSendResult } from '../sms.types'; +import { maskPhoneForLog, shortSessionId } from '../sms-log.util'; @Injectable() export class ChuanglanClient { + private readonly logger = new Logger(ChuanglanClient.name); private readonly cfg; constructor(config: ConfigService) { @@ -14,6 +16,10 @@ export class ChuanglanClient { async sendSms(mobile: string, msg: string, uid?: string): Promise { const nonce = String(Date.now()); + const maskedMobile = maskPhoneForLog(mobile); + const session = uid ? shortSessionId(uid) : 'n/a'; + + this.logger.log(`Chuanglan request mobile=${maskedMobile} session=${session}`); const body: Record = { account: this.cfg.account, @@ -42,6 +48,9 @@ export class ChuanglanClient { const data = (await res.json()) as ChuanglanSendResponse; if (data.code === '0') { + this.logger.log( + `Chuanglan success mobile=${maskedMobile} session=${session} messageId=${data.data?.messageId ?? 'n/a'}`, + ); return { success: true, code: data.code, @@ -50,9 +59,15 @@ export class ChuanglanClient { }; } + this.logger.warn( + `Chuanglan rejected mobile=${maskedMobile} session=${session} code=${data.code} message=${data.message ?? 'n/a'}`, + ); return { success: false, code: data.code, message: data.message }; } catch (err) { const message = err instanceof Error ? err.message : 'Unknown error'; + this.logger.error( + `Chuanglan HTTP error mobile=${maskedMobile} session=${session} error=${message}`, + ); return { success: false, code: 'HTTP_ERROR', message }; } finally { clearTimeout(timer); diff --git a/apps/api/src/domains/identity/sms/chuanglan/config.ts b/apps/api/src/domains/identity/sms/chuanglan/config.ts index 2f5dbb1..f6b9a2f 100644 --- a/apps/api/src/domains/identity/sms/chuanglan/config.ts +++ b/apps/api/src/domains/identity/sms/chuanglan/config.ts @@ -11,6 +11,8 @@ export interface ChuanglanConfig { export interface SmsBusinessConfig { codeTtlSeconds: number; rateLimitSeconds: number; + /** 为 true 时在日志输出验证码与完整 sessionId(仅联调,生产务必关闭) */ + debugLogCode: boolean; } export function loadChuanglanConfig(config: ConfigService): ChuanglanConfig { @@ -29,8 +31,10 @@ export function loadChuanglanConfig(config: ConfigService): ChuanglanConfig { } export function loadSmsBusinessConfig(config: ConfigService): SmsBusinessConfig { + const debugRaw = config.get('SMS_DEBUG_LOG_CODE', 'false'); return { codeTtlSeconds: Number(config.get('SMS_CODE_TTL_SECONDS', 300)), rateLimitSeconds: Number(config.get('SMS_RATE_LIMIT_SECONDS', 60)), + debugLogCode: debugRaw === 'true' || debugRaw === '1', }; } diff --git a/apps/api/src/domains/identity/sms/phone.util.ts b/apps/api/src/domains/identity/sms/phone.util.ts index 51fcf7f..a460a77 100644 --- a/apps/api/src/domains/identity/sms/phone.util.ts +++ b/apps/api/src/domains/identity/sms/phone.util.ts @@ -1,23 +1,14 @@ import { appBadRequest } from '../../../shared/common/app-error'; -import { isSupportedPhoneDial } from '@thebet365/shared'; /** 归一化为创蓝格式:国家区号 + 本地号码(纯数字,无 +) */ export function normalizePhone(countryDial: string, localInput: string): string { const dial = countryDial.replace(/\D/g, ''); - let local = stripLocalPhoneDigits(localInput); + const local = stripLocalPhoneDigits(localInput); if (!dial || !local) { throw appBadRequest('PHONE_REQUIRED'); } - if (!isSupportedPhoneDial(dial)) { - throw appBadRequest('PHONE_COUNTRY_UNSUPPORTED'); - } - - const combined = `${dial}${local}`; - if (combined.length < 10 || combined.length > 15) { - throw appBadRequest('PHONE_INVALID'); - } - return combined; + return `${dial}${local}`; } /** 提取本地号码(纯数字,去掉前导 0) */ @@ -48,7 +39,7 @@ export function resolvePlayerLoginUsername( digits = digits.replace(/^0+/, ''); } // 已输入含区号完整号码时,不再重复拼接 - if (dial && digits.startsWith(dial) && digits.length >= 10) { + if (dial && digits.startsWith(dial)) { return digits; } try { @@ -59,7 +50,7 @@ export function resolvePlayerLoginUsername( } const digits = trimmed.replace(/\D/g, ''); - if (digits.length >= 10) return digits; + if (digits.length > 0) return digits; return trimmed; } @@ -81,9 +72,5 @@ export function resolvePlayerLoginCandidates( } const digits = trimmed.replace(/\D/g, ''); - if (digits.length >= 10) { - return [digits]; - } - - return [digits]; + return [digits.length > 0 ? digits : trimmed]; } diff --git a/apps/api/src/domains/identity/sms/sms-log.util.ts b/apps/api/src/domains/identity/sms/sms-log.util.ts new file mode 100644 index 0000000..268f122 --- /dev/null +++ b/apps/api/src/domains/identity/sms/sms-log.util.ts @@ -0,0 +1,13 @@ +/** 日志用:仅保留末 4 位数字,不写完整手机号 */ +export function maskPhoneForLog(phone: string): string { + const digits = phone.replace(/\D/g, ''); + if (digits.length <= 4) return '****'; + return `****${digits.slice(-4)}`; +} + +/** 日志用:缩短 sessionId,便于关联同一次发码/验码 */ +export function shortSessionId(sessionId: string): string { + const trimmed = sessionId.trim(); + if (trimmed.length <= 8) return trimmed; + return `${trimmed.slice(0, 8)}…`; +} diff --git a/apps/api/src/domains/identity/sms/sms.service.ts b/apps/api/src/domains/identity/sms/sms.service.ts index eb49371..28494c2 100644 --- a/apps/api/src/domains/identity/sms/sms.service.ts +++ b/apps/api/src/domains/identity/sms/sms.service.ts @@ -1,4 +1,4 @@ -import { Injectable } from '@nestjs/common'; +import { Injectable, Logger } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { randomUUID } from 'node:crypto'; import { normalizeLocale } from '@thebet365/shared'; @@ -12,6 +12,7 @@ import { generateSixDigitCode } from './code'; import { normalizePhone, stripLocalPhoneDigits } from './phone.util'; import { renderResetPasswordSms, renderVerifySms } from './templates'; import type { SmsLang, SmsPurpose } from './sms.types'; +import { maskPhoneForLog, shortSessionId } from './sms-log.util'; const codeKey = (sessionId: string) => `sms:code:${sessionId}`; const phoneRateKey = (phone: string) => `sms:rate:phone:${phone}`; @@ -26,6 +27,7 @@ function mapLocaleToSmsLang(locale?: string): SmsLang { @Injectable() export class SmsService { + private readonly logger = new Logger(SmsService.name); private readonly smsCfg; constructor( @@ -47,9 +49,14 @@ export class SmsService { }): Promise<{ sessionId: string }> { const purpose: SmsPurpose = params.purpose ?? 'register'; const phone = normalizePhone(params.countryCode, params.phone); + const maskedPhone = maskPhoneForLog(phone); + + this.logger.log( + `SMS send request purpose=${purpose} phone=${maskedPhone} ip=${params.clientIp}`, + ); if (purpose === 'reset_password') { - await this.assertPlayerCanResetPassword(params.countryCode, params.phone); + await this.assertPlayerCanResetPassword(params.countryCode, params.phone, maskedPhone); } const [phoneLimited, ipLimited] = await Promise.all([ @@ -57,6 +64,9 @@ export class SmsService { this.redis.exists(ipRateKey(params.clientIp)), ]); if (phoneLimited || ipLimited) { + this.logger.warn( + `SMS rate limited purpose=${purpose} phone=${maskedPhone} ip=${params.clientIp} phoneLimited=${phoneLimited} ipLimited=${ipLimited}`, + ); throw appTooManyRequests('SMS_RATE_LIMIT'); } @@ -68,8 +78,17 @@ export class SmsService { ? renderResetPasswordSms(lang, code) : renderVerifySms(lang, code); + if (this.smsCfg.debugLogCode) { + this.logger.warn( + `[SMS_DEBUG] purpose=${purpose} phone=${phone} sessionId=${sessionId} code=${code} content=${msg}`, + ); + } + const result = await this.chuanglan.sendSms(phone, msg, sessionId); if (!result.success) { + this.logger.error( + `SMS send failed purpose=${purpose} phone=${maskedPhone} session=${shortSessionId(sessionId)} providerCode=${result.code} providerMessage=${result.message}`, + ); throw appBadRequest('SMS_SEND_FAILED'); } @@ -83,6 +102,10 @@ export class SmsService { this.redis.set(ipRateKey(params.clientIp), '1', this.smsCfg.rateLimitSeconds), ]); + this.logger.log( + `SMS sent purpose=${purpose} phone=${maskedPhone} session=${shortSessionId(sessionId)} messageId=${result.messageId ?? 'n/a'}`, + ); + return { sessionId }; } @@ -94,26 +117,50 @@ export class SmsService { expectedPurpose?: SmsPurpose; }): Promise { const phone = normalizePhone(params.countryCode, params.phone); + const maskedPhone = maskPhoneForLog(phone); + const expectedPurpose = params.expectedPurpose ?? 'register'; + const session = shortSessionId(params.sessionId); + + this.logger.log( + `SMS verify request purpose=${expectedPurpose} phone=${maskedPhone} session=${session}`, + ); + const raw = await this.redis.get(codeKey(params.sessionId)); if (!raw) { + this.logger.warn( + `SMS verify failed reason=expired purpose=${expectedPurpose} phone=${maskedPhone} session=${session}`, + ); throw appBadRequest('SMS_CODE_EXPIRED'); } const cached = JSON.parse(raw) as { phone: string; code: string; purpose?: SmsPurpose }; const cachedPurpose: SmsPurpose = cached.purpose ?? 'register'; - const expectedPurpose = params.expectedPurpose ?? 'register'; if (cachedPurpose !== expectedPurpose) { + this.logger.warn( + `SMS verify failed reason=purpose_mismatch expected=${expectedPurpose} cached=${cachedPurpose} phone=${maskedPhone} session=${session}`, + ); throw appBadRequest('SMS_CODE_INVALID'); } if (cached.phone !== phone || cached.code !== params.code.trim()) { + this.logger.warn( + `SMS verify failed reason=invalid_code purpose=${expectedPurpose} phone=${maskedPhone} session=${session}`, + ); throw appBadRequest('SMS_CODE_INVALID'); } await this.redis.del(codeKey(params.sessionId)); + + this.logger.log( + `SMS verify success purpose=${expectedPurpose} phone=${maskedPhone} session=${session}`, + ); } - private async assertPlayerCanResetPassword(countryCode: string, phone: string): Promise { + private async assertPlayerCanResetPassword( + countryCode: string, + phone: string, + maskedPhone: string, + ): Promise { const dial = countryCode.replace(/\D/g, ''); const phoneLocal = stripLocalPhoneDigits(phone); @@ -127,14 +174,19 @@ export class SmsService { }); if (!pref) { + this.logger.warn(`SMS reset_password blocked reason=phone_not_registered phone=${maskedPhone}`); throw appBadRequest('PHONE_NOT_REGISTERED'); } if (pref.user.status === 'DISABLED') { + this.logger.warn(`SMS reset_password blocked reason=account_disabled phone=${maskedPhone}`); throw appForbidden('ACCOUNT_DISABLED'); } const settings = await this.systemConfig.getPlayerAccountSettings(); if (!settings.allowPasswordChange) { + this.logger.warn( + `SMS reset_password blocked reason=password_change_disabled phone=${maskedPhone}`, + ); throw appForbidden('PASSWORD_CHANGE_DISABLED'); } } diff --git a/apps/player/src/components/AnnouncementMarquee.vue b/apps/player/src/components/AnnouncementMarquee.vue index a616485..24ef137 100644 --- a/apps/player/src/components/AnnouncementMarquee.vue +++ b/apps/player/src/components/AnnouncementMarquee.vue @@ -81,7 +81,7 @@ const text = computed(() => { .marquee-track { display: flex; width: max-content; - animation: marquee-scroll 22s linear infinite; + animation: marquee-scroll 35s linear infinite; } .marquee-text { diff --git a/apps/player/src/components/CustomerServiceModal.vue b/apps/player/src/components/CustomerServiceModal.vue index 09f14f5..fb1886d 100644 --- a/apps/player/src/components/CustomerServiceModal.vue +++ b/apps/player/src/components/CustomerServiceModal.vue @@ -1,19 +1,39 @@ + + +