羽球搭子 HarmonyOS 实战(20):邀请口令的解析、去重与过期处理
一、同一枚邀请码可能从四个入口进入
群聊里的一段分享文案、浏览器打开的 App Link、系统传入的 Want 参数、用户手动粘贴的剪贴板文本,都可能指向同一场羽毛球对局。若每个入口各写一套正则和跳转逻辑,相同口令会出现不同大小写、不同过期判断,甚至触发两次加入请求。
羽球搭子把这条链路分成三段:解析服务只负责得到规范口令;Ability 负责接收外部入口并暂存目标;云端协作页面负责登录检查、重复判断和真正的加入请求。解析失败不跳页,重复口令不再次加入,过期信息在进入网络层之前被拒绝。

这样做的重点不是“能识别一个字符串”,而是让不同入口共享同一份协议语义。页面可以清楚区分无效口令、已经加入、等待登录和正在加入四种状态。
二、先定义小而稳定的分享协议
分享对象除了邀请码,还包含协议版本、对局名称、网页链接、应用链接、创建时间和过期时间。当前服务端可能自行控制有效期,因此 expiresAt = 0 表示由服务端判定;当分享载荷提供大于零的时间时,客户端可以提前拒绝明显过期内容。
interface InvitePayload {
version: number
inviteCode: string
sessionName: string
joinUrl: string
appLink: string
createdAt: number
expiresAt: number
}
function buildInvite(
rawCode: string,
sessionName: string,
joinUrl: string,
createdAt: number = Date.now()
): InvitePayload | undefined {
const inviteCode = normalizeCode(rawCode)
if (!isValidCode(inviteCode)) {
return undefined
}
return {
version: 1,
inviteCode,
sessionName: sessionName.trim(),
joinUrl: joinUrl.trim(),
appLink: buildAppLink(inviteCode, createdAt, 0),
createdAt,
expiresAt: 0
}
}
协议版本字段给未来升级留出空间。例如新增签名、房间类型或短期有效期时,可以在解析层按版本分支,而不是靠文案猜测。应用链接承载机器可读参数,分享正文负责让用户理解下一步。
| 字段 | 是否必需 | 客户端用途 | 无效时处理 |
|---|---|---|---|
| inviteCode | 是 | 加入对局的唯一输入 | 直接拒绝 |
| version | 是 | 选择解析规则 | 不支持则提示升级 |
| createdAt | 否 | 展示与诊断 | 缺失时使用当前时间 |
| expiresAt | 否 | 本地提前判断过期 | 0 交给服务端 |
| sessionName | 否 | 分享文案展示 | 允许为空 |
| appLink | 否 | 直接唤起应用 | 回退到网页或手输 |
三、归一化要早于长度校验
用户可能粘贴 ab-12 cd 、全小写口令或带换行的文本。解析层先去空白、转大写,只保留 A-Z 和 0-9,再检查长度。这样网络层永远接收同一种格式,也避免页面和服务端对分隔符理解不一致。
function normalizeCode(value: string): string {
const source = value.trim().toUpperCase()
let result = ''
for (let index = 0; index < source.length; index++) {
const ch = source.charAt(index)
const isLetter = ch >= 'A' && ch <= 'Z'
const isDigit = ch >= '0' && ch <= '9'
if (isLetter || isDigit) {
result += ch
}
}
return result
}
function isValidCode(value: string): boolean {
return value.length >= 4 && value.length <= 12
}
归一化不应该吞掉所有错误。例如一个很长的普通聊天段落若被全部压缩成字母数字,可能意外满足长度条件。因此直接口令只在原字符串与归一化结果长度一致时接受;复杂文本则必须命中明确的“Invite code”或“邀请码”标记。
四、解析优先级避免来源互相覆盖
外部入口同时提供 URI、显式参数和分享正文时,需要固定优先级。显式 inviteCode 参数最可靠,其次是 URI 路径或查询参数,再其次是结构化分享文本,最后才从普通文本标记中提取。前一层得到有效值后立即返回。
function extractInviteCode(
rawUri: string,
parameterCode: string,
sharedText: string
): string {
const direct = normalizeCode(parameterCode)
if (isValidCode(direct)) {
return direct
}
const fromUri = extractFromUri(rawUri)
if (isValidCode(fromUri)) {
return fromUri
}
const parsed = parseShareText(sharedText)
if (parsed !== undefined) {
return parsed.inviteCode
}
const fromText = extractMarkedText(sharedText)
return isValidCode(fromText) ? fromText : ''
}
固定优先级还有一个安全收益:攻击者不能在分享正文里塞入另一枚口令,覆盖已经由可信路由参数传入的值。解析器只返回口令,不直接发网络请求,便于用纯输入输出方式测试。
五、过期判断必须支持“服务端控制”
过期时间来自分享文本或 App Link 查询参数。解析器将秒、毫秒或字符串统一读成时间戳,并在构造结果前执行有效期判断。值为 0 时表示客户端无法判断,由加入接口给出最终结果;大于 0 且不晚于当前时间时直接拒绝。
function isValidNow(expiresAt: number, now: number = Date.now()): boolean {
return expiresAt <= 0 || expiresAt > now
}
function parseCandidate(text: string): InvitePayload | undefined {
const fields = readInviteFields(text)
const inviteCode = normalizeCode(fields.inviteCode)
if (!isValidCode(inviteCode)) {
return undefined
}
if (!isValidNow(fields.expiresAt)) {
return undefined
}
return {
version: fields.version || 1,
inviteCode,
sessionName: fields.sessionName,
joinUrl: fields.joinUrl || defaultJoinUrl(inviteCode),
appLink: fields.appLink || buildAppLink(inviteCode, fields.createdAt, fields.expiresAt),
createdAt: fields.createdAt,
expiresAt: fields.expiresAt
}
}
客户端时间可能被用户修改,所以本地过期判断只是快速失败,不是安全判定。服务端仍需检查邀请码状态、房主轮换、成员权限和真实 TTL。
六、重复处理分三层拦截
去重不能只靠禁用按钮。剪贴板轮询可能重复读取同一口令,App Link 可能在 onCreate 和 onNewWant 间连续到达,用户也可能在请求未结束时再次点击。页面分别保存“上次处理的剪贴板口令”“当前活动对局的邀请码”和 cloudBusy。
async function handleInvite(code: string, manual: boolean): Promise<void> {
if (cloudBusy) {
return
}
if (!manual && code === lastClipboardCode) {
return
}
lastClipboardCode = code
const active = activeCloudSession()
if (active?.inviteCode.toUpperCase() === code) {
showMessage('当前账号已加入该对局')
return
}
if (!AuthSessionStore.isSignedIn()) {
pendingInviteCode = code
showMessage('连接云端账号后可加入')
return
}
await joinOnce(code)
}
服务端还应以“账号 + 对局”建立唯一成员约束。客户端拦截改善体验,服务端约束才负责最终幂等。两者缺一不可。
七、Ability 只暂存目标,不承担加入逻辑
Ability 接到外部 Want 后解析邀请码,写入一个短生命周期的 AppStorage 字段,并把目标标签设为邀请页。内容加载完成后才执行路由;页面读取并立即清空待处理口令,防止下次进入重复消费。
| 生命周期节点 | 责任 | 不应该做的事 |
|---|---|---|
| onCreate | 初始化存储、捕获首次 Want | 直接请求加入接口 |
| onNewWant | 捕获新的链接或分享 | 假设页面已经加载 |
| 页面加载完成 | 应用待处理目标并导航 | 重复解析分享正文 |
| 协作页面出现 | 读取口令、检查登录和去重 | 修改 Ability 生命周期状态 |
这条边界让冷启动和热启动使用相同语义。即使页面加载较慢,邀请码仍保留在运行期状态中;如果根本没有解析到有效值,应用保持原页面,不制造一次空跳转。
八、用输入矩阵覆盖边界
验证应至少覆盖:显式参数、/join/{code} 路径、inviteCode= 查询参数、中英文分享标记、纯口令、已过期链接、过短口令、同一剪贴板连续读取、已加入对局和未登录状态。对每个用例同时检查解析结果与页面行为。
推荐做一次热启动测试:应用已在邀请页时连续打开同一链接,页面只提示已经加入,不产生第二个网络请求。再轮换邀请码,旧口令由服务端拒绝,新口令成功加入,客户端错误提示保持可理解。
App Linking 的 URI 接收、Want 参数和生命周期行为应以 HarmonyOS App Linking 官方指南 为准。路由配置和目标 SDK 有差异时,应按真实设备入口复核。
九、总结
邀请口令是一条跨越分享协议、Ability 生命周期、页面状态和云端成员关系的输入链。解析服务统一来源与格式,过期判断提供快速失败,页面用忙碌态、最近口令和当前会话拦截重复,服务端再用唯一约束保证最终幂等。
把“解析”和“加入”拆开之后,链接、剪贴板和手工输入不再各自维护一套规则。用户看到的是一次明确的加入动作;工程内部得到的是可测试、可扩展、不会因连续触发而重复创建关系的处理链路。
更多推荐



所有评论(0)