feat: 修改为4/3比例的图
This commit is contained in:
parent
c8501c9895
commit
e29a718b2f
@ -126,14 +126,18 @@ register.vue setNickname.vue
|
||||
```
|
||||
|
||||
**优点:**
|
||||
- 阿里云 credentials 配置原本就在 gateway(OSS 用的是同一套),直接复用
|
||||
- 网关已有阿里云 AccessKey 配置,可直接用于 SMS(短信使用直接 AccessKey,非 STS)
|
||||
- 前端请求在网关层就能触发短信,不用穿透到后端服务
|
||||
- 网关是单点,处理短信很方便
|
||||
|
||||
**缺点:**
|
||||
- 短信发送成功后的后续注册流程还在 userService,逻辑割裂
|
||||
- 网关承担了越来越多的职责(路由、认证、OSS上传、现在又加短信),越来越重
|
||||
- 如果 SMS 认证配置要从 gateway 复用,需要确保 userService 能访问到同一份配置,增加了部署耦合
|
||||
- 网关承担了越来越多的职责(路由、认证、OSS上传签名、现在又加短信),越来越重
|
||||
- 网关当前 OSS 相关接口:
|
||||
- `GET /oss/signature` - 获取 OSS 上传签名
|
||||
- `GET /oss/presigned-url` - 获取 OSS 预签名URL(读取用)
|
||||
- `GET /oss/batch-presigned-urls` - 批量获取 OSS 预签名URL
|
||||
- SMS 和 OSS 认证方式不同(SMS 用直接 AccessKey,OSS 用 STS Role ARN),实际上并不能完全复用同一套配置
|
||||
|
||||
**适用场景**:适合短信作为独立操作、不属于用户注册主流程的场景。
|
||||
|
||||
@ -155,9 +159,9 @@ register.vue setNickname.vue
|
||||
|
||||
| 项目 | 选择 |
|
||||
|------|------|
|
||||
| 短信 SDK | `github.com/alibabacloud-go/dysmsapi-20180501/v2`(阿里官方 V2 Go SDK) |
|
||||
| 短信 SDK | `github.com/alibabacloud-go/dysmsapi-20180501/v2/client`(阿里官方 V2 Go SDK) |
|
||||
| 环境要求 | Go 1.10.x 或更高 |
|
||||
| 安装方式 | `go get github.com/alibabacloud-go/dysmsapi-20180501/v2` |
|
||||
| 安装方式 | `go get github.com/alibabacloud-go/dysmsapi-20180501/v2/client` |
|
||||
| 依赖包 | 还需 `github.com/alibabacloud-go/darabonba-openapi/v2/client` |
|
||||
| 认证方式 | 阿里云默认凭据链(AccessKey 等)自动查找 |
|
||||
| API Endpoint | `dysmsapi.aliyuncs.com` |
|
||||
@ -216,13 +220,70 @@ Value (Hash):
|
||||
TTL: 60 秒
|
||||
```
|
||||
|
||||
### 7.3 防暴力破解策略
|
||||
### 7.3 防暴力破解与限流策略
|
||||
|
||||
- **失败计数**:每次验证失败递增 attempts 字段,≥ 3 次后删除 Key,要求用户重新获取
|
||||
- **发送频率限制**:每小时最多发送 10 次,超限返回 429
|
||||
- 使用独立 Key `sms:limit:register:{mobile}` 计数(String 类型,TTL=3600 秒)
|
||||
- **IP 维度限流**(可选):每 IP 每小时最多请求 30 次
|
||||
- 使用独立 Key `sms:limit:ip:{ip}` 计数(String 类型,TTL=3600 秒)
|
||||
#### 限制规则总览
|
||||
|
||||
| 限制维度 | 限制规则 | TTL | 触发动作 |
|
||||
|---------|---------|-----|---------|
|
||||
| 同一手机号(发送) | 60 秒内不能重复发送 | 60s | 返回 429 |
|
||||
| 同一手机号(发送) | 每小时最多 10 次 | 3600s | 返回 429 |
|
||||
| 同一手机号(验证) | 最多失败 3 次 | 60s | 删除验证码,要求重新获取 |
|
||||
| 同一 IP(发送) | 每小时最多 30 次 | 3600s | 返回 429 |
|
||||
| 同一 IP(验证) | 每分钟最多 10 次 | 60s | 返回 429 |
|
||||
| 验证码有效期 | 60 秒 | 60s | 过期自动失效 |
|
||||
|
||||
#### Redis Key 设计
|
||||
|
||||
```
|
||||
# 验证码存储(Hash)
|
||||
sms:register:{mobile}
|
||||
code: "123456"
|
||||
created_at: "1700000000"
|
||||
attempts: "0"
|
||||
TTL: 60秒
|
||||
|
||||
# 手机号发送频率(String,每小时清一次)
|
||||
sms:limit:mobile:register:{mobile}
|
||||
value: "1" (计数器,每次发送 INCR)
|
||||
TTL: 3600秒
|
||||
|
||||
# IP 发送频率(String,每小时清一次)
|
||||
sms:limit:ip:send:{ip}
|
||||
value: "1"
|
||||
TTL: 3600秒
|
||||
|
||||
# IP 验证频率(String,每分钟清一次)
|
||||
sms:limit:ip:verify:{ip}
|
||||
value: "1"
|
||||
TTL: 60秒
|
||||
```
|
||||
|
||||
#### 防御措施详情
|
||||
|
||||
**1. 发送频率限制**
|
||||
- 60 秒内同一手机号只能发送 1 次(防止轰炸)
|
||||
- 每小时同一手机号最多发送 10 次(结合套餐包成本控制)
|
||||
- 每小时同一 IP 最多发送 30 次(防止 IP 轮询攻击)
|
||||
|
||||
**2. 验证失败限制**
|
||||
- 验证码错误后递增 attempts 计数器
|
||||
- 失败 3 次后强制删除验证码,要求用户重新获取
|
||||
- 60 秒内同一 IP 最多发起 10 次验证请求
|
||||
|
||||
**3. IP 黑名单机制**
|
||||
- 触发限流时记录来源 IP
|
||||
- 1 小时内触发 3 次限流的 IP,临时拉黑 30 分钟
|
||||
- 黑名单 Key: `sms:blacklist:ip:{ip}`,TTL=1800 秒
|
||||
|
||||
**4. 验证码安全**
|
||||
- 验证码为 6 位纯数字,共 100 万种组合
|
||||
- 有效期 60 秒,暴力破解窗口极小
|
||||
- 验证成功后立即删除,防止重放
|
||||
|
||||
**5. 异常检测**
|
||||
- 同一手机号在 5 分钟内连续失败 5 次,触发告警(可考虑临时冻结)
|
||||
- 同一 IP 在 5 分钟内请求超过 50 次,触发告警
|
||||
|
||||
### 7.4 验证后处理
|
||||
|
||||
@ -243,10 +304,13 @@ POST /api/v1/auth/send-code
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"mobile": "13800138000"
|
||||
"mobile": "13800138000",
|
||||
"scene": "register"
|
||||
}
|
||||
```
|
||||
|
||||
`scene` 可选值:`register`(注册)、`password`(找回密码)。
|
||||
|
||||
**响应**
|
||||
```json
|
||||
{
|
||||
@ -267,7 +331,8 @@ Content-Type: application/json
|
||||
|
||||
{
|
||||
"mobile": "13800138000",
|
||||
"code": "123456"
|
||||
"code": "123456",
|
||||
"scene": "register"
|
||||
}
|
||||
```
|
||||
|
||||
@ -325,7 +390,9 @@ Content-Type: application/json
|
||||
| 验证码已使用 | 400 | 提示"验证码已使用,请重新获取" |
|
||||
| 发送频率超限(60秒内重复发送) | 429 | 提示"发送过于频繁,请稍后再试" |
|
||||
| 每小时发送次数超限(>10次) | 429 | 提示"当前手机号发送次数超限,请稍后再试" |
|
||||
| IP 请求频率超限 | 429 | 提示"请求过于频繁,请稍后再试" |
|
||||
| IP 发送频率超限(>30次/小时) | 429 | 提示"请求过于频繁,请稍后再试" |
|
||||
| IP 验证频率超限(>10次/分钟) | 429 | 提示"请求过于频繁,请稍后再试" |
|
||||
| IP 被临时拉黑 | 403 | 提示"暂时无法操作,请稍后再试" |
|
||||
|
||||
---
|
||||
|
||||
@ -353,6 +420,8 @@ SMS_REGION=cn-hangzhou
|
||||
2. **verify_token 安全**:token 有效期 5 分钟,只能使用一次,验证后立即删除
|
||||
3. **防暴力破解**:验证失败 3 次后强制删除验证码,要求用户重新获取
|
||||
4. **限流保护**:从手机号和 IP 两个维度限制请求频率
|
||||
5. **IP 黑名单**:1 小时内触发 3 次限流的 IP,临时拉黑 30 分钟
|
||||
6. **异常告警**:同一手机号 5 分钟内连续失败 5 次,或同一 IP 5 分钟内请求超 50 次,触发告警
|
||||
|
||||
---
|
||||
|
||||
@ -371,24 +440,202 @@ SMS_REGION=cn-hangzhou
|
||||
|
||||
> 💡 **推荐**:5,000 条或 15,000 条套餐性价比最高,适合中等规模业务使用。
|
||||
|
||||
### 12.2 短信模板类型
|
||||
### 12.2 短信模板规范
|
||||
|
||||
#### 模板类型
|
||||
|
||||
| 模板类型 | 说明 | 审核时间 |
|
||||
|----------|------|----------|
|
||||
| 验证码模板 | 仅含变量(如 ${code}),用于发送随机验证码 | 通常 2 小时内 |
|
||||
| **验证码模板** | 仅含变量(如 ${code}),用于发送随机验证码 | 通常 2 小时内 |
|
||||
| 通知模板 | 含固定文本 + 少量变量 | 通常 2 小时内 |
|
||||
| 推广模板 | 营销类内容,审核更严格 | 通常 4 小时以上 |
|
||||
|
||||
**验证码模板示例:**
|
||||
```
|
||||
您的注册验证码是${code},5分钟内有效。如非本人操作,请忽略此短信。
|
||||
```
|
||||
#### 内容规范
|
||||
|
||||
### 12.3 签名
|
||||
**必须包含:**
|
||||
- 必须包含"**验证码、注册码、校验码、动态码**"之一
|
||||
- 必须体现"**使用平台、用途、失效时间**"之一
|
||||
|
||||
- 签名是短信发送者的标识,位于短信内容开头
|
||||
- 一个账号可创建多个签名,需审核通过后才能使用
|
||||
- 签名名称示例:`TopFans`、`TOPFANS官方`
|
||||
**禁止包含:**
|
||||
- 不能含有通知、营销、广告词语、退订方式
|
||||
- 结尾不能包含"拒收请回复R"
|
||||
- **不能含任何联系方式**:手机/固话/QQ/微信/抖音/钉钉/旺旺/闲鱼/小红书/邮箱等
|
||||
- 用途为"他用"的签名不支持申请金融相关模板
|
||||
|
||||
#### 格式规范
|
||||
|
||||
| 项目 | 规范 |
|
||||
|------|------|
|
||||
| **模板长度** | 1~500 个字符(含变量) |
|
||||
| **支持字符** | 中文、英文、数字、符号 |
|
||||
| **不支持** | 繁体字、特殊符号(如 `#`、`『』、『「」、「」、〖〗`、`m²`、`•`、`①`、`★`、`※`、`→` 等) |
|
||||
| **禁止** | 错别字、变体字、异体字、各类干扰符号 |
|
||||
| **模板内容中禁用** | `【】`符号(任意位置),首尾禁用`[ ]` |
|
||||
|
||||
#### 变量规范
|
||||
|
||||
| 变量类型 | 变量名示例 | 变量规范 |
|
||||
|---------|-----------|---------|
|
||||
| **单变量(仅数字)** | `${code}`、`${time}` | 长度限制 4~6 位 |
|
||||
| **单变量(数字+字母)** | `${code}` | 长度限制 4~6 位 |
|
||||
| **双变量** | `${code}`、`${time}` | 仅支持通过控制台"常用模板推荐"申请,不支持自定义 |
|
||||
|
||||
**变量格式要求:**
|
||||
- 变量格式为 `${变量名}`,其中 `$` 符号在 `{ }` 外面
|
||||
- 变量名首字母必须为英文字母
|
||||
- 变量名只能由字母、数字和下划线组成
|
||||
- **变量名不能为**:email、mobile、id、nick、site、ip 等
|
||||
|
||||
#### 验证码模板示例
|
||||
|
||||
| 应用场景 | 模板内容 |
|
||||
|---------|---------|
|
||||
| 登录 | 您的验证码`${code}`,该验证码5分钟内有效,请勿泄露给他人! |
|
||||
| 登录 | 验证码为:`${code}`,您正在登录,若非本人操作,请勿泄露。 |
|
||||
| 注册 | 您正在申请手机注册,验证码为:`${code}`,5分钟内有效! |
|
||||
| 注册 | 尊敬的用户,您的注册会员动态密码为:`${code}`,请勿泄露于他人! |
|
||||
| 注册 | 您的注册码:`${code}`,如非本人操作,请忽略本短信! |
|
||||
| 重置密码 | 您的动态码为:`${code}`,您正在进行密码重置操作,如非本人操作,请忽略本短信! |
|
||||
|
||||
#### 审核时长
|
||||
|
||||
- 预计 **2 个小时** 内审核完成
|
||||
- 工作时间:周一至周日 9:00~21:00(法定节假日顺延)
|
||||
|
||||
#### 常见审核失败原因
|
||||
|
||||
| 类别 | 驳回原因 | 处理建议 |
|
||||
|------|---------|---------|
|
||||
| 内容模糊 | 模板没有体现业务内容,含义不明 | 使用实际业务内容 |
|
||||
| 场景不详 | 模板使用场景不详 | 在场景说明中填写业务场景或线上链接 |
|
||||
| 含退订方式 | 验证码模板包含退订方式 | 删除退订方式后重新提交 |
|
||||
| 多变量 | 验证码模板包含多变量 | 去掉多余变量,仅支持单变量或双变量 |
|
||||
| 关键词不全 | 缺少"验证码/注册码/校验码/动态码"之一 | 添加验证码关键词之一 |
|
||||
| 变量格式错误 | 变量格式不符合规范 | 变量格式为 `${code}`,首字母必须为英文字母 |
|
||||
| 模板类型错误 | 模板类型选择与实际需求不匹配 | 根据场景选择验证码/通知/推广模板 |
|
||||
|
||||
##### 模板审核详细失败原因
|
||||
|
||||
| 类别 | 驳回原因 | 处理建议 |
|
||||
|------|---------|---------|
|
||||
| 内容模糊 | 模板没有体现业务内容,含义不明 | 使用实际业务内容进行测试 |
|
||||
| 内容模糊 | 模板使用场景不详 | 填写业务场景或线上链接,提供测试账号密码 |
|
||||
| 内容模糊 | 应用市场中未核实到App/公众号/小程序 | 填写正确链接,若产品未上线暂不支持申请 |
|
||||
| 内容模糊 | 业务内容不明确(如"加微信送礼") | 不支持加群内容,删除后重新提交 |
|
||||
| 格式问题 | 推广短信模板包含变量 | 将变量内容体现在模板文案中 |
|
||||
| 格式问题 | 推广短信没包含退订方式 | 加上 `拒收请回复R` |
|
||||
| 格式问题 | 验证码/通知模板包含退订方式 | 删除退订方式后重新提交 |
|
||||
| 格式问题 | 验证码模板包含多变量 | 去掉多余变量,仅支持单变量或双变量 |
|
||||
| 格式问题 | 验证码模板包含无关内容 | 删除与验证码无关的内容 |
|
||||
| 格式问题 | 验证码关键词不全 | 必须含验证码/注册码/校验码/动态码之一 |
|
||||
| 格式问题 | 通知模板包含推广内容 | 删除推广部分或申请推广模板 |
|
||||
| 格式问题 | 含错别字/繁体字/特殊符号/中括号 | 修改为正确内容,【】符号不能在模板内容中使用 |
|
||||
| 内容问题 | 内容涉及金融/交友/宗教/游戏 | 交友/游戏仅支持验证码模板;他用签名不支持金融相关 |
|
||||
| 内容问题 | 内容涉及第三方 | 需提供第三方企业证件和授权委托书 |
|
||||
| 内容问题 | 内容涉及营销信息 | 提供隐私协议截图、会员管理系统截图,备注投诉对接人 |
|
||||
| 变量问题 | 变量内传入链接/IP地址/App等 | 变量外使用链接,固定链接用一级域名+变量格式 |
|
||||
| 变量问题 | 变量内容模糊 | 修改表达以清晰判断参数内容 |
|
||||
| 变量问题 | 变量格式错误 | 变量格式`${code}`,首字母必须为英文字母,不能为email/mobile/id/nick/site/ip |
|
||||
| 变量问题 | 变量属性选择错误 | 根据业务场景选择合适的变量属性 |
|
||||
| 链接问题 | 链接无ICP备案 | 提供已ICP备案的网址链接 |
|
||||
| 链接问题 | 短链+变量格式不符合规范 | 改为一级的域名或官网链接+变量组合 |
|
||||
| 链接问题 | 链接存在跳转 | 将原链接压缩成短链 |
|
||||
| 链接问题 | 链接无法访问 | 提供公网可访问的链接 |
|
||||
| SEO推广 | 涉及数据排名/关键字搜索/精准拓客引流 | 修改模板内容 |
|
||||
|
||||
### 12.3 签名规范
|
||||
|
||||
#### 什么是短信签名
|
||||
|
||||
短信签名是短信发送方属性的一种标识,一条完整的短信由**短信签名**和**短信内容**组成。短信签名位于短信内容前的`【】`中,用于标识企业、产品或业务。
|
||||
|
||||
**示例**:`【阿里云】您的验证码是123456` - 签名`阿里云`会自动补全`【】`
|
||||
|
||||
#### 签名来源要求
|
||||
|
||||
| 签名来源 | 说明 |
|
||||
|---------|------|
|
||||
| **企事业单位名**(推荐) | 企业名称的全称或简称,极大提升签名报备成功率 |
|
||||
| 已注册商标名 | 需在国家知识产权局商标局可查且注册主体一致 |
|
||||
|
||||
> ⚠️ **不支持**:公众号、小程序、电商平台店铺名、已上线APP、测试/学习用途作为签名来源
|
||||
|
||||
#### 签名内容规范
|
||||
|
||||
| 项目 | 规范 |
|
||||
|------|------|
|
||||
| **签名内容** | 需能明确辨别发送方公司信息、产品或业务 |
|
||||
| **支持** | 企事业单位名、已注册商标名 |
|
||||
| **不支持** | 含义模糊的中性签名(如"客服通知"、"客户您好"、"温馨提示") |
|
||||
| **不支持** | 个人姓名 |
|
||||
| **不支持** | 含"测试"、"test"等字样的签名 |
|
||||
| **不支持** | 全英文签名、全数字签名、英文+数字组合 |
|
||||
| **不支持** | 特殊符号(含空格)、繁体字 |
|
||||
| **格式** | 申请时直接填写签名名称,无需添加【】等符号,系统会自动添加 |
|
||||
|
||||
#### 签名长度限制
|
||||
|
||||
- 长度:**2~12个字符**
|
||||
- 中文、英文、数字按1个字符计算
|
||||
|
||||
#### 签名用途
|
||||
|
||||
| 用途 | 说明 |
|
||||
|------|------|
|
||||
| **自用** | 资质对应的企业/个人信息与阿里云账号信息一致 |
|
||||
| **他用** | 需上传委托授权书,第三方仅支持企业及事业单位,不得为个人 |
|
||||
|
||||
> ⚠️ **个人认证用户**:自用签名**无法通过**签名实名制报备,请申请他用资质或升级为企业认证账号
|
||||
|
||||
#### 签名可申请次数
|
||||
|
||||
| 账户类型 | 可申请次数 |
|
||||
|---------|-----------|
|
||||
| 个人认证用户 | 同阿里云账号一个自然日内支持申请 **1个** 签名 |
|
||||
| 企业认证用户 | **无数量限制** |
|
||||
|
||||
#### 签名审核时长
|
||||
|
||||
- 预计 **2个小时内** 审核完成
|
||||
- 工作时间:周一至周日 9:00~21:00(法定节假日顺延)
|
||||
- 运营商实名报备:**5-10个工作日**(可能更长)
|
||||
|
||||
#### 签名状态异常原因
|
||||
|
||||
| 状态 | 异常原因 |
|
||||
|------|---------|
|
||||
| 可用-异常 | 签名来源不合规、未关联资质、资质信息不全、实名制报备异常、长期未使用等 |
|
||||
| 不可用 | 审批未通过、被禁用、被冻结 |
|
||||
|
||||
##### 签名审核详细失败原因
|
||||
|
||||
| 类别 | 驳回原因 | 处理建议 |
|
||||
|------|---------|---------|
|
||||
| 内容模糊 | App/业务内容较少,无法核实业务场景 | 完善线上业务信息后再提交,不支持未上线产品 |
|
||||
| 内容模糊 | 签名过于宽泛,如"客服通知"、"客户您好" | 申请与已上线应用名称或企事业单位名称作为签名 |
|
||||
| 内容模糊 | 已提供信息,因无关联性被驳回 | 提供关联后台认证截图,备注关联性 |
|
||||
| 内容模糊 | 未核实到商标信息 | 在场景说明中提供商标注册号及完整商标名 |
|
||||
| 链接问题 | 链接无法访问 | 提供正确或公网可访问的链接 |
|
||||
| 链接问题 | 链接与签名无关联 | 申请企业名称作为签名 |
|
||||
| 链接问题 | 下载/内测链接无法核实业务主体 | 提供应用商城链接以便核实 |
|
||||
| 链接问题 | IP地址无法核实企业所属性 | 提供App或其他链接以便核实 |
|
||||
| 授权书 | 授权书未签字 | 在授权书右下角落款处让法定代表人或负责人签字 |
|
||||
| 授权书 | 授权书盖章错误 | 盖授权方(签名归属方)的企事业单位公章或合同章 |
|
||||
| 授权书 | 授权书无日期/有效期短/过期 | 填写完整有效期限,建议1~3年 |
|
||||
| 授权书 | 授权书内容变更 | 委托授权书经法务评估后出具,不支持变更内容 |
|
||||
| 授权书 | 被授权方/授权方/风险承担方填写错误 | 被授权方为账号认证主体名称,授权方和风险承担方为短信内容所属方 |
|
||||
| 资质材料 | 涉及政企业务但材料未提供完全 | 需提供授权委托书和组织机构代码证,场景说明备注固话 |
|
||||
| 资质材料 | 营业执照水印非平台使用 | 去除水印或改为"仅供云通信备案使用" |
|
||||
| 资质材料 | 证件无法查看 | 支持JPG、PNG、GIF、JPEG格式,每张不大于2MB |
|
||||
| 格式问题 | 签名名称包含"测试"字样 | 删除测试字样后重新提交 |
|
||||
| 格式问题 | 签名字数不符合要求 | 限制在2~12个字符内 |
|
||||
| 格式问题 | 签名带符号/繁体字/首字母拼写 | 国内短信不支持全英文、全数字、繁体字、特殊符号签名 |
|
||||
| 格式问题 | 签名是个人姓名 | 签名不能为个人姓名 |
|
||||
| 格式问题 | 签名是小程序或公众号 | 不支持申请 |
|
||||
|
||||
#### 签名申请受限内容
|
||||
|
||||
禁止包含:违法违规、色情、赌博、毒品、党政、博彩、彩票、暴力、恐吓、走私、成人用品、虚拟货币、烟草酒类、代开发票、代办证件、刷单、贷款催款、法律维权、股票私募、整容医美、宗教迷信、投资理财、房地产推广、游戏推广、交友推广、金融推广(含银行/保险/借贷)、招商加盟等内容的短信。
|
||||
|
||||
### 12.4 开通流程
|
||||
|
||||
@ -412,7 +659,7 @@ SMS_REGION=cn-hangzhou
|
||||
#### 依赖安装
|
||||
|
||||
```bash
|
||||
go get github.com/alibabacloud-go/dysmsapi-20170525/v4/client
|
||||
go get github.com/alibabacloud-go/dysmsapi-20180501/v2/client
|
||||
go get github.com/alibabacloud-go/darabonba-openapi/v2/client
|
||||
go get github.com/alibabacloud-go/tea/tea
|
||||
go get github.com/alibabacloud-go/tea-utils/v2/service
|
||||
@ -446,7 +693,7 @@ setx ALIBABA_CLOUD_ACCESS_KEY_SECRET yourAccessKeySecret /M
|
||||
import (
|
||||
"os"
|
||||
openapi "github.com/alibabacloud-go/darabonba-openapi/v2/client"
|
||||
dysmsapi20170525 "github.com/alibabacloud-go/dysmsapi-20170525/v4/client"
|
||||
dysmsapi20170525 "github.com/alibabacloud-go/dysmsapi-20180501/v2/client"
|
||||
"github.com/alibabacloud-go/tea/tea"
|
||||
)
|
||||
|
||||
@ -465,30 +712,32 @@ func CreateClient() (_result *dysmsapi20170525.Client, _err error) {
|
||||
|
||||
#### 发送短信(注册验证码场景)
|
||||
|
||||
使用 `SendMessageWithTemplate` API 发送中国内地短信:
|
||||
|
||||
```go
|
||||
import (
|
||||
dysmsapi20170525 "github.com/alibabacloud-go/dysmsapi-20170525/v4/client"
|
||||
dysmsapi20170525 "github.com/alibabacloud-go/dysmsapi-20180501/v2/client"
|
||||
util "github.com/alibabacloud-go/tea-utils/v2/service"
|
||||
"github.com/alibabacloud-go/tea/tea"
|
||||
)
|
||||
|
||||
func SendVerificationCode() (_result *dysmsapi20170525.SendSmsResponse, _err error) {
|
||||
func SendVerificationCode() (_result *dysmsapi20170525.SendMessageWithTemplateResponse, _err error) {
|
||||
client, _err := CreateClient()
|
||||
if _err != nil {
|
||||
return nil, _err
|
||||
}
|
||||
|
||||
// 构造请求
|
||||
sendSmsRequest := &dysmsapi20170525.SendSmsRequest{
|
||||
PhoneNumbers: tea.String("13800138000"), // 手机号
|
||||
SignName: tea.String("TopFans"), // 签名
|
||||
TemplateCode: tea.String("SMS_xxxxxxx"), // 模板CODE
|
||||
TemplateParam: tea.String(`{"code":"123456"}`), // 模板变量
|
||||
request := &dysmsapi20170525.SendMessageWithTemplateRequest{
|
||||
ToNumber: tea.String("861503871XXXXX"), // 接收手机号,格式:国际区号+号码
|
||||
FromNumber: tea.String("TopFans"), // 发送方标识(中国内地填签名)
|
||||
TemplateCode: tea.String("SMS_xxxxxxx"), // 短信模板CODE
|
||||
TemplateParam: tea.String(`{"code":"123456"}`), // 模板变量(JSON格式)
|
||||
}
|
||||
|
||||
// 发送
|
||||
runtime := &util.RuntimeOptions{}
|
||||
response, _err := client.SendSmsWithOptions(sendSmsRequest, runtime)
|
||||
response, _err := client.SendMessageWithTemplateWithOptions(request, runtime)
|
||||
if _err != nil {
|
||||
return nil, _err
|
||||
}
|
||||
@ -496,6 +745,33 @@ func SendVerificationCode() (_result *dysmsapi20170525.SendSmsResponse, _err err
|
||||
}
|
||||
```
|
||||
|
||||
**请求参数说明**
|
||||
|
||||
| 参数 | 类型 | 必填 | 说明 |
|
||||
|------|------|------|------|
|
||||
| ToNumber | string | 是 | 接收手机号,格式:`86` + 国内手机号,如 `861503871XXXXX` |
|
||||
| FromNumber | string | 是 | 发送方标识,中国内地填短信签名 |
|
||||
| TemplateCode | string | 是 | 短信模板 CODE |
|
||||
| TemplateParam | string | 否 | 模板变量 JSON,如 `{"code":"123456"}` |
|
||||
| SmsUpExtendCode | string | 否 | 上行短信扩展码 |
|
||||
|
||||
**返回示例**
|
||||
|
||||
```json
|
||||
{
|
||||
"MessageId": "10080303003****",
|
||||
"NumberDetail": {
|
||||
"Carrier": "China Mobile",
|
||||
"Country": "China",
|
||||
"Region": "Nanjing, Jiangsu"
|
||||
},
|
||||
"ResponseCode": "OK",
|
||||
"ResponseDescription": "The SMS Send Request was accepted",
|
||||
"Segments": "1",
|
||||
"To": "861503871XXXXX"
|
||||
}
|
||||
```
|
||||
|
||||
#### 返回码说明
|
||||
|
||||
| Code | 说明 |
|
||||
@ -537,13 +813,285 @@ fmt.Println(tea.StringValue(response.Body.Code))
|
||||
| "Specified access key is not found" | AccessKey ID 错误或已删除 | 确认环境变量是否正确设置 |
|
||||
| "dial tcp: lookup xxx: no such host" | Endpoint 配置错误 | 确认 Endpoint 为 `dysmsapi.aliyuncs.com` |
|
||||
|
||||
### 12.6 相关文档
|
||||
### 12.6 开通步骤与资质要求
|
||||
|
||||
#### 重要提示
|
||||
|
||||
> ⚠️ **个人账号限制**:在当前的短信签名实名制要求下,个人账号的自用资质无法通过签名实名制报备。个人用户请使用短信认证产品或**升级为企业认证账号**。
|
||||
|
||||
#### 开通步骤
|
||||
|
||||
| 步骤 | 操作 | 说明 |
|
||||
|------|------|------|
|
||||
| ① 准备工作 | 注册阿里云账号 + 完成实名认证 + 开通短信服务 + 创建 AccessKey | API 调用必需 |
|
||||
| ② 申请资质 | 提交资质(企业/个人证明)→ 等待审核(预计 2 个工作日) | 国内短信必需 |
|
||||
| ③ 申请签名 | 提交签名 → 等待审核(预计 2 小时)→ 等待运营商报备(7-10 工作日) | 签名实名制报备 |
|
||||
| ④ 申请模板 | 提交模板 → 等待审核(预计 2 小时) | 验证码模板 |
|
||||
| ⑤ 发送短信 | 使用已审核通过的签名和模板发送 | 建议先少量测试 |
|
||||
| ⑥ 查询详情 | 查询发送状态、获取回执 | 可选 |
|
||||
| ⑦ 设置预警 | 配置联系人、验证码防盗刷、套餐包余量预警等 | 建议配置 |
|
||||
|
||||
#### 资质要求
|
||||
|
||||
##### 基本概念
|
||||
|
||||
| 类型 | 说明 |
|
||||
|------|------|
|
||||
| **个人认证** | 阿里云账号认证类型为个人。**个人认证自用资质无法通过签名实名制报备**,请申请"他用资质"或升级为企业认证账号 |
|
||||
| **企业认证** | 阿里云账号认证类型为企业 |
|
||||
| **自用资质** | 资质企业/个人信息与阿里云账号已认证信息完全一致 |
|
||||
| **他用资质** | 资质企业/个人信息与阿里云账号已认证信息不一致,需提供委托授权书 |
|
||||
|
||||
##### 资质材料清单
|
||||
|
||||
| 材料类型 | 具体材料 | 说明 | 适用对象 |
|
||||
|---------|---------|------|---------|
|
||||
| **企业信息** | 加载统一社会信用代码的证照 | 社会信用代码证书、营业执照、事业单位法人证书等(选择一种) | 企业认证(自用/他用)、个人认证(他用) |
|
||||
| **法定代表人信息** | 姓名 + 身份证件 | 若证件中无法定代表人信息,需提供负责人或首席代表的身份证件 | 企业认证(自用/他用)、个人认证(他用) |
|
||||
| **管理员信息** | 管理员姓名 + 身份证件 + 手机号 | 管理员是管理短信业务的运营人员,可与企业法定代表人为同一人。**一人一企**:同一管理员只能关联一个企业资质,否则报备失败 | 企业认证(自用/他用)、个人认证(他用) |
|
||||
| **个人信息** | 个人身份证明 | 个人认证自用资质**无法通过**签名实名制报备 | 个人认证(自用) |
|
||||
|
||||
##### 证件要求
|
||||
|
||||
- 彩色原件无需盖章
|
||||
- 复印件/黑白照片需加盖**企业红章**并拍照上传
|
||||
- 证件标记建议修改为"仅供短信业务使用"或"仅供运营商报备使用"
|
||||
|
||||
##### 资质审核时长
|
||||
|
||||
- 预计 **2个工作日** 内完成
|
||||
- 工作时间:周一至周日 9:00~21:00(法定节假日顺延)
|
||||
|
||||
##### 常见审核失败原因
|
||||
|
||||
| 类别 | 问题 | 建议 |
|
||||
|------|------|------|
|
||||
| 企业信息 | 企业经营状态异常 | 向市场监管部门移除经营异常名录后再提交 |
|
||||
| 企业信息 | 证件标记非平台使用 | 修改为"仅供短信业务使用" |
|
||||
| 法定代表人 | 非中国国籍 | 护照、港澳居民来往内地通行证视为有效证件 |
|
||||
| 管理员 | 非中国国籍或港澳居民 | **仅支持身份证**,否则无法报备成功 |
|
||||
|
||||
##### 资质审核详细失败原因
|
||||
|
||||
| 类别 | 问题 | 原因/处理建议 |
|
||||
|------|------|---------------|
|
||||
| 企业信息 | 企业经营状态异常 | 企业已被列入经营异常名单。建议向市场监管部门移除经营异常名录 |
|
||||
| 企业信息 | 证件标记非平台使用 | 修改为"仅供短信业务使用"或"仅供运营商报备使用" |
|
||||
| 法定代表人 | 非中国国籍人士或港澳居民 | 护照、港澳居民来往内地通行证视为有效证件 |
|
||||
| 法定代表人 | 工商个体户没有公章 | 可提供法定代表人签名 |
|
||||
| 管理员 | 非中国国籍或港澳居民(无身份证) | **仅支持身份证**,否则无法报备成功 |
|
||||
|
||||
##### 资质复用
|
||||
|
||||
- **跨产品复用**:企业账号同时使用语音服务、号码隐私保护等产品时,可复用已审核通过的资质
|
||||
- **短信服务内复用**:可重复使用同一账号下已审核通过的资质
|
||||
|
||||
#### 签名要求
|
||||
|
||||
- 签名需要能明确辨别发送方
|
||||
- 建议使用企事业单位名称作为签名
|
||||
- 个人账号自用资质无法通过签名实名制报备
|
||||
- **未报备的签名会被运营商拦截发送**,返回 `PORT_NOT_REGISTERED` 错误
|
||||
|
||||
#### 运营商报备时长
|
||||
|
||||
- 平均 **5-7 个工作日**
|
||||
- 部分运营商可能需要 **7-10 个工作日**
|
||||
- 运营商未承诺此时效,实际可能更长
|
||||
- **建议**:提前申请资质和签名,预留足够时间完成实名报备后再正式使用
|
||||
|
||||
#### 审核时间
|
||||
|
||||
| 审核项 | 审核时间 | 工作时间 |
|
||||
|--------|---------|---------|
|
||||
| 资质审核 | 预计 2 个工作日 | 周一至周日 9:00~21:00,法定节假日顺延 |
|
||||
| 签名审核 | 预计 2 小时内 | 周一至周日 9:00~21:00 |
|
||||
| 模板审核 | 预计 2 小时内 | 周一至周日 9:00~21:00 |
|
||||
|
||||
#### 建议配置项
|
||||
|
||||
| 配置项 | 说明 |
|
||||
|--------|------|
|
||||
| 联系人设置 | 设置预警联系人,接收通知 |
|
||||
| 验证码防盗刷预警 | 建议开启,防止验证码被大量消耗 |
|
||||
| 套餐包余量预警 | 余额不足时通知 |
|
||||
| 发送频率预警 | 异常发送时通知 |
|
||||
|
||||
---
|
||||
|
||||
### 12.7 相关文档
|
||||
|
||||
- [阿里云短信服务帮助文档](https://help.aliyun.com/zh/sms)
|
||||
- [快速入门(Go SDK)](https://help.aliyun.com/zh/sms/getting-started/get-started-with-sms)
|
||||
- [SDK 示例](https://help.aliyun.com/zh/sms/sdk-demo/go)
|
||||
- [SendSms API 文档](https://help.aliyun.com/zh/sms/developer-reference/sendsms)
|
||||
- [Go SDK 源码仓库](https://github.com/alibabacloud-go/dysmsapi-20180501/)
|
||||
- [资质申请指南](https://help.aliyun.com/zh/sms/user-guide/apply-qualification)
|
||||
|
||||
---
|
||||
|
||||
### 12.8 短信使用记录表
|
||||
|
||||
为方便后续资源核算,需记录每次短信发送情况。
|
||||
|
||||
#### 方案一:PostgreSQL 表记录
|
||||
|
||||
```sql
|
||||
CREATE TABLE sms_send_log (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
mobile VARCHAR(20) NOT NULL COMMENT '手机号(脱敏存储)',
|
||||
scene VARCHAR(20) NOT NULL DEFAULT 'register' COMMENT '使用场景:register/password',
|
||||
template_code VARCHAR(50) NOT NULL COMMENT '短信模板CODE',
|
||||
sign_name VARCHAR(50) NOT NULL COMMENT '短信签名',
|
||||
message_id VARCHAR(64) DEFAULT '' COMMENT '阿里云返回的MessageId',
|
||||
response_code VARCHAR(20) DEFAULT '' COMMENT '阿里云返回状态码',
|
||||
response_description VARCHAR(255) DEFAULT '' COMMENT '阿里云返回描述',
|
||||
status SMALLINT NOT NULL DEFAULT 1 COMMENT '发送状态:0=失败,1=成功',
|
||||
send_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '发送时间',
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE INDEX idx_sms_send_log_mobile ON sms_send_log(mobile);
|
||||
CREATE INDEX idx_sms_send_log_scene ON sms_send_log(scene);
|
||||
CREATE INDEX idx_sms_send_log_send_time ON sms_send_log(send_time);
|
||||
```
|
||||
|
||||
#### 方案二:Redis 记录(轻量级)
|
||||
|
||||
使用 Redis 哈希记录发送统计,按月汇总:
|
||||
|
||||
```
|
||||
Key: sms:stat:{year}:{month}
|
||||
Type: Hash
|
||||
Fields:
|
||||
- total_count: 总发送条数
|
||||
- success_count: 成功条数
|
||||
- fail_count: 失败条数
|
||||
- register_count: 注册场景条数
|
||||
- password_count: 密码找回场景条数(未来扩展)
|
||||
```
|
||||
|
||||
**推荐方案一(PostgreSQL)**,便于查询和导出做成本分析。
|
||||
|
||||
#### 资源核算查询示例
|
||||
|
||||
```sql
|
||||
-- 按月统计各场景短信发送量
|
||||
SELECT
|
||||
TO_CHAR(send_time, 'YYYY-MM') AS month,
|
||||
scene,
|
||||
COUNT(*) AS total_count,
|
||||
SUM(CASE WHEN status = 1 THEN 1 ELSE 0 END) AS success_count,
|
||||
SUM(CASE WHEN status = 0 THEN 1 ELSE 0 END) AS fail_count
|
||||
FROM sms_send_log
|
||||
GROUP BY TO_CHAR(send_time, 'YYYY-MM'), scene
|
||||
ORDER BY month DESC;
|
||||
|
||||
-- 统计剩余套餐条数(结合阿里云控制台数据)
|
||||
-- 当前已消耗 = 注册验证成功 + 失败重试 等
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 12.9 忘记密码/找回密码
|
||||
|
||||
#### 功能概述
|
||||
|
||||
用户可通过手机号验证码方式重置密码,与注册流程类似但更简洁:
|
||||
|
||||
1. 用户输入手机号 → 发送验证码
|
||||
2. 输入验证码 → 验证通过
|
||||
3. 输入新密码 → 完成修改
|
||||
|
||||
#### 前端页面变化
|
||||
|
||||
新建 `frontend/pages/password/forget.vue`(找回密码页面):
|
||||
|
||||
```
|
||||
forget.vue
|
||||
│
|
||||
├── 输入手机号
|
||||
├── 点击"发送验证码" ──→ 发送验证码
|
||||
├── 输入验证码
|
||||
├── 点击"验证验证码" ──→ 验证通过
|
||||
│ 返回 verify_token
|
||||
├── 输入新密码
|
||||
└── 点击"确认修改" ──→ 修改密码 API
|
||||
(mobile, verify_token, new_password)
|
||||
```
|
||||
|
||||
#### 后端 API 变化
|
||||
|
||||
| 接口 | 方法 | 说明 |
|
||||
|------|------|------|
|
||||
| `/api/v1/auth/send-code` | POST | 复用注册逻辑,scene=password |
|
||||
| `/api/v1/auth/verify-code` | POST | 复用注册逻辑,scene=password |
|
||||
| `/api/v1/auth/reset-password` | POST | 新接口,重置密码 |
|
||||
|
||||
**重置密码接口:**
|
||||
|
||||
```
|
||||
POST /api/v1/auth/reset-password
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"mobile": "13800138000",
|
||||
"verify_token": "vtf_abc123xyz789...",
|
||||
"new_password": "xxx"
|
||||
}
|
||||
```
|
||||
|
||||
**响应:**
|
||||
```json
|
||||
{
|
||||
"code": 200,
|
||||
"message": "密码修改成功",
|
||||
"data": null
|
||||
}
|
||||
```
|
||||
|
||||
**后端逻辑:**
|
||||
1. 根据 mobile 从 Redis 获取 `verify:password:{mobile}` 的值
|
||||
2. 与请求中的 verify_token 比对,不一致则拒绝
|
||||
3. 验证通过后删除该记录
|
||||
4. 使用新密码更新用户数据(需加密存储)
|
||||
5. 建议要求用户重新登录
|
||||
|
||||
#### Redis Key 复用
|
||||
|
||||
| 场景 | Key 格式 | 说明 |
|
||||
|------|----------|------|
|
||||
| 找回密码验证码 | `sms:password:{mobile}` | 验证码存储(TTL 60秒) |
|
||||
| 找回密码验证通过 | `verify:password:{mobile}` | 验证 token 存储(TTL 300秒) |
|
||||
|
||||
限流规则与注册场景完全一致,共享 `sms:limit:*` 规则。
|
||||
|
||||
#### 错误码
|
||||
|
||||
| 场景 | 错误码 | 提示 |
|
||||
|------|--------|------|
|
||||
| 密码修改成功 | 200 | "密码修改成功" |
|
||||
| verify_token 无效/已过期 | 400 | "验证码已失效,请重新获取" |
|
||||
| 验证码错误 | 400 | "验证码错误" |
|
||||
| 同一手机号找回密码频率超限 | 429 | "操作过于频繁,请稍后再试" |
|
||||
|
||||
#### 前端登录页入口
|
||||
|
||||
在 `frontend/pages/login/login.vue` 添加"忘记密码"入口:
|
||||
|
||||
```vue
|
||||
<view class="forget-password" @click="goToForgetPassword">
|
||||
<text>忘记密码</text>
|
||||
</view>
|
||||
```
|
||||
|
||||
跳转到找回密码页面:
|
||||
```js
|
||||
uni.navigateTo({
|
||||
url: '/pages/password/forget'
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
@ -553,3 +1101,4 @@ fmt.Println(tea.StringValue(response.Body.Code))
|
||||
- [ ] 验证码有效期(默认 60 秒是否合适)
|
||||
- [ ] 每小时同一手机号发送次数上限(默认 10 次)
|
||||
- [ ] 验证失败次数上限(默认 3 次后强制重新获取)
|
||||
- [ ] 找回密码页面是否需要单独创建,还是复用现有页面
|
||||
@ -1,52 +1,24 @@
|
||||
<template>
|
||||
<scroll-view
|
||||
class="waterfall-scroll"
|
||||
:style="scrollStyle"
|
||||
scroll-x
|
||||
:show-scrollbar="false"
|
||||
:scroll-left="isIOS ? undefined : scrollLeft"
|
||||
:bounce="false"
|
||||
@scroll="onScroll"
|
||||
@touchstart="onTouchStart"
|
||||
@touchend="onTouchEnd"
|
||||
@touchcancel="onTouchEnd"
|
||||
>
|
||||
<view
|
||||
ref="waterfallInnerRef"
|
||||
:class="{'waterfall-inner': true, 'ios-css-animate': !iosScrollPaused}"
|
||||
:style="innerStyle"
|
||||
>
|
||||
<view
|
||||
v-for="card in cards"
|
||||
:key="card.id"
|
||||
class="wf-card"
|
||||
:style="cardStyle(card)"
|
||||
@click="handleCardClick(card)"
|
||||
>
|
||||
<scroll-view class="waterfall-scroll" :style="scrollStyle" scroll-x :show-scrollbar="false"
|
||||
:scroll-left="isIOS ? undefined : scrollLeft" :bounce="false" @scroll="onScroll" @touchstart="onTouchStart"
|
||||
@touchend="onTouchEnd" @touchcancel="onTouchEnd">
|
||||
<view ref="waterfallInnerRef" :class="{ 'waterfall-inner': true, 'ios-css-animate': !iosScrollPaused }"
|
||||
:style="innerStyle">
|
||||
<view v-for="card in cards" :key="card.id" class="wf-card" :style="cardStyle(card)"
|
||||
@click="handleCardClick(card)">
|
||||
<!-- 渐变边框光效 -->
|
||||
<view class="wf-card-border" :style="borderStyle(card)" />
|
||||
|
||||
<!-- 封面图 -->
|
||||
<image
|
||||
v-if="card.coverUrl"
|
||||
class="wf-card-img"
|
||||
:src="card.coverUrl"
|
||||
mode="aspectFill"
|
||||
:style="{ width: card.w + 'px', height: card.h + 'px', borderRadius: card.radius + 'px' }"
|
||||
/>
|
||||
<image v-if="card.coverUrl" class="wf-card-img" :src="card.coverUrl" mode="aspectFill"
|
||||
:style="{ width: card.w + 'px', height: card.h + 'px', borderRadius: card.radius + 'px' }" />
|
||||
|
||||
<!-- 光波动画层 - 外层 -->
|
||||
<view
|
||||
class="wf-like-wave wf-like-wave-outer"
|
||||
:class="{ 'wf-like-wave-active': likingMap[card.id] }"
|
||||
:style="{ width: card.w + 'px', height: card.h + 'px', borderRadius: card.radius + 'px' }"
|
||||
/>
|
||||
<view class="wf-like-wave wf-like-wave-outer" :class="{ 'wf-like-wave-active': likingMap[card.id] }"
|
||||
:style="{ width: card.w + 'px', height: card.h + 'px', borderRadius: card.radius + 'px' }" />
|
||||
<!-- 光波动画层 - 内层 -->
|
||||
<view
|
||||
class="wf-like-wave wf-like-wave-inner"
|
||||
:class="{ 'wf-like-wave-active': likingMap[card.id] }"
|
||||
:style="{ width: card.w + 'px', height: card.h + 'px', borderRadius: card.radius + 'px' }"
|
||||
/>
|
||||
<view class="wf-like-wave wf-like-wave-inner" :class="{ 'wf-like-wave-active': likingMap[card.id] }"
|
||||
:style="{ width: card.w + 'px', height: card.h + 'px', borderRadius: card.radius + 'px' }" />
|
||||
|
||||
<!-- 底部点赞数 -->
|
||||
<view class="wf-card-footer">
|
||||
@ -184,6 +156,10 @@ const startAutoScroll = () => {
|
||||
scrollUpdateTimer = setInterval(() => {
|
||||
if (!isComponentMounted || userInteracting || isLoadingMore) return
|
||||
autoScrollPos += AUTO_SCROLL_SPEED_ANDROID
|
||||
// 滚到头时重置到 0,实现无缝循环
|
||||
if (autoScrollPos >= totalWidth.value) {
|
||||
autoScrollPos = 0
|
||||
}
|
||||
scrollLeft.value = autoScrollPos
|
||||
|
||||
// 预加载
|
||||
@ -358,6 +334,13 @@ class WaterfallLayout {
|
||||
// 列宽固定 = rowH × 9/16,严格竖长 9:16,span 只影响高度
|
||||
this.colW = Math.round(this.rowH * 4 / 3)
|
||||
this.curX = 0
|
||||
// 跨批次追踪连续 N 级卡片数量,凑满 5 个后在第 6 个 N 卡前插入空白格
|
||||
this.consecutiveNCount = 0
|
||||
}
|
||||
|
||||
_isNLevel(item) {
|
||||
const span = item.span != null ? item.span : this._span(item.likes || 0)
|
||||
return span === 1
|
||||
}
|
||||
|
||||
_cardSize(span) {
|
||||
@ -392,10 +375,28 @@ class WaterfallLayout {
|
||||
let i = 0
|
||||
while (i < remaining.length) {
|
||||
const rawSpan = remaining[i].span
|
||||
const isN = remaining[i]._blank ? false : this._isNLevel(remaining[i])
|
||||
|
||||
// N 级连续 5 张后,第 6 个 N 卡前先插入 1-2 个空白格
|
||||
if (isN && this.consecutiveNCount >= 5) {
|
||||
const blankCount = Math.random() < 0.5 ? 1 : 2
|
||||
for (let b = 0; b < blankCount; b++) {
|
||||
if (sum + 1 <= ROWS) {
|
||||
remaining.splice(i, 0, { id: idCounter++, span: 1, _blank: true, likes: 0 })
|
||||
}
|
||||
}
|
||||
this.consecutiveNCount = 0
|
||||
// 空白格仅占位,不断 i,直接 break 让外层开新列处理剩余卡片
|
||||
break
|
||||
}
|
||||
|
||||
// 如果当前卡片加入会导致超出 ROWS,尝试下一个
|
||||
if (sum + rawSpan > ROWS) {
|
||||
i++
|
||||
// 已检查完所有卡片仍无法放入 → 当前列已满但未凑满 ROWS,强开新列
|
||||
if (i >= remaining.length && col.length > 0) {
|
||||
break
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
@ -404,6 +405,15 @@ class WaterfallLayout {
|
||||
col.push(item)
|
||||
sum += rawSpan
|
||||
|
||||
// 非 N 卡重置连续计数;N 卡累加计数
|
||||
if (!item._blank) {
|
||||
if (this._isNLevel(item)) {
|
||||
this.consecutiveNCount++
|
||||
} else {
|
||||
this.consecutiveNCount = 0
|
||||
}
|
||||
}
|
||||
|
||||
// 如果已经达到 ROWS,结束这列
|
||||
if (sum >= ROWS) break
|
||||
}
|
||||
@ -413,6 +423,13 @@ class WaterfallLayout {
|
||||
const item = remaining.shift()
|
||||
col.push(item)
|
||||
sum = item.span
|
||||
if (!item._blank) {
|
||||
if (this._isNLevel(item)) {
|
||||
this.consecutiveNCount++
|
||||
} else {
|
||||
this.consecutiveNCount = 0
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
colIndex++
|
||||
@ -437,7 +454,7 @@ class WaterfallLayout {
|
||||
for (const u of colUsers) {
|
||||
const span = u.span || 1
|
||||
const { w, h } = this._cardSize(span)
|
||||
if (!u._pad) {
|
||||
if (!u._pad && !u._blank) {
|
||||
result.push({ ...u, left: colX, top: curY, w, h, radius: 8 })
|
||||
}
|
||||
curY += h + this.gap
|
||||
@ -450,6 +467,7 @@ class WaterfallLayout {
|
||||
// 计算全部卡片布局(重置状态)
|
||||
compute(users) {
|
||||
this.curX = 0
|
||||
this.consecutiveNCount = 0
|
||||
const columns = this._groupIntoColumns(users)
|
||||
const result = []
|
||||
for (const col of columns) {
|
||||
@ -508,9 +526,9 @@ const cardStyle = (card) => ({
|
||||
|
||||
const borderStyle = (card) => ({
|
||||
position: 'absolute',
|
||||
inset: `-${BORDER_W}px`,
|
||||
borderRadius: (card.radius + BORDER_W) + 'px',
|
||||
background: BORDER_COLORS[Math.abs(card.id) % BORDER_COLORS.length],
|
||||
inset: `-${BORDER_W}px`,
|
||||
zIndex: 0,
|
||||
opacity: 0.85,
|
||||
})
|
||||
@ -731,6 +749,12 @@ const appendMore = async () => {
|
||||
cards.value = [...cards.value, ...placed]
|
||||
totalWidth.value = layout.getTotalWidth()
|
||||
|
||||
// totalWidth 变化后重启 iOS CSS 动画,确保新宽度生效
|
||||
if (isIOS && !iosScrollPaused.value) {
|
||||
stopIOSAutoScroll()
|
||||
startIOSAutoScroll()
|
||||
}
|
||||
|
||||
} else {
|
||||
// 没有可追加的数据,标记失败停止
|
||||
appendFailed = true
|
||||
@ -999,8 +1023,11 @@ const loadUsersAndStartScroll = () => {
|
||||
}
|
||||
|
||||
@keyframes iosAutoScroll {
|
||||
|
||||
/* from { transform: translateX(0); } */
|
||||
to { transform: translateX(var(--scroll-dist)); }
|
||||
to {
|
||||
transform: translateX(var(--scroll-dist));
|
||||
}
|
||||
}
|
||||
|
||||
.wf-card {
|
||||
@ -1010,6 +1037,7 @@ const loadUsersAndStartScroll = () => {
|
||||
transform-origin: top center;
|
||||
overflow: visible;
|
||||
pointer-events: visible;
|
||||
transform: translateZ(0);
|
||||
}
|
||||
|
||||
.wf-card:active {
|
||||
@ -1019,6 +1047,7 @@ const loadUsersAndStartScroll = () => {
|
||||
.wf-card-border {
|
||||
position: absolute;
|
||||
pointer-events: none;
|
||||
will-change: opacity;
|
||||
}
|
||||
|
||||
.wf-card-img {
|
||||
@ -1048,8 +1077,7 @@ const loadUsersAndStartScroll = () => {
|
||||
background: linear-gradient(to bottom right,
|
||||
#F0E4B1 0%,
|
||||
#F08399 50%,
|
||||
#B94E73 100%
|
||||
);
|
||||
#B94E73 100%);
|
||||
border-radius: 999rpx;
|
||||
padding: 2rpx 10rpx;
|
||||
display: flex;
|
||||
@ -1099,10 +1127,10 @@ const loadUsersAndStartScroll = () => {
|
||||
transform: scale(1);
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
100% {
|
||||
transform: scale(1.1);
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
</style>
|
||||
|
||||
Loading…
Reference in New Issue
Block a user