zlwu 8 月之前
父節點
當前提交
ebfc39190d

+ 13 - 0
backend/init_database.py

@@ -205,6 +205,16 @@ CREATE TABLE IF NOT EXISTS conversation_energy_stones (
 COMMENT='对话能量石表'
 """
 
+# 用户赠送对话能量石表(活动/回馈等,展示时与 conversation_energy_stones 数量相加)
+CREATE_USER_BONUS_ENERGY_STONES_TABLE = """
+CREATE TABLE IF NOT EXISTS user_bonus_energy_stones (
+    user_id VARCHAR(64) PRIMARY KEY COMMENT '用户ID(关联 users.user_id)',
+    bonus_conversation_count INT NOT NULL DEFAULT 0 COMMENT '赠送的对话能量石个数',
+    updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间'
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
+COMMENT='用户赠送对话能量石数量表'
+"""
+
 
 async def init_tables():
     """初始化数据库表"""
@@ -242,6 +252,9 @@ async def init_tables():
             # 创建对话能量石表
             await cursor.execute(CREATE_CONVERSATION_ENERGY_STONES_TABLE)
 
+            # 创建用户赠送对话能量石表
+            await cursor.execute(CREATE_USER_BONUS_ENERGY_STONES_TABLE)
+
             # 写入默认任务类型
             await cursor.execute(
                 """

+ 9 - 0
backend/migrations/add_user_bonus_energy_stones.sql

@@ -0,0 +1,9 @@
+-- 用户赠送对话能量石表(活动/回馈等)
+-- 展示时:对话能量石总数 = conversation_energy_stones 条数 + user_bonus_energy_stones.bonus_conversation_count
+
+CREATE TABLE IF NOT EXISTS user_bonus_energy_stones (
+    user_id VARCHAR(64) PRIMARY KEY COMMENT '用户ID(关联 users.user_id)',
+    bonus_conversation_count INT NOT NULL DEFAULT 0 COMMENT '赠送的对话能量石个数',
+    updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间'
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
+COMMENT='用户赠送对话能量石数量表';

+ 15 - 0
backend/models_db.py

@@ -317,6 +317,20 @@ class ConversationEnergyStone(Base):
     )
 
 
+class UserBonusEnergyStone(Base):
+    """用户赠送的对话能量石数量(活动/回馈等,与 conversation_energy_stones 分开统计,展示时相加)"""
+    __tablename__ = "user_bonus_energy_stones"
+
+    user_id = Column(String(64), primary_key=True, nullable=False, comment="用户ID(关联 users.user_id)")
+    bonus_conversation_count = Column(Integer, default=0, nullable=False, comment="赠送的对话能量石个数")
+    updated_at = Column(
+        TIMESTAMP,
+        server_default=func.current_timestamp(),
+        onupdate=func.current_timestamp(),
+        comment="更新时间",
+    )
+
+
 class UserUIState(Base):
     """用户前端可视化状态表(页面与提示状态)"""
 
@@ -805,6 +819,7 @@ for _model in (
     MissionType,
     MissionEnergyStone,
     ConversationEnergyStone,
+    UserBonusEnergyStone,
     BackgroundImage,
     UserBackgroundImage,
     Avatar,

+ 11 - 3
backend/services/energy_stone_service.py

@@ -11,7 +11,7 @@ from sqlalchemy import select, func
 from sqlalchemy.exc import IntegrityError
 
 from database import db_manager
-from models_db import MissionEnergyStone, ConversationEnergyStone, MissionType
+from models_db import MissionEnergyStone, ConversationEnergyStone, UserBonusEnergyStone, MissionType
 from config import settings
 
 logger = logging.getLogger(__name__)
@@ -281,13 +281,21 @@ class EnergyStoneService:
                 )
                 mission_count = mission_result.scalar() or 0
 
-                # 获取对话能量石数量
+                # 对话能量石数量 = 对话获得的条数 + 赠送数量
                 conversation_result = await session.execute(
                     select(func.count(ConversationEnergyStone.stone_id)).where(
                         ConversationEnergyStone.user_id == user_id
                     )
                 )
-                conversation_count = conversation_result.scalar() or 0
+                conversation_earned = conversation_result.scalar() or 0
+
+                bonus_result = await session.execute(
+                    select(UserBonusEnergyStone.bonus_conversation_count).where(
+                        UserBonusEnergyStone.user_id == user_id
+                    )
+                )
+                bonus_count = bonus_result.scalar() or 0
+                conversation_count = conversation_earned + int(bonus_count)
 
                 return {
                     "mission_count": mission_count,

+ 2 - 2
frontend/src/i18n/locales/en/common.json

@@ -34,8 +34,8 @@
   "privacyPolicy": "Privacy Policy",
   "lightUp": "Light Up",
   "daysSinceLastMet": "It's been {{days}} days since we last met.",
-  "spiritStone": "Spirit Stone",
-  "spiritStoneDesc": "Get Energy Stones via tasks & chats with Lumi to shop for items. Accumulate 30s of voice input for 1 Chat Stone.",
+  "spiritStone": "Energy Stone",
+  "spiritStoneDesc": "Get Energy Stones via tasks & chats with Lumi to shop for items. Accumulate 30s of voice input for 1 Energy Stone.",
   "share": "Share",
   "firstLoginRewardDescription": "Oh, a tiny hat for me? I love it! New Year's near! Share my look to unlock the festive background.",
   "shareSuccessDescription": "Thank you for sharing Lumi with your friends! The New Year Limited Background is now yours. Start your first conversation of the year with Lumi and embrace a fresh start!",

+ 1 - 1
frontend/src/i18n/locales/en/profile.json

@@ -1 +1 @@
-{"changeMyName":"Change my name","changeLumiName":"Change Lumi's name","change":"Change","placeholderUsername":"Change my name","placeholderLumiName":"Change Lumi's name","head":"Head","background":"Background","feedbackAndHelp":"Feedback & Help","userAgreement":"User Agreement","privacyPolicy":"Privacy Policy","missionStoneTitle":"Mission Spirit Stone","missionStoneDescription":"Earn this Spirit Stone by completing tasks to save the Life Tree. Watch for hidden tasks!","chatStoneTitle":"Spirit Stone","chatStoneDescription":"Get Energy Stones via tasks & chats with Lumi to shop for items. Accumulate 30s of voice input for 1 Chat Stone."}
+{"changeMyName":"Change my name","changeLumiName":"Change Lumi's name","change":"Change","placeholderUsername":"Change my name","placeholderLumiName":"Change Lumi's name","head":"Head","background":"Background","feedbackAndHelp":"Feedback & Help","userAgreement":"User Agreement","privacyPolicy":"Privacy Policy","missionStoneTitle":"Mission Energy Stone","missionStoneDescription":"Earn this Energy Stone by completing tasks to save the Life Tree. Watch for hidden tasks!","chatStoneTitle":"Energy Stone","chatStoneDescription":"Get Energy Stones via tasks & chats with Lumi to shop for items. Accumulate 30s of voice input for 1 Energy Stone."}

+ 25 - 38
frontend/src/pages/ChatBotPage.tsx

@@ -21,7 +21,6 @@ import {
   setUserLevel,
   getCurrentBackgroundImageUrl,
   getCurrentBackgroundImageId,
-  getCurrentBackgroundImageOpacity,
   syncBackgroundImageToStorage,
   setUserName,
   getUserToken,
@@ -201,9 +200,8 @@ export const ChatBotPage = () => {
   const isMountedRef = useRef(true);
   const [, setUserLevelValue] = useState(() => getUserLevel());
   const [, setHasSharedAvatarState] = useState(() => getHasSharedAvatar());
-  // 背景图片 URL
-  const [backgroundImageUrl, setBackgroundImageUrl] = useState<string | null>(() => {
-    // 初始化时从 localStorage 获取背景图片 URL
+  // 背景图片 URL(只保留 setter,供同步逻辑使用)
+  const [, setBackgroundImageUrl] = useState<string | null>(() => {
     const url = getCurrentBackgroundImageUrl();
     return url || null;
   });
@@ -1894,11 +1892,9 @@ export const ChatBotPage = () => {
             debugLog('[AudioPlayer] onStart: cleared previous emotionGifTimer');
           }
 
-          // 仅在字幕开关打开时展示字幕;用户语音不会触发此回调,因此不会显示为字幕
+          // 仅在字幕开关打开且正在播放时展示字幕;不在此处隐藏字幕,字幕只在用户点击录音时消失
           if (subtitleOnRef.current && appStateSnapshot.currentState === 'playing') {
             setSubtitleVisible(true);
-          } else {
-            setSubtitleVisible(false);
           }
 
           // 用 flushSync 立即提交 GIF 状态,避免 React 批处理导致动画晚于语音 0.5~1s
@@ -1999,7 +1995,7 @@ export const ChatBotPage = () => {
           flushShowTalking();
         });
 
-        // 音频播放结束回调:强制回归 default,本次对话收尾 + 隐藏字幕
+        // 音频播放结束回调:强制回归 default,本次对话收尾(字幕保留到用户下次点击录音再消失)
         player.onEnd(() => {
           const snapshot = useAppStore.getState();
           const requestId = snapshot.currentRequestId;
@@ -2021,15 +2017,12 @@ export const ChatBotPage = () => {
           flushSync(() => {
             showDefaultGifRef.current?.();
           });
-          // 播放完成后隐藏字幕并清空字幕文本
-          setSubtitleVisible(false);
-          setRecognizedText('');
-          subtitleAccumulatorRequestIdRef.current = null;
+          // 字幕不再在播放结束时隐藏,保留到用户下次点击录音按钮时再消失(在 handleStartRecord 中处理)
           // 不在 onEnd 里清 responseCompleteRequestIdRef,否则 onEnd 之后才到的同轮滞后 chunk 会再次触发 onStart
           streamStartedForRequestIdRef.current = null; // 下一轮新 request 的首包可再次触发 onStart
           emotionGifShownForStreamRef.current = false;
           setState('idle');
-          reset();
+          reset({ keepRecognizedText: true }); // 保留字幕内容,仅在下一次点击录音时清空
         });
         playerRef.current = player;
 
@@ -2133,7 +2126,7 @@ export const ChatBotPage = () => {
               }
               // 恢复默认头像 GIF
               showDefaultGif();
-              reset();
+              reset({ keepRecognizedText: true });
               setError(null);
             }
             return;
@@ -2586,6 +2579,11 @@ export const ChatBotPage = () => {
     // 点击录音按钮时,隐藏进度条提示
     dismissProgressBarTooltip();
 
+    // 用户点击录音时隐藏上一轮字幕并清空,多保留一段时间方便用户查看
+    setSubtitleVisible(false);
+    setRecognizedText('');
+    subtitleAccumulatorRequestIdRef.current = null;
+
     // 🔥 清理播放器状态,确保上一次对话的残留状态被重置
     playerRef.current?.stop();
 
@@ -2595,8 +2593,7 @@ export const ChatBotPage = () => {
     updateRequestId(requestId);
     setState('recording');
     chunkIndexRef.current = 0;
-    setRecognizedText('');
-    subtitleAccumulatorRequestIdRef.current = null;
+    // 字幕已在上面点击录音时隐藏并清空
     // 新一轮对话开始,允许本次 AI 回复播报时再播放一次情感 GIF
     emotionGifShownForStreamRef.current = false;
     emotionGifPlayedThisRoundRef.current = false;
@@ -2657,7 +2654,7 @@ export const ChatBotPage = () => {
       // 恢复默认头像 GIF
       showDefaultGif();
       setState('idle');
-      reset();
+      reset({ keepRecognizedText: true });
       return;
     }
   };
@@ -2689,7 +2686,7 @@ export const ChatBotPage = () => {
       // 恢复默认头像 GIF
       showDefaultGif();
       performanceLogger.clearRequest(currentRequestId, 'websocket_unready_before_send');
-      reset();
+      reset({ keepRecognizedText: true });
       return;
     }
 
@@ -2712,7 +2709,7 @@ export const ChatBotPage = () => {
       // 恢复默认头像 GIF
       showDefaultGif();
       performanceLogger.clearRequest(currentRequestId, 'websocket_disconnected_before_send');
-      reset();
+      reset({ keepRecognizedText: true });
       return;
     }
     
@@ -2762,7 +2759,7 @@ export const ChatBotPage = () => {
         // 恢复默认头像 GIF
         showDefaultGif();
         setState('idle');
-        reset();
+        reset({ keepRecognizedText: true });
         performanceLogger.clearRequest(currentRequestId, '响应超时');
       }
     }, 10000);
@@ -2792,7 +2789,7 @@ export const ChatBotPage = () => {
     wsRef.current?.sendCancel(currentRequestId);
     cancelledRequestIdsRef.current.add(currentRequestId);
     performanceLogger.clearRequest(currentRequestId);
-    reset();
+    reset({ keepRecognizedText: true });
   };
 
   const currentAvatarAlt = currentState === 'playing' ? 'AnimaZone Avatar Animated' : 'AnimaZone Avatar';
@@ -2821,23 +2818,15 @@ export const ChatBotPage = () => {
     [spawnStone, fetchConversationStoneCount]
   );
 
-  // 根据背景图片获取对应的树木装饰图片URL(默认背景下录音时切到 bg-chatting.png,发送/取消后恢复)
+  // 根据背景图片获取对应的树木装饰图片URL(/chatbot 界面统一使用默认图片,不随用户选择的新年等背景切换)
   const getTreesImage = (): string | null => {
-    const bgId = getCurrentBackgroundImageId();
-    const isDefaultBg = !bgId || bgId === 'default';
-    if (isDefaultBg && currentState === 'recording') {
+    if (currentState === 'recording') {
       return bgChattingUrl;
     }
-    // 实时从 localStorage 读取最新的背景图片URL,使用背景图片作为树木装饰
-    const currentBgUrl = getCurrentBackgroundImageUrl() || backgroundImageUrl;
-    if (currentBgUrl && currentBgUrl.includes('init.svg')) {
-      // 如果是旧的 init.svg,强制替换为 init1.svg
-      return getOssUrl('init1.svg');
-    }
-    return currentBgUrl;
+    return getOssUrl('init1.svg');
   };
 
-  // 根据背景图片ID获取树木装饰图片的样式配置
+  // 根据背景图片ID获取树木装饰图片的样式配置(/chatbot 使用默认背景,透明度固定为 1)
   const getTreesImageStyle = (): React.CSSProperties => {
     const baseStyle: React.CSSProperties = {
       width: '100%',
@@ -2846,11 +2835,9 @@ export const ChatBotPage = () => {
       left: 0,
       objectFit: 'cover',
       zIndex: 1,
+      opacity: 1,
     };
-
-    // 从 localStorage 读取透明度配置(从数据库metadata中获取)
-    const opacity = getCurrentBackgroundImageOpacity();
-    return { ...baseStyle, opacity };
+    return baseStyle;
   };
 
   const defaultGifUrl = avatarGifSet?.default ?? '';
@@ -3219,7 +3206,7 @@ export const ChatBotPage = () => {
       </div>
 
       {/* 字幕区域 - 设计稿 393×852:top 500,高 92,宽 312,左右居中 */}
-      {subtitleOn && subtitleVisible && recognizedText && currentState === 'playing' && (
+      {subtitleOn && subtitleVisible && recognizedText && (
         <div
           ref={subtitleContainerRef}
           className="chatbot-subtitle-container"

+ 0 - 1
frontend/src/pages/LoginPage.tsx

@@ -197,7 +197,6 @@ export const LoginPage = () => {
           left: px2vw(24, DESIGN),
           top: px2vh(125, DESIGN),
           width: px2vw(48, DESIGN),
-          height: px2vh(48, DESIGN),
           padding: 0,
           border: 'none',
           background: 'none',

+ 0 - 1
frontend/src/pages/PhoneLogin.tsx

@@ -174,7 +174,6 @@ export const PhoneLogin = () => {
           left: px2vw(w393(24)),
           top: px2vh(h852(125)),
           width: px2vw(w393(48)),
-          height: px2vh(h852(48)),
           padding: 0,
           border: 'none',
           background: 'none',

+ 11 - 8
frontend/src/store/index.ts

@@ -27,7 +27,7 @@ interface AppState {
   setConnected: (connected: boolean) => void;
   updateProgressByDuration: (durationMs: number) => void; // 根据说话时长更新进度(30秒=100%)
   setProgress: (progress: number, loopCount?: number) => void; // 直接设置进度值(0-100),可选指定整圈数量
-  reset: () => void;
+  reset: (options?: { keepRecognizedText?: boolean }) => void;
 }
 
 export const useAppStore = create<AppState>((set) => ({
@@ -106,13 +106,16 @@ export const useAppStore = create<AppState>((set) => ({
           ? Math.max(0, Math.floor(loopCount))
           : state.progressLoopCount,
     })),
-  reset: () =>
-    set({
-      currentState: 'idle',
-      currentRequestId: null,
-      currentEmotion: 'neutral',
-      recognizedText: '',
-      errorMessage: null,
+  reset: (options) =>
+    set(() => {
+      const keep = options?.keepRecognizedText === true;
+      return {
+        currentState: 'idle',
+        currentRequestId: null,
+        currentEmotion: 'neutral',
+        ...(keep ? {} : { recognizedText: '' }),
+        errorMessage: null,
+      };
     }),
 }));
 

+ 10 - 0
plus50EnergyStones/plus50.conf

@@ -0,0 +1,10 @@
+# 回馈用户 +50 对话能量石 脚本配置文件
+# 仅用于 plus50_energy_stones.py
+# 敏感信息建议使用环境变量覆盖
+
+[database]
+mysql_host = 101.201.60.170
+mysql_port = 13306
+mysql_user = root
+mysql_password = Lyrria@Root@123456
+mysql_database = Lyrria

+ 194 - 0
plus50EnergyStones/plus50_energy_stones.py

@@ -0,0 +1,194 @@
+#!/usr/bin/env python3
+"""
+回馈用户:为指定邮箱的已注册用户增加 50 个对话能量石(赠送)。
+- 使用 user_bonus_energy_stones 表记录赠送数量,与 conversation_energy_stones 分开。
+- 根据 users 表的 email 字段匹配,仅对已注册用户增加。
+- 已发放过的用户会记录在 plus50_granted.txt,不重复增加。
+- 每 10 分钟执行一轮,直到清单内所有用户都已完成赠送后脚本自动退出(未注册用户会在后续注册后被下一轮发现并发放)。
+"""
+import configparser
+import os
+import sys
+import asyncio
+from pathlib import Path
+
+try:
+    import aiomysql
+except ImportError:
+    print("请安装 aiomysql: pip install aiomysql", file=sys.stderr)
+    sys.exit(1)
+
+# 输出重定向到文件时(如 nohup ... >> a.log)Python 会全缓冲,日志不实时写入;
+# 设为行缓冲,保证后台运行时 a.log 能及时看到每行输出
+if not sys.stdout.isatty():
+    sys.stdout.reconfigure(line_buffering=True)
+
+# 用户清单(邮箱)
+TARGET_EMAILS = [
+    "zczqcz3@ucl.ac.uk",
+    "xichun.fang.25@ucl.ac.uk",
+    "tinskii2020@gmail.com",
+    "Jiali.guo.22@ucl.ac.uk",
+    "K.Zhang-61@sms.ed.ac.uk",
+    "jiayi.ling.25@ucl.ac.uk",
+    "zirou.wei.24@ucl.ac.uk",
+    "2050622302@qq.com",
+    "huimin.mi.24@ucl.ac.uk",
+    "r.hu@ucl.ac.uk",
+    "xiaoyingwang25@163.com",
+    "shijunchen@connect.hku.hk",
+    "zhujun.dai.24@ucl.ac.uk",
+    "zhouxf626@sjtu.edu.cn",
+    "jinke.wu.25@ucl.ac.uk",
+    "xujiaqi_627@163.com",
+    "10826762@qq.com",
+    "neil.li@ucl.ac.uk",
+    "k.saito@ucl.ac.uk",
+    "a.revesz@ucl.ac.uk",
+    "704541015@qq.com",
+    "qtnvlh4@ucl.ac.uk",
+    "uclheliqun@163.com"
+]
+
+# 回馈数量(写入 user_bonus_energy_stones.bonus_conversation_count)
+BONUS_COUNT = 50
+# 每轮间隔(秒),未全部发放完则等待后再次检查(便于后续才注册的用户被发放)
+POLL_INTERVAL_SECONDS = 600
+
+SCRIPT_DIR = Path(__file__).resolve().parent
+GRANTED_FILE = SCRIPT_DIR / "plus50_granted.txt"
+CONF_FILE = SCRIPT_DIR / "plus50.conf"
+
+
+def load_config() -> configparser.ConfigParser:
+    if not CONF_FILE.exists():
+        print(f"配置文件不存在: {CONF_FILE}", file=sys.stderr)
+        sys.exit(1)
+    cfg = configparser.ConfigParser()
+    cfg.read(CONF_FILE, encoding="utf-8")
+    if not cfg.has_section("database"):
+        print("配置文件缺少 [database] 段", file=sys.stderr)
+        sys.exit(1)
+    return cfg
+
+
+def get_db_config(cfg: configparser.ConfigParser) -> dict:
+    return {
+        "host": os.environ.get("MYSQL_HOST") or cfg.get("database", "mysql_host"),
+        "port": cfg.getint("database", "mysql_port"),
+        "user": os.environ.get("MYSQL_USER") or cfg.get("database", "mysql_user"),
+        "password": os.environ.get("MYSQL_PASSWORD") or cfg.get("database", "mysql_password"),
+        "db": os.environ.get("MYSQL_DATABASE") or cfg.get("database", "mysql_database"),
+        "charset": "utf8mb4",
+    }
+
+
+def load_granted_emails() -> set[str]:
+    if not GRANTED_FILE.exists():
+        return set()
+    with open(GRANTED_FILE, "r", encoding="utf-8") as f:
+        return {line.strip().lower() for line in f if line.strip()}
+
+
+def append_granted_emails(emails: list[str]) -> None:
+    with open(GRANTED_FILE, "a", encoding="utf-8") as f:
+        for e in emails:
+            f.write(e.strip() + "\n")
+
+
+def all_targets_granted(granted: set[str]) -> bool:
+    """清单内所有邮箱是否都已记录为已发放(可含尚未注册的,一旦注册会在下一轮发放)。"""
+    target_keys = {e.strip().lower() for e in TARGET_EMAILS}
+    return target_keys <= granted
+
+
+async def run_one_round(db_config: dict) -> bool:
+    """
+    执行一轮:对「未在已发放列表中且已在 users 表注册」的用户发放 50 颗。
+    返回 True 表示清单内所有人都已发放完毕,可结束脚本;否则返回 False。
+    """
+    granted = load_granted_emails()
+
+    emails_to_process = []
+    seen = set()
+    for e in TARGET_EMAILS:
+        key = e.strip().lower()
+        if key not in seen:
+            seen.add(key)
+            emails_to_process.append((e.strip(), key))
+
+    to_grant = [(orig, key) for orig, key in emails_to_process if key not in granted]
+    already_granted = [(orig, key) for orig, key in emails_to_process if key in granted]
+
+    if not to_grant:
+        # 清单内所有人都已在已发放列表中
+        return True
+
+    conn = await aiomysql.connect(**db_config)
+    try:
+        async with conn.cursor(aiomysql.DictCursor) as cur:
+            placeholders = ", ".join(["%s"] * len(to_grant))
+            emails_only = [key for _, key in to_grant]
+            await cur.execute(
+                "SELECT user_id, email FROM users WHERE LOWER(email) IN (" + placeholders + ")",
+                emails_only,
+            )
+            rows = await cur.fetchall()
+        email_to_user: dict[str, str] = {}
+        for r in rows:
+            em = r["email"]
+            if em:
+                email_to_user[em.lower()] = r["user_id"]
+
+        added: list[tuple[str, str]] = []
+        not_registered: list[str] = []
+
+        for orig, key in to_grant:
+            user_id = email_to_user.get(key)
+            if user_id:
+                added.append((orig, user_id))
+            else:
+                not_registered.append(orig)
+
+        if added:
+            upsert_sql = """
+                INSERT INTO user_bonus_energy_stones (user_id, bonus_conversation_count)
+                VALUES (%s, %s)
+                ON DUPLICATE KEY UPDATE bonus_conversation_count = bonus_conversation_count + %s
+            """
+            async with conn.cursor() as cur:
+                for _orig, user_id in added:
+                    await cur.execute(upsert_sql, (user_id, BONUS_COUNT, BONUS_COUNT))
+            await conn.commit()
+            append_granted_emails([orig for orig, _ in added])
+            print(f"[本轮] 已为 {len(added)} 人增加 {BONUS_COUNT} 颗赠送能量石: {[x[0] for x in added]}")
+        if not_registered:
+            print(f"[本轮] 尚未注册(下轮再查): {not_registered}")
+    finally:
+        conn.close()
+
+    # 本轮后再次检查:是否清单内所有人都已进入已发放列表
+    return all_targets_granted(load_granted_emails())
+
+
+async def run():
+    cfg = load_config()
+    db_config = get_db_config(cfg)
+    round_num = 0
+    while True:
+        round_num += 1
+        print(f"\n===== 第 {round_num} 轮 =====")
+        all_done = await run_one_round(db_config)
+        if all_done:
+            print("清单内所有用户均已赠送 50 颗能量石,脚本正常退出。")
+            break
+        print(f"等待 {POLL_INTERVAL_SECONDS} 秒({POLL_INTERVAL_SECONDS // 60} 分钟)后再次检查...")
+        await asyncio.sleep(POLL_INTERVAL_SECONDS)
+
+
+def main():
+    asyncio.run(run())
+
+
+if __name__ == "__main__":
+    main()

+ 2 - 0
plus50EnergyStones/plus50_granted.txt

@@ -0,0 +1,2 @@
+Jiali.guo.22@ucl.ac.uk
+704541015@qq.com

+ 1 - 0
plus50EnergyStones/requirements.txt

@@ -0,0 +1 @@
+aiomysql>=0.2.0