Browse Source

提交代码

zlwu 10 months ago
parent
commit
2375bb0bef

+ 92 - 0
backend/migrations/migrate_add_area_code_to_users.py

@@ -0,0 +1,92 @@
+"""
+数据库迁移脚本:为 users 表添加 area_code 字段
+运行: python migrate_add_area_code_to_users.py
+"""
+import asyncio
+import sys
+import aiomysql
+from config import settings
+
+
+async def migrate_add_area_code_column() -> bool:
+    """为 users 表添加 area_code 字段"""
+    try:
+        conn = await aiomysql.connect(
+            host=settings.MYSQL_HOST,
+            port=settings.MYSQL_PORT,
+            user=settings.MYSQL_USER,
+            password=settings.MYSQL_PASSWORD,
+            db=settings.MYSQL_DATABASE,
+            charset="utf8mb4",
+        )
+
+        async with conn.cursor() as cursor:
+            # 检查 users 表是否存在
+            await cursor.execute(
+                """
+                SELECT COUNT(*) FROM information_schema.tables
+                WHERE table_schema = %s AND table_name = 'users'
+                """,
+                (settings.MYSQL_DATABASE,),
+            )
+            table_exists = (await cursor.fetchone())[0] > 0
+            if not table_exists:
+                print("❌ users 表不存在,请先创建表")
+                conn.close()
+                return False
+
+            # 检查 area_code 列是否已存在
+            await cursor.execute(
+                """
+                SELECT COUNT(*) FROM information_schema.columns
+                WHERE table_schema = %s
+                  AND table_name = 'users'
+                  AND column_name = 'area_code'
+                """,
+                (settings.MYSQL_DATABASE,),
+            )
+            has_area_code = (await cursor.fetchone())[0] > 0
+
+            if has_area_code:
+                print("✅ area_code 字段已存在,无需迁移")
+                conn.close()
+                return True
+
+            print("🔄 添加 area_code 字段...")
+            await cursor.execute(
+                """
+                ALTER TABLE users
+                ADD COLUMN area_code VARCHAR(10) NULL COMMENT '手机区号(如 +86)' AFTER user_type
+                """
+            )
+            print("✅ area_code 字段添加成功")
+
+            await conn.commit()
+            print("✅ 迁移完成")
+
+        conn.close()
+        return True
+
+    except Exception as e:  # noqa: BLE001
+        import traceback
+
+        print(f"❌ 迁移失败: {e}")
+        traceback.print_exc()
+        return False
+
+
+if __name__ == "__main__":
+    # 确认是否继续
+    if len(sys.argv) > 1 and sys.argv[1] == "--force":
+        confirm = "y"
+    else:
+        confirm = input("\n是否继续迁移 users 表添加 area_code 字段? (y/n): ")
+
+    if confirm.lower() != "y":
+        sys.exit(0)
+
+    success = asyncio.run(migrate_add_area_code_column())
+
+    if not success:
+        sys.exit(1)
+

+ 75 - 0
backend/migrations/migrate_add_area_code_to_verification_codes.py

@@ -0,0 +1,75 @@
+"""
+数据库迁移脚本:为 verification_codes 表添加 area_code 字段
+运行: python migrate_add_area_code_to_verification_codes.py
+"""
+import asyncio
+import sys
+from typing import Any
+
+import aiomysql
+
+from config import settings
+
+
+async def migrate_add_area_code_field() -> bool:
+    """为 verification_codes 表添加 area_code 字段"""
+    try:
+        conn: aiomysql.Connection = await aiomysql.connect(
+            host=settings.MYSQL_HOST,
+            port=settings.MYSQL_PORT,
+            user=settings.MYSQL_USER,
+            password=settings.MYSQL_PASSWORD,
+            db=settings.MYSQL_DATABASE,
+            charset="utf8mb4",
+        )
+
+        async with conn.cursor() as cursor:
+            # 检查字段是否已存在
+            await cursor.execute(
+                """
+                SELECT COUNT(*) FROM information_schema.columns
+                WHERE table_schema = %s 
+                AND table_name = 'verification_codes' 
+                AND column_name = 'area_code'
+                """,
+                (settings.MYSQL_DATABASE,),
+            )
+            exists_row: Any = await cursor.fetchone()
+            field_exists = bool(exists_row and exists_row[0] > 0)
+
+            if not field_exists:
+                print("🔄 为 verification_codes 表添加 area_code 字段...")
+                await cursor.execute(
+                    """
+                    ALTER TABLE verification_codes
+                    ADD COLUMN area_code VARCHAR(10) NULL COMMENT '手机区号(如 +86)'
+                    AFTER code_id
+                    """
+                )
+                await conn.commit()
+                print("✅ area_code 字段添加完成")
+            else:
+                print("✅ area_code 字段已存在")
+
+        conn.close()
+        return True
+    except Exception:
+        import traceback
+
+        traceback.print_exc()
+        return False
+
+
+if __name__ == "__main__":
+    if len(sys.argv) > 1 and sys.argv[1] == "--force":
+        confirm = "y"
+    else:
+        confirm = input("\n是否继续执行迁移? (y/n): ")
+
+    if confirm.lower() != "y":
+        sys.exit(0)
+
+    ok = asyncio.run(migrate_add_area_code_field())
+    if not ok:
+        sys.exit(1)
+

+ 305 - 0
backend/migrations/migrate_add_avatars.py

@@ -0,0 +1,305 @@
+"""
+数据库迁移脚本:创建形象相关表,并为 user_ui_states 添加 current_avatar_id
+运行: python migrate_add_avatars.py
+"""
+import asyncio
+import sys
+from typing import Any
+
+import aiomysql
+
+from config import settings
+
+
+DEFAULT_AVATAR_ID = "avatar_default"
+REGISTERED_AVATAR_ID = "avatar_registered_cap"
+
+# 默认形象 GIF 资源
+DEFAULT_AVATAR_ASSETS = [
+    (DEFAULT_AVATAR_ID, "default", "https://assets.lyrria.com/01-Default.gif"),
+    (DEFAULT_AVATAR_ID, "positive", "https://assets.lyrria.com/04-Positive-emotion.gif"),
+    (DEFAULT_AVATAR_ID, "negative", "https://assets.lyrria.com/05-Negative-emotion.gif"),
+    (DEFAULT_AVATAR_ID, "talking", "https://assets.lyrria.com/06-talking.gif"),
+]
+
+# 注册赠送形象 GIF 资源
+REGISTERED_AVATAR_ASSETS = [
+    (
+        REGISTERED_AVATAR_ID,
+        "default",
+        "https://assets.lyrria.com/01-Default-Registered.gif",
+    ),
+    (
+        REGISTERED_AVATAR_ID,
+        "positive",
+        "https://assets.lyrria.com/04-Positive-emotion-Registered.gif",
+    ),
+    (
+        REGISTERED_AVATAR_ID,
+        "negative",
+        "https://assets.lyrria.com/05-Negative-emotion-Registered.gif",
+    ),
+    (
+        REGISTERED_AVATAR_ID,
+        "talking",
+        "https://assets.lyrria.com/06-talking-Registered.gif",
+    ),
+]
+
+# 统一的字符序(用于跨表 join 避免字符集/排序规则不一致报错)
+COLLATE_NAME = "utf8mb4_unicode_ci"
+
+
+async def migrate_create_avatar_tables() -> bool:
+    """创建形象相关表,并初始化默认数据"""
+    try:
+        conn: aiomysql.Connection = await aiomysql.connect(
+            host=settings.MYSQL_HOST,
+            port=settings.MYSQL_PORT,
+            user=settings.MYSQL_USER,
+            password=settings.MYSQL_PASSWORD,
+            db=settings.MYSQL_DATABASE,
+            charset="utf8mb4",
+        )
+
+        async with conn.cursor() as cursor:
+            # 1. avatars 表
+            await cursor.execute(
+                """
+                SELECT COUNT(*) FROM information_schema.tables
+                WHERE table_schema = %s AND table_name = 'avatars'
+                """,
+                (settings.MYSQL_DATABASE,),
+            )
+            exists_row: Any = await cursor.fetchone()
+            avatars_exists = bool(exists_row and exists_row[0] > 0)
+
+            if not avatars_exists:
+                print("🔄 创建 avatars 表...")
+                await cursor.execute(
+                    """
+                    CREATE TABLE avatars (
+                        id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY COMMENT '主键',
+                        avatar_id VARCHAR(64) NOT NULL UNIQUE COMMENT '形象唯一标识',
+                        avatar_name VARCHAR(100) NOT NULL COMMENT '形象名称',
+                        description VARCHAR(255) NULL COMMENT '形象描述',
+                        is_active TINYINT(1) NOT NULL DEFAULT 1 COMMENT '是否启用',
+                        metadata JSON NULL COMMENT '扩展配置',
+                        created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
+                        updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
+                        INDEX idx_avatar_is_active (is_active),
+                        INDEX idx_avatar_id (avatar_id)
+                    ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='形象配置表';
+                    """
+                )
+                print("✅ avatars 表创建完成")
+            else:
+                print("✅ avatars 表已存在")
+
+            # 2. avatar_assets 表
+            await cursor.execute(
+                """
+                SELECT COUNT(*) FROM information_schema.tables
+                WHERE table_schema = %s AND table_name = 'avatar_assets'
+                """,
+                (settings.MYSQL_DATABASE,),
+            )
+            exists_row = await cursor.fetchone()
+            avatar_assets_exists = bool(exists_row and exists_row[0] > 0)
+
+            if not avatar_assets_exists:
+                print("🔄 创建 avatar_assets 表...")
+                await cursor.execute(
+                    """
+                    CREATE TABLE avatar_assets (
+                        id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY COMMENT '主键',
+                        avatar_id VARCHAR(64) NOT NULL COMMENT '形象ID',
+                        state ENUM('default', 'positive', 'negative', 'talking', 'idle', 'sleep') NOT NULL DEFAULT 'default' COMMENT '动作/情绪状态',
+                        image_url VARCHAR(500) NOT NULL COMMENT 'GIF 资源 URL',
+                        thumbnail_url VARCHAR(500) NULL COMMENT '缩略图URL',
+                        metadata JSON NULL COMMENT '扩展配置',
+                        created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
+                        updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
+                        UNIQUE KEY uk_avatar_asset_state (avatar_id, state),
+                        INDEX idx_avatar_asset_avatar_id (avatar_id)
+                    ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='形象动作资源表';
+                    """
+                )
+                print("✅ avatar_assets 表创建完成")
+            else:
+                print("✅ avatar_assets 表已存在")
+
+            # 3. user_avatars 表
+            await cursor.execute(
+                """
+                SELECT COUNT(*) FROM information_schema.tables
+                WHERE table_schema = %s AND table_name = 'user_avatars'
+                """,
+                (settings.MYSQL_DATABASE,),
+            )
+            exists_row = await cursor.fetchone()
+            user_avatars_exists = bool(exists_row and exists_row[0] > 0)
+
+            if not user_avatars_exists:
+                print("🔄 创建 user_avatars 表...")
+                await cursor.execute(
+                    """
+                    CREATE TABLE user_avatars (
+                        id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY COMMENT '主键',
+                        user_id VARCHAR(64) NOT NULL COMMENT '用户ID',
+                        avatar_id VARCHAR(64) NOT NULL COMMENT '形象ID',
+                        obtained_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '获得时间',
+                        obtained_type ENUM('default', 'register', 'level_up', 'mission', 'purchase', 'gift', 'event') NOT NULL DEFAULT 'default' COMMENT '获得方式',
+                        obtained_source VARCHAR(64) NULL COMMENT '获得来源(等级/任务/订单等)',
+                        metadata JSON NULL COMMENT '扩展信息',
+                        created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
+                        UNIQUE KEY uk_user_avatar (user_id, avatar_id),
+                        INDEX idx_user_avatar_user_obtained (user_id, obtained_at),
+                        INDEX idx_user_avatar_avatar_id (avatar_id)
+                    ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用户已拥有的形象';
+                    """
+                )
+                print("✅ user_avatars 表创建完成")
+            else:
+                print("✅ user_avatars 表已存在")
+
+            # 4. user_ui_states 表添加 current_avatar_id
+            await cursor.execute(
+                """
+                SELECT COUNT(*) FROM information_schema.columns
+                WHERE table_schema = %s
+                  AND table_name = 'user_ui_states'
+                  AND column_name = 'current_avatar_id'
+                """,
+                (settings.MYSQL_DATABASE,),
+            )
+            exists_row = await cursor.fetchone()
+            column_exists = bool(exists_row and exists_row[0] > 0)
+
+            if not column_exists:
+                print("🔄 为 user_ui_states 添加 current_avatar_id 字段...")
+                await cursor.execute(
+                    """
+                    ALTER TABLE user_ui_states
+                    ADD COLUMN current_avatar_id VARCHAR(64) NULL COMMENT '当前使用的形象ID(关联 avatars.avatar_id)'
+                    AFTER has_shared_avatar
+                    """
+                )
+                print("✅ current_avatar_id 字段添加完成")
+            else:
+                print("✅ current_avatar_id 字段已存在")
+
+            await conn.commit()
+
+            # 5. 初始化 avatars 数据
+            print("🔄 初始化 avatars 基础数据...")
+            await cursor.execute(
+                """
+                INSERT IGNORE INTO avatars (avatar_id, avatar_name, description, is_active)
+                VALUES 
+                    (%s, %s, %s, 1),
+                    (%s, %s, %s, 1)
+                """,
+                (
+                    DEFAULT_AVATAR_ID,
+                    "默认形象",
+                    "基础形象(未注册/通用)",
+                    REGISTERED_AVATAR_ID,
+                    "注册赠送形象",
+                    "注册奖励形象(带帽子)",
+                ),
+            )
+            await conn.commit()
+            print("✅ avatars 基础数据初始化完成")
+
+            # 6. 初始化 avatar_assets 数据
+            print("🔄 初始化 avatar_assets 数据...")
+            asset_rows = DEFAULT_AVATAR_ASSETS + REGISTERED_AVATAR_ASSETS
+            for avatar_id, state, image_url in asset_rows:
+                await cursor.execute(
+                    """
+                    INSERT IGNORE INTO avatar_assets (avatar_id, state, image_url)
+                    VALUES (%s, %s, %s)
+                    """,
+                    (avatar_id, state, image_url),
+                )
+            await conn.commit()
+            print(f"✅ 已初始化 {len(asset_rows)} 条 avatar_assets 数据")
+
+            # 7. 为所有用户分配默认形象
+            print("🔄 为用户添加默认形象拥有记录...")
+            await cursor.execute(
+                """
+                INSERT IGNORE INTO user_avatars (user_id, avatar_id, obtained_type, obtained_source)
+                SELECT user_id, %s, 'default', 'system_init'
+                FROM users
+                """,
+                (DEFAULT_AVATAR_ID,),
+            )
+            await conn.commit()
+            print(f"✅ 默认形象写入 user_avatars,受影响行数: {cursor.rowcount}")
+
+            # 8. 为注册用户分配注册赠送形象
+            print("🔄 为注册用户添加注册赠送形象...")
+            await cursor.execute(
+                """
+                INSERT IGNORE INTO user_avatars (user_id, avatar_id, obtained_type, obtained_source)
+                SELECT user_id, %s, 'register', 'register_bonus'
+                FROM users
+                WHERE user_type = 'registered'
+                """,
+                (REGISTERED_AVATAR_ID,),
+            )
+            await conn.commit()
+            print(f"✅ 注册赠送形象写入 user_avatars,受影响行数: {cursor.rowcount}")
+
+            # 9. 设置当前形象(注册用户优先注册赠送,其余默认)
+            print("🔄 设置 user_ui_states.current_avatar_id ...")
+            # 注意:部分历史表的字符集/排序规则不一致,使用显式 COLLATE 避免 1267 错误
+            await cursor.execute(
+                f"""
+                UPDATE user_ui_states ui
+                JOIN users u 
+                  ON ui.user_id COLLATE {COLLATE_NAME} = u.user_id COLLATE {COLLATE_NAME}
+                SET ui.current_avatar_id = %s
+                WHERE ui.current_avatar_id IS NULL
+                  AND u.user_type = 'registered'
+                """,
+                (REGISTERED_AVATAR_ID,),
+            )
+            await conn.commit()
+
+            await cursor.execute(
+                f"""
+                UPDATE user_ui_states
+                SET current_avatar_id = %s
+                WHERE current_avatar_id IS NULL
+                """,
+                (DEFAULT_AVATAR_ID,),
+            )
+            await conn.commit()
+            print("✅ current_avatar_id 初始化完成")
+
+        conn.close()
+        return True
+    except Exception:
+        import traceback
+
+        traceback.print_exc()
+        return False
+
+
+if __name__ == "__main__":
+    if len(sys.argv) > 1 and sys.argv[1] == "--force":
+        confirm = "y"
+    else:
+        confirm = input("\n是否继续执行迁移? (y/n): ")
+
+    if confirm.lower() != "y":
+        sys.exit(0)
+
+    ok = asyncio.run(migrate_create_avatar_tables())
+    if not ok:
+        sys.exit(1)
+
+

+ 226 - 0
backend/migrations/migrate_add_background_images.py

@@ -0,0 +1,226 @@
+"""
+数据库迁移脚本:创建背景图片相关表并初始化数据
+运行: python migrate_add_background_images.py
+"""
+import asyncio
+import sys
+from typing import Any
+
+import aiomysql
+
+from config import settings
+
+
+async def migrate_create_background_images_tables() -> bool:
+    """创建背景图片相关表并初始化数据"""
+    try:
+        conn: aiomysql.Connection = await aiomysql.connect(
+            host=settings.MYSQL_HOST,
+            port=settings.MYSQL_PORT,
+            user=settings.MYSQL_USER,
+            password=settings.MYSQL_PASSWORD,
+            db=settings.MYSQL_DATABASE,
+            charset="utf8mb4",
+        )
+
+        async with conn.cursor() as cursor:
+            # 1. 检查并创建 background_images 表
+            await cursor.execute(
+                """
+                SELECT COUNT(*) FROM information_schema.tables
+                WHERE table_schema = %s AND table_name = 'background_images'
+                """,
+                (settings.MYSQL_DATABASE,),
+            )
+            exists_row: Any = await cursor.fetchone()
+            bg_images_exists = bool(exists_row and exists_row[0] > 0)
+
+            if not bg_images_exists:
+                print("🔄 创建 background_images 表...")
+                await cursor.execute(
+                    """
+                    CREATE TABLE background_images (
+                        id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY COMMENT '主键',
+                        image_id VARCHAR(64) NOT NULL UNIQUE COMMENT '背景图片唯一标识',
+                        image_name VARCHAR(100) NOT NULL COMMENT '背景图片名称',
+                        image_url VARCHAR(500) NOT NULL COMMENT '背景图片URL',
+                        thumbnail_url VARCHAR(500) NULL COMMENT '缩略图URL',
+                        unlock_type ENUM('default', 'level', 'purchase', 'mission') NOT NULL DEFAULT 'default' COMMENT '解锁类型',
+                        unlock_level INT NULL COMMENT '解锁所需等级(unlock_type=level时)',
+                        unlock_mission_type VARCHAR(64) NULL COMMENT '解锁所需任务类型(unlock_type=mission时)',
+                        display_order INT NOT NULL DEFAULT 0 COMMENT '显示顺序',
+                        is_active TINYINT(1) NOT NULL DEFAULT 1 COMMENT '是否启用',
+                        description VARCHAR(255) NULL COMMENT '背景图片描述',
+                        metadata JSON NULL COMMENT '扩展配置',
+                        created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
+                        updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
+                        INDEX idx_image_id (image_id),
+                        INDEX idx_unlock_type (unlock_type),
+                        INDEX idx_display_order (display_order)
+                    ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='背景图片配置表';
+                    """
+                )
+                print("✅ background_images 表创建完成")
+            else:
+                print("✅ background_images 表已存在")
+
+            # 2. 检查并创建 user_background_images 表
+            await cursor.execute(
+                """
+                SELECT COUNT(*) FROM information_schema.tables
+                WHERE table_schema = %s AND table_name = 'user_background_images'
+                """,
+                (settings.MYSQL_DATABASE,),
+            )
+            exists_row = await cursor.fetchone()
+            user_bg_images_exists = bool(exists_row and exists_row[0] > 0)
+
+            if not user_bg_images_exists:
+                print("🔄 创建 user_background_images 表...")
+                await cursor.execute(
+                    """
+                    CREATE TABLE user_background_images (
+                        id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY COMMENT '主键',
+                        user_id VARCHAR(64) NOT NULL COMMENT '用户ID',
+                        image_id VARCHAR(64) NOT NULL COMMENT '背景图片ID',
+                        obtained_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '获得时间',
+                        obtained_type ENUM('default', 'level_up', 'purchase', 'mission', 'gift') NOT NULL DEFAULT 'default' COMMENT '获得方式',
+                        obtained_source VARCHAR(64) NULL COMMENT '获得来源(任务类型、等级等)',
+                        metadata JSON NULL COMMENT '扩展信息',
+                        created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
+                        UNIQUE KEY uk_user_background_image (user_id, image_id),
+                        INDEX idx_user_id (user_id),
+                        INDEX idx_image_id (image_id),
+                        INDEX idx_user_obtained (user_id, obtained_at)
+                    ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用户背景图片关联表';
+                    """
+                )
+                print("✅ user_background_images 表创建完成")
+            else:
+                print("✅ user_background_images 表已存在")
+
+            # 3. 检查并添加 users 表的 current_background_image_id 字段
+            await cursor.execute(
+                """
+                SELECT COUNT(*) FROM information_schema.columns
+                WHERE table_schema = %s 
+                  AND table_name = 'users'
+                  AND column_name = 'current_background_image_id'
+                """,
+                (settings.MYSQL_DATABASE,),
+            )
+            exists_row = await cursor.fetchone()
+            column_exists = bool(exists_row and exists_row[0] > 0)
+
+            if not column_exists:
+                print("🔄 为 users 表添加 current_background_image_id 字段...")
+                await cursor.execute(
+                    """
+                    ALTER TABLE users
+                    ADD COLUMN current_background_image_id VARCHAR(64) NULL COMMENT '当前使用的背景图片ID'
+                    AFTER last_active_at
+                    """
+                )
+                print("✅ current_background_image_id 字段添加完成")
+            else:
+                print("✅ current_background_image_id 字段已存在")
+
+            await conn.commit()
+
+            # 4. 初始化背景图片数据
+            print("🔄 初始化背景图片数据...")
+            
+            # 检查默认背景图片是否存在
+            await cursor.execute(
+                "SELECT COUNT(*) FROM background_images WHERE image_id = 'default'"
+            )
+            default_exists = (await cursor.fetchone())[0] > 0
+            
+            if not default_exists:
+                await cursor.execute(
+                    """
+                    INSERT INTO background_images 
+                    (image_id, image_name, image_url, unlock_type, display_order, description)
+                    VALUES 
+                    ('default', '默认背景', 'https://assets.lyrria.com/init.jpg', 'default', 1, '默认背景图片')
+                    """
+                )
+                print("✅ 默认背景图片已添加")
+            else:
+                print("✅ 默认背景图片已存在")
+
+            # 检查新年背景图片是否存在
+            await cursor.execute(
+                "SELECT COUNT(*) FROM background_images WHERE image_id = 'happynewyear'"
+            )
+            newyear_exists = (await cursor.fetchone())[0] > 0
+            
+            if not newyear_exists:
+                await cursor.execute(
+                    """
+                    INSERT INTO background_images 
+                    (image_id, image_name, image_url, unlock_type, display_order, description)
+                    VALUES 
+                    ('happynewyear', '新年背景', 'https://assets.lyrria.com/bg-happynewyear.png', 'default', 2, '新年背景图片')
+                    """
+                )
+                print("✅ 新年背景图片已添加")
+            else:
+                print("✅ 新年背景图片已存在")
+
+            await conn.commit()
+
+            # 5. 为所有现有用户设置默认背景图片
+            print("🔄 为现有用户设置默认背景图片...")
+            
+            # 更新所有用户的 current_background_image_id 为 'default'(如果为 NULL)
+            await cursor.execute(
+                """
+                UPDATE users 
+                SET current_background_image_id = 'default'
+                WHERE current_background_image_id IS NULL
+                """
+            )
+            updated_users = cursor.rowcount
+            print(f"✅ 已为 {updated_users} 个用户设置默认背景图片")
+
+            # 6. 为所有现有用户添加默认背景图片到已获得列表
+            print("🔄 为现有用户添加默认背景图片到已获得列表...")
+            
+            await cursor.execute(
+                """
+                INSERT IGNORE INTO user_background_images 
+                (user_id, image_id, obtained_type, obtained_source)
+                SELECT user_id, 'default', 'default', 'system_init'
+                FROM users
+                """
+            )
+            added_records = cursor.rowcount
+            print(f"✅ 已为 {added_records} 个用户添加默认背景图片到已获得列表")
+
+            await conn.commit()
+            print("✅ 迁移完成")
+
+        await conn.ensure_closed()
+        return True
+    except Exception as exc:  # noqa: BLE001
+        import traceback
+
+        print(f"❌ 迁移失败: {exc}")
+        traceback.print_exc()
+        return False
+
+
+if __name__ == "__main__":
+    if len(sys.argv) > 1 and sys.argv[1] == "--force":
+        confirm = "y"
+    else:
+        confirm = input("\n是否继续创建背景图片相关表并初始化数据? (y/n): ")
+
+    if confirm.lower() != "y":
+        sys.exit(0)
+
+    ok = asyncio.run(migrate_create_background_images_tables())
+    if not ok:
+        sys.exit(1)
+

+ 74 - 0
backend/migrations/migrate_add_bot_name.py

@@ -0,0 +1,74 @@
+"""
+数据库迁移脚本:为 users 表添加 bot_name 字段
+运行: python migrate_add_bot_name.py
+"""
+import asyncio
+import aiomysql
+from config import settings
+import sys
+
+
+async def migrate_add_bot_name():
+    """为 users 表添加 bot_name 字段"""
+    
+    try:
+        # 创建数据库连接
+        conn = await aiomysql.connect(
+            host=settings.MYSQL_HOST,
+            port=settings.MYSQL_PORT,
+            user=settings.MYSQL_USER,
+            password=settings.MYSQL_PASSWORD,
+            db=settings.MYSQL_DATABASE,
+            charset='utf8mb4'
+        )
+        
+        async with conn.cursor() as cursor:
+            # 检查字段是否已存在
+            await cursor.execute("""
+                SELECT COUNT(*) 
+                FROM INFORMATION_SCHEMA.COLUMNS 
+                WHERE TABLE_SCHEMA = %s 
+                AND TABLE_NAME = 'users' 
+                AND COLUMN_NAME = 'bot_name'
+            """, (settings.MYSQL_DATABASE,))
+            
+            result = await cursor.fetchone()
+            if result[0] > 0:
+                conn.close()
+                return True
+            
+            # 添加 bot_name 字段
+            await cursor.execute("""
+                ALTER TABLE users 
+                ADD COLUMN bot_name VARCHAR(100) COMMENT '用户给机器人取的名字' 
+                AFTER user_type
+            """)
+            
+            await conn.commit()
+        
+        conn.close()
+        
+        return True
+        
+    except Exception as e:
+        import traceback
+        traceback.print_exc()
+        return False
+
+
+if __name__ == "__main__":
+    # 确认是否继续
+    if len(sys.argv) > 1 and sys.argv[1] == "--force":
+        confirm = "y"
+    else:
+        confirm = input("\n是否继续执行迁移? (y/n): ")
+    
+    if confirm.lower() != 'y':
+        sys.exit(0)
+    
+    # 执行迁移
+    success = asyncio.run(migrate_add_bot_name())
+    
+    if not success:
+        sys.exit(1)
+

+ 113 - 0
backend/migrations/migrate_add_email_qq_fields.py

@@ -0,0 +1,113 @@
+"""
+数据库迁移脚本:为 users 表添加 email 和 qq_openid 字段
+运行: python migrate_add_email_qq_fields.py
+"""
+import asyncio
+import aiomysql
+from config import settings
+import sys
+
+
+async def migrate_add_email_qq_fields():
+    """为 users 表添加 email 和 qq_openid 字段"""
+    
+    try:
+        # 创建数据库连接
+        conn = await aiomysql.connect(
+            host=settings.MYSQL_HOST,
+            port=settings.MYSQL_PORT,
+            user=settings.MYSQL_USER,
+            password=settings.MYSQL_PASSWORD,
+            db=settings.MYSQL_DATABASE,
+            charset='utf8mb4'
+        )
+        
+        async with conn.cursor() as cursor:
+            # 检查 users 表是否存在
+            await cursor.execute("""
+                SELECT COUNT(*) FROM information_schema.tables 
+                WHERE table_schema = %s AND table_name = 'users'
+            """, (settings.MYSQL_DATABASE,))
+            table_exists = (await cursor.fetchone())[0] > 0
+            
+            if not table_exists:
+                print("❌ users 表不存在,请先创建表")
+                conn.close()
+                return False
+            
+            # 检查是否已存在 email 列
+            await cursor.execute("""
+                SELECT COUNT(*) FROM information_schema.columns 
+                WHERE table_schema = %s 
+                AND table_name = 'users' 
+                AND column_name = 'email'
+            """, (settings.MYSQL_DATABASE,))
+            has_email = (await cursor.fetchone())[0] > 0
+            
+            # 检查是否已存在 qq_openid 列
+            await cursor.execute("""
+                SELECT COUNT(*) FROM information_schema.columns 
+                WHERE table_schema = %s 
+                AND table_name = 'users' 
+                AND column_name = 'qq_openid'
+            """, (settings.MYSQL_DATABASE,))
+            has_qq_openid = (await cursor.fetchone())[0] > 0
+            
+            if has_email and has_qq_openid:
+                print("✅ email 和 qq_openid 字段已存在,无需迁移")
+                conn.close()
+                return True
+            
+            # 添加 email 字段(如果不存在)
+            if not has_email:
+                print("🔄 添加 email 字段...")
+                await cursor.execute("""
+                    ALTER TABLE users 
+                    ADD COLUMN email VARCHAR(200) UNIQUE NULL COMMENT '邮箱' AFTER phone
+                """)
+                await cursor.execute("""
+                    CREATE INDEX idx_email ON users(email)
+                """)
+                print("✅ email 字段添加成功")
+            
+            # 添加 qq_openid 字段(如果不存在)
+            if not has_qq_openid:
+                print("🔄 添加 qq_openid 字段...")
+                await cursor.execute("""
+                    ALTER TABLE users 
+                    ADD COLUMN qq_openid VARCHAR(100) UNIQUE NULL COMMENT 'QQ OpenID' AFTER wechat_openid
+                """)
+                await cursor.execute("""
+                    CREATE INDEX idx_qq_openid ON users(qq_openid)
+                """)
+                print("✅ qq_openid 字段添加成功")
+            
+            await conn.commit()
+            print("✅ 迁移完成")
+        
+        conn.close()
+        return True
+        
+    except Exception as e:
+        import traceback
+        print(f"❌ 迁移失败: {e}")
+        traceback.print_exc()
+        return False
+
+
+if __name__ == "__main__":
+    # 确认是否继续
+    if len(sys.argv) > 1 and sys.argv[1] == "--force":
+        confirm = "y"
+    else:
+        confirm = input("\n是否继续迁移 users 表添加 email 和 qq_openid 字段? (y/n): ")
+    
+    if confirm.lower() != 'y':
+        sys.exit(0)
+    
+    # 执行迁移
+    success = asyncio.run(migrate_add_email_qq_fields())
+    
+    if not success:
+        sys.exit(1)
+

+ 93 - 0
backend/migrations/migrate_add_feedbacks_table.py

@@ -0,0 +1,93 @@
+"""
+数据库迁移脚本:创建反馈表
+运行: python migrate_add_feedbacks_table.py
+"""
+import asyncio
+import sys
+from typing import Any
+
+import aiomysql
+
+from config import settings
+
+
+async def migrate_create_feedbacks_table() -> bool:
+    """创建反馈表"""
+    try:
+        conn: aiomysql.Connection = await aiomysql.connect(
+            host=settings.MYSQL_HOST,
+            port=settings.MYSQL_PORT,
+            user=settings.MYSQL_USER,
+            password=settings.MYSQL_PASSWORD,
+            db=settings.MYSQL_DATABASE,
+            charset="utf8mb4",
+        )
+
+        async with conn.cursor() as cursor:
+            # 检查表是否存在
+            await cursor.execute(
+                """
+                SELECT COUNT(*) FROM information_schema.tables
+                WHERE table_schema = %s AND table_name = 'feedbacks'
+                """,
+                (settings.MYSQL_DATABASE,),
+            )
+            exists_row: Any = await cursor.fetchone()
+            table_exists = bool(exists_row and exists_row[0] > 0)
+
+            if not table_exists:
+                print("🔄 创建 feedbacks 表...")
+                await cursor.execute(
+                    """
+                    CREATE TABLE feedbacks (
+                        id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY COMMENT '主键',
+                        feedback_id VARCHAR(64) NOT NULL UNIQUE COMMENT '反馈ID',
+                        user_id VARCHAR(64) NOT NULL COMMENT '用户ID',
+                        
+                        -- 反馈内容
+                        content TEXT NOT NULL COMMENT '反馈内容',
+                        
+                        -- 状态信息
+                        status ENUM('pending', 'reviewed', 'resolved', 'closed') NOT NULL DEFAULT 'pending' COMMENT '反馈状态',
+                        
+                        -- 扩展信息
+                        metadata JSON NULL COMMENT '扩展信息',
+                        
+                        -- 时间信息
+                        created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
+                        updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
+                        
+                        INDEX idx_feedback_id (feedback_id),
+                        INDEX idx_user_id (user_id),
+                        INDEX idx_feedback_user_created (user_id, created_at)
+                    ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
+                    COMMENT='用户反馈表'
+                    """
+                )
+                await conn.commit()
+                print("✅ feedbacks 表创建完成")
+            else:
+                print("✅ feedbacks 表已存在")
+
+        conn.close()
+        return True
+    except Exception:
+        import traceback
+
+        traceback.print_exc()
+        return False
+
+
+if __name__ == "__main__":
+    if len(sys.argv) > 1 and sys.argv[1] == "--force":
+        confirm = "y"
+    else:
+        confirm = input("\n是否继续执行迁移? (y/n): ")
+
+    if confirm.lower() != "y":
+        sys.exit(0)
+
+    ok = asyncio.run(migrate_create_feedbacks_table())
+    if not ok:
+        sys.exit(1)
+

+ 74 - 0
backend/migrations/migrate_add_has_shared_avatar.py

@@ -0,0 +1,74 @@
+"""
+数据库迁移脚本:为 user_ui_states 表添加 has_shared_avatar 字段
+运行: python migrate_add_has_shared_avatar.py
+"""
+import asyncio
+import sys
+
+import aiomysql
+
+from config import settings
+
+
+async def migrate_add_has_shared_avatar() -> bool:
+  """为 user_ui_states 表添加 has_shared_avatar 字段"""
+  try:
+    conn = await aiomysql.connect(
+      host=settings.MYSQL_HOST,
+      port=settings.MYSQL_PORT,
+      user=settings.MYSQL_USER,
+      password=settings.MYSQL_PASSWORD,
+      db=settings.MYSQL_DATABASE,
+      charset="utf8mb4",
+    )
+
+    async with conn.cursor() as cursor:
+      # 检查字段是否已存在
+      await cursor.execute(
+        """
+        SELECT COUNT(*)
+        FROM INFORMATION_SCHEMA.COLUMNS
+        WHERE TABLE_SCHEMA = %s
+          AND TABLE_NAME = 'user_ui_states'
+          AND COLUMN_NAME = 'has_shared_avatar'
+        """,
+        (settings.MYSQL_DATABASE,),
+      )
+      result = await cursor.fetchone()
+      if result and result[0] > 0:
+        conn.close()
+        return True
+
+      # 添加字段,默认 false
+      await cursor.execute(
+        """
+        ALTER TABLE user_ui_states
+        ADD COLUMN has_shared_avatar TINYINT(1) NOT NULL DEFAULT 0 COMMENT '是否已分享头像奖励'
+        AFTER avatar_c_unlocked
+        """
+      )
+      await conn.commit()
+
+    conn.close()
+    return True
+  except Exception:
+    import traceback
+
+    traceback.print_exc()
+    return False
+
+
+if __name__ == "__main__":
+  if len(sys.argv) > 1 and sys.argv[1] == "--force":
+    confirm = "y"
+  else:
+    confirm = input("\n是否继续执行迁移? (y/n): ")
+
+  if confirm.lower() != "y":
+    sys.exit(0)
+
+  ok = asyncio.run(migrate_add_has_shared_avatar())
+  if not ok:
+    sys.exit(1)
+
+

+ 99 - 0
backend/migrations/migrate_add_qq_unionid.py

@@ -0,0 +1,99 @@
+"""
+数据库迁移脚本:为 users 表添加 qq_unionid 字段
+运行: python migrate_add_qq_unionid.py
+"""
+import asyncio
+import sys
+
+import aiomysql
+
+from config import settings
+
+
+async def migrate_add_qq_unionid():
+    """为 users 表添加 qq_unionid 字段"""
+    conn = None
+    try:
+        conn = await aiomysql.connect(
+            host=settings.MYSQL_HOST,
+            port=settings.MYSQL_PORT,
+            user=settings.MYSQL_USER,
+            password=settings.MYSQL_PASSWORD,
+            db=settings.MYSQL_DATABASE,
+            charset="utf8mb4",
+        )
+
+        async with conn.cursor() as cursor:
+            # 检查 users 表是否存在
+            await cursor.execute(
+                """
+                SELECT COUNT(*)
+                FROM information_schema.tables 
+                WHERE table_schema = %s AND table_name = 'users'
+                """,
+                (settings.MYSQL_DATABASE,),
+            )
+            table_exists = (await cursor.fetchone())[0] > 0
+            if not table_exists:
+                print("❌ users 表不存在,请先创建表")
+                await conn.ensure_closed()
+                return False
+
+            # 检查是否已存在 qq_unionid 列
+            await cursor.execute(
+                """
+                SELECT COUNT(*)
+                FROM information_schema.columns 
+                WHERE table_schema = %s 
+                AND table_name = 'users' 
+                AND column_name = 'qq_unionid'
+                """,
+                (settings.MYSQL_DATABASE,),
+            )
+            has_unionid = (await cursor.fetchone())[0] > 0
+
+            if has_unionid:
+                print("✅ qq_unionid 字段已存在,无需迁移")
+                await conn.ensure_closed()
+                return True
+
+            print("🔄 添加 qq_unionid 字段...")
+            await cursor.execute(
+                """
+                ALTER TABLE users 
+                ADD COLUMN qq_unionid VARCHAR(128) UNIQUE NULL COMMENT 'QQ UnionID' 
+                AFTER qq_openid
+                """
+            )
+            await cursor.execute(
+                """
+                CREATE INDEX idx_qq_unionid ON users(qq_unionid)
+                """
+            )
+            await conn.commit()
+            print("✅ qq_unionid 字段添加成功")
+            await conn.ensure_closed()
+            return True
+    except Exception as exc:
+        import traceback
+
+        print(f"❌ 迁移失败: {exc}")
+        traceback.print_exc()
+        if conn and not conn.closed:
+            await conn.ensure_closed()
+        return False
+
+
+if __name__ == "__main__":
+    if len(sys.argv) > 1 and sys.argv[1] == "--force":
+        confirm = "y"
+    else:
+        confirm = input("\n是否继续迁移 users 表添加 qq_unionid 字段? (y/n): ")
+
+    if confirm.lower() != "y":
+        sys.exit(0)
+
+    success = asyncio.run(migrate_add_qq_unionid())
+    if not success:
+        sys.exit(1)
+

+ 69 - 0
backend/migrations/migrate_add_user_level.py

@@ -0,0 +1,69 @@
+"""
+数据库迁移脚本:为 users 表添加 user_level 字段
+运行: python migrate_add_user_level.py
+"""
+import asyncio
+import sys
+import aiomysql
+from config import settings
+
+
+async def migrate_add_user_level():
+    """为 users 表添加 user_level 字段"""
+    try:
+        conn = await aiomysql.connect(
+            host=settings.MYSQL_HOST,
+            port=settings.MYSQL_PORT,
+            user=settings.MYSQL_USER,
+            password=settings.MYSQL_PASSWORD,
+            db=settings.MYSQL_DATABASE,
+            charset="utf8mb4",
+        )
+
+        async with conn.cursor() as cursor:
+            await cursor.execute(
+                """
+                SELECT COUNT(*)
+                FROM INFORMATION_SCHEMA.COLUMNS
+                WHERE TABLE_SCHEMA = %s
+                  AND TABLE_NAME = 'users'
+                  AND COLUMN_NAME = 'user_level'
+                """,
+                (settings.MYSQL_DATABASE,),
+            )
+            result = await cursor.fetchone()
+            if result and result[0] > 0:
+                conn.close()
+                return True
+
+            await cursor.execute(
+                """
+                ALTER TABLE users
+                ADD COLUMN user_level INT NOT NULL DEFAULT 0 COMMENT '用户等级(0-5)'
+                AFTER total_speech_duration_ms
+                """
+            )
+            await conn.commit()
+
+        conn.close()
+        return True
+    except Exception:
+        import traceback
+
+        traceback.print_exc()
+        return False
+
+
+if __name__ == "__main__":
+    if len(sys.argv) > 1 and sys.argv[1] == "--force":
+        confirm = "y"
+    else:
+        confirm = input("\n是否继续执行迁移? (y/n): ")
+
+    if confirm.lower() != "y":
+        sys.exit(0)
+
+    success = asyncio.run(migrate_add_user_level())
+    if not success:
+        sys.exit(1)
+

+ 89 - 0
backend/migrations/migrate_add_user_ui_state_table.py

@@ -0,0 +1,89 @@
+"""
+数据库迁移脚本:创建 user_ui_states 表
+运行: python migrate_add_user_ui_state_table.py
+"""
+import asyncio
+import sys
+from typing import Any
+
+import aiomysql
+
+from config import settings
+
+
+async def migrate_create_user_ui_states_table() -> bool:
+    """创建 user_ui_states 表(如果不存在)"""
+    try:
+        conn: aiomysql.Connection = await aiomysql.connect(
+            host=settings.MYSQL_HOST,
+            port=settings.MYSQL_PORT,
+            user=settings.MYSQL_USER,
+            password=settings.MYSQL_PASSWORD,
+            db=settings.MYSQL_DATABASE,
+            charset="utf8mb4",
+        )
+
+        async with conn.cursor() as cursor:
+            # 检查表是否已存在
+            await cursor.execute(
+                """
+                SELECT COUNT(*) FROM information_schema.tables
+                WHERE table_schema = %s AND table_name = 'user_ui_states'
+                """,
+                (settings.MYSQL_DATABASE,),
+            )
+            exists_row: Any = await cursor.fetchone()
+            table_exists = bool(exists_row and exists_row[0] > 0)
+
+            if table_exists:
+                print("✅ user_ui_states 表已存在,无需迁移")
+                await conn.ensure_closed()
+                return True
+
+            print("🔄 创建 user_ui_states 表...")
+            await cursor.execute(
+                """
+                CREATE TABLE user_ui_states (
+                    id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY COMMENT '主键',
+                    user_id VARCHAR(64) NOT NULL UNIQUE COMMENT '用户ID(关联 users.user_id)',
+                    current_page VARCHAR(64) NULL COMMENT '当前业务流程页面标识',
+                    visited_routes JSON NULL COMMENT '已访问路由列表',
+                    has_shown_tap_to_chat TINYINT(1) NOT NULL DEFAULT 0 COMMENT '是否已展示 TapToChat 提示',
+                    has_shown_cancel_tooltip TINYINT(1) NOT NULL DEFAULT 0 COMMENT '是否已展示取消按钮提示',
+                    has_shown_send_tooltip TINYINT(1) NOT NULL DEFAULT 0 COMMENT '是否已展示发送按钮提示',
+                    has_shown_progress_bar_tooltip TINYINT(1) NOT NULL DEFAULT 0 COMMENT '是否已展示进度条提示',
+                    avatar_c_unlocked TINYINT(1) NOT NULL DEFAULT 0 COMMENT '是否已解锁 Avatar-C',
+                    created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
+                    updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
+                    INDEX idx_user_ui_state_user_id (user_id)
+                ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用户前端可视化状态表';
+                """
+            )
+
+            await conn.commit()
+            print("✅ user_ui_states 表创建完成")
+
+        await conn.ensure_closed()
+        return True
+    except Exception as exc:  # noqa: BLE001
+        import traceback
+
+        print(f"❌ 迁移失败: {exc}")
+        traceback.print_exc()
+        return False
+
+
+if __name__ == "__main__":
+    if len(sys.argv) > 1 and sys.argv[1] == "--force":
+        confirm = "y"
+    else:
+        confirm = input("\n是否继续创建 user_ui_states 表? (y/n): ")
+
+    if confirm.lower() != "y":
+        sys.exit(0)
+
+    ok = asyncio.run(migrate_create_user_ui_states_table())
+    if not ok:
+        sys.exit(1)
+
+

+ 99 - 0
backend/migrations/migrate_add_verification_codes_table.py

@@ -0,0 +1,99 @@
+"""
+数据库迁移脚本:创建验证码表
+运行: python migrate_add_verification_codes_table.py
+"""
+import asyncio
+import sys
+from typing import Any
+
+import aiomysql
+
+from config import settings
+
+
+async def migrate_create_verification_codes_table() -> bool:
+    """创建验证码表"""
+    try:
+        conn: aiomysql.Connection = await aiomysql.connect(
+            host=settings.MYSQL_HOST,
+            port=settings.MYSQL_PORT,
+            user=settings.MYSQL_USER,
+            password=settings.MYSQL_PASSWORD,
+            db=settings.MYSQL_DATABASE,
+            charset="utf8mb4",
+        )
+
+        async with conn.cursor() as cursor:
+            # 检查表是否存在
+            await cursor.execute(
+                """
+                SELECT COUNT(*) FROM information_schema.tables
+                WHERE table_schema = %s AND table_name = 'verification_codes'
+                """,
+                (settings.MYSQL_DATABASE,),
+            )
+            exists_row: Any = await cursor.fetchone()
+            table_exists = bool(exists_row and exists_row[0] > 0)
+
+            if not table_exists:
+                print("🔄 创建 verification_codes 表...")
+                await cursor.execute(
+                    """
+                    CREATE TABLE verification_codes (
+                        id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY COMMENT '主键',
+                        code_id VARCHAR(64) NOT NULL UNIQUE COMMENT '验证码ID',
+                        
+                        -- 接收方信息(手机号或邮箱,根据发送类型二选一)
+                        phone VARCHAR(20) NULL COMMENT '手机号',
+                        email VARCHAR(200) NULL COMMENT '邮箱',
+                        
+                        -- 验证码信息
+                        code VARCHAR(10) NOT NULL COMMENT '验证码',
+                        send_type ENUM('domestic_sms', 'international_sms', 'email') NOT NULL COMMENT '发送类型:国内短信、国外短信、邮箱',
+                        
+                        -- 状态信息
+                        is_sent TINYINT(1) NOT NULL DEFAULT 0 COMMENT '是否已发送',
+                        is_valid TINYINT(1) NOT NULL DEFAULT 1 COMMENT '是否有效',
+                        
+                        -- 时间信息
+                        created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
+                        sent_at TIMESTAMP NULL COMMENT '发送时间',
+                        
+                        -- 扩展信息
+                        metadata JSON NULL COMMENT '扩展信息(发送结果、错误信息等)',
+                        
+                        INDEX idx_code_id (code_id),
+                        INDEX idx_verification_code_phone (phone, is_valid, created_at),
+                        INDEX idx_verification_code_email (email, is_valid, created_at),
+                        INDEX idx_verification_code_send_type (send_type, is_sent, is_valid)
+                    ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
+                    COMMENT='验证码表'
+                    """
+                )
+                await conn.commit()
+                print("✅ verification_codes 表创建完成")
+            else:
+                print("✅ verification_codes 表已存在")
+
+        conn.close()
+        return True
+    except Exception:
+        import traceback
+
+        traceback.print_exc()
+        return False
+
+
+if __name__ == "__main__":
+    if len(sys.argv) > 1 and sys.argv[1] == "--force":
+        confirm = "y"
+    else:
+        confirm = input("\n是否继续执行迁移? (y/n): ")
+
+    if confirm.lower() != "y":
+        sys.exit(0)
+
+    ok = asyncio.run(migrate_create_verification_codes_table())
+    if not ok:
+        sys.exit(1)
+

+ 101 - 0
backend/migrations/migrate_metadata_column.py

@@ -0,0 +1,101 @@
+"""
+数据库迁移脚本:将 messages 表的 metadata 列重命名为 message_metadata
+运行: python migrate_metadata_column.py
+"""
+import asyncio
+import aiomysql
+from config import settings
+import sys
+
+
+async def migrate_metadata_column():
+    """将 messages 表的 metadata 列重命名为 message_metadata"""
+    
+    try:
+        # 创建数据库连接
+        conn = await aiomysql.connect(
+            host=settings.MYSQL_HOST,
+            port=settings.MYSQL_PORT,
+            user=settings.MYSQL_USER,
+            password=settings.MYSQL_PASSWORD,
+            db=settings.MYSQL_DATABASE,
+            charset='utf8mb4'
+        )
+        
+        async with conn.cursor() as cursor:
+            # 检查 messages 表是否存在
+            await cursor.execute("""
+                SELECT COUNT(*) FROM information_schema.tables 
+                WHERE table_schema = %s AND table_name = 'messages'
+            """, (settings.MYSQL_DATABASE,))
+            table_exists = (await cursor.fetchone())[0] > 0
+            
+            if not table_exists:
+                print("❌ messages 表不存在,无需迁移")
+                conn.close()
+                return True
+            
+            # 检查是否存在 metadata 列
+            await cursor.execute("""
+                SELECT COUNT(*) FROM information_schema.columns 
+                WHERE table_schema = %s 
+                AND table_name = 'messages' 
+                AND column_name = 'metadata'
+            """, (settings.MYSQL_DATABASE,))
+            has_metadata = (await cursor.fetchone())[0] > 0
+            
+            # 检查是否已存在 message_metadata 列
+            await cursor.execute("""
+                SELECT COUNT(*) FROM information_schema.columns 
+                WHERE table_schema = %s 
+                AND table_name = 'messages' 
+                AND column_name = 'message_metadata'
+            """, (settings.MYSQL_DATABASE,))
+            has_message_metadata = (await cursor.fetchone())[0] > 0
+            
+            if has_message_metadata:
+                print("✅ message_metadata 列已存在,无需迁移")
+                conn.close()
+                return True
+            
+            if not has_metadata:
+                print("⚠️  metadata 列不存在,可能表结构已更新或表为空")
+                conn.close()
+                return True
+            
+            # 执行重命名操作
+            print("🔄 开始迁移: metadata -> message_metadata")
+            await cursor.execute("""
+                ALTER TABLE messages 
+                CHANGE COLUMN metadata message_metadata JSON 
+                COMMENT '扩展信息(音频路径、识别耗时等)'
+            """)
+            await conn.commit()
+            print("✅ 迁移成功: metadata 列已重命名为 message_metadata")
+        
+        conn.close()
+        return True
+        
+    except Exception as e:
+        import traceback
+        print(f"❌ 迁移失败: {e}")
+        traceback.print_exc()
+        return False
+
+
+if __name__ == "__main__":
+    # 确认是否继续
+    if len(sys.argv) > 1 and sys.argv[1] == "--force":
+        confirm = "y"
+    else:
+        confirm = input("\n是否继续迁移 messages 表的 metadata 列? (y/n): ")
+    
+    if confirm.lower() != 'y':
+        sys.exit(0)
+    
+    # 执行迁移
+    success = asyncio.run(migrate_metadata_column())
+    
+    if not success:
+        sys.exit(1)
+

+ 103 - 0
backend/start_prod.sh

@@ -0,0 +1,103 @@
+#!/bin/bash
+
+# 生产环境启动脚本
+# 使用 .env.lyrria 作为环境配置文件
+
+set -e  # 遇到错误立即退出
+
+# 获取脚本所在目录(backend 目录)
+SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+cd "$SCRIPT_DIR"
+
+# 颜色输出
+GREEN='\033[0;32m'
+YELLOW='\033[1;33m'
+RED='\033[0;31m'
+NC='\033[0m' # No Color
+
+echo -e "${GREEN}🚀 启动 Lyrria 生产环境服务...${NC}"
+
+# 检查 .env.lyrria 文件是否存在
+ENV_FILE="$SCRIPT_DIR/.env.lyrria"
+if [ ! -f "$ENV_FILE" ]; then
+    echo -e "${RED}❌ 错误: 未找到环境配置文件 $ENV_FILE${NC}"
+    echo -e "${YELLOW}提示: 请确保 .env.lyrria 文件存在于 backend 目录${NC}"
+    exit 1
+fi
+
+echo -e "${GREEN}✅ 找到环境配置文件: $ENV_FILE${NC}"
+
+# 检查 Python 环境
+if ! command -v python3 &> /dev/null; then
+    echo -e "${RED}❌ 错误: 未找到 python3 命令${NC}"
+    exit 1
+fi
+
+# 检查虚拟环境(可选,如果使用虚拟环境)
+if [ -d "venv" ] || [ -d ".venv" ]; then
+    VENV_DIR=""
+    if [ -d "venv" ]; then
+        VENV_DIR="venv"
+    else
+        VENV_DIR=".venv"
+    fi
+    
+    echo -e "${GREEN}✅ 激活虚拟环境: $VENV_DIR${NC}"
+    source "$VENV_DIR/bin/activate"
+fi
+
+# 检查依赖是否已安装
+if ! python3 -c "import fastapi" &> /dev/null; then
+    echo -e "${YELLOW}⚠️  警告: 未检测到 FastAPI,正在安装依赖...${NC}"
+    pip install -r requirements.txt
+fi
+
+# 设置环境变量,指定使用 .env.lyrria
+export ENV_FILE=".env.lyrria"
+
+# 设置生产环境标志
+export DEBUG="false"
+
+# 检查服务是否已在运行
+PID_FILE="$SCRIPT_DIR/backend.pid"
+if [ -f "$PID_FILE" ]; then
+    OLD_PID=$(cat "$PID_FILE")
+    if ps -p "$OLD_PID" > /dev/null 2>&1; then
+        echo -e "${YELLOW}⚠️  服务已在运行 (PID: $OLD_PID)${NC}"
+        echo -e "${YELLOW}如需重启,请先执行: ./stop_prod.sh${NC}"
+        exit 1
+    else
+        # PID 文件存在但进程不存在,清理旧文件
+        rm -f "$PID_FILE"
+    fi
+fi
+
+# 启动服务(后台运行)
+echo -e "${GREEN}✅ 启动 FastAPI 服务(后台模式)...${NC}"
+echo -e "${YELLOW}环境文件: $ENV_FILE${NC}"
+echo -e "${YELLOW}工作目录: $SCRIPT_DIR${NC}"
+echo ""
+
+# 使用 uvicorn 启动(生产环境建议使用 gunicorn + uvicorn workers)
+# 如果已安装 gunicorn,可以使用以下命令:
+# nohup gunicorn main:app --worker-class uvicorn.workers.UvicornWorker --workers 4 --bind 0.0.0.0:8766 > backend_nohup.log 2>&1 &
+
+# 直接使用 uvicorn 启动(适合单进程或小规模部署)
+nohup python3 -m uvicorn main:app \
+    --host 0.0.0.0 \
+    --port 8766 \
+    --workers 1 \
+    --no-access-log \
+    --log-level info \
+    > backend_nohup.log 2>&1 &
+
+BACKEND_PID=$!
+echo $BACKEND_PID > "$PID_FILE"
+
+echo -e "${GREEN}✅ 服务已启动,PID: $BACKEND_PID${NC}"
+echo -e "${YELLOW}后台日志: backend_nohup.log${NC}"
+echo -e "${YELLOW}查看状态: ./status_prod.sh${NC}"
+echo -e "${YELLOW}停止服务: ./stop_prod.sh${NC}"
+echo ""
+echo -e "${GREEN}🚀 服务启动完成!${NC}"
+

+ 72 - 0
backend/status_prod.sh

@@ -0,0 +1,72 @@
+#!/bin/bash
+
+# 生产环境服务状态查看脚本
+# 用于查看 FastAPI 后端服务的运行状态
+
+# 获取脚本所在目录
+SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+
+# 颜色输出
+GREEN='\033[0;32m'
+YELLOW='\033[1;33m'
+RED='\033[0;31m'
+BLUE='\033[0;34m'
+NC='\033[0m' # No Color
+
+echo -e "${BLUE}📊 Lyrria 后端服务状态${NC}"
+echo "=================================="
+echo ""
+
+PID_FILE="$SCRIPT_DIR/backend.pid"
+if [ -f "$PID_FILE" ]; then
+    PID=$(cat "$PID_FILE")
+    if ps -p "$PID" > /dev/null 2>&1; then
+        echo -e "${GREEN}✅ 服务运行中${NC}"
+        echo "  PID: $PID"
+        ps -p "$PID" -o pid,etime,cmd --no-headers | awk '{print "  运行时间: " $2}'
+        
+        # 显示端口占用情况
+        PORT_INFO=$(lsof -i :8000 -P -n 2>/dev/null | grep LISTEN || true)
+        if [ -n "$PORT_INFO" ]; then
+            echo "  端口 8000: 已监听"
+        else
+            echo -e "  ${YELLOW}端口 8000: 未监听${NC}"
+        fi
+    else
+        echo -e "${RED}❌ 服务未运行 (PID 文件存在但进程不存在)${NC}"
+    fi
+else
+    # 尝试通过进程名查找
+    PIDS=$(pgrep -f "uvicorn main:app" || true)
+    if [ -n "$PIDS" ]; then
+        echo -e "${YELLOW}⚠️  服务运行中 (但未找到 PID 文件)${NC}"
+        echo "  PIDs: $PIDS"
+    else
+        echo -e "${RED}❌ 服务未运行${NC}"
+    fi
+fi
+
+echo ""
+
+# 显示日志文件信息
+if [ -f "$SCRIPT_DIR/backend_nohup.log" ]; then
+    LOG_SIZE=$(ls -lh "$SCRIPT_DIR/backend_nohup.log" | awk '{print $5}')
+    LOG_TIME=$(stat -f "%Sm" -t "%Y-%m-%d %H:%M:%S" "$SCRIPT_DIR/backend_nohup.log" 2>/dev/null || stat -c "%y" "$SCRIPT_DIR/backend_nohup.log" 2>/dev/null | cut -d. -f1)
+    echo "  后台日志: backend_nohup.log ($LOG_SIZE, 最后更新: $LOG_TIME)"
+fi
+
+if [ -d "$SCRIPT_DIR/logs" ]; then
+    LOG_COUNT=$(ls -1 "$SCRIPT_DIR/logs"/*.log 2>/dev/null | wc -l | tr -d ' ')
+    if [ "$LOG_COUNT" -gt 0 ]; then
+        echo "  应用日志: logs/ 目录 ($LOG_COUNT 个日志文件)"
+    fi
+fi
+
+echo ""
+echo "=================================="
+echo -e "${BLUE}💡 提示:${NC}"
+echo "  启动服务: ./start_prod.sh"
+echo "  停止服务: ./stop_prod.sh"
+echo "  查看日志: tail -f backend_nohup.log"
+echo "  查看应用日志: tail -f logs/request_*.log"
+

+ 60 - 0
backend/stop_prod.sh

@@ -0,0 +1,60 @@
+#!/bin/bash
+
+# 生产环境停止脚本
+# 用于停止 FastAPI 后端服务
+
+set -e  # 遇到错误立即退出
+
+# 获取脚本所在目录
+SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+
+# 颜色输出
+GREEN='\033[0;32m'
+YELLOW='\033[1;33m'
+RED='\033[0;31m'
+NC='\033[0m' # No Color
+
+PID_FILE="$SCRIPT_DIR/backend.pid"
+
+if [ -f "$PID_FILE" ]; then
+    PID=$(cat "$PID_FILE")
+    if ps -p "$PID" > /dev/null 2>&1; then
+        echo -e "${YELLOW}🛑 正在停止服务 (PID: $PID)...${NC}"
+        kill "$PID"
+        # 等待进程结束
+        for i in {1..10}; do
+            if ! ps -p "$PID" > /dev/null 2>&1; then
+                break
+            fi
+            sleep 1
+        done
+        # 如果还在运行,强制杀死
+        if ps -p "$PID" > /dev/null 2>&1; then
+            echo -e "${YELLOW}⚠️  强制停止服务...${NC}"
+            kill -9 "$PID" 2>/dev/null || true
+        fi
+        rm -f "$PID_FILE"
+        echo -e "${GREEN}✅ 服务已停止${NC}"
+    else
+        echo -e "${YELLOW}⚠️  服务进程不存在 (PID: $PID)${NC}"
+        rm -f "$PID_FILE"
+    fi
+else
+    # 尝试通过进程名查找
+    PIDS=$(pgrep -f "uvicorn main:app" || true)
+    if [ -n "$PIDS" ]; then
+        echo -e "${YELLOW}🛑 正在停止服务 (通过进程名查找)...${NC}"
+        echo "$PIDS" | xargs kill 2>/dev/null || true
+        sleep 2
+        # 如果还在运行,强制杀死
+        REMAINING=$(pgrep -f "uvicorn main:app" || true)
+        if [ -n "$REMAINING" ]; then
+            echo -e "${YELLOW}⚠️  强制停止服务...${NC}"
+            echo "$REMAINING" | xargs kill -9 2>/dev/null || true
+        fi
+        echo -e "${GREEN}✅ 服务已停止${NC}"
+    else
+        echo -e "${YELLOW}⚠️  未找到运行中的服务${NC}"
+    fi
+fi
+

+ 49 - 0
backend/文档/ENV_CONFIG.md

@@ -0,0 +1,49 @@
+| 变量名 | 默认值 | 说明 |
+| --- | --- | --- |
+| `MYSQL_CONNECT_TIMEOUT` | `10` | MySQL 连接超时时间(秒),防止初始握手阻塞。 |
+| `MYSQL_READ_TIMEOUT` | `10` | ⚠️ 当前未使用:`aiomysql` 不支持此参数,已从连接池配置中移除。保留配置项以便未来兼容。 |
+| `MYSQL_POOL_RECYCLE` | `7200` | MySQL 连接池自动回收间隔(秒),保持长连接,小于服务器 `wait_timeout` 可避免闲置断连。 |
+| `MYSQL_HEALTHCHECK_INTERVAL` | `600` | MySQL 连接池健康检查间隔(秒),定期 `PING` 保持连接活跃,仅在连接断开时重建。 |
+| `REDIS_HEALTHCHECK_INTERVAL` | `300` | Redis 连接健康检查间隔(秒),定期 `PING` 保持连接活跃,自动检测并重建断开的连接。 |
+| `HTTPX_MAX_KEEPALIVE_CONNECTIONS` | `20` | HTTP 客户端 keepalive 连接池大小,用于 DashScope API 连接复用。 |
+| `HTTPX_MAX_CONNECTIONS` | `50` | HTTP 客户端最大连接数,支持并发请求。 |
+| `HTTPX_KEEPALIVE_EXPIRY` | `600.0` | HTTP 客户端 keepalive 连接过期时间(秒),保持连接更长时间以减少重建开销。默认10分钟,配合主动保活机制(每4分钟心跳),确保连接始终活跃。 |
+| `LLM_HEALTHCHECK_INTERVAL` | `300` | LLM 连接健康检查间隔(秒),长时间未使用时自动检查连接状态,失效时重建。 |
+| `TTS_HEALTHCHECK_INTERVAL` | `300` | TTS 连接健康检查间隔(秒),长时间未使用时自动检查连接状态,失效时重建。确保长时间不交互后首次TTS调用也能快速响应。 |
+| `ENERGY_STONE_TTL_SECONDS` | `20` | 能量石在前端展示的生存时间(秒),超时后自动触发消失事件。 |
+| `CONVERSATION_STONE_INTERVAL_SECONDS` | `30` | 对话能量石触发间隔(秒),基于用户累计说话时长达到该阈值时生成。 |
+| `STT_MODEL` | `qwen3-asr-flash-realtime` | STT 模型名称,对应 DashScope `qwen3-asr-flash-realtime` 流式识别。 |
+| `STT_WS_URL` | *(空)* | STT WebSocket 自定义调用地址,留空时根据 `USE_DASHSCOPE_INTL` 自动拼接 DashScope 默认地址。 |
+| `STT_LANGUAGE` | `zh` | STT 目标语种(`zh`/`en`/`ja`/`ko`/`auto`),用于 DashScope 识别参数。 |
+| `LLM_MODEL` | `qwen-flash` | LLM 模型名称,传递给 DashScope 兼容模式 Chat Completions API。 |
+| `LLM_BASE_URL` | *(空)* | LLM HTTP 调用地址,留空则自动根据 `USE_DASHSCOPE_INTL` 选择 `compatible-mode/v1` 域名。 |
+| `TTS_MODEL` | `qwen3-tts-flash` | TTS 模型名称,传递给 DashScope `MultiModalConversation`。 |
+| `TTS_API_URL` | *(空)* | 自定义 TTS HTTP 调用地址,留空则使用 `https://dashscope(或 dashscope-intl).aliyuncs.com/api/v1`。 |
+| `TTS_VOICE` | `Cherry` | TTS 音色(如 `Cherry`/`Stella`),用于生成目标发音。 |
+| `TTS_SPEECH_RATE` | `1.1` | TTS 语速,1.0 为标准,>1 加速,<1 减速。 |
+| `TTS_MIN_INTERVAL` | `0.8` | 两次 TTS 调用之间的最小时长(秒),避免触发限流。 |
+| `TTS_MAX_RETRIES` | `3` | 单次 TTS 调用的最大重试次数。 |
+| `TTS_RETRY_DELAY` | `5.0` | TTS 初次重试等待时间(秒)。 |
+| `TTS_RETRY_BACKOFF` | `2.0` | TTS 重试指数退避倍数。 |
+| `LEARNER_PROFICIENCY` | `A1-A2` | LLM 提示词中对学习者水平的设定。 |
+| `AGE_TAG` | `Adult` | LLM 提示词使用的年龄段标签。 |
+| `TOPIC_TAG` | `Daily Conversation` | LLM 提示词使用的主题标签。 |
+| `EXPERIMENT_TAG` | `control` | 提示词实验分组标记(`control` 或 `experiment`)。 |
+| `USE_SIMPLIFIED_PROMPT` | `false` | 是否启用简化提示词逻辑,降低 LLM 延迟。 |
+| `WECHAT_OPEN_APP_ID` | *(空)* | 微信开放平台 AppID,用于微信登录(OAuth)。<br>⚠️ **兼容旧配置**:如果未设置,会尝试读取 `WECHAT_APP_ID`。 |
+| `WECHAT_OPEN_APP_SECRET` | *(空)* | 微信开放平台 AppSecret,用于微信登录(OAuth)。<br>⚠️ **兼容旧配置**:如果未设置,会尝试读取 `WECHAT_APP_SECRET`。 |
+| `WECHAT_MP_APP_ID` | *(空)* | 微信公众平台 AppID(公众号AppID),用于JSSDK分享功能。<br>⚠️ **兼容旧配置**:如果未设置,会尝试读取 `WECHAT_APP_ID`。 |
+| `WECHAT_MP_APP_SECRET` | *(空)* | 微信公众平台 AppSecret(公众号AppSecret),用于JSSDK分享功能。<br>⚠️ **兼容旧配置**:如果未设置,会尝试读取 `WECHAT_APP_SECRET`。 |
+| `WECHAT_APP_ID` | *(空)* | ⚠️ **已废弃**:建议使用 `WECHAT_OPEN_APP_ID` 和 `WECHAT_MP_APP_ID` 分别配置。<br>为了兼容旧配置,如果新配置未设置,会尝试读取此值。 |
+| `WECHAT_APP_SECRET` | *(空)* | ⚠️ **已废弃**:建议使用 `WECHAT_OPEN_APP_SECRET` 和 `WECHAT_MP_APP_SECRET` 分别配置。<br>为了兼容旧配置,如果新配置未设置,会尝试读取此值。 |
+| `WECHAT_REDIRECT_URI` | *(空)* | 微信OAuth回调地址,格式:`http://www.chengyantek.cn/api/auth/wechat/callback`。 |
+| `WECHAT_JSAPI_TICKET_CACHE_TTL` | `7200` | 微信jsapi_ticket缓存时间(秒),默认2小时(与微信服务器有效期一致)。 |
+| `WECHAT_MP_TOKEN` | *(空)* | 微信公众平台服务器配置Token,用于公众号消息推送和服务器验证。必须与微信公众平台中配置的Token完全一致。 |
+| `QQ_APP_ID` | *(空)* | QQ开放平台 AppID,用于QQ登录和分享。 |
+| `QQ_APP_KEY` | *(空)* | QQ开放平台 AppKey,用于QQ登录。 |
+| `QQ_REDIRECT_URI` | *(空)* | QQ OAuth回调地址,格式:`http://www.chengyantek.cn/api/qq/mp/verify`。 |
+| `QQ_APP_NAME` | `Lyrria` | QQ应用名称,用于分享功能(显示在QQ分享卡片中)。 |
+| `FRONTEND_URL` | `http://localhost:5173` | 前端URL,用于OAuth回调后的前端跳转。 |
+| `API_CLIENT_KEY` | *(空)* | 前端API Key,用于验证请求来源。前后端必须配置相同的值,前端通过 `X-Client-Key` header 发送。 |
+| `API_KEY_ENABLED` | `true` | 是否启用API Key验证。设为 `false` 可临时禁用验证(仅用于开发调试)。 |
+

+ 115 - 0
项目文档/SSL证书配置.md

@@ -0,0 +1,115 @@
+# SSL证书配置指南
+
+## 环境说明
+
+根据您的Nginx配置:
+- **生产域名**: www.hikelly.chat
+- **服务器IP**: 101.201.60.170
+- **后端端口**: 8766(Nginx代理目标)
+- **WebSocket路径**: /ws
+
+## 快速配置
+
+### 开发环境
+
+#### 1. 生成自签名证书
+
+```bash
+cd ssl
+openssl req -x509 -newkey rsa:4096 -nodes \
+  -out cert.pem -keyout key.pem -days 365 \
+  -config openssl.cnf -extensions v3_req
+```
+
+#### 2. 直接启动(自动使用本地证书)
+
+```bash
+# 后端
+cd backend
+python main.py
+
+# 前端
+cd frontend
+npm run dev
+```
+
+### 生产环境
+
+#### 方式一:直接配置证书(不推荐)
+
+**后端配置** (`backend/.env`):
+```bash
+SSL_KEYFILE=/etc/nginx/cert/www.hikelly.chat.key
+SSL_CERTFILE=/etc/nginx/cert/www.hikelly.chat.pem
+```
+
+**前端配置** (`frontend/.env`):
+```bash
+VITE_SSL_KEYFILE=/etc/nginx/cert/www.hikelly.chat.key
+VITE_SSL_CERTFILE=/etc/nginx/cert/www.hikelly.chat.pem
+```
+
+#### 方式二:Nginx反向代理(推荐)
+
+前后端都不配置SSL,证书统一由Nginx管理:
+
+```nginx
+server {
+    listen 443 ssl;
+    server_name www.hikelly.chat;
+    
+    ssl_certificate /etc/nginx/cert/www.hikelly.chat.pem;
+    ssl_certificate_key /etc/nginx/cert/www.hikelly.chat.key;
+    
+    # 前端
+    location / {
+        root /path/to/Lyrria/frontend/dist;
+        index index.html;
+        try_files $uri $uri/ /index.html;
+    }
+    
+    # 后端API
+    location /api/ {
+        proxy_pass http://127.0.0.1:8000;
+    }
+    
+    # WebSocket
+    location /ws {
+        proxy_pass http://127.0.0.1:8000;
+        proxy_http_version 1.1;
+        proxy_set_header Upgrade $http_upgrade;
+        proxy_set_header Connection "upgrade";
+    }
+}
+```
+
+## 证书权限检查
+
+```bash
+# 检查证书文件
+ls -l /etc/nginx/cert/www.hikelly.chat.*
+
+# 证书应该是 644
+# 私钥应该是 600 或 400
+sudo chmod 644 /etc/nginx/cert/www.hikelly.chat.pem
+sudo chmod 600 /etc/nginx/cert/www.hikelly.chat.key
+```
+
+## 访问地址
+
+- 开发环境:
+  - 前端: https://localhost:3000
+  - 后端: https://localhost:8000
+  - WebSocket: wss://localhost:8000/ws
+
+- 生产环境:
+  - 前端: https://www.hikelly.chat
+  - 后端API: https://www.hikelly.chat/api
+  - WebSocket: wss://www.hikelly.chat/ws
+
+## 详细文档
+
+- 后端配置: `backend/SSL_CONFIG.md`
+- 前端配置: `frontend/SSL_CONFIG.md`
+- 本地证书: `ssl/README.md`
+

+ 146 - 0
项目文档/postcss-px-to-viewport插件的注意点.md

@@ -0,0 +1,146 @@
+使用 postcss-px-to-viewport 进行移动端开发时,需要注意以下几个关键点:
+
+# 1. 配置参数设置
+javascript
+// postcss.config.js
+module.exports = {
+  plugins: {
+    'postcss-px-to-viewport': {
+      viewportWidth: 375, // 设计稿宽度(通常是 iPhone 6/7/8 的 375px)
+      viewportHeight: 667, // 设计稿高度
+      unitPrecision: 5, // 转换后的小数位数
+      viewportUnit: 'vw', // 转换单位
+      selectorBlackList: ['.ignore', '.hairlines'], // 不转换的类名
+      minPixelValue: 1, // 小于等于该值的px不转换
+      mediaQuery: false, // 是否转换媒体查询中的px
+      exclude: /node_modules/, // 排除的文件夹
+    }
+  }
+}
+
+# 2. 设计稿适配
+设计稿基准:以 iPhone 6/7/8(375×667)为基准最常用
+
+Retina屏幕:viewportWidth 应按物理像素的 1/2 设置
+
+多设计稿适配:可通过环境变量或不同文件配置
+
+javascript
+// 根据设计稿动态设置
+const designWidth = process.env.DESIGN_WIDTH || 375
+
+# 3. 常见问题及解决方案
+第三方组件样式问题
+javascript
+exclude: [/node_modules/], // 排除 node_modules
+// 或使用 include 只处理特定目录
+include: /src/
+1px边框问题
+css
+/* 使用 transform 解决 */
+.border-1px {
+  position: relative;
+  &:after {
+    content: '';
+    position: absolute;
+    bottom: 0;
+    left: 0;
+    width: 100%;
+    height: 1px;
+    background: #ddd;
+    transform: scaleY(0.5);
+  }
+}
+固定尺寸元素
+css
+/* 使用 PX 或 px 避免转换 */
+.fixed-size {
+  width: 100PX; /* 大写 PX 不转换 */
+  /* 或添加到 selectorBlackList */
+}
+
+# 4. 响应式处理
+媒体查询使用 rem/em
+css
+@media (min-width: 768px) {
+  .container {
+    /* 使用 rem 而非 px */
+    max-width: 20rem;
+  }
+}
+配合 vw + rem 方案
+javascript
+// 同时使用 postcss-px-to-viewport 和 postcss-pxtorem
+{
+  'postcss-px-to-viewport': {
+    viewportWidth: 375,
+    exclude: [/node_modules/],
+  },
+  'postcss-pxtorem': {
+    rootValue: 37.5,
+    propList: ['*'],
+    exclude: /src/,
+  }
+}
+
+# 5. 特殊场景处理
+内联样式无法转换
+javascript
+// JS 中需要手动处理
+const pxToVw = (px) => `${(px / 375 * 100)}vw`
+图片和背景图
+css
+.icon {
+  /* 小的固定尺寸图标使用 px */
+  width: 20px;
+  height: 20px;
+  background-size: 20px 20px;
+}
+
+.full-bg {
+  /* 全屏背景使用 vw/vh */
+  width: 100vw;
+  height: 100vh;
+}
+
+# 6. 最佳实践建议
+统一设计规范:团队使用相同设计稿尺寸
+
+样式审查:定期检查转换效果,特别是第三方组件
+
+性能考虑:避免过多嵌套和复杂选择器
+
+兼容性测试:在不同尺寸设备上测试显示效果
+
+使用 CSS 变量:定义常用尺寸变量
+
+css
+:root {
+  --safe-area-inset-bottom: env(safe-area-inset-bottom);
+}
+
+# 7. 调试技巧
+使用浏览器开发者工具检查转换后的值
+
+添加 sourcemap 方便调试
+
+使用注释标记特殊不需要转换的样式
+
+css
+/* px-to-viewport-ignore-next */
+.ignore-conversion {
+  width: 100px; /* 这行不会被转换 */
+}
+
+# 8. 与其他插件配合
+注意插件执行顺序:
+
+javascript
+plugins: [
+  'postcss-import',
+  'postcss-flexbugs-fixes',
+  'postcss-preset-env',
+  'postcss-px-to-viewport', // 像素转换
+  'cssnano' // 压缩
+]
+通过合理配置和注意事项,postcss-px-to-viewport 可以很好地支持移动端响应式开发,提高开发效率和页面适配性。