Compare commits

...

3 Commits

27 changed files with 780 additions and 297 deletions

View File

@ -5,6 +5,8 @@ import (
"fmt"
"net/http"
"strconv"
"strings"
"time"
"dubbo.apache.org/dubbo-go/v3/client"
"dubbo.apache.org/dubbo-go/v3/common/constant"
@ -13,9 +15,12 @@ import (
"github.com/topfans/backend/gateway/dto"
"github.com/topfans/backend/gateway/pkg/ossutil"
"github.com/topfans/backend/gateway/pkg/response"
"github.com/topfans/backend/pkg/database"
"github.com/topfans/backend/pkg/jwt"
"github.com/topfans/backend/pkg/logger"
pb "github.com/topfans/backend/pkg/proto/user"
"go.uber.org/zap"
"gorm.io/gorm"
)
// UserController 用户控制器
@ -614,3 +619,146 @@ func (ctrl *UserController) GetMyFanIdentities(c *gin.Context) {
data := dto.ToMyFanIdentitiesResponseDTO(resp.Items, resp.CurrentStarId)
response.Success(c, data)
}
// DeleteAccount 注销账号
// @Summary 注销账号
// @Description 软删除当前用户账号:停用账号和所有粉丝身份,释放手机号供重新注册
// @Tags users
// @Accept json
// @Produce json
// @Security BearerAuth
// @Success 200 {object} response.Response
// @Router /api/user/delete-account [post]
func (ctrl *UserController) DeleteAccount(c *gin.Context) {
// 从认证中间件获取用户信息
userID, exists := c.Get("user_id")
if !exists {
response.Error(c, http.StatusUnauthorized, "未授权")
return
}
uid := userID.(int64)
logger.Logger.Info("DeleteAccount request",
zap.Int64("user_id", uid),
)
// 获取数据库连接
db := database.GetDB()
if db == nil {
logger.Logger.Error("DeleteAccount: database not initialized")
response.Error(c, http.StatusInternalServerError, "服务内部错误")
return
}
// 提前黑名单当前 token防止事务成功但黑名单失败导致的漏洞
// 解析 token 获取过期时间TTL = 剩余有效时间
tokenTTL := 7 * 24 * time.Hour // 默认 7 天
authHeader := c.GetHeader("Authorization")
rawToken := strings.TrimPrefix(authHeader, "Bearer ")
if claims, parseErr := jwt.ParseToken(rawToken); parseErr == nil && claims.ExpiresAt != nil {
if remaining := time.Until(claims.ExpiresAt.Time); remaining > 0 {
tokenTTL = remaining
}
}
// 黑名单写入Redis 不可用时记录日志但不阻断注销流程)
blacklistCtx := context.Background()
if redisErr := database.AddToBlacklist(blacklistCtx, rawToken, uid, "account_deleted", tokenTTL); redisErr != nil {
logger.Logger.Warn("DeleteAccount: failed to blacklist token",
zap.Int64("user_id", uid),
zap.Error(redisErr),
)
}
// 在事务中执行软删除
err := db.Transaction(func(tx *gorm.DB) error {
now := time.Now().UnixMilli()
// 1. 软删除用户停用账号、释放手机号、清除token
releasedMobile := fmt.Sprintf("D%010d", uid) // D + 用户ID补齐10位 = 11位符合varchar(11)
result := tx.Model(&struct {
ID int64 `gorm:"column:id"`
IsActive bool `gorm:"column:is_active"`
Mobile string `gorm:"column:mobile"`
AccessToken *string `gorm:"column:access_token"`
DeletedAt *int64 `gorm:"column:deleted_at"`
UpdatedAt int64 `gorm:"column:updated_at"`
}{}).
Table("users").
Where("id = ?", uid).
Updates(map[string]interface{}{
"is_active": false,
"mobile": releasedMobile,
"access_token": nil,
"deleted_at": now,
"updated_at": now,
})
if result.Error != nil {
logger.Logger.Error("DeleteAccount: failed to soft-delete user",
zap.Int64("user_id", uid),
zap.Error(result.Error),
)
return result.Error
}
if result.RowsAffected == 0 {
logger.Logger.Warn("DeleteAccount: user already deleted, still deactivating fan profiles",
zap.Int64("user_id", uid),
)
}
// 2. 停用所有粉丝身份,并释放昵称(解除 uniqueIndex 占用)
var profiles []struct {
ID int64 `gorm:"column:id"`
Nickname string `gorm:"column:nickname"`
}
if err := tx.Table("fan_profiles").
Where("user_id = ? AND is_active = ?", uid, true).
Find(&profiles).Error; err != nil {
logger.Logger.Error("DeleteAccount: failed to query fan profiles",
zap.Int64("user_id", uid),
zap.Error(err),
)
return err
}
for _, p := range profiles {
releasedNickname := fmt.Sprintf("D%010d", p.ID) // D + profile ID 补齐 10 位
if err := tx.Table("fan_profiles").
Where("id = ?", p.ID).
Updates(map[string]interface{}{
"is_active": false,
"nickname": releasedNickname,
"updated_at": now,
}).Error; err != nil {
logger.Logger.Error("DeleteAccount: failed to deactivate fan profile",
zap.Int64("user_id", uid),
zap.Int64("profile_id", p.ID),
zap.Error(err),
)
return err
}
}
logger.Logger.Info("DeleteAccount: fan profiles deactivated",
zap.Int64("user_id", uid),
zap.Int("count", len(profiles)),
)
return nil
})
if err != nil {
logger.Logger.Error("DeleteAccount transaction failed",
zap.Int64("user_id", uid),
zap.Error(err),
)
response.Error(c, http.StatusInternalServerError, "注销失败,请稍后重试")
return
}
logger.Logger.Info("DeleteAccount successful",
zap.Int64("user_id", uid),
)
response.SuccessWithMessage(c, "账号已注销", nil)
}

View File

@ -5130,6 +5130,10 @@ const docTemplate = `{
"current_identity": {
"$ref": "#/definitions/dto.CurrentIdentityDTO"
},
"mobile": {
"description": "脱敏手机号,如 139****0001",
"type": "string"
},
"nickname": {
"type": "string"
},

View File

@ -60,6 +60,7 @@ type GetMeResponseDTO struct {
Nickname string `json:"nickname"`
AvatarURL string `json:"avatar_url,omitempty"`
ChainAddress string `json:"chain_address,omitempty"`
Mobile string `json:"mobile,omitempty"` // 脱敏手机号,如 139****0001
CurrentIdentity CurrentIdentityDTO `json:"current_identity"`
}

View File

@ -118,6 +118,11 @@ func ToGetMeResponseDTO(user *pb.User, profile *pb.FanProfile, star *pb.Star) Ge
dto.ChainAddress = profile.ChainAddress
}
// 脱敏手机号139****0001
if user.Mobile != "" && len(user.Mobile) == 11 {
dto.Mobile = user.Mobile[:3] + "****" + user.Mobile[7:]
}
return dto
}

View File

@ -225,8 +225,12 @@ func main() {
// 4.13 初始化 Activity HubWebSocket 实时推送)
redisClient := database.GetRedis()
activityHub := socket.NewActivityHub(redisClient, cfg.WebSocket.ActivityPath)
go activityHub.Run(context.Background())
defer activityHub.Close()
hubCtx, hubCancel := context.WithCancel(context.Background())
go activityHub.Run(hubCtx)
defer func() {
hubCancel()
activityHub.Close()
}()
logger.Logger.Info("ActivityHub initialized",
zap.String("path", cfg.WebSocket.ActivityPath),
zap.Bool("redis_available", redisClient != nil),
@ -261,5 +265,7 @@ func main() {
<-quit
logger.Logger.Info("Shutting down gateway server...")
hubCancel()
activityHub.Close()
logger.Logger.Info("Gateway server stopped")
}

View File

@ -210,6 +210,13 @@ func SetupRouter(userClient *client.Client, socialClient *client.Client, assetCl
account.POST("/password", userCtrl.UpdatePassword) // 更新密码
}
// 用户相关路由(需要认证)
user := v1.Group("/user")
user.Use(middleware.AuthMiddleware())
{
user.POST("/delete-account", userCtrl.DeleteAccount) // 注销账号
}
// 我的粉丝身份管理路由(需要认证)
myFanIdentities := v1.Group("/my/fan-identities")
myFanIdentities.Use(middleware.AuthMiddleware())

View File

@ -58,13 +58,41 @@ func (h *ActivityHub) ActivityPath() string {
return h.activityPath
}
// Run 启动 Redis PSubscribe收到 publish 后 fanout 到本地连接
// Run 启动 Redis PSubscribe 主循环,带自动重连。
// 当 Redis 不可用时定时重试,不会永久阻塞;
// ch 意外关闭后自动重新订阅,确保 fanout 链路始终存活。
func (h *ActivityHub) Run(ctx context.Context) {
for {
select {
case <-ctx.Done():
logger.Logger.Info("ActivityHub Run loop exiting due to context done")
return
default:
}
h.runOnce(ctx)
// runOnce 退出说明 PSubscribe 断开或 Redis 不可用;等待后重试
logger.Logger.Warn("ActivityHub runOnce exited, will retry after 5s")
select {
case <-ctx.Done():
return
case <-time.After(5 * time.Second):
}
}
}
// runOnce 执行单次 PSubscribe → fanout 循环。
func (h *ActivityHub) runOnce(ctx context.Context) {
if h.redisClient == nil {
logger.Logger.Warn("ActivityHub: redisClient is nil, Pub/Sub fanout disabled")
<-ctx.Done()
logger.Logger.Warn("ActivityHub: redisClient is nil, Pub/Sub fanout disabled, waiting...")
select {
case <-ctx.Done():
case <-time.After(5 * time.Second):
}
return
}
sub := h.redisClient.PSubscribe(ctx, "act:*:messages", "act:*:contributions")
defer sub.Close()
ch := sub.Channel()
@ -72,11 +100,11 @@ func (h *ActivityHub) Run(ctx context.Context) {
for {
select {
case <-ctx.Done():
logger.Logger.Info("ActivityHub Run loop exiting due to context done")
logger.Logger.Info("ActivityHub runOnce exiting due to context done")
return
case msg, ok := <-ch:
if !ok {
logger.Logger.Warn("ActivityHub Redis Pub/Sub channel closed")
logger.Logger.Warn("ActivityHub Redis Pub/Sub channel closed, will reconnect")
return
}
var payload map[string]interface{}
@ -89,7 +117,8 @@ func (h *ActivityHub) Run(ctx context.Context) {
}
}
// fanout 把 payload 推送到订阅该 channel 的所有本地连接
// fanout 把 payload 推送到订阅该 channel 的所有本地连接。
// 写失败时主动清理死连接,不等 Ping/Pong 超时(最长 ~90s
func (h *ActivityHub) fanout(channel string, payload map[string]interface{}) {
h.mu.RLock()
conns := h.subscriptions[channel]
@ -99,11 +128,22 @@ func (h *ActivityHub) fanout(channel string, payload map[string]interface{}) {
}
h.mu.RUnlock()
var dead []*ActivityConn
for _, c := range targets {
if err := c.writeJSON(payload); err != nil {
logger.Logger.Error("ActivityHub writeJSON failed", zap.Int64("user_id", c.UserID), zap.Error(err))
dead = append(dead, c)
}
}
// 批量清理死连接(需要写锁,与 RLock 分离以避免死锁)
if len(dead) > 0 {
h.mu.Lock()
for _, c := range dead {
h.removeConnLocked(c)
}
h.mu.Unlock()
}
}
// HandleWebSocket 处理 /activity 握手
@ -148,12 +188,17 @@ func (h *ActivityHub) HandleWebSocket(w http.ResponseWriter, r *http.Request) {
zap.Int64("star_id", starID),
)
// 立即推送 auth_response
// 立即推送 auth_response附带 pub/sub 状态
pubsubStatus := "connected"
if h.redisClient == nil {
pubsubStatus = "pubsub_disabled"
}
_ = conn.WriteJSON(map[string]interface{}{
"type": "auth_response",
"success": true,
"user_id": userID,
"star_id": starID,
"type": "auth_response",
"success": true,
"user_id": userID,
"star_id": starID,
"pubsub_status": pubsubStatus,
})
go c.readPump()
@ -309,7 +354,12 @@ func (h *ActivityHub) unsubscribe(c *ActivityConn, activityID int64, topics []st
// unregister 断开时清理
func (h *ActivityHub) unregister(c *ActivityConn) {
h.mu.Lock()
defer h.mu.Unlock()
h.removeConnLocked(c)
h.mu.Unlock()
}
// removeConnLocked 从 clients 和所有 subscriptions 中移除连接(需持有 h.mu 写锁)
func (h *ActivityHub) removeConnLocked(c *ActivityConn) {
if conns, ok := h.clients[c.UserID]; ok {
delete(conns, c)
if len(conns) == 0 {

View File

@ -31,8 +31,8 @@ var upgrader = websocket.Upgrader{
// Hub 管理所有 AI Chat WebSocket 连接
type Hub struct {
// 用户连接映射: userId -> *Connection
clients map[int64]*Connection
// 用户连接映射: userId -> set of *Connection(支持同一用户多设备)
clients map[int64]map[*Connection]struct{}
// Dubbo 客户端
aiChatClient *client.Client
@ -69,7 +69,7 @@ func (c *Connection) sendError(code, message string) {
// NewHub 创建 Hub 实例
func NewHub(aiChatClient *client.Client, aiChatPath string) *Hub {
return &Hub{
clients: make(map[int64]*Connection),
clients: make(map[int64]map[*Connection]struct{}),
aiChatClient: aiChatClient,
aiChatPath: aiChatPath,
}
@ -113,9 +113,12 @@ func (h *Hub) HandleWebSocket(w http.ResponseWriter, r *http.Request) {
Hub: h,
}
// 注册连接
// 注册连接(同一用户多设备各自独立连接)
h.mu.Lock()
h.clients[userID] = connection
if h.clients[userID] == nil {
h.clients[userID] = make(map[*Connection]struct{})
}
h.clients[userID][connection] = struct{}{}
h.mu.Unlock()
logger.Logger.Info("WebSocket connection established",
@ -166,7 +169,12 @@ func (h *Hub) validateToken(token string) (int64, int64, error) {
func (c *Connection) readPump() {
defer func() {
c.Hub.mu.Lock()
delete(c.Hub.clients, c.UserID)
if conns, ok := c.Hub.clients[c.UserID]; ok {
delete(conns, c)
if len(conns) == 0 {
delete(c.Hub.clients, c.UserID)
}
}
c.Hub.mu.Unlock()
c.Conn.Close()
}()
@ -467,12 +475,14 @@ func (c *Connection) getPersonas() {
// sendError 发送错误消息 (使用 Send 通道)
// Close 关闭连接
// Close 关闭所有连接
func (h *Hub) Close() {
h.mu.Lock()
defer h.mu.Unlock()
for _, conn := range h.clients {
conn.Conn.Close()
for _, conns := range h.clients {
for c := range conns {
c.Conn.Close()
}
}
}

View File

@ -178,6 +178,7 @@ func (s *authService) Register(ctx context.Context, req *pb.RegisterRequest) (*p
UserID: user.ID,
StarID: req.StarId,
Nickname: req.Nickname,
AvatarURL: user.AvatarURL,
Level: 1,
Times: 1,
Social: 0,

View File

@ -258,12 +258,20 @@ func (s *identityService) AddIdentity(req *pb.AddIdentityRequest, userID int64)
return nil, fmt.Errorf("failed to check existing fan profile: %w", err)
}
// 5. 创建新的粉丝档案
// 5. 查询用户头像(用于新粉丝档案)
var avatarURL *string
user, err := s.userRepo.GetByID(userID)
if err == nil && user != nil {
avatarURL = user.AvatarURL
}
// 6. 创建新的粉丝档案
now := time.Now().UnixMilli()
fanProfile := &models.FanProfile{
UserID: userID,
StarID: req.StarId,
Nickname: req.Nickname,
AvatarURL: avatarURL,
Level: 1,
Times: 1,
Social: 0,

View File

@ -13,9 +13,27 @@ export default {
// square handleEnterTopfans true
globalData: {
welcomeShownThisSession: false,
// onLaunch onShow
shouldShowQuickLogin: false,
},
onLaunch: function () {
console.log("App Launch");
// token
// onLaunch /app
// HIDE_TIME_KEY App //
// 5 onLaunch quickLogin
const token = uni.getStorageSync("access_token");
const lastHideTime = uni.getStorageSync(HIDE_TIME_KEY) || 0;
const RECENT_RESTART_THRESHOLD = 5 * 60 * 1000; // 5
const isRecentRestart = Date.now() - lastHideTime < RECENT_RESTART_THRESHOLD;
this.globalData.shouldShowQuickLogin = !!token && !isRecentRestart;
// needs_welcome square TopfansWelcome
// welcomeShownThisSession false needs_welcome storage
if (isRecentRestart) {
uni.removeStorageSync("needs_welcome");
}
// TopfansWelcome
//
// 1) "" onLaunch
@ -34,12 +52,30 @@ export default {
},
onShow: function () {
console.log("App Show");
// token onShow
// shouldShowQuickLogin false
if (this.globalData.shouldShowQuickLogin) {
this.globalData.shouldShowQuickLogin = false;
// needs_welcome App quickLogin square TopfansWelcome
// / login.vue / selectRole.vue
uni.removeStorageSync("needs_welcome");
// 使
this.getAllNotice();
this.clearBadgeAndNotifications();
uni.reLaunch({ url: "/pages/login/quickLogin" });
return;
}
this.handleBackgroundReturn();
this.getAllNotice();
// WebSocketonHide
this.initWebSocket();
// App
this.clearBadgeAndNotifications();
this.getAllNotice();
// App
this.clearBadgeAndNotifications();
},
onHide: function () {
console.log("App Hide");

View File

@ -3,10 +3,6 @@
<!-- 状态栏占位 -->
<view class="status-bar"></view>
<view v-if="showBack" class="nav-back" @tap="goBack">
<text class="nav-back-icon"></text>
</view>
<!-- 背景图片层 -->
<image
class="background-image"
@ -48,15 +44,12 @@ defineProps({
type: Boolean,
default: true,
},
showBack: {
type: Boolean,
default: true,
},
// showBack: {
// type: Boolean,
// default: true,
// },
});
const goBack = () => {
uni.navigateBack();
};
</script>
<style scoped>
@ -76,25 +69,6 @@ const goBack = () => {
flex-shrink: 0;
}
.nav-back {
width: 80rpx;
height: 80rpx;
display: flex;
align-items: center;
justify-content: center;
position: absolute;
top: 96rpx;
left: 32rpx;
/* background: rgba(255,255,255,0.5);
border-radius: 50%; */
}
.nav-back-icon {
font-size: 48rpx;
font-weight: bold;
color: #fff;
}
/* 背景图:覆盖整个屏幕 */
.background-image {
position: absolute;

View File

@ -172,8 +172,8 @@ export default {
}
this.isErrorProcessing = true
this.isTyping = false
uni.showToast({ title: data.message || data.error || '发生错误', icon: 'none' })
this.aiMessage = '亲爱的你来辣 ~~'
uni.showToast({ title: data.message || data.error || '发生错误', icon: 'none' })
//
setTimeout(() => {
this.isErrorProcessing = false

View File

@ -1,8 +1,10 @@
<template>
<view class="avatar-wrapper" :style="wrapperStyle">
<view class="avatar-circle" :style="avatarStyle">
<image class="avatar-image" :src="avatarImage" mode="aspectFill"></image>
<image class="avatar-image-k" src="/static/square/gerentouxiangkuang.png" mode="aspectFill" />
<view class="avatar-box" :style="avatarBoxStyle">
<image class="avatar-frame" src="/static/login/portal/setnickname-avatar-box.png" mode="aspectFit"></image>
<view class="avatar-inner" :style="avatarInnerStyle">
<image class="avatar-img" :src="avatarImage" mode="aspectFill"></image>
</view>
</view>
<view class="level-badge" v-if="showLevel && level" :style="badgeStyle">
<text class="level-text" :style="badgeTextStyle">Lv {{ level }}</text>
@ -138,15 +140,25 @@ const wrapperStyle = computed(() => ({
flexShrink: 0
}));
//
const avatarStyle = computed(() => {
//
const shadowSize = props.size >= 160 ? '0 8rpx 32rpx rgba(0, 0, 0, 0.2)' : '0 4rpx 16rpx rgba(0, 0, 0, 0.2)';
// 稿 270rpx inset 18rpx
// = 18 / 270 = 1/15 0.0667
const FRAME_INSET_RATIO = 18 / 270;
//
const avatarBoxStyle = computed(() => ({
position: 'relative',
width: `${props.size}rpx`,
height: `${props.size}rpx`
}));
// size inset
const avatarInnerStyle = computed(() => {
const inset = props.size * FRAME_INSET_RATIO;
return {
width: `${props.size}rpx`,
height: `${props.size}rpx`,
borderWidth: `${props.borderWidth}rpx`,
boxShadow: shadowSize
top: `${inset}rpx`,
left: `${inset}rpx`,
right: `${inset}rpx`,
bottom: `${inset}rpx`
};
});
@ -156,8 +168,6 @@ const badgeStyle = computed(() => {
if (props.size >= 160) {
// profile
return {
// top: '-10rpx',
// right: '-10rpx',
borderRadius: '20rpx',
padding: '6rpx 16rpx',
border: '3rpx solid rgba(255, 255, 255, 0.8)',
@ -168,8 +178,6 @@ const badgeStyle = computed(() => {
//
const offset = -props.size * 0.18; //
return {
// top: `${offset}rpx`,
// right: `${offset}rpx`,
borderRadius: '10rpx',
padding: '0 8rpx',
border: '2rpx solid rgba(255, 255, 255, 0.8)',
@ -198,33 +206,44 @@ const badgeTextStyle = computed(() => {
flex-shrink: 0;
}
.avatar-circle {
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
border-style: solid;
border-color: rgba(255, 255, 255, 0.3);
overflow: hidden;
/* 头像框外层容器 */
.avatar-box {
position: relative;
}
.avatar-image {
/* 头像框装饰图setnickname-avatar-box.png*/
.avatar-frame {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
filter: drop-shadow(0 0 23rpx rgba(135, 16, 16, 0.67));
}
/* 头像内圆区域 */
.avatar-inner {
position: absolute;
border-radius: 50%;
/* background: linear-gradient(
147deg,
rgba(223, 223, 146, 0.87) 17%,
rgba(135, 197, 216, 0.87) 39%,
rgba(194, 123, 209, 0.87) 99%
); */
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
}
/* 头像图片 */
.avatar-img {
width: 100%;
height: 100%;
border-radius: 50%;
object-fit: cover;
}
.avatar-image-k {
width: 100%;
height: 100%;
display: block;
position: absolute;
top: 0;
transform: scale(1.05);
}
.level-badge {
position: absolute;
background: linear-gradient(165deg, #F0E4B1 0%, #F08399 50%, #B94E73 90%, #834B9E 100%);

View File

@ -4,15 +4,16 @@
<view class="login-content">
<!-- 头像 -->
<view class="avatar-wrapper">
<image
class="avatar-image"
src="/static/login/portal/register-avatar.png"
mode="aspectFit"
></image>
<image class="avatar-image" src="/static/login/portal/register-avatar.png" mode="aspectFit"></image>
</view>
<!-- 登录卡片 -->
<view class="login-card">
<view class="nav-back" @tap="goBack">
<text class="nav-back-icon"></text>
</view>
<!-- 标题 -->
<view class="card-title-wrapper">
<text class="card-title">账号密码登录</text>
@ -24,14 +25,8 @@
<view class="input-group">
<view class="input-outer">
<view class="input-inner">
<input
class="input-field"
type="number"
v-model="form.phone"
placeholder="输入您的手机号"
placeholder-class="input-placeholder"
maxlength="11"
/>
<input class="input-field" type="number" v-model="form.phone" placeholder="输入您的手机号"
placeholder-class="input-placeholder" maxlength="11" />
</view>
</view>
</view>
@ -40,13 +35,8 @@
<view class="input-group">
<view class="input-outer">
<view class="input-inner">
<input
class="input-field"
:type="showPassword ? 'text' : 'password'"
v-model="form.password"
placeholder="输入您的密码"
placeholder-class="input-placeholder"
/>
<input class="input-field" :type="showPassword ? 'text' : 'password'" v-model="form.password"
placeholder="输入您的密码" placeholder-class="input-placeholder" />
</view>
</view>
</view>
@ -59,9 +49,7 @@
<!-- 忘记密码 -->
<view class="forgot-password-wrapper">
<text class="forgot-password" @tap="handleForgotPassword"
>忘记密码</text
>
<text class="forgot-password" @tap="handleForgotPassword">忘记密码</text>
</view>
<!-- 分割线 -->
@ -74,6 +62,22 @@
</view>
</view>
</view>
<!-- 用户不存在弹窗 -->
<view v-if="showRegisterDialog" class="dialog-overlay" @tap="closeRegisterDialog">
<view class="dialog-card" @tap.stop>
<view class="dialog-title">账号不存在</view>
<view class="dialog-message">该手机号尚未注册是否前往注册页面创建账号</view>
<view class="dialog-actions">
<view class="dialog-btn dialog-btn-cancel" @tap="closeRegisterDialog">
<text class="dialog-btn-text">取消</text>
</view>
<view class="dialog-btn dialog-btn-confirm" @tap="goToRegister">
<text class="dialog-btn-text">去注册</text>
</view>
</view>
</view>
</view>
</view>
</LoginBackground>
</template>
@ -95,6 +99,11 @@ const form = ref({
});
const showPassword = ref(false);
const errorMessage = ref("");
const showRegisterDialog = ref(false);
const goBack = () => {
uni.reLaunch({ url: "/pages/login/portal" })
};
//
const getPageParams = () => {
@ -130,6 +139,11 @@ const handleForgotPassword = () => {
uni.showToast({ title: "忘记密码功能开发中", icon: "none" });
};
//
const closeRegisterDialog = () => {
showRegisterDialog.value = false;
};
//
const handleLogin = async () => {
const phoneValidation = validatePhone(form.value.phone);
@ -161,6 +175,16 @@ const handleLogin = async () => {
uni.reLaunch({ url: "/pages/square/square" });
} catch (error) {
uni.hideLoading();
//
if (
error.code === 404 ||
(error.message && error.message.includes("用户不存在"))
) {
showRegisterDialog.value = true;
return;
}
errorMessage.value = error.message || "登录失败,请重试";
uni.showToast({
title: errorMessage.value,
@ -214,6 +238,23 @@ const handleLogin = async () => {
z-index: 12;
}
.nav-back {
width: 80rpx;
height: 80rpx;
display: flex;
align-items: center;
justify-content: center;
position: absolute;
left: 32rpx;
}
.nav-back-icon {
font-size: 48rpx;
font-weight: bold;
color: #fff;
}
/* 标题 */
.card-title-wrapper {
position: relative;
@ -257,12 +298,10 @@ const handleLogin = async () => {
width: 100%;
height: 74rpx;
border-radius: 58rpx;
background: linear-gradient(
90deg,
rgba(255, 222, 8, 0.06) 0%,
rgba(252, 100, 102, 0.12) 64%,
rgba(244, 88, 104, 0.12) 100%
);
background: linear-gradient(90deg,
rgba(255, 222, 8, 0.06) 0%,
rgba(252, 100, 102, 0.12) 64%,
rgba(244, 88, 104, 0.12) 100%);
box-shadow: 4rpx 4rpx 8rpx 0 rgba(242, 21, 21, 0.47);
display: flex;
align-items: center;
@ -343,12 +382,10 @@ const handleLogin = async () => {
width: 270rpx;
height: 90rpx;
border-radius: 58rpx;
background: linear-gradient(
90deg,
rgba(255, 222, 8, 0.28) 0%,
rgba(252, 100, 102, 0.58) 64%,
rgba(244, 88, 104, 0.58) 100%
);
background: linear-gradient(90deg,
rgba(255, 222, 8, 0.28) 0%,
rgba(252, 100, 102, 0.58) 64%,
rgba(244, 88, 104, 0.58) 100%);
box-shadow: 4rpx 4rpx 8rpx 0 rgba(242, 21, 21, 0.47);
display: flex;
align-items: center;
@ -362,4 +399,80 @@ const handleLogin = async () => {
text-shadow: -2rpx 2rpx 8rpx rgba(0, 0, 0, 0.84);
font-family: "Abhaya Libre Medium", sans-serif;
}
/* 注册引导弹窗 */
.dialog-overlay {
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;
}
.dialog-card {
width: 560rpx;
background: #fff;
border-radius: 32rpx;
padding: 48rpx 40rpx 36rpx;
display: flex;
flex-direction: column;
align-items: center;
}
.dialog-title {
font-size: 36rpx;
font-weight: 600;
color: #333;
margin-bottom: 20rpx;
}
.dialog-message {
font-size: 28rpx;
color: #666;
text-align: center;
line-height: 1.6;
margin-bottom: 40rpx;
}
.dialog-actions {
display: flex;
gap: 24rpx;
width: 100%;
}
.dialog-btn {
flex: 1;
height: 80rpx;
border-radius: 58rpx;
display: flex;
align-items: center;
justify-content: center;
}
.dialog-btn-cancel {
background: #f5f5f5;
}
.dialog-btn-confirm {
background: linear-gradient(90deg,
rgba(255, 222, 8, 0.28) 0%,
rgba(252, 100, 102, 0.58) 64%,
rgba(244, 88, 104, 0.58) 100%);
box-shadow: 4rpx 4rpx 8rpx 0 rgba(242, 21, 21, 0.47);
}
.dialog-btn-text {
font-size: 30rpx;
color: #333;
}
.dialog-btn-confirm .dialog-btn-text {
color: #fff9e7;
text-shadow: -2rpx 2rpx 8rpx rgba(0, 0, 0, 0.84);
}
</style>

View File

@ -210,7 +210,7 @@ const openAgreementFromTip = () => {
.portal-card {
width: 606rpx;
height: 388rpx;
margin-top: 660rpx;
margin-top: 532rpx;
padding: 20rpx 50rpx;
box-sizing: border-box;
border-radius: 28px;
@ -337,8 +337,8 @@ const openAgreementFromTip = () => {
}
.checkbox-inner {
width: 24rpx;
height: 24rpx;
width: 30rpx;
height: 30rpx;
border-radius: 50%;
background: #ffffff;
}

View File

@ -54,7 +54,9 @@
class="agreement-checkbox"
:class="{ checked: agreedToTerms }"
@tap="toggleAgreement"
></view>
>
<view v-if="agreedToTerms" class="checkbox-inner"></view>
</view>
<view class="agreement-text">
<text class="agreement-line"
>登陆代表您已阅读并接受统一认证服务条款以及</text
@ -130,7 +132,7 @@ const maskPhone = (phone) => {
};
const maskedPhone = ref(
userInfo.mobile_masked || maskPhone(userInfo.mobile) || "174****2223",
userInfo.mobile_masked|| userInfo.mobile || maskPhone(userInfo.mobile) || "174****2223",
);
const userAvatar = ref(
userInfo.avatar_url || "/static/login/portal/quicklogin-avatar.png",
@ -155,8 +157,11 @@ const handleQuickLogin = () => {
uni.showLoading({ title: "登录中..." });
setTimeout(() => {
uni.hideLoading();
// TopfansWelcome
uni.setStorageSync("needs_welcome", true);
// TopfansWelcome
uni.setStorageSync("needs_welcome", true);
// quickLogin App + token App.vue
// needs_welcome TopfansWelcome
// needs_welcome login.vue selectRole.vue
uni.reLaunch({ url: "/pages/square/square" });
}, 800);
};
@ -408,8 +413,11 @@ const openAgreementFromTip = () => {
opacity: 0.96;
}
.agreement-checkbox.checked {
background: #fff;
.checkbox-inner {
width: 30rpx;
height: 30rpx;
border-radius: 50%;
background: #ffffff;
}
.agreement-text {

View File

@ -1438,7 +1438,7 @@ const confirmDeleteAccount = async () => {
//
setTimeout(() => {
uni.reLaunch({
url: "/pages/login/login",
url: "/pages/login/portal",
});
}, 1500);
} else {
@ -1486,7 +1486,7 @@ const confirmLogout = () => {
//
uni.reLaunch({
url: "/pages/login/login",
url: "/pages/login/portal",
});
};

View File

@ -13,6 +13,11 @@
<!-- 玻璃拟态卡片 -->
<view class="glass-card">
<view class="nav-back" @tap="goBack">
<text class="nav-back-icon"></text>
</view>
<!-- 标题选择明星 -->
<view class="card-title-wrapper">
<text class="card-title">选择明星</text>
@ -29,6 +34,7 @@
placeholder="请输入TA的名称"
placeholder-class="star-input-placeholder"
:adjust-position="true"
disabled
/>
</view>
</view>
@ -106,7 +112,7 @@ const emit = defineEmits(["confirm"]);
const store = useStore();
//
const searchText = ref("");
const searchText = ref("张艺兴");
const selectedStar = ref(null);
//
@ -149,7 +155,9 @@ const showConfirmModal = (options) => {
};
};
const goBack = () => {
uni.navigateBack();
};
@ -162,7 +170,7 @@ const selectStar = (star) => {
//
const resetSelection = () => {
selectedStar.value = null;
searchText.value = "";
// searchText.value = "";
};
//
@ -175,7 +183,7 @@ const handleConfirm = () => {
// selectedStar
selectedStar.value = {
id: Date.now(),
star_id: 0,
star_id: 87,
nameCn: query,
nameEn: "",
image: "/static/login/portal/selectrole-photo1.png",
@ -211,13 +219,17 @@ const handleNext = async () => {
const mobile = uni.getStorageSync("temp_register_mobile");
const password = uni.getStorageSync("temp_register_password");
const nickname = uni.getStorageSync("temp_register_nickname");
const verify_token = uni.getStorageSync("temp_register_verify_token") || "";
const avatar_url = uni.getStorageSync("temp_register_avatar_url") || "https://top-fans-test.oss-cn-shanghai.aliyuncs.com/avatar/13/87/character.png";
const star_id = targetStar.star_id;
console.log("注册信息:", { mobile, password, nickname, star_id, avatar_url });
if (!mobile || !password || !nickname || !star_id) {
uni.showToast({ title: "注册信息不完整,请重新注册", icon: "none" });
uni.removeStorageSync("temp_register_mobile");
uni.removeStorageSync("temp_register_password");
uni.removeStorageSync("temp_register_nickname");
uni.removeStorageSync("temp_register_verify_token");
uni.removeStorageSync("temp_register_avatar_url");
setTimeout(() => {
uni.reLaunch({ url: "/pages/register/register" });
}, 1500);
@ -231,12 +243,16 @@ const handleNext = async () => {
password,
star_id,
nickname,
verify_token,
avatar_url,
});
uni.hideLoading();
uni.removeStorageSync("temp_register_mobile");
uni.removeStorageSync("temp_register_password");
uni.removeStorageSync("temp_register_nickname");
uni.removeStorageSync("temp_register_verify_token");
uni.removeStorageSync("temp_register_avatar_url");
uni.setStorageSync("is_new_user", true);
// square TopfansWelcome
uni.setStorageSync("needs_welcome", true);
@ -314,6 +330,22 @@ const handleNext = async () => {
z-index: 12;
}
.nav-back {
width: 80rpx;
height: 80rpx;
display: flex;
align-items: center;
justify-content: center;
position: absolute;
left: 32rpx;
}
.nav-back-icon {
font-size: 48rpx;
font-weight: bold;
color: #fff;
}
/* 标题胶囊:选择明星 */
.card-title-wrapper {
position: relative;

View File

@ -13,6 +13,11 @@
<!-- 设置卡片 -->
<view class="nickname-card">
<view class="nav-back" @tap="goBack">
<text class="nav-back-icon"></text>
</view>
<!-- 标题 -->
<view class="card-title-wrapper">
<text class="card-title">设置头像与昵称</text>
@ -130,22 +135,8 @@ 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";
uni.reLaunch({ url: authPageUrl });
};
//
const goBack = () => {
const pages = getCurrentPages();
if (pages.length > 1) {
uni.navigateBack({ fail: goToAuthPage });
return;
}
goToAuthPage();
uni.navigateBack()
};
//
@ -273,67 +264,15 @@ const handleNext = async () => {
return;
}
// storage selectRole
uni.setStorageSync("temp_register_nickname", trimmedNickname);
const avatar_url = userAvatarUrl.value || "https://top-fans-test.oss-cn-shanghai.aliyuncs.com/avatar/13/87/character.png";
uni.setStorageSync("temp_register_avatar_url", avatar_url);
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) {
uni.showToast({
title: "注册信息不完整,请重新注册",
icon: "none",
});
uni.removeStorageSync("temp_register_mobile");
uni.removeStorageSync("temp_register_password");
uni.removeStorageSync("temp_register_nickname");
uni.removeStorageSync("temp_register_verify_token");
setTimeout(() => {
uni.reLaunch({ url: "/pages/register/register" });
}, 1500);
isChecking.value = false;
return;
}
uni.showLoading({ title: "注册中...", mask: true });
await store.dispatch("user/register", {
mobile,
password,
star_id,
nickname: trimmedNickname,
verify_token,
avatar_url,
});
uni.hideLoading();
uni.removeStorageSync("temp_register_mobile");
uni.removeStorageSync("temp_register_password");
uni.removeStorageSync("temp_register_nickname");
uni.removeStorageSync("temp_register_verify_token");
uni.setStorageSync("is_new_user", true);
// square TopfansWelcome
uni.setStorageSync("needs_welcome", true);
uni.reLaunch({ url: "/pages/profile/selectRole" });
} catch (error) {
uni.hideLoading();
if (
error.code === 3 ||
error.message.includes("昵称") ||
error.message.includes("已存在")
) {
uni.showModal({
title: "昵称已存在",
content: "该昵称已被使用,请返回修改昵称",
showCancel: false,
confirmText: "知道了",
});
} else {
errorMessage.value = error.message || "注册失败,请重试";
uni.showToast({ title: errorMessage.value, icon: "none" });
}
errorMessage.value = error.message || "检查昵称失败,请重试";
uni.showToast({ title: errorMessage.value, icon: "none" });
} finally {
isChecking.value = false;
}
@ -383,6 +322,22 @@ const handleNext = async () => {
z-index: 12;
}
.nav-back {
width: 80rpx;
height: 80rpx;
display: flex;
align-items: center;
justify-content: center;
position: absolute;
left: 32rpx;
}
.nav-back-icon {
font-size: 48rpx;
font-weight: bold;
color: #fff;
}
/* 标题 */
.card-title-wrapper {
position: relative;

View File

@ -4,15 +4,16 @@
<view class="register-content">
<!-- 头像 -->
<view class="avatar-wrapper">
<image
class="avatar-image"
src="/static/login/portal/register-avatar.png"
mode="aspectFit"
></image>
<image class="avatar-image" src="/static/login/portal/register-avatar.png" mode="aspectFit"></image>
</view>
<!-- 注册卡片 -->
<view class="register-card">
<view class="nav-back" @tap="goBack">
<text class="nav-back-icon"></text>
</view>
<!-- 标题 -->
<view class="card-title-wrapper">
<text class="card-title">手机号注册</text>
@ -24,15 +25,8 @@
<view class="input-group">
<view class="input-outer">
<view class="input-inner">
<input
class="input-field"
type="number"
v-model="form.phone"
placeholder="输入您的手机号"
placeholder-class="input-placeholder"
maxlength="11"
@input="handlePhoneInput"
/>
<input class="input-field" type="number" v-model="form.phone" placeholder="输入您的手机号"
placeholder-class="input-placeholder" maxlength="11" @input="handlePhoneInput" />
</view>
</view>
</view>
@ -41,31 +35,16 @@
<view class="input-group code-group">
<view class="input-outer code-outer">
<view class="input-inner code-inner">
<input
class="input-field"
type="number"
v-model="form.code"
placeholder="输入验证码"
placeholder-class="input-placeholder"
maxlength="6"
:disabled="codeStatus === 'verified'"
/>
<input class="input-field" type="number" v-model="form.code" placeholder="输入验证码"
placeholder-class="input-placeholder" maxlength="6" :disabled="codeStatus === 'verified'" />
</view>
</view>
<view
class="send-code-btn"
:class="{
countdown: codeStatus === 'countdown',
verified: codeStatus === 'verified',
}"
@tap="handleSendCode"
>
<text v-if="codeStatus === 'unsent' || codeStatus === 'resend'"
>发送验证码</text
>
<text v-else-if="codeStatus === 'countdown'"
>{{ countdown }}</text
>
<view class="send-code-btn" :class="{
countdown: codeStatus === 'countdown',
verified: codeStatus === 'verified',
}" @tap="handleSendCode">
<text v-if="codeStatus === 'unsent' || codeStatus === 'resend'">发送验证码</text>
<text v-else-if="codeStatus === 'countdown'">{{ countdown }}</text>
<text v-else-if="codeStatus === 'verified'">已验证</text>
</view>
</view>
@ -79,13 +58,8 @@
<view class="input-group">
<view class="input-outer">
<view class="input-inner">
<input
class="input-field"
:type="showPassword ? 'text' : 'password'"
v-model="form.password"
placeholder="创建密码"
placeholder-class="input-placeholder"
/>
<input class="input-field" :type="showPassword ? 'text' : 'password'" v-model="form.password"
placeholder="创建密码" placeholder-class="input-placeholder" />
</view>
</view>
</view>
@ -94,13 +68,8 @@
<view class="input-group">
<view class="input-outer">
<view class="input-inner">
<input
class="input-field"
:type="showPassword ? 'text' : 'password'"
v-model="confirmPassword"
placeholder="确认密码"
placeholder-class="input-placeholder"
/>
<input class="input-field" :type="showPassword ? 'text' : 'password'" v-model="confirmPassword"
placeholder="确认密码" placeholder-class="input-placeholder" />
</view>
</view>
</view>
@ -150,6 +119,10 @@ const verifyToken = ref("");
const countdownTimer = ref(null);
const isVerifying = ref(false);
const goBack = () => {
uni.reLaunch({ url: "/pages/login/portal" })
};
// /
const togglePassword = () => {
showPassword.value = !showPassword.value;
@ -347,6 +320,22 @@ const handleRegister = async () => {
z-index: 12;
}
.nav-back {
width: 80rpx;
height: 80rpx;
display: flex;
align-items: center;
justify-content: center;
position: absolute;
left: 32rpx;
}
.nav-back-icon {
font-size: 48rpx;
font-weight: bold;
color: #fff;
}
/* 标题 */
.card-title-wrapper {
position: relative;
@ -400,12 +389,10 @@ const handleRegister = async () => {
width: 100%;
height: 74rpx;
border-radius: 58rpx;
background: linear-gradient(
90deg,
rgba(255, 222, 8, 0.06) 0%,
rgba(252, 100, 102, 0.12) 64%,
rgba(244, 88, 104, 0.12) 100%
);
background: linear-gradient(90deg,
rgba(255, 222, 8, 0.06) 0%,
rgba(252, 100, 102, 0.12) 64%,
rgba(244, 88, 104, 0.12) 100%);
box-shadow: 4rpx 4rpx 8rpx 0 rgba(242, 21, 21, 0.47);
display: flex;
align-items: center;
@ -457,12 +444,10 @@ const handleRegister = async () => {
width: 152rpx;
height: 74rpx;
border-radius: 36rpx;
background: linear-gradient(
90deg,
rgba(255, 222, 8, 0.06) 0%,
rgba(252, 100, 102, 0.12) 64%,
rgba(244, 88, 104, 0.12) 100%
);
background: linear-gradient(90deg,
rgba(255, 222, 8, 0.06) 0%,
rgba(252, 100, 102, 0.12) 64%,
rgba(244, 88, 104, 0.12) 100%);
box-shadow: 4rpx 4rpx 8rpx 0 rgba(242, 21, 21, 0.47);
display: flex;
align-items: center;
@ -511,12 +496,10 @@ const handleRegister = async () => {
width: 270rpx;
height: 90rpx;
border-radius: 58rpx;
background: linear-gradient(
90deg,
rgba(255, 222, 8, 0.28) 0%,
rgba(252, 100, 102, 0.58) 64%,
rgba(244, 88, 104, 0.58) 100%
);
background: linear-gradient(90deg,
rgba(255, 222, 8, 0.28) 0%,
rgba(252, 100, 102, 0.58) 64%,
rgba(244, 88, 104, 0.58) 100%);
box-shadow: 4rpx 4rpx 8rpx 0 rgba(242, 21, 21, 0.47);
display: flex;
align-items: center;

View File

@ -70,6 +70,11 @@ export function useContributionRealtime(activityId, isPageActive) {
function onWsConnect() {
if (usingWS) return
// 即使 WS 已连接,如果后端 Pub/Sub 不可用,也不切到 WS保持轮询
if (!socket.isPubSubEnabled()) {
console.log('[useContributionRealtime] WS connected but pubsub disabled, keeping polling')
return
}
usingWS = true
stopPolling() // 停掉可能的轮询
socket.subscribe(activityId.value, ['contributions'])
@ -81,9 +86,19 @@ export function useContributionRealtime(activityId, isPageActive) {
startPolling() // 降级为轮询
}
// 后端上报 pub/sub 不可用:如果之前已切到 WS回退到轮询
function onPubsubDisabled() {
if (!usingWS) return
console.warn('[useContributionRealtime] Pub/Sub disabled, falling back to polling')
usingWS = false
socket.unsubscribe(activityId.value, ['contributions'])
startPolling()
}
socket.onContributionsResponse(onWsMessage)
socket.on('connect', onWsConnect)
socket.on('disconnect', onWsDisconnect)
socket.on('pubsub_disabled', onPubsubDisabled)
onMounted(() => {
// 总是调用 connect():SocketManager.connect() 内部会判断 token 是否变化
@ -93,11 +108,12 @@ export function useContributionRealtime(activityId, isPageActive) {
if (token) {
socket.connect(token).catch(err => console.warn('[useContributionRealtime] connect error:', err))
}
// 同步分支:如果 WS 已连接(单例复用导致 'connect' 事件不会再次触发),
// 同步分支:如果 WS 已连接(单例复用导致 'connect' 事件不会再次触发)且 pubsub 可用,
// 必须直接调 onWsConnect 停轮询,否则 polling 会一直跑。
// 异步分支:WS 还没连上时,先起 polling 兜底;
// 等 'connect' 事件触发 onWsConnect 后会停掉轮询。
if (socket.isConnected) {
// 但如果 pubsub 被禁用,即使 WS 已连接也不切,保持轮询。
if (socket.isConnected && socket.isPubSubEnabled()) {
onWsConnect()
} else {
startPolling()
@ -108,6 +124,7 @@ export function useContributionRealtime(activityId, isPageActive) {
if (usingWS) socket.unsubscribe(activityId.value, ['contributions'])
socket.off('connect', onWsConnect)
socket.off('disconnect', onWsDisconnect)
socket.off('pubsub_disabled', onPubsubDisabled)
socket.offContributionsResponse(onWsMessage)
stopPolling()
resetPolling()

View File

@ -94,13 +94,27 @@ export function useMessageRealtime(activityId) {
if (token) {
socket.connect(token).catch(err => console.warn('[useMessageRealtime] connect error:', err))
}
// 同步分支:如果 WS 已连接(单例复用,另一个 composable 可能先连上了),
// subscribe 会直接发送;否则缓存到 _topics等待 connect 事件触发 resubscribeAll
socket.subscribe(activityId.value, ['messages'])
socket.onMessagesResponse(onWsMessage)
// 如果后端 pub/sub 被禁用WS 订阅无效,需要手动刷新才能看到新消息
if (!socket.isPubSubEnabled()) {
console.warn('[useMessageRealtime] Pub/Sub disabled, real-time messages unavailable. Pull-to-refresh to see new messages.')
}
})
// 后端 pub/sub 状态变化:禁用时提醒用户
function onPubsubDisabled() {
console.warn('[useMessageRealtime] Pub/Sub became disabled, new messages will not arrive in real-time')
}
socket.on('pubsub_disabled', onPubsubDisabled)
onUnmounted(() => {
socket.unsubscribe(activityId.value, ['messages'])
socket.offMessagesResponse(onWsMessage)
socket.off('pubsub_disabled', onPubsubDisabled)
})
return {

View File

@ -145,7 +145,7 @@ const actions = {
// 缓存登录手机号
const loginMobile = mobile
uni.setStorageSync('login_mobile', loginMobile)
// uni.setStorageSync('login_mobile', loginMobile)
uni.setStorageSync('user', JSON.stringify(user))
commit('SET_USER_INFO', user)

View File

@ -90,7 +90,7 @@ export function request(options) {
// 跳转到登录页
uni.reLaunch({
url: '/pages/login/login'
url: '/pages/login/portal'
})
reject(new Error('登录已过期,请重新登录'))
@ -112,7 +112,7 @@ export function request(options) {
// 保留错误消息用于显示
const errorMsg = res.data.message || '登录已过期,请重新登录'
uni.reLaunch({
url: '/pages/login/login?error=' + encodeURIComponent(
url: '/pages/login/portal?error=' + encodeURIComponent(
errorMsg)
})
@ -284,7 +284,7 @@ export function deleteAccountApi() {
return mockDeleteAccountApi()
}
return request({
url: '/api/user/delete-account',
url: '/api/v1/user/delete-account',
method: 'POST'
})
}

View File

@ -10,13 +10,25 @@ class GlobalSocketManager {
this.sockets = {} // serviceName -> SocketManager
this.token = null
this.isAllConnected = false
this._initialized = false // 防止重复 init 导致监听器累积
// 保存监听器引用,用于 cleanup
this._aiChatConnectHandler = null
this._aiChatErrorHandler = null
this._activityConnectHandler = null
this._activityErrorHandler = null
}
/**
* 初始化所有连接
* 初始化所有连接幂等重复调用会先清理旧监听器再重连
*/
init(token) {
this.token = token
// 总是先清理旧监听器再注册新的,防止累积。
// 注意closeAll() 会清空 this.sockets所以 cleanup 必须通过单例 getter 获取 socket 引用。
this._cleanupListeners()
this._initialized = true
this._initAiChat()
this._initActivity()
// Future: this._initNotification()
@ -24,22 +36,49 @@ class GlobalSocketManager {
async _initAiChat() {
const aiChat = getAiChatSocket()
aiChat.on('connect', () => console.log('AI Chat connected'))
aiChat.on('error', (err) => console.error('AI Chat error:', err))
this._aiChatConnectHandler = () => console.log('AI Chat connected')
this._aiChatErrorHandler = (err) => console.error('AI Chat error:', err)
aiChat.on('connect', this._aiChatConnectHandler)
aiChat.on('error', this._aiChatErrorHandler)
await aiChat.connect(this.token)
this.sockets['ai_chat'] = aiChat
}
async _initActivity() {
const activity = getActivitySocket()
activity.on('connect', () => console.log('Activity socket connected'))
activity.on('error', (err) => console.error('Activity socket error:', err))
this._activityConnectHandler = () => console.log('Activity socket connected')
this._activityErrorHandler = (err) => console.error('Activity socket error:', err)
activity.on('connect', this._activityConnectHandler)
activity.on('error', this._activityErrorHandler)
if (this.token) {
await activity.connect(this.token)
}
this.sockets['activity'] = activity
}
/**
* 清理之前注册的监听器防止每次 init() 累积
* 通过单例 getter 获取 socket 引用而非 this.sockets
* 因为 closeAll() 会清空 this.sockets 但单例仍存活
*/
_cleanupListeners() {
try {
const aiChat = getAiChatSocket()
if (this._aiChatConnectHandler) {
aiChat.off('connect', this._aiChatConnectHandler)
aiChat.off('error', this._aiChatErrorHandler)
}
} catch (e) { /* singleton not yet created, skip */ }
try {
const activity = getActivitySocket()
if (this._activityConnectHandler) {
activity.off('connect', this._activityConnectHandler)
activity.off('error', this._activityErrorHandler)
}
} catch (e) { /* singleton not yet created, skip */ }
}
getSocket(serviceName) {
return this.sockets[serviceName]
}
@ -47,6 +86,7 @@ class GlobalSocketManager {
closeAll() {
Object.values(this.sockets).forEach(socket => socket.close())
this.sockets = {}
this._initialized = false // 关闭后允许下次 init() 重新注册监听器
}
}

View File

@ -21,6 +21,7 @@ class SocketManager {
this.isConnected = false
this.isAuthed = false
this.isClosing = false // 标记是否主动关闭
this.pubsubStatus = 'connected' // 'connected' | 'pubsub_disabled'(后端 auth_response 下发)
// 事件处理器
this.eventHandlers = {
@ -28,6 +29,7 @@ class SocketManager {
'disconnect': [],
'auth_success': [],
'auth_fail': [],
'pubsub_disabled': [], // 后端上报 pub/sub 不可用
'error': [],
'message': [] // 通用消息处理
}
@ -107,8 +109,22 @@ class SocketManager {
this._isConnecting = true
console.log(`[${this.serviceName}] _doConnect called, clearing old socket`)
// 清理旧连接
// 清除上一次连接的超时定时器,防止跨连接干扰
if (this._connectTimeout) {
clearTimeout(this._connectTimeout)
this._connectTimeout = null
}
// 清理旧连接:先关闭再丢弃,防止旧 socket 的回调污染状态
if (this.socket) {
try {
if (typeof this.socket.close === 'function') {
this.socket.close()
} else if (typeof this.socket.complete === 'function') {
this.socket.complete()
}
} catch (e) {
console.warn(`[${this.serviceName}] Error closing old socket:`, e)
}
this.socket = null
}
this.isConnected = false
@ -153,10 +169,22 @@ class SocketManager {
const socket = this.socket
const self = this
// 连接超时保护15s 内未连接成功则重置 _isConnecting防止永久阻塞
// 存储在实例上而非局部变量,防止旧连接的定时器在新连接建立后误触发
self._connectTimeout = setTimeout(() => {
if (self._isConnecting) {
console.warn(`[${self.serviceName}] Connection timeout (15s), resetting _isConnecting`)
self._isConnecting = false
self._connectTimeout = null
self._emit('error', { code: 'CONNECT_TIMEOUT', message: '连接超时' })
}
}, 15000)
// 连接打开
if (typeof socket.onOpen === 'function') {
socket.onOpen(function() {
console.log(`[${self.serviceName}] WebSocket connected`)
clearTimeout(self._connectTimeout); self._connectTimeout = null
self.isConnected = true
self._isConnecting = false // 连接成功,允许后续重连
// 清除重连计时器
@ -171,6 +199,7 @@ class SocketManager {
// 标准 WebSocket 风格
socket.onopen(function() {
console.log(`[${self.serviceName}] WebSocket connected`)
clearTimeout(self._connectTimeout); self._connectTimeout = null
self.isConnected = true
self._isConnecting = false // 连接成功,允许后续重连
// 清除重连计时器
@ -202,6 +231,7 @@ class SocketManager {
if (typeof socket.onClose === 'function') {
socket.onClose(function() {
console.log(`[${self.serviceName}] WebSocket closed`)
clearTimeout(self._connectTimeout); self._connectTimeout = null
self._isConnecting = false // 连接关闭,允许后续重连
self._cleanup()
self._emit('disconnect')
@ -210,6 +240,7 @@ class SocketManager {
} else if (typeof socket.onclose === 'function') {
socket.onclose(function() {
console.log(`[${self.serviceName}] WebSocket closed`)
clearTimeout(self._connectTimeout); self._connectTimeout = null
self._isConnecting = false // 连接关闭,允许后续重连
self._cleanup()
self._emit('disconnect')
@ -218,10 +249,13 @@ class SocketManager {
}
// 连接错误
var handleSocketError = function(err) {
let handleSocketError = function(err) {
console.error(`[${self.serviceName}] WebSocket error:`, err)
clearTimeout(self._connectTimeout); self._connectTimeout = null
// 重置连接中状态,避免后续 connect() 调用被永久阻塞
self._isConnecting = false
// 检查是否是鉴权相关的错误401/403
var errMsg = (err && (err.errMsg || err.message || '')).toLowerCase()
let errMsg = (err && (err.errMsg || err.message || '')).toLowerCase()
if (errMsg.indexOf('auth') !== -1 || errMsg.indexOf('reject') !== -1 || errMsg.indexOf('401') !== -1) {
console.warn(`[${self.serviceName}] Connection rejected (auth failure), clearing token`)
self._emit('auth_fail', err)
@ -248,6 +282,12 @@ class SocketManager {
if (type === 'auth_response') {
if (data.success) {
this.isAuthed = true
// 记录后端上报的 pub/sub 状态,用于判断是否降级到轮询
this.pubsubStatus = data.pubsub_status || 'connected'
if (this.pubsubStatus === 'pubsub_disabled') {
console.warn(`[${this.serviceName}] Pub/Sub disabled by server, real-time push unavailable`)
this._emit('pubsub_disabled', data)
}
this._emit('auth_success', data)
this._startHeartbeat()
} else {
@ -347,8 +387,20 @@ class SocketManager {
clearTimeout(this.reconnectTimer)
this.reconnectTimer = null
}
if (this._connectTimeout) {
clearTimeout(this._connectTimeout)
this._connectTimeout = null
}
this.isConnected = false
this.isAuthed = false
this.pubsubStatus = 'connected' // 重置为默认值,等下次 auth_response 更新
}
/**
* 后端 Pub/Sub 是否可用用于判断是否降级到 HTTP 轮询
*/
isPubSubEnabled() {
return this.pubsubStatus !== 'pubsub_disabled'
}
/**