feat: 短信调试日志、Tawk 客服、手机号校验放宽与 Docker 文档

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 <cursoragent@cursor.com>
This commit is contained in:
2026-06-12 14:08:00 +08:00
parent ff89c31b51
commit cb9a1e8708
21 changed files with 870 additions and 34 deletions

View File

@@ -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 };
}

View File

@@ -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<SmsSendResult> {
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<string, string> = {
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);

View File

@@ -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<string>('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',
};
}

View File

@@ -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];
}

View File

@@ -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)}`;
}

View File

@@ -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<void> {
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<void> {
private async assertPlayerCanResetPassword(
countryCode: string,
phone: string,
maskedPhone: string,
): Promise<void> {
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');
}
}