feat: 修改短信认证bug

This commit is contained in:
zerosaturation 2026-06-02 15:43:57 +08:00
parent aca45f7822
commit e1829b2296
19 changed files with 556 additions and 82 deletions

View File

@ -20,6 +20,6 @@ REDIS_DB=0
# 与 asset.env 中的阿里云 AccessKey 相同(同一账号) # 与 asset.env 中的阿里云 AccessKey 相同(同一账号)
SMS_ACCESS_KEY_ID=LTAI5t6QcdJHpYbCPxM8SXYE SMS_ACCESS_KEY_ID=LTAI5t6QcdJHpYbCPxM8SXYE
SMS_ACCESS_KEY_SECRET=ybvjSEb7wilMt3qT5nOppYPoNVayCD SMS_ACCESS_KEY_SECRET=ybvjSEb7wilMt3qT5nOppYPoNVayCD
SMS_SIGN_NAME=TopFans SMS_SIGN_NAME=上海顶粉数字科技
SMS_TEMPLATE_CODE=SMS_314621237 SMS_TEMPLATE_CODE=SMS_314621237
SMS_REGION=cn-hangzhou SMS_REGION=cn-hangzhou

View File

@ -93,6 +93,18 @@ REDIS_DB="${REDIS_DB:-0}"
DB_ARGS=(-db-host="$DB_HOST" -db-port="$DB_PORT" -db-user="$DB_USER" -db-password="$DB_PASSWORD" -db-name="$DB_NAME") DB_ARGS=(-db-host="$DB_HOST" -db-port="$DB_PORT" -db-user="$DB_USER" -db-password="$DB_PASSWORD" -db-name="$DB_NAME")
REDIS_ARGS=(-redis-host="$REDIS_HOST" -redis-port="$REDIS_PORT" -redis-db="$REDIS_DB" -redis-password="$REDIS_PASSWORD") REDIS_ARGS=(-redis-host="$REDIS_HOST" -redis-port="$REDIS_PORT" -redis-db="$REDIS_DB" -redis-password="$REDIS_PASSWORD")
# 加载服务私有 env(对齐 deploy/envs/ 部署路径)
# 用法: load_service_env name -> source deploy/envs/<shortName>.env
load_service_env() {
local name=$1
local service_env="$SCRIPT_DIR/deploy/envs/${name%Service}.env"
if [ -f "$service_env" ]; then
set -a
source "$service_env"
set +a
fi
}
# 启动一个服务 # 启动一个服务
# 用法: start_service name binary port use_db use_redis # 用法: start_service name binary port use_db use_redis
start_service() { start_service() {
@ -104,6 +116,13 @@ start_service() {
echo -e "${GREEN}🚀 启动 $name...${NC}" echo -e "${GREEN}🚀 启动 $name...${NC}"
# Only services whose deploy/envs entry adds new vars not in
# backend/.env need the per-service source. Loading the rest
# would clobber dev's ports/JWT_SECRET/OSS keys with prod values.
case "$name" in
userService) load_service_env "$name" ;;
esac
local args=("-port=$port") local args=("-port=$port")
if [ "$use_db" = "1" ]; then if [ "$use_db" = "1" ]; then
args+=("${DB_ARGS[@]}") args+=("${DB_ARGS[@]}")
@ -210,6 +229,12 @@ restart_service() {
# Step 3: 启动新进程 # Step 3: 启动新进程
sleep 1 sleep 1
cd "$SCRIPT_DIR" cd "$SCRIPT_DIR"
# 重新加载服务私有 env(只有给 dev 引入新变量的服务才需要)
case "$name" in
userService) load_service_env "$name" ;;
esac
local args=("-port=$port") local args=("-port=$port")
if [ "$use_db" = "1" ]; then if [ "$use_db" = "1" ]; then
args+=("${DB_ARGS[@]}") args+=("${DB_ARGS[@]}")

View File

@ -895,6 +895,76 @@ func (ctrl *AssetController) GetOSSUploadSignature(c *gin.Context) {
response.Success(c, policyToken) response.Success(c, policyToken)
} }
// GetPublicOSSUploadSignature 公开版 OSS 上传签名(用于注册等未登录场景的头像上传)
// @Summary 获取公开 OSS 上传签名(注册流程用)
// @Description 用于注册等未登录场景下上传头像,无需鉴权。
// @Description 限定 key 必须以 avatar/register-pending/{key}/ 开头key 由前端传入(如手机号),并要求 key 与 scene+key 一致。
// @Tags assets
// @Accept json
// @Produce json
// @Param scene query string true "场景,目前固定为 register"
// @Param key query string true "前端传入的命名空间(注册时传 mobile仅允许 [a-zA-Z0-9_-],最长 32 字符"
// @Success 200 {object} response.Response "成功返回上传签名信息"
// @Failure 400 {object} response.Response "参数错误"
// @Failure 500 {object} response.Response "OSS配置错误或生成签名失败"
// @Router /api/v1/public/oss/upload-signature [get]
func (ctrl *AssetController) GetPublicOSSUploadSignature(c *gin.Context) {
scene := c.Query("scene")
key := c.Query("key")
if scene != "register" {
response.Error(c, http.StatusBadRequest, "参数错误: scene 仅支持 register")
return
}
if !isValidNamespaceKey(key) {
response.Error(c, http.StatusBadRequest, "参数错误: key 仅允许字母数字下划线中划线,最长 32 字符")
return
}
cfg := config.Load()
if cfg.OSS.BucketName == "" || cfg.OSS.RoleArn == "" {
response.Error(c, http.StatusInternalServerError, "OSS 配置未完成")
return
}
// 强制使用 avatar 目录,并将 key 限定到 avatar/register-pending/{key}/ 子目录
uploadDir := fmt.Sprintf("%sregister-pending/%s/", cfg.OSS.AvatarDir, key)
token, err := ctrl.generateOSSPolicyTokenWithDir(cfg.OSS, uploadDir)
if err != nil {
logger.Logger.Error("Generate public OSS signature failed",
zap.Error(err),
zap.String("scene", scene),
zap.String("key", key),
)
response.Error(c, http.StatusInternalServerError, "生成签名失败: "+err.Error())
return
}
logger.Logger.Info("Public OSS signature generated",
zap.String("scene", scene),
zap.String("key", key),
)
response.Success(c, token)
}
// isValidNamespaceKey 校验公开上传的命名空间 key仅允许字母数字下划线中划线
func isValidNamespaceKey(key string) bool {
if key == "" || len(key) > 32 {
return false
}
for _, r := range key {
switch {
case r >= 'a' && r <= 'z':
case r >= 'A' && r <= 'Z':
case r >= '0' && r <= '9':
case r == '_' || r == '-':
default:
return false
}
}
return true
}
// generateOSSPolicyToken 生成 OSS 上传策略和签名 // generateOSSPolicyToken 生成 OSS 上传策略和签名
func (ctrl *AssetController) generateOSSPolicyToken( func (ctrl *AssetController) generateOSSPolicyToken(
ossConfig config.OSSConfig, ossConfig config.OSSConfig,
@ -902,7 +972,22 @@ func (ctrl *AssetController) generateOSSPolicyToken(
starID interface{}, starID interface{},
uploadType string, uploadType string,
) (map[string]interface{}, error) { ) (map[string]interface{}, error) {
// 根据上传类型获取基础目录
baseDir := ossConfig.GetUploadDir(uploadType)
// 动态生成上传目录基于用户ID和上传类型
uploadDir := baseDir
if userID != nil && starID != nil {
uploadDir = fmt.Sprintf("%s%d/%d/", baseDir, userID, starID)
}
return ctrl.generateOSSPolicyTokenWithDir(ossConfig, uploadDir)
}
// generateOSSPolicyTokenWithDir 按显式目录生成 OSS 上传策略和签名
// (用于公开场景等需要把 key 限定到非 userID/starID 命名空间的场景)
func (ctrl *AssetController) generateOSSPolicyTokenWithDir(
ossConfig config.OSSConfig,
uploadDir string,
) (map[string]interface{}, error) {
// 1. 创建 STS 凭证提供器 // 1. 创建 STS 凭证提供器
credConfig := new(credentials.Config). credConfig := new(credentials.Config).
SetType("ram_role_arn"). SetType("ram_role_arn").
@ -929,15 +1014,6 @@ func (ctrl *AssetController) generateOSSPolicyToken(
date := utcTime.Format("20060102") date := utcTime.Format("20060102")
expiration := utcTime.Add(time.Duration(ossConfig.TokenExpireTime) * time.Second) expiration := utcTime.Add(time.Duration(ossConfig.TokenExpireTime) * time.Second)
// 根据上传类型获取基础目录
baseDir := ossConfig.GetUploadDir(uploadType)
// 动态生成上传目录基于用户ID和上传类型
uploadDir := baseDir
if userID != nil && starID != nil {
uploadDir = fmt.Sprintf("%s%d/%d/", baseDir, userID, starID)
}
// 构建 Policy 条件 // 构建 Policy 条件
conditions := []interface{}{ conditions := []interface{}{
map[string]string{"bucket": ossConfig.BucketName}, map[string]string{"bucket": ossConfig.BucketName},

View File

@ -135,6 +135,12 @@ func SetupRouter(userClient *client.Client, socialClient *client.Client, assetCl
fanIdentities.GET("", userCtrl.GetFanIdentities) // 获取可选粉丝身份列表 fanIdentities.GET("", userCtrl.GetFanIdentities) // 获取可选粉丝身份列表
} }
// 公开 OSS 上传签名(用于注册等未登录场景的上传,如注册头像)
public := v1.Group("/public")
{
public.GET("/oss/upload-signature", assetCtrl.GetPublicOSSUploadSignature) // 公开 OSS 上传签名
}
// 当前用户相关路由(需要认证) // 当前用户相关路由(需要认证)
me := v1.Group("/me") me := v1.Group("/me")
me.Use(middleware.AuthMiddleware()) me.Use(middleware.AuthMiddleware())

View File

@ -390,6 +390,7 @@ type RegisterRequest struct {
StarId int64 `protobuf:"varint,3,opt,name=star_id,json=starId,proto3" json:"star_id,omitempty"` // 选择第一个粉丝身份的明星ID StarId int64 `protobuf:"varint,3,opt,name=star_id,json=starId,proto3" json:"star_id,omitempty"` // 选择第一个粉丝身份的明星ID
Nickname string `protobuf:"bytes,4,opt,name=nickname,proto3" json:"nickname,omitempty"` // 第一个粉丝身份的昵称 Nickname string `protobuf:"bytes,4,opt,name=nickname,proto3" json:"nickname,omitempty"` // 第一个粉丝身份的昵称
VerifyToken string `protobuf:"bytes,5,opt,name=verify_token,json=verifyToken,proto3" json:"verify_token,omitempty"` // 短信验证token注册前必须先通过短信验证 VerifyToken string `protobuf:"bytes,5,opt,name=verify_token,json=verifyToken,proto3" json:"verify_token,omitempty"` // 短信验证token注册前必须先通过短信验证
AvatarUrl string `protobuf:"bytes,6,opt,name=avatar_url,json=avatarUrl,proto3" json:"avatar_url,omitempty"` // 头像URL可选为空则后端使用默认头像
unknownFields protoimpl.UnknownFields unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache sizeCache protoimpl.SizeCache
} }
@ -459,6 +460,13 @@ func (x *RegisterRequest) GetVerifyToken() string {
return "" return ""
} }
func (x *RegisterRequest) GetAvatarUrl() string {
if x != nil {
return x.AvatarUrl
}
return ""
}
// 注册响应 // 注册响应
type RegisterResponse struct { type RegisterResponse struct {
state protoimpl.MessageState `protogen:"open.v1"` state protoimpl.MessageState `protogen:"open.v1"`
@ -3147,13 +3155,15 @@ const file_user_proto_rawDesc = "" +
"\tis_active\x18\a \x01(\bR\bisActive\x12\x1d\n" + "\tis_active\x18\a \x01(\bR\bisActive\x12\x1d\n" +
"\n" + "\n" +
"created_at\x18\b \x01(\x03R\tcreatedAt\x12\x10\n" + "created_at\x18\b \x01(\x03R\tcreatedAt\x12\x10\n" +
"\x03tag\x18\t \x01(\tR\x03tag\"\x9d\x01\n" + "\x03tag\x18\t \x01(\tR\x03tag\"\xbc\x01\n" +
"\x0fRegisterRequest\x12\x16\n" + "\x0fRegisterRequest\x12\x16\n" +
"\x06mobile\x18\x01 \x01(\tR\x06mobile\x12\x1a\n" + "\x06mobile\x18\x01 \x01(\tR\x06mobile\x12\x1a\n" +
"\bpassword\x18\x02 \x01(\tR\bpassword\x12\x17\n" + "\bpassword\x18\x02 \x01(\tR\bpassword\x12\x17\n" +
"\astar_id\x18\x03 \x01(\x03R\x06starId\x12\x1a\n" + "\astar_id\x18\x03 \x01(\x03R\x06starId\x12\x1a\n" +
"\bnickname\x18\x04 \x01(\tR\bnickname\x12!\n" + "\bnickname\x18\x04 \x01(\tR\bnickname\x12!\n" +
"\fverify_token\x18\x05 \x01(\tR\vverifyToken\"\xe9\x01\n" + "\fverify_token\x18\x05 \x01(\tR\vverifyToken\x12\x1d\n" +
"\n" +
"avatar_url\x18\x06 \x01(\tR\tavatarUrl\"\xe9\x01\n" +
"\x10RegisterResponse\x120\n" + "\x10RegisterResponse\x120\n" +
"\x04base\x18\x01 \x01(\v2\x1c.topfans.common.BaseResponseR\x04base\x12!\n" + "\x04base\x18\x01 \x01(\v2\x1c.topfans.common.BaseResponseR\x04base\x12!\n" +
"\faccess_token\x18\x02 \x01(\tR\vaccessToken\x12\x1d\n" + "\faccess_token\x18\x02 \x01(\tR\vaccessToken\x12\x1d\n" +

View File

@ -61,6 +61,7 @@ message RegisterRequest {
int64 star_id = 3; // ID int64 star_id = 3; // ID
string nickname = 4; // string nickname = 4; //
string verify_token = 5; // token string verify_token = 5; // token
string avatar_url = 6; // URL使
} }
// //

View File

@ -68,7 +68,49 @@ apt-get install protobuf-compiler
yum install protobuf-compiler yum install protobuf-compiler
``` ```
### 4. 生成代码 ### 4. 环境变量配置 ⚠️
**本项目有两套互相独立的配置文件,设计上是"二选一"关系,不要混用:**
| 文件 | 用途 | 何时生效 |
| --- | --- | --- |
| `backend/.env` | **Dev(本机开发)** 配置 | `./backend/dev.sh` 自动 source |
| `backend/deploy/envs/*.env` | **生产多机部署** 配置(每个服务一份) | 仅在目标服务器上由 systemd / docker / 部署脚本 source |
**为什么不能混用** — 同一个变量在两套里值不同,后者会覆盖前者:
| 变量 | `backend/.env`(dev) | `deploy/envs/gateway.env`(生产) | 混用后果 |
| --- | --- | --- | --- |
| `JWT_SECRET` | `topfans-secret-key-please-change-in-production` | `your_secure_jwt_secret_here` | 所有 token 校验失败,需要登录的接口 401 |
| `DUBBO_GALLERY_SERVICE_URL` | `tri://127.0.0.1:20004` | `tri://localhost:20001` | 调用 gallery 服务的接口连接失败 |
| `DUBBO_ACTIVITY_SERVICE_URL` | `tri://127.0.0.1:20005` | `tri://localhost:20004` | 调用 activity 服务的接口连接失败 |
| `OSS_ACCESS_KEY_ID` | `LTAI5t99tafzfyrzbbEbjryH` | `LTAI5t6QcdJHpYbCPxM8SXYE` | OSS 上传/下载 403 |
| `DB_USER` / `DB_PASSWORD` | `postgres` / `123456` | `haihuizhu` / `admin` | 启动期就连不上 DB |
| `ENV` | `development` | `production` | 日志格式、限流策略等会按生产行为跑 |
**dev.sh 当前的加载规则**(见 `dev.sh:load_service_env`):
- `dev.sh` 启动时**只** source `backend/.env`
- 启动每个服务前,按服务名(去掉 `Service` 后缀)source 对应的 `deploy/envs/<name>.env`,**但仅对 `userService`** 生效
- `userService` 之所以特殊:它的 `deploy/envs/user.env` 里有 `SMS_ACCESS_KEY_ID` / `SMS_ACCESS_KEY_SECRET``backend/.env` 没有的变量,需要叠加
- **其他服务不再 source `deploy/envs/`** — 否则会被生产端口/密钥覆盖 dev 端口/密钥,导致接口大面积失败(典型症状:token 401、Dubbo 调用超时、OSS 403)
**生产部署时**(由部署脚本负责,与 dev.sh 无关):
- 把对应服务的 `deploy/envs/<name>.env` 放到目标服务器的 `/etc/topfans/<name>.env`
- systemd unit / docker-compose 引用该文件
- **不要** source `backend/.env`
**新增配置项时的纪律:**
1. 如果是 dev 和生产都需要的(如 `DB_*`、Dubbo URL 模板),在 `backend/.env` 改 + 在 `deploy/envs/*.env` 同步改
2. 如果仅生产需要(如 `SMS_*`、OSS 角色),只改 `deploy/envs/<name>.env`
3. 如果仅 dev 需要(如本地 mock 开关),只改 `backend/.env`
4. 改完后,在 dev 环境跑一次 `./dev.sh` 验证没有端口/密钥错乱
---
### 5. 生成代码
```bash ```bash
# 生成 gRPC 代码 # 生成 gRPC 代码
@ -81,6 +123,24 @@ make swagger
make all make all
``` ```
### 6. 启动服务
```bash
# 一键启动所有服务(带文件热更新)
./dev.sh
# 或者只跑某个服务(略)
# 详见 dev.sh 注释
```
启动后会监听:
- Gateway: http://localhost:8080
- UserService: tri://localhost:20000
- Swagger UI: http://localhost:8080/swagger/index.html
> ⚠️ 启动前确认 `backend/.env` 里的 `DB_HOST/REDIS_HOST` 在本机能通(默认 `localhost`)。如果用远程 DB/Redis,改 `backend/.env` 而不是 `deploy/envs/*.env`
## 项目结构 ## 项目结构
``` ```

View File

@ -0,0 +1,7 @@
-- Drop the orphan reverse FK on fan_profiles.user_id.
-- fan_profiles already has a CASCADE FK fk_fan_profiles_user on the same
-- (user_id) column. The duplicate fk_users_fan_profiles (declared without
-- ON DELETE CASCADE) blocks hard-deletes of users and breaks the soft-delete
-- re-registration path.
ALTER TABLE fan_profiles DROP CONSTRAINT IF EXISTS fk_users_fan_profiles;

View File

@ -4,7 +4,9 @@ go 1.25.5
require ( require (
dubbo.apache.org/dubbo-go/v3 v3.3.1 dubbo.apache.org/dubbo-go/v3 v3.3.1
github.com/alibabacloud-go/dysmsapi-20180501/v2 v2.0.8 github.com/alibabacloud-go/darabonba-openapi/v2 v2.1.16
github.com/alibabacloud-go/dysmsapi-20170525/v5 v5.5.1
github.com/alibabacloud-go/tea v1.3.13
github.com/redis/go-redis/v9 v9.19.0 github.com/redis/go-redis/v9 v9.19.0
github.com/topfans/backend v0.0.0 github.com/topfans/backend v0.0.0
go.uber.org/zap v1.27.1 go.uber.org/zap v1.27.1
@ -19,9 +21,7 @@ require (
github.com/afex/hystrix-go v0.0.0-20180502004556-fa1af6a1f4f5 // indirect github.com/afex/hystrix-go v0.0.0-20180502004556-fa1af6a1f4f5 // indirect
github.com/alibaba/sentinel-golang v1.0.4 // indirect github.com/alibaba/sentinel-golang v1.0.4 // indirect
github.com/alibabacloud-go/alibabacloud-gateway-spi v0.0.5 // indirect github.com/alibabacloud-go/alibabacloud-gateway-spi v0.0.5 // indirect
github.com/alibabacloud-go/darabonba-openapi/v2 v2.1.16 // indirect
github.com/alibabacloud-go/debug v1.0.1 // indirect github.com/alibabacloud-go/debug v1.0.1 // indirect
github.com/alibabacloud-go/tea v1.3.13 // indirect
github.com/alibabacloud-go/tea-utils v1.4.4 // indirect github.com/alibabacloud-go/tea-utils v1.4.4 // indirect
github.com/alibabacloud-go/tea-utils/v2 v2.0.7 // indirect github.com/alibabacloud-go/tea-utils/v2 v2.0.7 // indirect
github.com/aliyun/alibaba-cloud-sdk-go v1.61.1800 // indirect github.com/aliyun/alibaba-cloud-sdk-go v1.61.1800 // indirect

View File

@ -79,7 +79,7 @@ github.com/alibabacloud-go/darabonba-encode-util v0.0.2 h1:1uJGrbsGEVqWcWxrS9MyC
github.com/alibabacloud-go/darabonba-encode-util v0.0.2/go.mod h1:JiW9higWHYXm7F4PKuMgEUETNZasrDM6vqVr/Can7H8= github.com/alibabacloud-go/darabonba-encode-util v0.0.2/go.mod h1:JiW9higWHYXm7F4PKuMgEUETNZasrDM6vqVr/Can7H8=
github.com/alibabacloud-go/darabonba-map v0.0.2 h1:qvPnGB4+dJbJIxOOfawxzF3hzMnIpjmafa0qOTp6udc= github.com/alibabacloud-go/darabonba-map v0.0.2 h1:qvPnGB4+dJbJIxOOfawxzF3hzMnIpjmafa0qOTp6udc=
github.com/alibabacloud-go/darabonba-map v0.0.2/go.mod h1:28AJaX8FOE/ym8OUFWga+MtEzBunJwQGceGQlvaPGPc= github.com/alibabacloud-go/darabonba-map v0.0.2/go.mod h1:28AJaX8FOE/ym8OUFWga+MtEzBunJwQGceGQlvaPGPc=
github.com/alibabacloud-go/darabonba-openapi/v2 v2.1.13/go.mod h1:lxFGfobinVsQ49ntjpgWghXmIF0/Sm4+wvBJ1h5RtaE= github.com/alibabacloud-go/darabonba-openapi/v2 v2.1.14/go.mod h1:lxFGfobinVsQ49ntjpgWghXmIF0/Sm4+wvBJ1h5RtaE=
github.com/alibabacloud-go/darabonba-openapi/v2 v2.1.16 h1:LHhjxZkNWAKWepxcWyzgFgo0X6TUVhL7sC7ANc60p8A= github.com/alibabacloud-go/darabonba-openapi/v2 v2.1.16 h1:LHhjxZkNWAKWepxcWyzgFgo0X6TUVhL7sC7ANc60p8A=
github.com/alibabacloud-go/darabonba-openapi/v2 v2.1.16/go.mod h1:lxFGfobinVsQ49ntjpgWghXmIF0/Sm4+wvBJ1h5RtaE= github.com/alibabacloud-go/darabonba-openapi/v2 v2.1.16/go.mod h1:lxFGfobinVsQ49ntjpgWghXmIF0/Sm4+wvBJ1h5RtaE=
github.com/alibabacloud-go/darabonba-signature-util v0.0.7 h1:UzCnKvsjPFzApvODDNEYqBHMFt1w98wC7FOo0InLyxg= github.com/alibabacloud-go/darabonba-signature-util v0.0.7 h1:UzCnKvsjPFzApvODDNEYqBHMFt1w98wC7FOo0InLyxg=
@ -90,8 +90,8 @@ github.com/alibabacloud-go/debug v0.0.0-20190504072949-9472017b5c68/go.mod h1:6p
github.com/alibabacloud-go/debug v1.0.0/go.mod h1:8gfgZCCAC3+SCzjWtY053FrOcd4/qlH6IHTI4QyICOc= github.com/alibabacloud-go/debug v1.0.0/go.mod h1:8gfgZCCAC3+SCzjWtY053FrOcd4/qlH6IHTI4QyICOc=
github.com/alibabacloud-go/debug v1.0.1 h1:MsW9SmUtbb1Fnt3ieC6NNZi6aEwrXfDksD4QA6GSbPg= github.com/alibabacloud-go/debug v1.0.1 h1:MsW9SmUtbb1Fnt3ieC6NNZi6aEwrXfDksD4QA6GSbPg=
github.com/alibabacloud-go/debug v1.0.1/go.mod h1:8gfgZCCAC3+SCzjWtY053FrOcd4/qlH6IHTI4QyICOc= github.com/alibabacloud-go/debug v1.0.1/go.mod h1:8gfgZCCAC3+SCzjWtY053FrOcd4/qlH6IHTI4QyICOc=
github.com/alibabacloud-go/dysmsapi-20180501/v2 v2.0.8 h1:aDPyz6C+nenypx24N5qEt09NjpS6mu7Cu1A+wf9UTaY= github.com/alibabacloud-go/dysmsapi-20170525/v5 v5.5.1 h1:CyJ1adk5jlg7acrbG1sgdZ+EXTZNZHwhNQAf6VFfySo=
github.com/alibabacloud-go/dysmsapi-20180501/v2 v2.0.8/go.mod h1:e/vWJ5gLVnraPROSh+3oMSodf5ukaUlqNgH0IIcnz98= github.com/alibabacloud-go/dysmsapi-20170525/v5 v5.5.1/go.mod h1:J1zab9/VxVJGdZ5pSK/BbUot7CkaSkRXdaLKAXXRLoY=
github.com/alibabacloud-go/endpoint-util v1.1.0 h1:r/4D3VSw888XGaeNpP994zDUaxdgTSHBbVfZlzf6b5Q= github.com/alibabacloud-go/endpoint-util v1.1.0 h1:r/4D3VSw888XGaeNpP994zDUaxdgTSHBbVfZlzf6b5Q=
github.com/alibabacloud-go/endpoint-util v1.1.0/go.mod h1:O5FuCALmCKs2Ff7JFJMudHs0I5EBgecXXxZRyswlEjE= github.com/alibabacloud-go/endpoint-util v1.1.0/go.mod h1:O5FuCALmCKs2Ff7JFJMudHs0I5EBgecXXxZRyswlEjE=
github.com/alibabacloud-go/openapi-util v0.1.0 h1:0z75cIULkDrdEhkLWgi9tnLe+KhAFE/r5Pb3312/eAY= github.com/alibabacloud-go/openapi-util v0.1.0 h1:0z75cIULkDrdEhkLWgi9tnLe+KhAFE/r5Pb3312/eAY=

View File

@ -150,6 +150,12 @@ func (s *authService) Register(ctx context.Context, req *pb.RegisterRequest) (*p
UpdatedAt: now, UpdatedAt: now,
} }
// 如果前端传入了头像URL则使用,否则保持空前端会基于userId渲染默认头像
if req.AvatarUrl != "" {
avatarURL := req.AvatarUrl
user.AvatarURL = &avatarURL
}
// 手动加密密码(因为需要在事务中使用) // 手动加密密码(因为需要在事务中使用)
hashedPassword, err := repository.HashPassword(req.Password) hashedPassword, err := repository.HashPassword(req.Password)
if err != nil { if err != nil {

View File

@ -8,7 +8,7 @@ import (
"math/big" "math/big"
"time" "time"
dysmsapi20170525 "github.com/alibabacloud-go/dysmsapi-20180501/v2/client" dysmsapi20170525 "github.com/alibabacloud-go/dysmsapi-20170525/v5/client"
"github.com/alibabacloud-go/darabonba-openapi/v2/client" "github.com/alibabacloud-go/darabonba-openapi/v2/client"
"github.com/alibabacloud-go/tea/tea" "github.com/alibabacloud-go/tea/tea"
"github.com/topfans/backend/pkg/logger" "github.com/topfans/backend/pkg/logger"
@ -22,15 +22,17 @@ var smsClient *dysmsapi20170525.Client
func InitSMSClient() error { func InitSMSClient() error {
cfg := config.GetSMSConfig() cfg := config.GetSMSConfig()
// If credentials are not set, skip initialization // Fail fast when credentials are missing. Returning nil here would let
// the service come up with a nil smsClient, which SendVerificationCode
// silently skips — making send-code return 200 without delivering any SMS.
if cfg.AccessKeyID == "" || cfg.AccessKeySecret == "" { if cfg.AccessKeyID == "" || cfg.AccessKeySecret == "" {
logger.Logger.Warn("SMS credentials not configured, SMS client not initialized") return errors.New("SMS credentials missing: SMS_ACCESS_KEY_ID and SMS_ACCESS_KEY_SECRET must be set")
return nil
} }
openapiConfig := &client.Config{ openapiConfig := &client.Config{
AccessKeyId: tea.String(cfg.AccessKeyID), AccessKeyId: tea.String(cfg.AccessKeyID),
AccessKeySecret: tea.String(cfg.AccessKeySecret), AccessKeySecret: tea.String(cfg.AccessKeySecret),
RegionId: tea.String(cfg.Region),
} }
openapiConfig.Endpoint = tea.String("dysmsapi.aliyuncs.com") openapiConfig.Endpoint = tea.String("dysmsapi.aliyuncs.com")
@ -130,26 +132,38 @@ func SendVerificationCode(ctx context.Context, mobile, ip string) (int, error) {
return 0, fmt.Errorf("failed to generate code: %w", err) return 0, fmt.Errorf("failed to generate code: %w", err)
} }
// Step 6: Call Aliyun SMS API to send (if client is initialized) // Step 6: Call Aliyun SMS API to send. smsClient==nil is a programming
if smsClient != nil { // error (InitSMSClient should have failed-fast at startup); surface it
// as 5xx instead of pretending success.
if smsClient == nil {
return 0, errors.New("SMS client not initialized")
}
cfg := config.GetSMSConfig() cfg := config.GetSMSConfig()
sendReq := &dysmsapi20170525.SendMessageWithTemplateRequest{ sendReq := &dysmsapi20170525.SendSmsRequest{
To: tea.String("86" + mobile), // China area code + mobile PhoneNumbers: tea.String(mobile),
From: tea.String(cfg.SignName), SignName: tea.String(cfg.SignName),
TemplateCode: tea.String(cfg.TemplateCode), TemplateCode: tea.String(cfg.TemplateCode),
TemplateParam: tea.String(fmt.Sprintf(`{"code":"%s"}`, code)), TemplateParam: tea.String(fmt.Sprintf(`{"code":"%s"}`, code)),
} }
_, err := smsClient.SendMessageWithTemplate(sendReq) resp, err := smsClient.SendSms(sendReq)
if err != nil { if err != nil {
logger.Error("Failed to send SMS via Aliyun", zap.Error(err)) logger.Error("Failed to send SMS via Aliyun", zap.Error(err))
return 0, fmt.Errorf("failed to send SMS: %w", err) return 0, fmt.Errorf("failed to send SMS: %w", err)
} }
if resp == nil || resp.Body == nil {
logger.Info("SMS sent successfully via Aliyun") return 0, errors.New("Aliyun SMS response empty")
} else {
logger.Warn("SMS client not initialized, skipping actual SMS send")
} }
if resp.Body.Code != nil && *resp.Body.Code != "OK" {
logger.Error("Aliyun SMS business error",
zap.String("code", tea.StringValue(resp.Body.Code)),
zap.String("message", tea.StringValue(resp.Body.Message)),
zap.String("request_id", tea.StringValue(resp.Body.RequestId)))
return 0, fmt.Errorf("Aliyun SMS error: code=%s, message=%s",
tea.StringValue(resp.Body.Code), tea.StringValue(resp.Body.Message))
}
logger.Info("SMS sent successfully via Aliyun")
// Step 7: Save code to Redis with 60s TTL // Step 7: Save code to Redis with 60s TTL
err = SaveSMSCode(ctx, mobile, code, 60*time.Second) err = SaveSMSCode(ctx, mobile, code, 60*time.Second)

View File

@ -3,7 +3,7 @@
"appid" : "__UNI__F199FF4", "appid" : "__UNI__F199FF4",
"description" : "", "description" : "",
"versionName" : "1.0.5", "versionName" : "1.0.5",
"versionCode" : 103, "versionCode" : 105,
"transformPx" : false, "transformPx" : false,
/* 5+App */ /* 5+App */
"app-plus" : { "app-plus" : {

View File

@ -1199,6 +1199,7 @@ onUnmounted(() => {
font-variant-numeric: tabular-nums; font-variant-numeric: tabular-nums;
text-shadow: 0 2px 8px rgba(0, 0, 0, 0.7); text-shadow: 0 2px 8px rgba(0, 0, 0, 0.7);
display: flex; display: flex;
align-items: center;
} }
.countdown-val { .countdown-val {

View File

@ -570,12 +570,14 @@ defineExpose({
.task-text-label { .task-text-label {
font-weight: 500; font-weight: 500;
font-size: 12rpx; font-size: 24rpx;
line-height: 100%; /* line-height: 100%;
letter-spacing: 0%; letter-spacing: 0%; */
color: #fff9e7; color: #fff9e7;
text-shadow: 1rpx 1rpx 2rpx rgba(0, 0, 0, 0.84); text-shadow: 1rpx 1rpx 2rpx rgba(0, 0, 0, 0.84);
margin-top: 32rpx; margin-top: 32rpx;
white-space: nowrap;
transform: scale(0.5);
} }
/* 水晶余额组件 */ /* 水晶余额组件 */

View File

@ -11,14 +11,17 @@
<view class="nav-back" @click="goBack"> <view class="nav-back" @click="goBack">
<text class="back-icon"></text> <text class="back-icon"></text>
</view> </view>
<text class="nav-title">设置昵称</text> <text class="nav-title">设置头像与昵称</text>
</view> </view>
<!-- 表单区域居中部分 --> <!-- 表单区域居中部分 -->
<view class="form-container"> <view class="form-container">
<!-- 头像 --> <!-- 头像点击可上传 -->
<view class="avatar-wrapper"> <view class="avatar-wrapper" @tap="handleAvatarClick">
<Avatar :nickname="nickname" :size="200" :borderWidth="8" /> <Avatar :nickname="nickname" :size="200" :borderWidth="8" :avatarUrl="userAvatarUrl" />
<view class="avatar-edit-badge">
<text class="avatar-edit-icon"></text>
</view>
</view> </view>
<!-- 昵称输入框 --> <!-- 昵称输入框 -->
@ -46,6 +49,29 @@
</view> </view>
</view> </view>
</view> </view>
<!-- 头像上传弹窗 -->
<view class="avatar-modal" v-if="showAvatarModal" @tap="closeAvatarModal">
<view class="modal-content" @tap.stop>
<view class="modal-title">设置头像</view>
<!-- 头像预览 -->
<view class="avatar-preview">
<Avatar :nickname="nickname" :size="180" :borderWidth="6" :avatarUrl="userAvatarUrl" />
</view>
<!-- 上传按钮 -->
<button class="upload-avatar-btn" @tap="handleUploadAvatar" :disabled="uploadingAvatar">
{{ uploadingAvatar ? '上传中...' : '上传头像' }}
</button>
<view class="upload-hint">支持JPGPNG格式大小不超过10MB</view>
<view class="modal-buttons">
<button class="modal-btn-cancel" @tap="closeAvatarModal">取消</button>
</view>
</view>
</view>
</view> </view>
</template> </template>
@ -53,7 +79,7 @@
import { ref } from 'vue'; import { ref } from 'vue';
import { useStore } from 'vuex'; import { useStore } from 'vuex';
import Avatar from '../components/Avatar.vue'; import Avatar from '../components/Avatar.vue';
import { checkNicknameApi } from '@/utils/api.js'; import { checkNicknameApi, getPublicOssSignatureApi } from '@/utils/api.js';
import { validateNickname } from '@/utils/validator.js'; import { validateNickname } from '@/utils/validator.js';
const store = useStore(); const store = useStore();
@ -63,6 +89,11 @@ const nickname = ref('');
const errorMessage = ref(''); const errorMessage = ref('');
const isChecking = ref(false); const isChecking = ref(false);
//
const userAvatarUrl = ref('');
const showAvatarModal = ref(false);
const uploadingAvatar = ref(false);
const goToAuthPage = () => { const goToAuthPage = () => {
const hasRegisterDraft = Boolean(uni.getStorageSync('temp_register_mobile')); const hasRegisterDraft = Boolean(uni.getStorageSync('temp_register_mobile'));
const authPageUrl = hasRegisterDraft ? '/pages/register/register' : '/pages/login/login'; const authPageUrl = hasRegisterDraft ? '/pages/register/register' : '/pages/login/login';
@ -87,6 +118,122 @@ const goBack = () => {
goToAuthPage(); goToAuthPage();
}; };
//
const handleAvatarClick = () => {
showAvatarModal.value = true;
};
//
const closeAvatarModal = () => {
if (uploadingAvatar.value) return;
showAvatarModal.value = false;
};
//
const handleUploadAvatar = () => {
uni.chooseImage({
count: 1,
sizeType: ['compressed'],
sourceType: ['album', 'camera'],
success: async (res) => {
const tempFilePath = res.tempFilePaths[0];
uni.getFileInfo({
filePath: tempFilePath,
success: async (fileInfo) => {
if (fileInfo.size > 10 * 1024 * 1024) {
uni.showToast({
title: '图片大小不能超过10MB',
icon: 'none'
});
return;
}
await uploadAvatarToOss(tempFilePath);
},
fail: (error) => {
console.error('获取文件信息失败:', error);
uni.showToast({
title: '获取文件信息失败',
icon: 'none'
});
}
});
},
fail: (error) => {
console.error('选择图片失败:', error);
}
});
};
// OSS
const uploadAvatarToOss = async (filePath) => {
try {
uploadingAvatar.value = true;
uni.showLoading({ title: '上传中...', mask: true });
// 1. 使便/
const mobile = uni.getStorageSync('temp_register_mobile') || 'anon';
const signRes = await getPublicOssSignatureApi('register', mobile);
if (signRes.code !== 200) {
throw new Error(signRes.message || '获取签名失败');
}
// 2. OSS avatar.png policy
uni.uploadFile({
url: signRes.data.host,
filePath: filePath,
name: 'file',
formData: {
key: signRes.data.dir + 'avatar.png',
policy: signRes.data.policy,
success_action_status: '200',
'x-oss-credential': signRes.data.x_oss_credential,
'x-oss-date': signRes.data.x_oss_date,
'x-oss-security-token': signRes.data.security_token,
'x-oss-signature': signRes.data.signature,
'x-oss-signature-version': signRes.data.x_oss_signature_version
},
success: (uploadRes) => {
uni.hideLoading();
if (uploadRes.statusCode === 200 || uploadRes.statusCode === 204) {
userAvatarUrl.value = `${signRes.data.host}/${signRes.data.dir}avatar.png`;
uni.showToast({
title: '头像已选择',
icon: 'success'
});
closeAvatarModal();
} else {
uni.showToast({
title: `上传失败,状态码: ${uploadRes.statusCode}`,
icon: 'none',
duration: 2000
});
}
uploadingAvatar.value = false;
},
fail: (error) => {
console.error('OSS上传失败:', error);
uni.hideLoading();
uni.showToast({
title: '上传失败',
icon: 'none',
duration: 2000
});
uploadingAvatar.value = false;
}
});
} catch (error) {
console.error('上传头像失败:', error);
uni.hideLoading();
uni.showToast({
title: error.message || '上传失败',
icon: 'none',
duration: 2000
});
uploadingAvatar.value = false;
}
};
// //
const handleNext = async () => { const handleNext = async () => {
// //
@ -108,8 +255,6 @@ const handleNext = async () => {
isChecking.value = true; isChecking.value = true;
try { try {
const trimmedNickname = nickname.value.trim();
// //
const res = await checkNicknameApi(trimmedNickname); const res = await checkNicknameApi(trimmedNickname);
if (res.data.exists === true) { if (res.data.exists === true) {
@ -125,21 +270,12 @@ const handleNext = async () => {
// //
uni.setStorageSync('temp_register_nickname', trimmedNickname); uni.setStorageSync('temp_register_nickname', trimmedNickname);
// //
// uni.navigateTo({
// url: '/pages/welcome/welcome'
// });
// //
// uni.navigateTo({
// url: '/pages/profile/selectRole'
// });
// //
const mobile = uni.getStorageSync('temp_register_mobile'); const mobile = uni.getStorageSync('temp_register_mobile');
const password = uni.getStorageSync('temp_register_password'); const password = uni.getStorageSync('temp_register_password');
const star_id = 87; // const star_id = 87; //
const verify_token = uni.getStorageSync('temp_register_verify_token') || ''; const verify_token = uni.getStorageSync('temp_register_verify_token') || '';
const avatar_url = userAvatarUrl.value || '';
// //
if (!mobile || !password || !trimmedNickname || !star_id) { if (!mobile || !password || !trimmedNickname || !star_id) {
@ -172,7 +308,8 @@ const handleNext = async () => {
password, password,
star_id, star_id,
nickname: trimmedNickname, nickname: trimmedNickname,
verify_token verify_token,
avatar_url
}); });
uni.hideLoading(); uni.hideLoading();
@ -232,7 +369,6 @@ const handleNext = async () => {
left: 0; left: 0;
width: 100%; width: 100%;
height: 100%; height: 100%;
/* filter: blur(20rpx); */
transform: scale(1.1); transform: scale(1.1);
} }
@ -301,6 +437,7 @@ const handleNext = async () => {
} }
.avatar-wrapper { .avatar-wrapper {
position: relative;
width: 100%; width: 100%;
margin-bottom: 80rpx; margin-bottom: 80rpx;
display: flex; display: flex;
@ -308,6 +445,28 @@ const handleNext = async () => {
align-items: center; align-items: center;
} }
.avatar-edit-badge {
position: absolute;
right: 30%;
bottom: 0;
width: 56rpx;
height: 56rpx;
border-radius: 50%;
background: linear-gradient(165deg, #F0E4B1 0%, #F08399 50%, #B94E73 90%, #834B9E 100%);
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 4rpx 12rpx rgba(0, 0, 0, 0.3);
border: 4rpx solid #fff;
}
.avatar-edit-icon {
color: #fff;
font-size: 28rpx;
font-weight: bold;
line-height: 1;
}
.input-wrapper { .input-wrapper {
position: relative; position: relative;
@ -381,5 +540,96 @@ const handleNext = async () => {
color: #e6e6e6; color: #e6e6e6;
line-height: 1; line-height: 1;
} }
</style>
/* 头像上传弹窗 */
.avatar-modal {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
}
.modal-content {
width: 80%;
max-width: 600rpx;
background-image: url('/static/starbookcontent/beijing.png');
background-size: cover;
background-position: center bottom;
border-radius: 30rpx;
padding: 60rpx 40rpx;
box-sizing: border-box;
}
.modal-title {
font-size: 36rpx;
font-weight: bold;
color: #333333;
text-align: center;
margin-bottom: 40rpx;
}
.avatar-preview {
display: flex;
justify-content: center;
align-items: center;
margin-bottom: 40rpx;
}
.upload-avatar-btn {
width: 100%;
height: 88rpx;
background: linear-gradient(165deg, #F0E4B1 0%, #F08399 50%, #B94E73 90%, #834B9E 100%);
border-radius: 44rpx;
color: #ffffff;
font-size: 32rpx;
font-weight: 500;
border: none;
margin-bottom: 20rpx;
}
.upload-avatar-btn::after {
border: none;
}
.upload-avatar-btn:disabled {
opacity: 0.6;
}
.upload-hint {
font-size: 24rpx;
color: #999999;
text-align: center;
margin-bottom: 40rpx;
}
.modal-buttons {
display: flex;
gap: 20rpx;
}
.modal-btn-cancel {
flex: 1;
height: 88rpx;
line-height: 88rpx;
border-radius: 44rpx;
font-size: 32rpx;
font-weight: 500;
border: none;
background: #f5f5f5;
color: #666666;
}
.modal-btn-cancel::after {
border: none;
}
.modal-btn-cancel:active {
background: #e0e0e0;
}
</style>

View File

@ -336,11 +336,11 @@ onUnmounted(() => {
/* 第一排3个大图突出显示 */ /* 第一排3个大图突出显示 */
.grid-card:nth-child(-n + 3) { .grid-card:nth-child(-n + 3) {
width: calc(33.333% - 12rpx); width: calc(33% - 18rpx);
} }
.grid-card:nth-child(-n + 3) .card-image { .grid-card:nth-child(-n + 3) .card-image {
height: 236rpx; height: 252rpx;
box-shadow: 3px 3px 4.5px 2px rgba(0, 0, 0, 0.15); box-shadow: 3px 3px 4.5px 2px rgba(0, 0, 0, 0.15);
backdrop-filter: blur(0px); backdrop-filter: blur(0px);
padding: 8rpx; padding: 8rpx;
@ -382,7 +382,9 @@ onUnmounted(() => {
} }
.corner-decoration.top-corner-decoration { .corner-decoration.top-corner-decoration {
left: -24rpx; width: 56rpx;
height: 56rpx;
left: -16rpx;
right: 0; right: 0;
} }

View File

@ -119,9 +119,9 @@ const actions = {
}, },
// 注册 // 注册
async register({ commit }, { mobile, password, star_id, nickname, verify_token = '' }) { async register({ commit }, { mobile, password, star_id, nickname, verify_token = '', avatar_url = '' }) {
try { try {
const res = await registerApi(mobile, password, star_id, nickname, verify_token) const res = await registerApi(mobile, password, star_id, nickname, verify_token, avatar_url)
if (res.code === 200 && res.data) { if (res.code === 200 && res.data) {
// 缓存 access_token // 缓存 access_token
const accessToken = res.data.access_token const accessToken = res.data.access_token

View File

@ -185,7 +185,7 @@ export function checkmobileApi(mobile) {
} }
// 注册接口 // 注册接口
export function registerApi(mobile, password, star_id, nickname, verify_token = '') { export function registerApi(mobile, password, star_id, nickname, verify_token = '', avatar_url = '') {
return request({ return request({
url: '/api/v1/auth/register', url: '/api/v1/auth/register',
method: 'POST', method: 'POST',
@ -194,7 +194,8 @@ export function registerApi(mobile, password, star_id, nickname, verify_token =
password, password,
star_id, star_id,
nickname, nickname,
verify_token verify_token,
avatar_url
} }
}) })
} }
@ -508,6 +509,19 @@ export function getOssSignatureApi(type = 'asset', orderId = '') {
/** 兼容旧调用名:与 getOssSignatureApi 相同 */ /** 兼容旧调用名:与 getOssSignatureApi 相同 */
export const getOssUploadSignatureApi = getOssSignatureApi export const getOssUploadSignatureApi = getOssSignatureApi
/**
* 获取公开 OSS 上传签名注册等未登录场景用
* 对应网关GET /api/v1/public/oss/upload-signature
* @param {string} scene 场景目前固定为 'register'
* @param {string} key 命名空间注册场景下传 mobile用于服务端限定上传目录
*/
export function getPublicOssSignatureApi(scene, key) {
return request({
url: `/api/v1/public/oss/upload-signature?scene=${encodeURIComponent(scene)}&key=${encodeURIComponent(key)}`,
method: 'GET'
})
}
/** /**
* 获取 OSS 预签名 GET URL私有桶读图 * 获取 OSS 预签名 GET URL私有桶读图
* 对应网关GET /api/v1/assets/oss/presigned-url * 对应网关GET /api/v1/assets/oss/presigned-url