You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 

11 KiB

用户昵称字段设计方案

方案名称:新增 nickname 字段,优化用户身份体系
创建时间:2025-01-14
版本:v1.0


1. 背景与问题

原有设计

  • username:唯一标识,首次登录自动生成,仅允许修改一次
  • username_changed:布尔字段,标记是否已修改过用户名

存在的问题

  1. 缺少灵活的显示名称字段
  2. username 既作为唯一标识,又作为显示名称,职责不清
  3. 用户希望能随时修改显示名称,但 username 限制修改次数

2. 设计决策

关于 username_changed 字段

决策: 保留

理由

  1. 性能最优:单表查询 WHERE username_changed = false,无需 JOIN
  2. 语义清晰:布尔值直接表达业务含义
  3. 成本极低:仅占用 1 字节存储
  4. 逻辑可靠:不依赖字符串匹配或外部表

替代方案分析

  • 方案 B(正则匹配):用户可能修改成类似格式导致误判
  • 方案 C(历史表):过度设计,增加查询复杂度

关于 nickname 字段

决策: 新增

设计规格

  • 类型:TEXT
  • 约束:允许 NULL
  • 唯一性:无需唯一约束(允许重复)
  • 默认值:NULL
  • 长度限制:1-50 字符(应用层校验)
  • 修改限制:无限制,可随时修改
  • 索引:不需要唯一索引

3. 字段体系对比

字段 用途 唯一性 修改限制 典型场景
username 账号标识 必须唯一 只能改一次 登录、@提及、URL
nickname 显示名称 可重复 随时修改 评论、个人资料、社交
username_changed 修改标记 - 系统维护 业务逻辑判断

4. 数据库变更

4.1 表结构变更

-- 在 users 表中新增 nickname 字段
ALTER TABLE users
ADD COLUMN nickname TEXT;

-- 添加注释
COMMENT ON COLUMN users.nickname IS '用户昵称(可重复、可随时修改)';

4.2 数据迁移(可选)

-- 为现有用户初始化 nickname(复制 username)
UPDATE users
SET nickname = username
WHERE nickname IS NULL AND deleted_at IS NULL;

4.3 完整表结构

CREATE TABLE users (
    user_id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,

    -- 登录方式
    phone TEXT,
    country_code TEXT DEFAULT '+86',
    phone_verified BOOLEAN NOT NULL DEFAULT false,
    wechat_openid TEXT,
    wechat_unionid TEXT,
    wechat_platform TEXT CHECK (wechat_platform IN ('mp', 'open')),

    -- 基本信息
    email TEXT,
    username TEXT NOT NULL,
    username_changed BOOLEAN NOT NULL DEFAULT false,
    nickname TEXT,  -- 新增字段
    password_hash TEXT,
    avatar_url TEXT,
    avatar_id BIGINT REFERENCES attachments(attachment_id) ON DELETE SET NULL,

    -- 算力积分信息
    ai_credits_balance INTEGER NOT NULL DEFAULT 0,
    total_recharged_amount NUMERIC(10, 2) NOT NULL DEFAULT 0.00,
    total_credits_earned INTEGER NOT NULL DEFAULT 0,
    total_credits_consumed INTEGER NOT NULL DEFAULT 0,

    -- 时间戳
    created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
    updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
    deleted_at TIMESTAMPTZ,

    -- 唯一约束
    CONSTRAINT users_phone_unique UNIQUE (phone, country_code) NULLS NOT DISTINCT,
    CONSTRAINT users_email_unique UNIQUE (email) NULLS NOT DISTINCT,
    CONSTRAINT users_username_unique UNIQUE (username) NULLS NOT DISTINCT,
    CONSTRAINT users_wechat_openid_unique UNIQUE (wechat_openid, wechat_platform) NULLS NOT DISTINCT
);

5. 代码实现

5.1 数据模型

# app/models/user.py
from sqlalchemy import Column, BigInteger, String, Boolean, Text
from app.core.database import Base

class User(Base):
    __tablename__ = "users"

    user_id = Column(BigInteger, primary_key=True, autoincrement=True)

    # 基本信息
    email = Column(String(255))
    username = Column(String(255), nullable=False)
    username_changed = Column(Boolean, default=False)
    nickname = Column(Text)  # 新增
    password_hash = Column(String(255))
    avatar_url = Column(String(500))

    # ... 其他字段

5.2 Schema 定义

# app/schemas/user.py
from pydantic import BaseModel, Field
from typing import Optional

class UserUpdate(BaseModel):
    """更新用户信息(支持修改 nickname)"""
    nickname: Optional[str] = Field(None, min_length=1, max_length=50)
    avatar_url: Optional[str] = None

class UsernameUpdate(BaseModel):
    """修改用户名(仅允许一次)"""
    username: str = Field(..., min_length=3, max_length=50, pattern="^[a-zA-Z0-9_\u4e00-\u9fa5-]+$")

class UserResponse(BaseModel):
    """用户信息响应"""
    user_id: int
    phone: Optional[str]
    email: Optional[str]
    username: str
    nickname: Optional[str]  # 新增
    avatar_url: Optional[str]
    ai_credits_balance: int
    total_recharged_amount: float
    username_changed: bool
    created_at: datetime

    class Config:
        from_attributes = True

5.3 Service 层

# app/services/user_service.py
class UserService:
    async def update_user(
        self,
        user_id: int,
        user_data: UserUpdate
    ) -> User:
        """更新用户信息(包括 nickname)"""
        user = await self.repository.get_by_id(user_id)
        if not user:
            raise NotFoundError("用户不存在")

        update_data = user_data.dict(exclude_unset=True)

        # nickname 可随时修改,无需额外校验
        return await self.repository.update(user_id, update_data)

    async def update_username(
        self,
        user_id: int,
        username: str
    ) -> User:
        """修改用户名(仅允许一次)"""
        user = await self.repository.get_by_id(user_id)
        if not user:
            raise NotFoundError("用户不存在")

        # 检查是否已修改过(使用 username_changed 字段)
        if user.username_changed:
            raise ValidationError("用户名只能修改一次")

        # 检查用户名是否已被使用
        existing_user = await self.repository.get_by_username(username)
        if existing_user:
            raise ValidationError("用户名已被使用")

        return await self.repository.update(user_id, {
            'username': username,
            'username_changed': True
        })

6. API 接口

6.1 更新用户信息(支持修改 nickname)

PUT /api/v1/users/me

请求体

{
  "nickname": "新昵称"
}

响应

{
  "user_id": 123,
  "username": "my_username",
  "nickname": "新昵称",
  "avatar_url": "https://storage.jointo.ai/avatars/123.jpg",
  "username_changed": false
}

6.2 修改用户名(仅允许一次)

PUT /api/v1/users/me/username

请求体

{
  "username": "new_username"
}

响应

{
  "message": "用户名修改成功",
  "user": {
    "user_id": 123,
    "username": "new_username",
    "username_changed": true
  }
}

错误响应

{
  "error": "用户名只能修改一次"
}

7. 业务逻辑流程

7.1 用户注册流程

1. 用户首次登录(手机号/微信)
2. 系统自动生成 username(格式:user_{timestamp}_{random4})
3. nickname 初始为 NULL
4. username_changed = false

7.2 修改 nickname 流程

1. 用户调用 PUT /api/v1/users/me
2. 传入 nickname 参数
3. 系统直接更新 nickname 字段
4. 无需校验 username_changed
5. 可随时修改,无次数限制

7.3 修改 username 流程

1. 用户调用 PUT /api/v1/users/me/username
2. 系统检查 username_changed 字段
3. 如果 username_changed = true,返回错误
4. 如果 username_changed = false,允许修改
5. 修改成功后,设置 username_changed = true
6. 后续无法再次修改

8. 优势总结

8.1 用户体验

  • username 作为稳定的账号标识
  • nickname 作为灵活的显示名称
  • 用户可随时修改昵称,无心理负担

8.2 技术实现

  • username_changed 字段性能最优
  • 单表查询,无需 JOIN
  • 逻辑清晰,易于维护

8.3 扩展性

  • 未来可支持 nickname 历史记录
  • 可添加 nickname 敏感词过滤
  • 可支持 nickname 搜索功能

9. 注意事项

9.1 数据迁移

  • 现有用户的 nickname 初始为 NULL
  • 可选择性地将 username 复制到 nickname
  • 建议在应用层处理默认值

9.2 前端展示

  • 优先显示 nickname(如果存在)
  • nickname 为空时,回退显示 username
  • 示例:display_name = user.nickname || user.username

9.3 敏感词过滤

  • nickname 允许重复,但需过滤敏感词
  • 建议在应用层实现敏感词检测
  • 可使用第三方服务或自建词库

10. 测试用例

10.1 修改 nickname

# 测试:可随时修改 nickname
def test_update_nickname():
    # 第一次修改
    response = client.put("/api/v1/users/me", json={"nickname": "昵称1"})
    assert response.status_code == 200
    assert response.json()["nickname"] == "昵称1"

    # 第二次修改(应该成功)
    response = client.put("/api/v1/users/me", json={"nickname": "昵称2"})
    assert response.status_code == 200
    assert response.json()["nickname"] == "昵称2"

10.2 修改 username

# 测试:只能修改一次 username
def test_update_username_once():
    # 第一次修改
    response = client.put("/api/v1/users/me/username", json={"username": "new_name"})
    assert response.status_code == 200
    assert response.json()["user"]["username_changed"] == True

    # 第二次修改(应该失败)
    response = client.put("/api/v1/users/me/username", json={"username": "another_name"})
    assert response.status_code == 400
    assert "只能修改一次" in response.json()["error"]

11. 迁移脚本

11.1 数据库迁移(Alembic)

# migrations/versions/xxxx_add_nickname_field.py
"""add nickname field to users table

Revision ID: xxxx
Revises: yyyy
Create Date: 2025-01-14

"""
from alembic import op
import sqlalchemy as sa

def upgrade():
    # 新增 nickname 字段
    op.add_column('users', sa.Column('nickname', sa.Text(), nullable=True))

    # 可选:为现有用户初始化 nickname
    op.execute("""
        UPDATE users
        SET nickname = username
        WHERE nickname IS NULL AND deleted_at IS NULL
    """)

def downgrade():
    # 回滚:删除 nickname 字段
    op.drop_column('users', 'nickname')

12. 相关文档


方案版本:v1.0
最后更新:2025-01-14