HarmonyOS开发:短信服务SMS服务
HarmonyOS开发:短信服务SMS服务
📌 核心要点:SMS Kit不只是"发个验证码",验证码短信发送、模板管理、送达率优化、防刷策略,一套组合拳确保验证码既安全又稳定。
背景与动机
你的App需要手机号验证。发个验证码嘛,多简单的事——调用运营商的短信API,6位数字发出去,用户输入,验证通过。
然后呢?验证码被拦截了怎么办?短信发不出去怎么办?用户收不到怎么办?一分钟发了50次验证码被刷了怎么办?验证码过期了用户还在输入怎么办?
"发个验证码"这件事,背后是一整套短信服务体系。你自己对接运营商API,得处理签名、模板审核、发送频率限制、送达状态回调、费用结算……一套下来,验证码还没上线,短信服务的代码比业务代码还多。
SMS Kit就是来解决这些问题的。你不用对接运营商,不用管模板审核,不用自己搞防刷,SDK一集成,验证码发送和校验全有了。
但SMS Kit不是"调个API发短信"这么简单。送达率优化、防刷策略、模板管理——这些你搞不清楚,验证码发不出去或者被刷爆,用户体验直接崩。
核心原理
SMS Kit的架构:客户端请求验证码 → SMS服务生成验证码 → 通过运营商发送短信 → 用户输入验证码 → 客户端校验。
flowchart TD
A[用户输入手机号] --> B[客户端请求验证码]
B --> C[SMS服务端]
C --> D{校验请求}
D -->|频率超限| E[返回频率限制错误]
D -->|手机号异常| F[返回号码无效错误]
D -->|校验通过| G[生成6位验证码]
G --> H[存储验证码到缓存]
H --> I[调用运营商短信API]
I --> J{发送结果}
J -->|成功| K[返回发送成功 + VerifyId]
J -->|失败| L[返回发送失败]
K --> M[运营商下发短信]
M --> N[用户手机收到短信]
N --> O[用户输入验证码]
O --> P[客户端提交验证码 + VerifyId]
P --> Q[SMS服务端校验]
Q --> R{校验结果}
R -->|验证码正确| S[验证通过]
R -->|验证码错误| T[返回错误,剩余次数-1]
R -->|验证码过期| U[返回过期,需重新发送]
R -->|超过最大尝试次数| V[验证码失效,需重新发送]
classDef client fill:#1565C0,color:#fff,stroke:#0D47A1
classDef server fill:#E65100,color:#fff,stroke:#BF360C
classDef carrier fill:#2E7D32,color:#fff,stroke:#1B5E20
classDef result fill:#6A1B9A,color:#fff,stroke:#4A148C
classDef error fill:#C62828,color:#fff,stroke:#B71C1C
class A,B,O,P,client
class C,D,G,H,Q,server
class I,J,K,L,M,N,carrier
class S,result
class E,F,T,U,V,error
验证码生命周期
一个验证码从生成到失效,经历以下阶段:
- 生成:6位数字,有效期通常5分钟
- 发送:通过运营商下发到用户手机
- 等待输入:用户在有效期内输入
- 校验:验证码正确则通过,错误则扣减尝试次数
- 失效:超过有效期或尝试次数上限,验证码失效
短信模板
SMS Kit使用模板发送短信,不能随意编辑短信内容。模板需要提前在AGC控制台审核通过才能使用。
模板示例:
【我的应用】您的验证码为${code},${expire}分钟内有效,请勿泄露给他人。
模板中的${code}和${expire}是变量,发送时替换为实际值。
送达率优化
短信送达率受很多因素影响:手机号是否真实、运营商是否拦截、用户是否在信号覆盖区。SMS Kit提供送达状态回调,你可以根据回执统计送达率,优化发送策略。
代码实战
基础用法:发送验证码短信
最核心的操作:用户输入手机号,发送验证码,然后校验。
// SmsVerifyService.ets
import { smsService } from '@kit.SmsKit';
import { authentication } from '@kit.AuthenticationKit';
export class SmsVerifyService {
private static instance: SmsVerifyService;
private currentVerifyId: string = '';
private currentPhone: string = '';
private cooldownEndTime: number = 0; // 冷却结束时间
private constructor() {}
static getInstance(): SmsVerifyService {
if (!SmsVerifyService.instance) {
SmsVerifyService.instance = new SmsVerifyService();
}
return SmsVerifyService.instance;
}
/**
* 发送验证码
* @param phoneNumber 手机号(带国际区号,如+8613800138000)
* @param action 操作类型:登录/注册/绑定手机号
*/
async sendVerifyCode(
phoneNumber: string,
action: authentication.VerifyCodeAction = authentication.VerifyCodeAction.SIGN_IN_OR_SIGN_UP
): Promise<{ success: boolean; error?: string; cooldownSeconds?: number }> {
// 1. 检查冷却时间(60秒内不能重复发送)
const now = Date.now();
if (now < this.cooldownEndTime) {
const remainingSeconds = Math.ceil((this.cooldownEndTime - now) / 1000);
return {
success: false,
error: `请${remainingSeconds}秒后再试`,
cooldownSeconds: remainingSeconds
};
}
// 2. 校验手机号格式
if (!this.isValidPhoneNumber(phoneNumber)) {
return { success: false, error: '手机号格式不正确' };
}
try {
// 3. 调用SMS服务发送验证码
const result = await smsService.sendVerifyCode({
phoneNumber: phoneNumber,
action: action,
locale: 'zh_CN', // 短信语言
sendInterval: 60 // 发送间隔(秒)
});
// 4. 保存验证ID(校验时需要)
this.currentVerifyId = result.verifyId;
this.currentPhone = phoneNumber;
// 5. 设置冷却时间
this.cooldownEndTime = now + 60000; // 60秒冷却
console.info(`[SmsVerify] 验证码已发送: ${phoneNumber}, verifyId=${this.currentVerifyId}`);
return { success: true, cooldownSeconds: 60 };
} catch (error) {
const smsError = error as smsService.SmsError;
switch (smsError.code) {
case smsService.SmsErrorCode.RATE_LIMIT:
return { success: false, error: '发送太频繁,请稍后再试' };
case smsService.SmsErrorCode.INVALID_PHONE:
return { success: false, error: '手机号无效' };
case smsService.SmsErrorCode.TEMPLATE_ERROR:
return { success: false, error: '短信模板异常,请联系客服' };
default:
console.error(`[SmsVerify] 发送失败: ${JSON.stringify(error)}`);
return { success: false, error: '验证码发送失败,请重试' };
}
}
}
/**
* 校验验证码
*/
async verifyCode(code: string): Promise<{ success: boolean; error?: string }> {
if (!this.currentVerifyId) {
return { success: false, error: '请先发送验证码' };
}
if (!code || code.length !== 6) {
return { success: false, error: '请输入6位验证码' };
}
try {
// 使用Authentication Kit的PhoneAuthProvider校验
const authService = authentication.getService();
const credential = new authentication.PhoneAuthProvider()
.credential(this.currentPhone, code, this.currentVerifyId);
await authService.signIn(credential);
// 验证成功,清除状态
this.currentVerifyId = '';
console.info('[SmsVerify] 验证码校验成功');
return { success: true };
} catch (error) {
const authError = error as authentication.AuthError;
switch (authError.code) {
case authentication.AuthErrorCode.VERIFY_CODE_INVALID:
return { success: false, error: '验证码错误' };
case authentication.AuthErrorCode.VERIFY_CODE_EXPIRED:
this.currentVerifyId = '';
return { success: false, error: '验证码已过期,请重新发送' };
default:
console.error(`[SmsVerify] 校验失败: ${JSON.stringify(error)}`);
return { success: false, error: '验证失败,请重试' };
}
}
}
/**
* 获取当前冷却剩余秒数
*/
getCooldownRemaining(): number {
const now = Date.now();
if (now >= this.cooldownEndTime) return 0;
return Math.ceil((this.cooldownEndTime - now) / 1000);
}
/**
* 校验手机号格式
*/
private isValidPhoneNumber(phone: string): boolean {
// 简单校验:+86开头 + 11位数字
const phoneRegex = /^\+86\d{11}$/;
return phoneRegex.test(phone);
}
}
进阶用法:短信模板管理与防刷策略
验证码发送不是无限制的,必须有防刷策略。同时,不同场景用不同的短信模板。
// SmsTemplateManager.ets
import { smsService } from '@kit.SmsKit';
// 短信模板定义
interface SmsTemplate {
id: string; // 模板ID
name: string; // 模板名称
scene: string; // 使用场景
content: string; // 模板内容
variables: string[]; // 变量列表
}
// 预定义的短信模板
const SMS_TEMPLATES: Record<string, SmsTemplate> = {
// 登录/注册验证码
'verify_login': {
id: 'SMS_001',
name: '登录验证码',
scene: 'login',
content: '【我的应用】您的验证码为${code},5分钟内有效,请勿泄露给他人。',
variables: ['code']
},
// 绑定手机号验证码
'verify_bind_phone': {
id: 'SMS_002',
name: '绑定手机号验证码',
scene: 'bind_phone',
content: '【我的应用】您正在绑定手机号,验证码为${code},5分钟内有效。如非本人操作,请忽略。',
variables: ['code']
},
// 修改密码验证码
'verify_reset_password': {
id: 'SMS_003',
name: '修改密码验证码',
scene: 'reset_password',
content: '【我的应用】您正在修改密码,验证码为${code},5分钟内有效。如非本人操作,请立即修改密码。',
variables: ['code']
}
};
// 防刷策略配置
interface AntiSpamConfig {
maxSendsPerMinute: number; // 每分钟最大发送次数
maxSendsPerHour: number; // 每小时最大发送次数
maxSendsPerDay: number; // 每天最大发送次数
maxVerifyAttempts: number; // 每个验证码最大校验次数
codeExpireMinutes: number; // 验证码有效期(分钟)
ipBlacklist: string[]; // IP黑名单
phoneBlacklist: string[]; // 手机号黑名单
}
const DEFAULT_ANTI_SPAM: AntiSpamConfig = {
maxSendsPerMinute: 1, // 每分钟1次(即60秒冷却)
maxSendsPerHour: 5, // 每小时5次
maxSendsPerDay: 10, // 每天10次
maxVerifyAttempts: 5, // 最多校验5次
codeExpireMinutes: 5, // 5分钟有效
ipBlacklist: [],
phoneBlacklist: []
};
export class SmsAntiSpamService {
private config: AntiSpamConfig;
// 发送记录:key=手机号, value=时间戳数组
private sendRecords: Map<string, number[]> = new Map();
// 校验记录:key=verifyId, value=已尝试次数
private verifyAttempts: Map<string, number> = new Map();
constructor(config?: Partial<AntiSpamConfig>) {
this.config = { ...DEFAULT_ANTI_SPAM, ...config };
}
/**
* 检查是否可以发送验证码
*/
canSend(phoneNumber: string): { allowed: boolean; reason?: string; waitSeconds?: number } {
// 1. 黑名单检查
if (this.config.phoneBlacklist.includes(phoneNumber)) {
return { allowed: false, reason: '该号码已被限制' };
}
const now = Date.now();
const records = this.sendRecords.get(phoneNumber) || [];
// 2. 每分钟限制
const oneMinuteAgo = now - 60000;
const recentSends = records.filter(t => t > oneMinuteAgo);
if (recentSends.length >= this.config.maxSendsPerMinute) {
const waitSeconds = Math.ceil((recentSends[0]! + 60000 - now) / 1000);
return { allowed: false, reason: '发送太频繁', waitSeconds };
}
// 3. 每小时限制
const oneHourAgo = now - 3600000;
const hourSends = records.filter(t => t > oneHourAgo);
if (hourSends.length >= this.config.maxSendsPerHour) {
return { allowed: false, reason: '每小时发送次数已达上限' };
}
// 4. 每天限制
const oneDayAgo = now - 86400000;
const daySends = records.filter(t => t > oneDayAgo);
if (daySends.length >= this.config.maxSendsPerDay) {
return { allowed: false, reason: '今日发送次数已达上限' };
}
return { allowed: true };
}
/**
* 记录一次发送
*/
recordSend(phoneNumber: string): void {
const now = Date.now();
if (!this.sendRecords.has(phoneNumber)) {
this.sendRecords.set(phoneNumber, []);
}
this.sendRecords.get(phoneNumber)!.push(now);
// 清理过期记录(超过24小时的)
const oneDayAgo = now - 86400000;
const cleaned = this.sendRecords.get(phoneNumber)!.filter(t => t > oneDayAgo);
this.sendRecords.set(phoneNumber, cleaned);
}
/**
* 检查是否可以校验
*/
canVerify(verifyId: string): { allowed: boolean; remainingAttempts: number } {
const attempts = this.verifyAttempts.get(verifyId) || 0;
const remaining = this.config.maxVerifyAttempts - attempts;
return {
allowed: remaining > 0,
remainingAttempts: Math.max(0, remaining)
};
}
/**
* 记录一次校验尝试
*/
recordVerifyAttempt(verifyId: string): void {
const current = this.verifyAttempts.get(verifyId) || 0;
this.verifyAttempts.set(verifyId, current + 1);
}
/**
* 清除验证记录(验证成功或过期后调用)
*/
clearVerifyRecord(verifyId: string): void {
this.verifyAttempts.delete(verifyId);
}
}
完整示例:生产级验证码登录页面
把发送、校验、防刷、UI倒计时串成完整链路。
// PhoneLoginPage.ets
import { SmsVerifyService } from '../services/SmsVerifyService';
import { SmsAntiSpamService } from '../services/SmsAntiSpamService';
import { authentication } from '@kit.AuthenticationKit';
@Entry
@Component
struct PhoneLoginPage {
@State phoneNumber: string = ''; // 手机号输入
@State verifyCode: string = ''; // 验证码输入
@State countdown: number = 0; // 倒计时秒数
@State isLoading: boolean = false; // 加载状态
@State errorMessage: string = ''; // 错误提示
@State step: 'input_phone' | 'input_code' = 'input_phone'; // 当前步骤
private smsService: SmsVerifyService = SmsVerifyService.getInstance();
private antiSpam: SmsAntiSpamService = new SmsAntiSpamService();
private countdownTimer: number = -1;
aboutToDisappear() {
// 清理定时器
if (this.countdownTimer !== -1) {
clearInterval(this.countdownTimer);
}
}
/**
* 发送验证码
*/
async sendCode() {
this.errorMessage = '';
// 格式化手机号(添加+86前缀)
const formattedPhone = this.formatPhoneNumber(this.phoneNumber);
// 防刷检查
const checkResult = this.antiSpam.canSend(formattedPhone);
if (!checkResult.allowed) {
this.errorMessage = checkResult.reason || '发送受限';
if (checkResult.waitSeconds) {
this.countdown = checkResult.waitSeconds;
this.startCountdown();
}
return;
}
this.isLoading = true;
try {
const result = await this.smsService.sendVerifyCode(
formattedPhone,
authentication.VerifyCodeAction.SIGN_IN_OR_SIGN_UP
);
if (result.success) {
// 记录发送
this.antiSpam.recordSend(formattedPhone);
// 切换到验证码输入步骤
this.step = 'input_code';
// 开始倒计时
this.countdown = result.cooldownSeconds || 60;
this.startCountdown();
} else {
this.errorMessage = result.error || '发送失败';
}
} catch (error) {
this.errorMessage = '网络异常,请重试';
} finally {
this.isLoading = false;
}
}
/**
* 提交验证码
*/
async submitCode() {
this.errorMessage = '';
if (this.verifyCode.length !== 6) {
this.errorMessage = '请输入6位验证码';
return;
}
this.isLoading = true;
try {
const result = await this.smsService.verifyCode(this.verifyCode);
if (result.success) {
// 验证成功,跳转到主页
// router.replaceUrl({ url: 'pages/MainPage' });
console.info('[PhoneLogin] 登录成功');
} else {
this.errorMessage = result.error || '验证失败';
}
} catch (error) {
this.errorMessage = '网络异常,请重试';
} finally {
this.isLoading = false;
}
}
/**
* 重新发送验证码
*/
async resendCode() {
if (this.countdown > 0) return;
this.verifyCode = '';
await this.sendCode();
}
/**
* 开始倒计时
*/
private startCountdown() {
if (this.countdownTimer !== -1) {
clearInterval(this.countdownTimer);
}
this.countdownTimer = setInterval(() => {
this.countdown--;
if (this.countdown <= 0) {
this.countdown = 0;
clearInterval(this.countdownTimer);
this.countdownTimer = -1;
}
}, 1000);
}
/**
* 格式化手机号
*/
private formatPhoneNumber(phone: string): string {
// 如果没有+86前缀,自动添加
if (phone.startsWith('+86')) return phone;
return `+86${phone}`;
}
build() {
Column() {
// 标题
Text('手机号登录')
.fontSize(24)
.fontWeight(FontWeight.Bold)
.margin({ bottom: 40 })
if (this.step === 'input_phone') {
// 第一步:输入手机号
TextInput({ placeholder: '请输入手机号', text: this.phoneNumber })
.type(InputType.PhoneNumber)
.maxLength(11)
.onChange((value) => { this.phoneNumber = value })
.width('100%')
.height(48)
.margin({ bottom: 16 })
Button('获取验证码')
.width('100%')
.height(48)
.enabled(this.phoneNumber.length === 11 && !this.isLoading)
.onClick(() => this.sendCode())
} else {
// 第二步:输入验证码
Text(`验证码已发送至 ${this.phoneNumber}`)
.fontSize(14)
.fontColor(Color.Gray)
.margin({ bottom: 24 })
TextInput({ placeholder: '请输入6位验证码', text: this.verifyCode })
.type(InputType.Number)
.maxLength(6)
.onChange((value) => { this.verifyCode = value })
.width('100%')
.height(48)
.margin({ bottom: 16 })
Button('登录')
.width('100%')
.height(48)
.enabled(this.verifyCode.length === 6 && !this.isLoading)
.onClick(() => this.submitCode())
// 重新发送
Row() {
if (this.countdown > 0) {
Text(`${this.countdown}秒后可重新发送`)
.fontSize(14)
.fontColor(Color.Gray)
} else {
Text('重新发送')
.fontSize(14)
.fontColor('#1976D2')
.onClick(() => this.resendCode())
}
}
.margin({ top: 16 })
// 返回修改手机号
Text('更换手机号')
.fontSize(14)
.fontColor('#1976D2')
.margin({ top: 12 })
.onClick(() => {
this.step = 'input_phone';
this.verifyCode = '';
this.errorMessage = '';
})
}
// 错误提示
if (this.errorMessage) {
Text(this.errorMessage)
.fontSize(14)
.fontColor(Color.Red)
.margin({ top: 16 })
}
// 加载指示器
if (this.isLoading) {
LoadingProgress()
.width(32)
.height(32)
.margin({ top: 16 })
}
}
.padding(24)
.width('100%')
.height('100%')
.justifyContent(FlexAlign.Center)
}
}
踩坑与注意事项
坑1:手机号必须带国际区号
SMS Kit要求手机号带国际区号,如+8613800138000。如果你只传了13800138000,发送失败。而且错误信息可能不会明确告诉你"缺少区号",而是返回一个通用的"参数错误"。
建议:在UI层就引导用户选择国家/地区,自动添加区号前缀。
坑2:短信模板需要审核
短信模板不是你写什么就发什么。模板必须在AGC控制台提交审核,审核通过后才能使用。审核通常需要1-2个工作日。
建议:提前准备好所有可能用到的模板,一次性提交审核。别等上线了才发现模板还没审核通过。
坑3:验证码不要存客户端
验证码的校验必须在服务端完成。如果你在客户端校验验证码,等于把验证码的逻辑暴露给了攻击者,安全形同虚设。
SMS Kit的验证码校验是通过Authentication Kit的PhoneAuthProvider完成的,校验逻辑在服务端,客户端只传验证码和VerifyId。
坑4:验证码有效期别设太短
5分钟是常见的有效期。如果你设了1分钟,用户在信号不好的地方,短信延迟30秒才到,用户只剩30秒输入——体验很差。
但也别设太长。30分钟的验证码,安全性大打折扣。5分钟是平衡安全性和体验的最佳值。
坑5:短信签名必须和模板一致
短信签名(如【我的应用】)必须和模板中的一致,而且签名本身也需要审核。你不能在模板里写【我的应用】,实际发送时改成【其他应用】。
坑6:海外手机号的送达率
海外手机号的短信送达率明显低于国内。部分国家/地区的运营商对验证码短信有拦截策略。如果你的App有海外用户,建议同时提供邮箱验证作为备选方案。
HarmonyOS 6适配说明
HarmonyOS 6对SMS Kit做了以下更新:
- 短信自动填充:HarmonyOS 6新增了验证码自动填充能力。用户收到验证码短信后,系统自动提取验证码并填充到输入框,用户不需要手动输入。
// HarmonyOS 6验证码自动填充
import { smsService } from '@kit.SmsKit';
// 监听验证码自动填充
smsService.onAutoFill((code: string) => {
this.verifyCode = code; // 自动填充到输入框
});
-
语音验证码:新增语音验证码能力。当短信送达失败时,可以自动切换为语音验证码——系统拨打用户电话,语音播报验证码。送达率从95%提升到99%。
-
智能风控:SMS Kit新增了智能风控模块,自动识别异常发送行为(如同一IP大量请求、虚拟号码等),无需你手动配置防刷规则。
-
多语言模板:短信模板支持多语言版本。同一模板可以有中文、英文、日文等版本,系统根据用户手机的语言设置自动选择。
-
送达回执增强:新增详细的送达状态回执,包括"已提交运营商"、“已送达”、“已读”(部分运营商支持)等状态,送达率统计更精确。
总结
SMS Kit让你不用对接运营商就能发验证码,但"发出去"只是第一步。送达率、防刷、模板管理、用户体验——每个环节都影响最终效果。
核心记住三点:
- 手机号必须带国际区号,忘了就发送失败
- 防刷策略不能少,没有防刷,验证码接口分分钟被刷爆
- 短信模板提前审核,别等上线了才发现模板没过审
| 评估维度 | 说明 |
|---|---|
| 学习难度 | ⭐⭐ 发送验证码简单,防刷策略和送达率优化需要经验 |
| 使用频率 | ⭐⭐⭐⭐⭐ 手机号验证是几乎所有App的标配 |
| 重要程度 | ⭐⭐⭐⭐⭐ 没有短信验证,手机号登录就是空谈 |
SMS Kit不是可选项。你自己对接运营商发短信,成本高、送达率低、防刷难做——何必呢?
- 点赞
- 收藏
- 关注作者
评论(0)