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:
@@ -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 };
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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',
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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];
|
||||
}
|
||||
|
||||
13
apps/api/src/domains/identity/sms/sms-log.util.ts
Normal file
13
apps/api/src/domains/identity/sms/sms-log.util.ts
Normal 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)}…`;
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -1,19 +1,39 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { CUSTOMER_SERVICE_URL } from '../config/customerService';
|
||||
import { buildCustomerServiceUrl } from '../config/customerService';
|
||||
import { useAuthStore } from '../stores/auth';
|
||||
import { usePlayerProfile } from '../composables/usePlayerProfile';
|
||||
|
||||
const props = defineProps<{ modelValue: boolean }>();
|
||||
const emit = defineEmits<{ 'update:modelValue': [boolean] }>();
|
||||
|
||||
const { t } = useI18n();
|
||||
const auth = useAuthStore();
|
||||
const { profileRaw, avatarUrl } = usePlayerProfile();
|
||||
|
||||
const visible = computed({
|
||||
get: () => props.modelValue,
|
||||
set: (v) => emit('update:modelValue', v),
|
||||
});
|
||||
|
||||
const hasUrl = computed(() => Boolean(CUSTOMER_SERVICE_URL));
|
||||
const iframeSrc = computed(() => {
|
||||
const visitor = auth.user
|
||||
? {
|
||||
name:
|
||||
profileRaw.value?.username ||
|
||||
profileRaw.value?.preferences?.phone ||
|
||||
auth.user.username ||
|
||||
'',
|
||||
avatar: avatarUrl.value
|
||||
? new URL(avatarUrl.value, window.location.origin).href
|
||||
: '',
|
||||
id: String(profileRaw.value?.id ?? auth.user.id ?? ''),
|
||||
}
|
||||
: null;
|
||||
|
||||
return buildCustomerServiceUrl(t('support.connecting'), visitor);
|
||||
});
|
||||
|
||||
function close() {
|
||||
visible.value = false;
|
||||
@@ -34,13 +54,13 @@ function close() {
|
||||
|
||||
<div class="cs-body">
|
||||
<iframe
|
||||
v-if="hasUrl"
|
||||
v-if="visible"
|
||||
:key="iframeSrc"
|
||||
class="cs-frame"
|
||||
:src="CUSTOMER_SERVICE_URL"
|
||||
:src="iframeSrc"
|
||||
:title="t('support.title')"
|
||||
allow="clipboard-read; clipboard-write"
|
||||
allow="microphone; camera; clipboard-read; clipboard-write"
|
||||
/>
|
||||
<p v-else class="cs-empty">{{ t('support.url_pending') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,3 +1,25 @@
|
||||
/** 客服 iframe 地址,可通过环境变量 VITE_CUSTOMER_SERVICE_URL 覆盖 */
|
||||
export const CUSTOMER_SERVICE_URL =
|
||||
(import.meta.env.VITE_CUSTOMER_SERVICE_URL as string | undefined)?.trim() || '';
|
||||
/** 客服 iframe 基址,可通过 VITE_CUSTOMER_SERVICE_URL 覆盖(如外部客服页) */
|
||||
export const CUSTOMER_SERVICE_BASE =
|
||||
(import.meta.env.VITE_CUSTOMER_SERVICE_URL as string | undefined)?.trim() || '/tawk.html';
|
||||
|
||||
export type TawkVisitor = {
|
||||
name?: string;
|
||||
avatar?: string;
|
||||
id?: string;
|
||||
};
|
||||
|
||||
export function buildCustomerServiceUrl(
|
||||
loadingText: string,
|
||||
visitor?: TawkVisitor | null,
|
||||
): string {
|
||||
const params = new URLSearchParams({ loadingText });
|
||||
|
||||
if (visitor) {
|
||||
if (visitor.name) params.set('name', visitor.name);
|
||||
if (visitor.avatar) params.set('avatar', visitor.avatar);
|
||||
if (visitor.id) params.set('id', visitor.id);
|
||||
}
|
||||
|
||||
const separator = CUSTOMER_SERVICE_BASE.includes('?') ? '&' : '?';
|
||||
return `${CUSTOMER_SERVICE_BASE}${separator}${params.toString()}`;
|
||||
}
|
||||
|
||||
@@ -122,6 +122,7 @@ export default {
|
||||
title: 'Customer Support',
|
||||
open: 'Open customer support',
|
||||
close: 'Close',
|
||||
connecting: 'Connecting to support...',
|
||||
url_pending: 'Support URL is not configured yet.',
|
||||
},
|
||||
wallet: {
|
||||
|
||||
@@ -128,6 +128,7 @@ export default {
|
||||
title: 'Khidmat Pelanggan',
|
||||
open: 'Buka khidmat pelanggan',
|
||||
close: 'Tutup',
|
||||
connecting: 'Menyambung ke khidmat pelanggan...',
|
||||
url_pending: 'Pautan khidmat pelanggan belum dikonfigurasi.',
|
||||
},
|
||||
wallet: {
|
||||
|
||||
@@ -122,6 +122,7 @@ export default {
|
||||
title: '在线客服',
|
||||
open: '打开在线客服',
|
||||
close: '关闭',
|
||||
connecting: '正在连接客服...',
|
||||
url_pending: '客服链接暂未配置,请联系管理员。',
|
||||
},
|
||||
wallet: {
|
||||
|
||||
Reference in New Issue
Block a user