feat: 修改为4/3比例的图

This commit is contained in:
zheng020 2026-05-22 18:43:29 +08:00
parent c8501c9895
commit e29a718b2f
2 changed files with 666 additions and 89 deletions

View File

@ -126,14 +126,18 @@ register.vue setNickname.vue
``` ```
**优点:** **优点:**
- 阿里云 credentials 配置原本就在 gatewayOSS 用的是同一套),直接复用 - 网关已有阿里云 AccessKey 配置,可直接用于 SMS短信使用直接 AccessKey非 STS
- 前端请求在网关层就能触发短信,不用穿透到后端服务 - 前端请求在网关层就能触发短信,不用穿透到后端服务
- 网关是单点,处理短信很方便 - 网关是单点,处理短信很方便
**缺点:** **缺点:**
- 短信发送成功后的后续注册流程还在 userService逻辑割裂 - 短信发送成功后的后续注册流程还在 userService逻辑割裂
- 网关承担了越来越多的职责路由、认证、OSS上传、现在又加短信越来越重 - 网关承担了越来越多的职责路由、认证、OSS上传签名、现在又加短信越来越重
- 如果 SMS 认证配置要从 gateway 复用,需要确保 userService 能访问到同一份配置,增加了部署耦合 - 网关当前 OSS 相关接口:
- `GET /oss/signature` - 获取 OSS 上传签名
- `GET /oss/presigned-url` - 获取 OSS 预签名URL读取用
- `GET /oss/batch-presigned-urls` - 批量获取 OSS 预签名URL
- SMS 和 OSS 认证方式不同SMS 用直接 AccessKeyOSS 用 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 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` | | 依赖包 | 还需 `github.com/alibabacloud-go/darabonba-openapi/v2/client` |
| 认证方式 | 阿里云默认凭据链AccessKey 等)自动查找 | | 认证方式 | 阿里云默认凭据链AccessKey 等)自动查找 |
| API Endpoint | `dysmsapi.aliyuncs.com` | | API Endpoint | `dysmsapi.aliyuncs.com` |
@ -216,13 +220,70 @@ Value (Hash):
TTL: 60 秒 TTL: 60 秒
``` ```
### 7.3 防暴力破解策略 ### 7.3 防暴力破解与限流策略
- **失败计数**:每次验证失败递增 attempts 字段,≥ 3 次后删除 Key要求用户重新获取 #### 限制规则总览
- **发送频率限制**:每小时最多发送 10 次,超限返回 429
- 使用独立 Key `sms:limit:register:{mobile}` 计数String 类型TTL=3600 秒) | 限制维度 | 限制规则 | TTL | 触发动作 |
- **IP 维度限流**(可选):每 IP 每小时最多请求 30 次 |---------|---------|-----|---------|
- 使用独立 Key `sms:limit:ip:{ip}` 计数String 类型TTL=3600 秒) | 同一手机号(发送) | 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 验证后处理 ### 7.4 验证后处理
@ -243,10 +304,13 @@ POST /api/v1/auth/send-code
Content-Type: application/json Content-Type: application/json
{ {
"mobile": "13800138000" "mobile": "13800138000",
"scene": "register"
} }
``` ```
`scene` 可选值:`register`(注册)、`password`(找回密码)。
**响应** **响应**
```json ```json
{ {
@ -267,7 +331,8 @@ Content-Type: application/json
{ {
"mobile": "13800138000", "mobile": "13800138000",
"code": "123456" "code": "123456",
"scene": "register"
} }
``` ```
@ -325,7 +390,9 @@ Content-Type: application/json
| 验证码已使用 | 400 | 提示"验证码已使用,请重新获取" | | 验证码已使用 | 400 | 提示"验证码已使用,请重新获取" |
| 发送频率超限60秒内重复发送 | 429 | 提示"发送过于频繁,请稍后再试" | | 发送频率超限60秒内重复发送 | 429 | 提示"发送过于频繁,请稍后再试" |
| 每小时发送次数超限(>10次 | 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 分钟,只能使用一次,验证后立即删除 2. **verify_token 安全**token 有效期 5 分钟,只能使用一次,验证后立即删除
3. **防暴力破解**:验证失败 3 次后强制删除验证码,要求用户重新获取 3. **防暴力破解**:验证失败 3 次后强制删除验证码,要求用户重新获取
4. **限流保护**:从手机号和 IP 两个维度限制请求频率 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 条套餐性价比最高,适合中等规模业务使用。 > 💡 **推荐**5,000 条或 15,000 条套餐性价比最高,适合中等规模业务使用。
### 12.2 短信模板类型 ### 12.2 短信模板规范
#### 模板类型
| 模板类型 | 说明 | 审核时间 | | 模板类型 | 说明 | 审核时间 |
|----------|------|----------| |----------|------|----------|
| 验证码模板 | 仅含变量(如 ${code}),用于发送随机验证码 | 通常 2 小时内 | | **验证码模板** | 仅含变量(如 ${code}),用于发送随机验证码 | 通常 2 小时内 |
| 通知模板 | 含固定文本 + 少量变量 | 通常 2 小时内 | | 通知模板 | 含固定文本 + 少量变量 | 通常 2 小时内 |
| 推广模板 | 营销类内容,审核更严格 | 通常 4 小时以上 | | 推广模板 | 营销类内容,审核更严格 | 通常 4 小时以上 |
**验证码模板示例:** #### 内容规范
```
您的注册验证码是${code}5分钟内有效。如非本人操作请忽略此短信。
```
### 12.3 签名 **必须包含:**
- 必须包含"**验证码、注册码、校验码、动态码**"之一
- 必须体现"**使用平台、用途、失效时间**"之一
- 签名是短信发送者的标识,位于短信内容开头 **禁止包含:**
- 一个账号可创建多个签名,需审核通过后才能使用 - 不能含有通知、营销、广告词语、退订方式
- 签名名称示例:`TopFans`、`TOPFANS官方` - 结尾不能包含"拒收请回复R"
- **不能含任何联系方式**:手机/固话/QQ/微信/抖音/钉钉/旺旺/闲鱼/小红书/邮箱等
- 用途为"他用"的签名不支持申请金融相关模板
#### 格式规范
| 项目 | 规范 |
|------|------|
| **模板长度** | 1500 个字符(含变量) |
| **支持字符** | 中文、英文、数字、符号 |
| **不支持** | 繁体字、特殊符号(如 `#`、`『』、『「」、「」、〖〗`、`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 开通流程 ### 12.4 开通流程
@ -412,7 +659,7 @@ SMS_REGION=cn-hangzhou
#### 依赖安装 #### 依赖安装
```bash ```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/darabonba-openapi/v2/client
go get github.com/alibabacloud-go/tea/tea go get github.com/alibabacloud-go/tea/tea
go get github.com/alibabacloud-go/tea-utils/v2/service go get github.com/alibabacloud-go/tea-utils/v2/service
@ -446,7 +693,7 @@ setx ALIBABA_CLOUD_ACCESS_KEY_SECRET yourAccessKeySecret /M
import ( import (
"os" "os"
openapi "github.com/alibabacloud-go/darabonba-openapi/v2/client" 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" "github.com/alibabacloud-go/tea/tea"
) )
@ -465,30 +712,32 @@ func CreateClient() (_result *dysmsapi20170525.Client, _err error) {
#### 发送短信(注册验证码场景) #### 发送短信(注册验证码场景)
使用 `SendMessageWithTemplate` API 发送中国内地短信:
```go ```go
import ( 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" util "github.com/alibabacloud-go/tea-utils/v2/service"
"github.com/alibabacloud-go/tea/tea" "github.com/alibabacloud-go/tea/tea"
) )
func SendVerificationCode() (_result *dysmsapi20170525.SendSmsResponse, _err error) { func SendVerificationCode() (_result *dysmsapi20170525.SendMessageWithTemplateResponse, _err error) {
client, _err := CreateClient() client, _err := CreateClient()
if _err != nil { if _err != nil {
return nil, _err return nil, _err
} }
// 构造请求 // 构造请求
sendSmsRequest := &dysmsapi20170525.SendSmsRequest{ request := &dysmsapi20170525.SendMessageWithTemplateRequest{
PhoneNumbers: tea.String("13800138000"), // 手机号 ToNumber: tea.String("861503871XXXXX"), // 接收手机号,格式:国际区号+号码
SignName: tea.String("TopFans"), // 签名 FromNumber: tea.String("TopFans"), // 发送方标识(中国内地填签名)
TemplateCode: tea.String("SMS_xxxxxxx"), // 模板CODE TemplateCode: tea.String("SMS_xxxxxxx"), // 短信模板CODE
TemplateParam: tea.String(`{"code":"123456"}`), // 模板变量 TemplateParam: tea.String(`{"code":"123456"}`), // 模板变量JSON格式
} }
// 发送 // 发送
runtime := &util.RuntimeOptions{} runtime := &util.RuntimeOptions{}
response, _err := client.SendSmsWithOptions(sendSmsRequest, runtime) response, _err := client.SendMessageWithTemplateWithOptions(request, runtime)
if _err != nil { if _err != nil {
return nil, _err 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 | 说明 | | Code | 说明 |
@ -537,13 +813,285 @@ fmt.Println(tea.StringValue(response.Body.Code))
| "Specified access key is not found" | AccessKey ID 错误或已删除 | 确认环境变量是否正确设置 | | "Specified access key is not found" | AccessKey ID 错误或已删除 | 确认环境变量是否正确设置 |
| "dial tcp: lookup xxx: no such host" | Endpoint 配置错误 | 确认 Endpoint 为 `dysmsapi.aliyuncs.com` | | "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) - [阿里云短信服务帮助文档](https://help.aliyun.com/zh/sms)
- [快速入门Go SDK](https://help.aliyun.com/zh/sms/getting-started/get-started-with-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) - [SDK 示例](https://help.aliyun.com/zh/sms/sdk-demo/go)
- [SendSms API 文档](https://help.aliyun.com/zh/sms/developer-reference/sendsms) - [SendSms API 文档](https://help.aliyun.com/zh/sms/developer-reference/sendsms)
- [Go SDK 源码仓库](https://github.com/alibabacloud-go/dysmsapi-20180501/) - [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'
});
```
--- ---
@ -552,4 +1100,5 @@ fmt.Println(tea.StringValue(response.Body.Code))
- [ ] 阿里云短信签名和模板CODE需在阿里云控制台创建 - [ ] 阿里云短信签名和模板CODE需在阿里云控制台创建
- [ ] 验证码有效期(默认 60 秒是否合适) - [ ] 验证码有效期(默认 60 秒是否合适)
- [ ] 每小时同一手机号发送次数上限(默认 10 次) - [ ] 每小时同一手机号发送次数上限(默认 10 次)
- [ ] 验证失败次数上限(默认 3 次后强制重新获取) - [ ] 验证失败次数上限(默认 3 次后强制重新获取)
- [ ] 找回密码页面是否需要单独创建,还是复用现有页面

View File

@ -1,52 +1,24 @@
<template> <template>
<scroll-view <scroll-view class="waterfall-scroll" :style="scrollStyle" scroll-x :show-scrollbar="false"
class="waterfall-scroll" :scroll-left="isIOS ? undefined : scrollLeft" :bounce="false" @scroll="onScroll" @touchstart="onTouchStart"
:style="scrollStyle" @touchend="onTouchEnd" @touchcancel="onTouchEnd">
scroll-x <view ref="waterfallInnerRef" :class="{ 'waterfall-inner': true, 'ios-css-animate': !iosScrollPaused }"
:show-scrollbar="false" :style="innerStyle">
:scroll-left="isIOS ? undefined : scrollLeft" <view v-for="card in cards" :key="card.id" class="wf-card" :style="cardStyle(card)"
:bounce="false" @click="handleCardClick(card)">
@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)" /> <view class="wf-card-border" :style="borderStyle(card)" />
<!-- 封面图 --> <!-- 封面图 -->
<image <image v-if="card.coverUrl" class="wf-card-img" :src="card.coverUrl" mode="aspectFill"
v-if="card.coverUrl" :style="{ width: card.w + 'px', height: card.h + 'px', borderRadius: card.radius + 'px' }" />
class="wf-card-img"
:src="card.coverUrl"
mode="aspectFill"
:style="{ width: card.w + 'px', height: card.h + 'px', borderRadius: card.radius + 'px' }"
/>
<!-- 光波动画层 - 外层 --> <!-- 光波动画层 - 外层 -->
<view <view class="wf-like-wave wf-like-wave-outer" :class="{ 'wf-like-wave-active': likingMap[card.id] }"
class="wf-like-wave wf-like-wave-outer" :style="{ width: card.w + 'px', height: card.h + 'px', borderRadius: card.radius + 'px' }" />
:class="{ 'wf-like-wave-active': likingMap[card.id] }"
:style="{ width: card.w + 'px', height: card.h + 'px', borderRadius: card.radius + 'px' }"
/>
<!-- 光波动画层 - 内层 --> <!-- 光波动画层 - 内层 -->
<view <view class="wf-like-wave wf-like-wave-inner" :class="{ 'wf-like-wave-active': likingMap[card.id] }"
class="wf-like-wave wf-like-wave-inner" :style="{ width: card.w + 'px', height: card.h + 'px', borderRadius: card.radius + 'px' }" />
: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"> <view class="wf-card-footer">
@ -184,6 +156,10 @@ const startAutoScroll = () => {
scrollUpdateTimer = setInterval(() => { scrollUpdateTimer = setInterval(() => {
if (!isComponentMounted || userInteracting || isLoadingMore) return if (!isComponentMounted || userInteracting || isLoadingMore) return
autoScrollPos += AUTO_SCROLL_SPEED_ANDROID autoScrollPos += AUTO_SCROLL_SPEED_ANDROID
// 0
if (autoScrollPos >= totalWidth.value) {
autoScrollPos = 0
}
scrollLeft.value = autoScrollPos scrollLeft.value = autoScrollPos
// //
@ -358,6 +334,13 @@ class WaterfallLayout {
// = rowH × 9/16 9:16span // = rowH × 9/16 9:16span
this.colW = Math.round(this.rowH * 4 / 3) this.colW = Math.round(this.rowH * 4 / 3)
this.curX = 0 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) { _cardSize(span) {
@ -392,10 +375,28 @@ class WaterfallLayout {
let i = 0 let i = 0
while (i < remaining.length) { while (i < remaining.length) {
const rawSpan = remaining[i].span 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 // ROWS
if (sum + rawSpan > ROWS) { if (sum + rawSpan > ROWS) {
i++ i++
// ROWS
if (i >= remaining.length && col.length > 0) {
break
}
continue continue
} }
@ -404,6 +405,15 @@ class WaterfallLayout {
col.push(item) col.push(item)
sum += rawSpan sum += rawSpan
// N N
if (!item._blank) {
if (this._isNLevel(item)) {
this.consecutiveNCount++
} else {
this.consecutiveNCount = 0
}
}
// ROWS // ROWS
if (sum >= ROWS) break if (sum >= ROWS) break
} }
@ -413,6 +423,13 @@ class WaterfallLayout {
const item = remaining.shift() const item = remaining.shift()
col.push(item) col.push(item)
sum = item.span sum = item.span
if (!item._blank) {
if (this._isNLevel(item)) {
this.consecutiveNCount++
} else {
this.consecutiveNCount = 0
}
}
} }
colIndex++ colIndex++
@ -437,7 +454,7 @@ class WaterfallLayout {
for (const u of colUsers) { for (const u of colUsers) {
const span = u.span || 1 const span = u.span || 1
const { w, h } = this._cardSize(span) 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 }) result.push({ ...u, left: colX, top: curY, w, h, radius: 8 })
} }
curY += h + this.gap curY += h + this.gap
@ -450,6 +467,7 @@ class WaterfallLayout {
// //
compute(users) { compute(users) {
this.curX = 0 this.curX = 0
this.consecutiveNCount = 0
const columns = this._groupIntoColumns(users) const columns = this._groupIntoColumns(users)
const result = [] const result = []
for (const col of columns) { for (const col of columns) {
@ -508,9 +526,9 @@ const cardStyle = (card) => ({
const borderStyle = (card) => ({ const borderStyle = (card) => ({
position: 'absolute', position: 'absolute',
inset: `-${BORDER_W}px`,
borderRadius: (card.radius + BORDER_W) + 'px', borderRadius: (card.radius + BORDER_W) + 'px',
background: BORDER_COLORS[Math.abs(card.id) % BORDER_COLORS.length], background: BORDER_COLORS[Math.abs(card.id) % BORDER_COLORS.length],
inset: `-${BORDER_W}px`,
zIndex: 0, zIndex: 0,
opacity: 0.85, opacity: 0.85,
}) })
@ -566,7 +584,7 @@ const batchGetPresignedUrls = async (urls) => {
} }
return map return map
} }
} catch (_) {} } catch (_) { }
return {} return {}
} }
@ -641,7 +659,7 @@ const loadUsers = async () => {
span: item.span ?? null, span: item.span ?? null,
} }
}) })
allUsers.value = withData allUsers.value = withData
cards.value = layout.compute(withData) cards.value = layout.compute(withData)
totalWidth.value = layout.getTotalWidth() totalWidth.value = layout.getTotalWidth()
@ -731,6 +749,12 @@ const appendMore = async () => {
cards.value = [...cards.value, ...placed] cards.value = [...cards.value, ...placed]
totalWidth.value = layout.getTotalWidth() totalWidth.value = layout.getTotalWidth()
// totalWidth iOS CSS
if (isIOS && !iosScrollPaused.value) {
stopIOSAutoScroll()
startIOSAutoScroll()
}
} else { } else {
// //
appendFailed = true appendFailed = true
@ -774,7 +798,7 @@ const handleCardClick = (card) => {
}); });
} else { } else {
// //
if(card.id){ if (card.id) {
cardTapTimers[card.id] = setTimeout(() => { cardTapTimers[card.id] = setTimeout(() => {
delete cardTapTimers[card.id]; delete cardTapTimers[card.id];
uni.navigateTo({ url: `/pages/asset-detail/asset-detail?asset_id=${card.id}` }); uni.navigateTo({ url: `/pages/asset-detail/asset-detail?asset_id=${card.id}` });
@ -915,7 +939,7 @@ onUnmounted(() => {
try { try {
uni.offAppShow(onAppShowHandler) uni.offAppShow(onAppShowHandler)
uni.offAppHide(onAppHideHandler) uni.offAppHide(onAppHideHandler)
} catch (_) {} } catch (_) { }
} }
}) })
@ -999,8 +1023,11 @@ const loadUsersAndStartScroll = () => {
} }
@keyframes iosAutoScroll { @keyframes iosAutoScroll {
/* from { transform: translateX(0); } */ /* from { transform: translateX(0); } */
to { transform: translateX(var(--scroll-dist)); } to {
transform: translateX(var(--scroll-dist));
}
} }
.wf-card { .wf-card {
@ -1010,6 +1037,7 @@ const loadUsersAndStartScroll = () => {
transform-origin: top center; transform-origin: top center;
overflow: visible; overflow: visible;
pointer-events: visible; pointer-events: visible;
transform: translateZ(0);
} }
.wf-card:active { .wf-card:active {
@ -1019,6 +1047,7 @@ const loadUsersAndStartScroll = () => {
.wf-card-border { .wf-card-border {
position: absolute; position: absolute;
pointer-events: none; pointer-events: none;
will-change: opacity;
} }
.wf-card-img { .wf-card-img {
@ -1046,10 +1075,9 @@ const loadUsersAndStartScroll = () => {
.wf-likes-wrap { .wf-likes-wrap {
background: linear-gradient(to bottom right, background: linear-gradient(to bottom right,
#F0E4B1 0%, #F0E4B1 0%,
#F08399 50%, #F08399 50%,
#B94E73 100% #B94E73 100%);
);
border-radius: 999rpx; border-radius: 999rpx;
padding: 2rpx 10rpx; padding: 2rpx 10rpx;
display: flex; display: flex;
@ -1099,10 +1127,10 @@ const loadUsersAndStartScroll = () => {
transform: scale(1); transform: scale(1);
opacity: 1; opacity: 1;
} }
100% { 100% {
transform: scale(1.1); transform: scale(1.1);
opacity: 0; opacity: 0;
} }
} }
</style> </style>