diff --git a/backend/deploy/envs/user.env b/backend/deploy/envs/user.env index 844a8be..84c07ea 100644 --- a/backend/deploy/envs/user.env +++ b/backend/deploy/envs/user.env @@ -20,6 +20,6 @@ REDIS_DB=0 # 与 asset.env 中的阿里云 AccessKey 相同(同一账号) SMS_ACCESS_KEY_ID=LTAI5t6QcdJHpYbCPxM8SXYE SMS_ACCESS_KEY_SECRET=ybvjSEb7wilMt3qT5nOppYPoNVayCD -SMS_SIGN_NAME=TopFans +SMS_SIGN_NAME=上海顶粉数字科技 SMS_TEMPLATE_CODE=SMS_314621237 SMS_REGION=cn-hangzhou diff --git a/backend/dev.sh b/backend/dev.sh index 22c937e..692d67a 100755 --- a/backend/dev.sh +++ b/backend/dev.sh @@ -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") 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/.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() { @@ -104,6 +116,13 @@ start_service() { 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") if [ "$use_db" = "1" ]; then args+=("${DB_ARGS[@]}") @@ -210,6 +229,12 @@ restart_service() { # Step 3: 启动新进程 sleep 1 cd "$SCRIPT_DIR" + + # 重新加载服务私有 env(只有给 dev 引入新变量的服务才需要) + case "$name" in + userService) load_service_env "$name" ;; + esac + local args=("-port=$port") if [ "$use_db" = "1" ]; then args+=("${DB_ARGS[@]}") diff --git a/backend/gateway/controller/asset_controller.go b/backend/gateway/controller/asset_controller.go index 170f090..8b96a4a 100644 --- a/backend/gateway/controller/asset_controller.go +++ b/backend/gateway/controller/asset_controller.go @@ -895,6 +895,76 @@ func (ctrl *AssetController) GetOSSUploadSignature(c *gin.Context) { 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 上传策略和签名 func (ctrl *AssetController) generateOSSPolicyToken( ossConfig config.OSSConfig, @@ -902,7 +972,22 @@ func (ctrl *AssetController) generateOSSPolicyToken( starID interface{}, uploadType string, ) (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 凭证提供器 credConfig := new(credentials.Config). SetType("ram_role_arn"). @@ -929,15 +1014,6 @@ func (ctrl *AssetController) generateOSSPolicyToken( date := utcTime.Format("20060102") 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 条件 conditions := []interface{}{ map[string]string{"bucket": ossConfig.BucketName}, diff --git a/backend/gateway/router/router.go b/backend/gateway/router/router.go index 8c1b69d..de62f11 100644 --- a/backend/gateway/router/router.go +++ b/backend/gateway/router/router.go @@ -135,6 +135,12 @@ func SetupRouter(userClient *client.Client, socialClient *client.Client, assetCl fanIdentities.GET("", userCtrl.GetFanIdentities) // 获取可选粉丝身份列表 } + // 公开 OSS 上传签名(用于注册等未登录场景的上传,如注册头像) + public := v1.Group("/public") + { + public.GET("/oss/upload-signature", assetCtrl.GetPublicOSSUploadSignature) // 公开 OSS 上传签名 + } + // 当前用户相关路由(需要认证) me := v1.Group("/me") me.Use(middleware.AuthMiddleware()) diff --git a/backend/pkg/proto/user/user.pb.go b/backend/pkg/proto/user/user.pb.go index 6711966..39c3f46 100644 --- a/backend/pkg/proto/user/user.pb.go +++ b/backend/pkg/proto/user/user.pb.go @@ -390,6 +390,7 @@ type RegisterRequest struct { 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"` // 第一个粉丝身份的昵称 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 sizeCache protoimpl.SizeCache } @@ -459,6 +460,13 @@ func (x *RegisterRequest) GetVerifyToken() string { return "" } +func (x *RegisterRequest) GetAvatarUrl() string { + if x != nil { + return x.AvatarUrl + } + return "" +} + // 注册响应 type RegisterResponse struct { 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" + "\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" + "\x06mobile\x18\x01 \x01(\tR\x06mobile\x12\x1a\n" + "\bpassword\x18\x02 \x01(\tR\bpassword\x12\x17\n" + "\astar_id\x18\x03 \x01(\x03R\x06starId\x12\x1a\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" + "\x04base\x18\x01 \x01(\v2\x1c.topfans.common.BaseResponseR\x04base\x12!\n" + "\faccess_token\x18\x02 \x01(\tR\vaccessToken\x12\x1d\n" + diff --git a/backend/proto/user.proto b/backend/proto/user.proto index 5dbfad3..34aaa53 100644 --- a/backend/proto/user.proto +++ b/backend/proto/user.proto @@ -61,6 +61,7 @@ message RegisterRequest { int64 star_id = 3; // 选择第一个粉丝身份的明星ID string nickname = 4; // 第一个粉丝身份的昵称 string verify_token = 5; // 短信验证token(注册前必须先通过短信验证) + string avatar_url = 6; // 头像URL(可选;为空则后端使用默认头像) } // 注册响应 diff --git a/backend/readme.md b/backend/readme.md index 841546f..7c3884f 100644 --- a/backend/readme.md +++ b/backend/readme.md @@ -68,7 +68,49 @@ apt-get 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/.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/.env` 放到目标服务器的 `/etc/topfans/.env` +- systemd unit / docker-compose 引用该文件 +- **不要** source `backend/.env` + +**新增配置项时的纪律:** + +1. 如果是 dev 和生产都需要的(如 `DB_*`、Dubbo URL 模板),在 `backend/.env` 改 + 在 `deploy/envs/*.env` 同步改 +2. 如果仅生产需要(如 `SMS_*`、OSS 角色),只改 `deploy/envs/.env` +3. 如果仅 dev 需要(如本地 mock 开关),只改 `backend/.env` +4. 改完后,在 dev 环境跑一次 `./dev.sh` 验证没有端口/密钥错乱 + +--- + +### 5. 生成代码 ```bash # 生成 gRPC 代码 @@ -81,6 +123,24 @@ make swagger 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`。 + ## 项目结构 ``` diff --git a/backend/scripts/migrations/migrate_drop_orphan_fk_fan_profiles.sql b/backend/scripts/migrations/migrate_drop_orphan_fk_fan_profiles.sql new file mode 100644 index 0000000..25a03ed --- /dev/null +++ b/backend/scripts/migrations/migrate_drop_orphan_fk_fan_profiles.sql @@ -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; diff --git a/backend/services/userService/go.mod b/backend/services/userService/go.mod index 92290ab..7366f92 100644 --- a/backend/services/userService/go.mod +++ b/backend/services/userService/go.mod @@ -4,7 +4,9 @@ go 1.25.5 require ( 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/topfans/backend v0.0.0 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/alibaba/sentinel-golang v1.0.4 // 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/tea v1.3.13 // indirect github.com/alibabacloud-go/tea-utils v1.4.4 // indirect github.com/alibabacloud-go/tea-utils/v2 v2.0.7 // indirect github.com/aliyun/alibaba-cloud-sdk-go v1.61.1800 // indirect diff --git a/backend/services/userService/go.sum b/backend/services/userService/go.sum index d3107f3..cd5b336 100644 --- a/backend/services/userService/go.sum +++ b/backend/services/userService/go.sum @@ -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-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-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/go.mod h1:lxFGfobinVsQ49ntjpgWghXmIF0/Sm4+wvBJ1h5RtaE= 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.1 h1:MsW9SmUtbb1Fnt3ieC6NNZi6aEwrXfDksD4QA6GSbPg= 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-20180501/v2 v2.0.8/go.mod h1:e/vWJ5gLVnraPROSh+3oMSodf5ukaUlqNgH0IIcnz98= +github.com/alibabacloud-go/dysmsapi-20170525/v5 v5.5.1 h1:CyJ1adk5jlg7acrbG1sgdZ+EXTZNZHwhNQAf6VFfySo= +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/go.mod h1:O5FuCALmCKs2Ff7JFJMudHs0I5EBgecXXxZRyswlEjE= github.com/alibabacloud-go/openapi-util v0.1.0 h1:0z75cIULkDrdEhkLWgi9tnLe+KhAFE/r5Pb3312/eAY= diff --git a/backend/services/userService/service/auth_service.go b/backend/services/userService/service/auth_service.go index 75d8108..f6b74f0 100644 --- a/backend/services/userService/service/auth_service.go +++ b/backend/services/userService/service/auth_service.go @@ -150,6 +150,12 @@ func (s *authService) Register(ctx context.Context, req *pb.RegisterRequest) (*p UpdatedAt: now, } + // 如果前端传入了头像URL则使用,否则保持空(前端会基于userId渲染默认头像) + if req.AvatarUrl != "" { + avatarURL := req.AvatarUrl + user.AvatarURL = &avatarURL + } + // 手动加密密码(因为需要在事务中使用) hashedPassword, err := repository.HashPassword(req.Password) if err != nil { diff --git a/backend/services/userService/service/sms_service.go b/backend/services/userService/service/sms_service.go index e92d4db..bf86a05 100644 --- a/backend/services/userService/service/sms_service.go +++ b/backend/services/userService/service/sms_service.go @@ -8,7 +8,7 @@ import ( "math/big" "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/tea/tea" "github.com/topfans/backend/pkg/logger" @@ -22,15 +22,17 @@ var smsClient *dysmsapi20170525.Client func InitSMSClient() error { 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 == "" { - logger.Logger.Warn("SMS credentials not configured, SMS client not initialized") - return nil + return errors.New("SMS credentials missing: SMS_ACCESS_KEY_ID and SMS_ACCESS_KEY_SECRET must be set") } openapiConfig := &client.Config{ AccessKeyId: tea.String(cfg.AccessKeyID), AccessKeySecret: tea.String(cfg.AccessKeySecret), + RegionId: tea.String(cfg.Region), } openapiConfig.Endpoint = tea.String("dysmsapi.aliyuncs.com") @@ -130,27 +132,39 @@ func SendVerificationCode(ctx context.Context, mobile, ip string) (int, error) { return 0, fmt.Errorf("failed to generate code: %w", err) } - // Step 6: Call Aliyun SMS API to send (if client is initialized) - if smsClient != nil { - cfg := config.GetSMSConfig() - sendReq := &dysmsapi20170525.SendMessageWithTemplateRequest{ - To: tea.String("86" + mobile), // China area code + mobile - From: tea.String(cfg.SignName), - TemplateCode: tea.String(cfg.TemplateCode), - TemplateParam: tea.String(fmt.Sprintf(`{"code":"%s"}`, code)), - } - - _, err := smsClient.SendMessageWithTemplate(sendReq) - if err != nil { - logger.Error("Failed to send SMS via Aliyun", zap.Error(err)) - return 0, fmt.Errorf("failed to send SMS: %w", err) - } - - logger.Info("SMS sent successfully via Aliyun") - } else { - logger.Warn("SMS client not initialized, skipping actual SMS send") + // Step 6: Call Aliyun SMS API to send. smsClient==nil is a programming + // 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() + sendReq := &dysmsapi20170525.SendSmsRequest{ + PhoneNumbers: tea.String(mobile), + SignName: tea.String(cfg.SignName), + TemplateCode: tea.String(cfg.TemplateCode), + TemplateParam: tea.String(fmt.Sprintf(`{"code":"%s"}`, code)), + } + + resp, err := smsClient.SendSms(sendReq) + if err != nil { + logger.Error("Failed to send SMS via Aliyun", zap.Error(err)) + return 0, fmt.Errorf("failed to send SMS: %w", err) + } + if resp == nil || resp.Body == nil { + return 0, errors.New("Aliyun SMS response empty") + } + 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 err = SaveSMSCode(ctx, mobile, code, 60*time.Second) if err != nil { diff --git a/frontend/manifest.json b/frontend/manifest.json index dfaa23c..1d3d05c 100644 --- a/frontend/manifest.json +++ b/frontend/manifest.json @@ -3,7 +3,7 @@ "appid" : "__UNI__F199FF4", "description" : "", "versionName" : "1.0.5", - "versionCode" : 103, + "versionCode" : 105, "transformPx" : false, /* 5+App特有相关 */ "app-plus" : { diff --git a/frontend/pages/asset-detail/asset-detail.vue b/frontend/pages/asset-detail/asset-detail.vue index a9f1f09..0020ba1 100644 --- a/frontend/pages/asset-detail/asset-detail.vue +++ b/frontend/pages/asset-detail/asset-detail.vue @@ -1199,6 +1199,7 @@ onUnmounted(() => { font-variant-numeric: tabular-nums; text-shadow: 0 2px 8px rgba(0, 0, 0, 0.7); display: flex; + align-items: center; } .countdown-val { diff --git a/frontend/pages/components/Header.vue b/frontend/pages/components/Header.vue index f615e5a..95998b5 100644 --- a/frontend/pages/components/Header.vue +++ b/frontend/pages/components/Header.vue @@ -570,12 +570,14 @@ defineExpose({ .task-text-label { font-weight: 500; - font-size: 12rpx; - line-height: 100%; - letter-spacing: 0%; + font-size: 24rpx; + /* line-height: 100%; + letter-spacing: 0%; */ color: #fff9e7; text-shadow: 1rpx 1rpx 2rpx rgba(0, 0, 0, 0.84); margin-top: 32rpx; + white-space: nowrap; + transform: scale(0.5); } /* 水晶余额组件 */ diff --git a/frontend/pages/profile/setNickname.vue b/frontend/pages/profile/setNickname.vue index 58723db..b60ba5d 100644 --- a/frontend/pages/profile/setNickname.vue +++ b/frontend/pages/profile/setNickname.vue @@ -3,7 +3,7 @@ - + @@ -11,33 +11,36 @@ - 设置昵称 + 设置头像与昵称 - + - - - + + + + + + - + - - + {{ errorMessage }} - + + + 支持JPG、PNG格式,大小不超过10MB + + + + + + @@ -53,7 +79,7 @@ import { ref } from 'vue'; import { useStore } from 'vuex'; 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'; const store = useStore(); @@ -63,6 +89,11 @@ const nickname = ref(''); const errorMessage = ref(''); const isChecking = ref(false); +// 头像上传相关 +const userAvatarUrl = ref(''); +const showAvatarModal = ref(false); +const uploadingAvatar = ref(false); + const goToAuthPage = () => { const hasRegisterDraft = Boolean(uni.getStorageSync('temp_register_mobile')); const authPageUrl = hasRegisterDraft ? '/pages/register/register' : '/pages/login/login'; @@ -87,6 +118,122 @@ const goBack = () => { 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 () => { // 验证昵称 @@ -108,8 +255,6 @@ const handleNext = async () => { isChecking.value = true; try { - const trimmedNickname = nickname.value.trim(); - // 检查昵称是否已被注册 const res = await checkNicknameApi(trimmedNickname); if (res.data.exists === true) { @@ -125,21 +270,12 @@ const handleNext = async () => { // 暂存昵称到临时存储 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 password = uni.getStorageSync('temp_register_password'); const star_id = 87; // 默认身份 const verify_token = uni.getStorageSync('temp_register_verify_token') || ''; + const avatar_url = userAvatarUrl.value || ''; // 验证数据完整性 if (!mobile || !password || !trimmedNickname || !star_id) { @@ -172,7 +308,8 @@ const handleNext = async () => { password, star_id, nickname: trimmedNickname, - verify_token + verify_token, + avatar_url }); uni.hideLoading(); @@ -232,7 +369,6 @@ const handleNext = async () => { left: 0; width: 100%; height: 100%; - /* filter: blur(20rpx); */ transform: scale(1.1); } @@ -301,6 +437,7 @@ const handleNext = async () => { } .avatar-wrapper { + position: relative; width: 100%; margin-bottom: 80rpx; display: flex; @@ -308,6 +445,28 @@ const handleNext = async () => { 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 { position: relative; @@ -381,5 +540,96 @@ const handleNext = async () => { color: #e6e6e6; line-height: 1; } - +/* 头像上传弹窗 */ +.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; +} + diff --git a/frontend/pages/square/components/HotCategoryBlock.vue b/frontend/pages/square/components/HotCategoryBlock.vue index 108eb2c..fc1f7a1 100644 --- a/frontend/pages/square/components/HotCategoryBlock.vue +++ b/frontend/pages/square/components/HotCategoryBlock.vue @@ -336,11 +336,11 @@ onUnmounted(() => { /* 第一排:3个大图突出显示 */ .grid-card:nth-child(-n + 3) { - width: calc(33.333% - 12rpx); + width: calc(33% - 18rpx); } .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); backdrop-filter: blur(0px); padding: 8rpx; @@ -382,7 +382,9 @@ onUnmounted(() => { } .corner-decoration.top-corner-decoration { - left: -24rpx; + width: 56rpx; + height: 56rpx; + left: -16rpx; right: 0; } diff --git a/frontend/store/modules/user.js b/frontend/store/modules/user.js index cec3a2d..258d40a 100644 --- a/frontend/store/modules/user.js +++ b/frontend/store/modules/user.js @@ -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 { - 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) { // 缓存 access_token const accessToken = res.data.access_token diff --git a/frontend/utils/api.js b/frontend/utils/api.js index c5a272c..d40e1cd 100644 --- a/frontend/utils/api.js +++ b/frontend/utils/api.js @@ -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({ url: '/api/v1/auth/register', method: 'POST', @@ -194,7 +194,8 @@ export function registerApi(mobile, password, star_id, nickname, verify_token = password, star_id, nickname, - verify_token + verify_token, + avatar_url } }) } @@ -508,6 +509,19 @@ export function getOssSignatureApi(type = 'asset', orderId = '') { /** 兼容旧调用名:与 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(私有桶读图) * 对应网关:GET /api/v1/assets/oss/presigned-url