zlwu 8 tháng trước cách đây
mục cha
commit
5848c48b91

Những thai đổi đã bị hủy bỏ vì nó quá lớn
+ 24 - 0
frontend/public/privacy-policy-modal-en.html


Những thai đổi đã bị hủy bỏ vì nó quá lớn
+ 26 - 0
frontend/public/privacy-policy-modal-zh.html


Những thai đổi đã bị hủy bỏ vì nó quá lớn
+ 30 - 0
frontend/public/user-agreement-modal-en.html


Những thai đổi đã bị hủy bỏ vì nó quá lớn
+ 30 - 0
frontend/public/user-agreement-modal-zh.html


+ 10 - 2
frontend/src/components/PrivacyPolicyModal.tsx

@@ -1,4 +1,5 @@
 import React, { useEffect } from 'react';
+import { useTranslation } from 'react-i18next';
 import { px2vmin, px2vh, px2vw } from '@/utils/viewport';
 
 interface PrivacyPolicyModalProps {
@@ -10,6 +11,8 @@ export const PrivacyPolicyModal: React.FC<PrivacyPolicyModalProps> = ({
   visible,
   onClose,
 }) => {
+  const { i18n } = useTranslation();
+
   useEffect(() => {
     if (visible) {
       document.body.style.overflow = 'hidden';
@@ -26,6 +29,11 @@ export const PrivacyPolicyModal: React.FC<PrivacyPolicyModalProps> = ({
     return null;
   }
 
+  const currentLang = i18n.language?.startsWith('zh') ? 'zh' : 'en';
+  const iframeSrc =
+    currentLang === 'zh' ? '/privacy-policy-modal-zh.html' : '/privacy-policy-modal-en.html';
+  const iframeTitle = currentLang === 'zh' ? '隐私政策' : 'Privacy Policy';
+
   return (
     <>
       {/* 遮罩层 */}
@@ -83,14 +91,14 @@ export const PrivacyPolicyModal: React.FC<PrivacyPolicyModalProps> = ({
           }}
         >
           <iframe
-            src="/privacy-policy-modal.html"
+            src={iframeSrc}
             style={{
               width: '100%',
               height: '100%',
               border: 'none',
               backgroundColor: '#34313D',
             }}
-            title="隐私政策"
+            title={iframeTitle}
           />
         </div>
       </div>

+ 10 - 2
frontend/src/components/UserAgreementModal.tsx

@@ -1,4 +1,5 @@
 import React, { useEffect } from 'react';
+import { useTranslation } from 'react-i18next';
 import { px2vmin, px2vh, px2vw } from '@/utils/viewport';
 
 interface UserAgreementModalProps {
@@ -10,6 +11,8 @@ export const UserAgreementModal: React.FC<UserAgreementModalProps> = ({
   visible,
   onClose,
 }) => {
+  const { i18n } = useTranslation();
+
   useEffect(() => {
     if (visible) {
       document.body.style.overflow = 'hidden';
@@ -26,6 +29,11 @@ export const UserAgreementModal: React.FC<UserAgreementModalProps> = ({
     return null;
   }
 
+  const currentLang = i18n.language?.startsWith('zh') ? 'zh' : 'en';
+  const iframeSrc =
+    currentLang === 'zh' ? '/user-agreement-modal-zh.html' : '/user-agreement-modal-en.html';
+  const iframeTitle = currentLang === 'zh' ? '用户协议' : 'User Agreement';
+
   return (
     <>
       {/* 遮罩层 */}
@@ -83,14 +91,14 @@ export const UserAgreementModal: React.FC<UserAgreementModalProps> = ({
           }}
         >
           <iframe
-            src="/user-agreement-modal.html"
+            src={iframeSrc}
             style={{
               width: '100%',
               height: '100%',
               border: 'none',
               backgroundColor: '#34313D',
             }}
-            title="用户协议"
+            title={iframeTitle}
           />
         </div>
       </div>

+ 1 - 0
frontend/src/i18n/locales/en/common.json

@@ -61,6 +61,7 @@
   "initFailedCheckMic": "Initialization failed, please check microphone permission",
   "sessionLoggedInElsewhere": "Session logged in on another device, please re-enter",
   "micStartFailed": "Microphone start failed, check permission or retry",
+  "audioProcessingError": "AI audio processing error, please try again later",
   "a11y": {
     "back": "Back",
     "clear": "Clear",

+ 1 - 0
frontend/src/i18n/locales/zh/common.json

@@ -61,6 +61,7 @@
   "initFailedCheckMic": "初始化失败,请检查麦克风权限",
   "sessionLoggedInElsewhere": "会话已在其他设备登录,请重新进入",
   "micStartFailed": "麦克风启动失败,请检查权限或重试",
+  "audioProcessingError": "AI 语音处理异常,请稍后重试",
   "a11y": {
     "back": "返回",
     "clear": "清除",

+ 47 - 68
frontend/src/pages/ChatBotPage.tsx

@@ -2501,6 +2501,8 @@ export const ChatBotPage = () => {
         // 恢复默认头像 GIF
         showDefaultGif();
         performanceLogger.clearRequest(requestId, `${message.code}: ${message.message}`);
+        setState('idle');
+        antMessage.error(tCommon('audioProcessingError'));
         break;
 
       case 'pong':
@@ -2524,7 +2526,11 @@ export const ChatBotPage = () => {
     if (currentState === 'recording') {
       return;
     }
-    
+    // AI 音频返回/播放中不允许开始录音,需等播放结束
+    if (currentState === 'playing' || currentState === 'sending') {
+      return;
+    }
+
     // 🔥 改进:允许在连接建立过程中开始录音
     // 1. 如果 WebSocket 不存在,说明还未初始化,提示等待
     // 2. 如果 WebSocket 存在,检查是否正在连接中或已就绪
@@ -2547,45 +2553,6 @@ export const ChatBotPage = () => {
     }
     
     // 如果正在连接中或已就绪,允许开始录音(消息会被缓冲)
-    
-    // 保存旧请求ID(用于取消和过滤旧消息)
-    const oldRequestId = currentRequestId;
-    const wasPlaying = currentState === 'playing' || currentState === 'sending';
-    
-    // 如果正在播放,先停止播放并重置状态
-    if (wasPlaying) {
-      // 停止音频播放(包括可能正在播放的首句)
-      playerRef.current?.stop();
-      
-      // 清理情绪 GIF 切换定时器
-      if (emotionGifTimerRef.current) {
-        clearTimeout(emotionGifTimerRef.current);
-        emotionGifTimerRef.current = null;
-      }
-      
-      // 立即恢复默认头像 GIF
-      showDefaultGif();
-      
-      // 取消当前的请求(如果有),丢弃上一次请求的返回
-      if (oldRequestId) {
-        wsRef.current?.sendCancel(oldRequestId);
-        cancelledRequestIdsRef.current.add(oldRequestId);
-        performanceLogger.clearRequest(oldRequestId, '用户中断播放开始新录音');
-      }
-      
-      // 清理旧请求的状态(丢弃上一次请求的返回)
-      setRecognizedText('');
-      subtitleAccumulatorRequestIdRef.current = null;
-      setEmotion('neutral');
-      
-      // 清理发送超时
-      if (sendingTimeoutRef.current) {
-        clearTimeout(sendingTimeoutRef.current);
-        sendingTimeoutRef.current = null;
-      }
-    }
-    
-    // 其他状态(idle/error/sending)都允许开始录音
 
     // 一旦用户主动开始录音,则以用户为准:后续所有首句 greeting 消息全部丢弃
     greetingSuppressedRef.current = true;
@@ -2892,6 +2859,8 @@ export const ChatBotPage = () => {
   // 仅在发送/播放时把动画/对话层置顶,录音期间保持 default 层在上,多留时间消化上一轮残留
   const showConversationOnTop = currentState === 'sending' || currentState === 'playing';
   const conversationLayerSrc = currentState === 'playing' ? (currentAvatarGif || defaultGifUrl) : defaultGifUrl;
+  // AI 音频返回/播放中禁用录音按钮,结束后才可点击
+  const isRecordDisabled = !isConnected || currentState === 'playing' || currentState === 'sending';
 
   return (
     <div
@@ -2972,28 +2941,14 @@ export const ChatBotPage = () => {
           })}
         </div>
 
-        {/* 对话能量石数量:顶部 24,宽 55,左右居中,点击弹出对话能量石说明弹窗 */}
+        {/* 对话能量石数量:顶部 24,宽 55,左右居中,仅展示数量,不再可点击弹出说明弹窗 */}
         <div
-          role="button"
-          tabIndex={0}
-          onClick={() => {
-            setEnergyStoneModalType('conversation');
-            setShowMissionStoneModal(true);
-          }}
-          onKeyDown={(e) => {
-            if (e.key === 'Enter' || e.key === ' ') {
-              e.preventDefault();
-              setEnergyStoneModalType('conversation');
-              setShowMissionStoneModal(true);
-            }
-          }}
           style={{
             position: 'absolute',
             top: px2vmin(24, 'v2'),
             left: '50%',
             transform: 'translateX(-50%)',
             width: px2vmin(55, 'v2'),
-            cursor: 'pointer',
           }}
         >
           <img
@@ -3563,27 +3518,27 @@ export const ChatBotPage = () => {
         >
           {currentState !== 'recording' && (
             <div
-              className={`chatbot-record-button touch-manipulation flex items-center justify-center ${isConnected ? 'cursor-pointer' : 'cursor-not-allowed'}`}
+              className={`chatbot-record-button touch-manipulation flex items-center justify-center ${isRecordDisabled ? 'cursor-not-allowed' : 'cursor-pointer'}`}
               onClick={handleStartRecord}
               style={{
                 width: px2vmin(84),
                 height: px2vmin(82),
-                opacity: isConnected ? 1 : 0.55,
+                opacity: isRecordDisabled ? 0.55 : 1,
                 userSelect: 'none',
                 overflow: 'visible',
                 position: 'relative',
               }}
               role="button"
               tabIndex={-1}
-              aria-disabled={!isConnected}
+              aria-disabled={isRecordDisabled}
             >
               <img
-                src={isConnected ? luyinDefault : luyinDisable}
+                src={isRecordDisabled ? luyinDisable : luyinDefault}
                 alt={tCommon('a11y.record')}
                 className="pointer-events-none w-full h-full object-contain"
                 draggable={false}
               />
-              {ENABLE_CHATBOT_TIPS && showTapToChat && isConnected && (
+              {ENABLE_CHATBOT_TIPS && showTapToChat && !isRecordDisabled && (
                 <img
                   src={tapToChat}
                   alt={tCommon('a11y.tapToChat')}
@@ -3696,24 +3651,48 @@ export const ChatBotPage = () => {
         canLightUpToday={canLightUpToday}
         loading={intimacyLoading}
         onClose={() => {
-          setShowIntimacyModal(false);
-          // 弹窗关闭后,如果尚未触发 greeting 且用户也未开始录音,则由前端显式发起首句
-          startGreeting();
+          const uid = getUserId();
+          // 没有 userId 时无法调用后端,直接关闭并触发首句即可
+          if (!uid || !canLightUpToday) {
+            setShowIntimacyModal(false);
+            startGreeting();
+            return;
+          }
+          // 关闭弹窗时也默认帮用户点亮,避免当天重复弹出
+          setIntimacyLoading(true);
+          lightUpIntimacy(uid)
+            .then((res) => {
+              if (res?.success && res.data) {
+                setIntimacyHeartCount(res.data.heart_count);
+              }
+            })
+            .finally(() => {
+              setIntimacyLoading(false);
+              setShowIntimacyModal(false);
+              // 弹窗关闭后,如果尚未触发 greeting 且用户也未开始录音,则由前端显式发起首句
+              startGreeting();
+            });
         }}
         onLightUp={() => {
           const uid = getUserId();
-          if (!uid) return;
+          if (!uid || !canLightUpToday) {
+            setShowIntimacyModal(false);
+            startGreeting();
+            return;
+          }
           setIntimacyLoading(true);
           lightUpIntimacy(uid)
             .then((res) => {
               if (res?.success && res.data) {
                 setIntimacyHeartCount(res.data.heart_count);
-                setShowIntimacyModal(false);
-                // 点亮成功并关闭弹窗后,同样按需触发首句 greeting
-                startGreeting();
               }
             })
-            .finally(() => setIntimacyLoading(false));
+            .finally(() => {
+              setIntimacyLoading(false);
+              setShowIntimacyModal(false);
+              // 点亮成功并关闭弹窗后,同样按需触发首句 greeting
+              startGreeting();
+            });
         }}
       />
     </div>

+ 3 - 59
frontend/src/pages/ProfilePage.tsx

@@ -8,7 +8,6 @@ import {
   updateBotName,
   getUserUIState,
   updateUserUIState,
-  updateBackgroundImageId,
   // 统一的 API 工具:包含 userId/token 头部和与 ws 一致的 base URL 推导
   getApiBaseUrl,
   getApiHeaders,
@@ -21,7 +20,6 @@ import {
   setCurrentAvatarId as setAvatarIdToStorage,
   setCurrentBackgroundImageId as setBackgroundImageIdToStorage,
   getCurrentBackgroundImageId,
-  syncBackgroundImageToStorage,
   clearUserSessionAndRedirectToBegin,
 } from '@/utils/storage';
 import { getOssUrl } from '@/utils/oss';
@@ -280,32 +278,6 @@ export const ProfilePage = () => {
     }
   }, [hasCap, currentAvatarId, navigate]);
 
-  const handleBackgroundClick = useCallback(async () => {
-    const userId = getUserId();
-    if (!userId) return;
-
-    if (!hasBackgroundReward) {
-      navigate('/first-login-reward');
-      return;
-    }
-
-    // 已拥有情况下,切换背景图片
-    const newBackgroundImageId = currentBackgroundImageId === 'happynewyear' 
-      ? 'default' 
-      : 'happynewyear';
-    
-    try {
-      const result = await updateBackgroundImageId(userId, newBackgroundImageId);
-      if (result.success) {
-        setCurrentBackgroundImageId(newBackgroundImageId); // 更新 React state
-        // 同步到 localStorage(包括 URL 和透明度)
-        syncBackgroundImageToStorage(newBackgroundImageId, null);
-      }
-    } catch (error) {
-      console.error('切换背景图片失败:', error);
-    }
-  }, [hasBackgroundReward, currentBackgroundImageId, navigate]);
-
   // 打开编辑用户名弹窗
   const handleEditUsername = useCallback(() => {
     setTempUsername(username);
@@ -902,9 +874,8 @@ export const ProfilePage = () => {
                 height: px2vmin(92),
               }}
             >
-              {/* 108*70 边框区域 */}
+              {/* 108*70 边框区域(暂不支持点击/分享,仅展示) */}
               <div
-                onClick={handleBackgroundClick}
                 style={{
                   width: px2vmin(108),
                   height: px2vmin(70),
@@ -918,7 +889,7 @@ export const ProfilePage = () => {
                   transform: 'translateX(-50%)',
                   position: 'relative',
                   overflow: 'hidden',
-                  cursor: 'pointer',
+                  cursor: 'default',
                 }}
               >
                 <img
@@ -930,34 +901,7 @@ export const ProfilePage = () => {
                     objectFit: 'cover',
                   }}
                 />
-                {!hasBackgroundReward && (
-                  <>
-                    {/* 蒙层 */}
-                    <div
-                      style={{
-                        position: 'absolute',
-                        top: 0,
-                        left: 0,
-                        right: 0,
-                        bottom: 0,
-                        backgroundColor: 'rgba(245, 243, 240, 0.3)',
-                        display: 'flex',
-                        alignItems: 'center',
-                        justifyContent: 'center',
-                      }}
-                    >
-                      {/* 礼物图标 */}
-                      <img
-                        src={getOssUrl('Gift.gif')}
-                        alt="Gift"
-                        style={{
-                          width: px2vmin(40),
-                          height: px2vmin(40),
-                        }}
-                      />
-                    </div>
-                  </>
-                )}
+                {/* 暂不支持分享:不再显示礼物图标和可点击蒙层 */}
               </div>
               {/* Background 文字 */}
               <div

+ 34 - 0
sendEmailAsFeedBack/send_feedback.conf

@@ -0,0 +1,34 @@
+# 新用户注册邮件通知服务配置文件
+# 用于 send_new_user_email.py
+#
+# 说明:
+# 1. 数据库、SMTP 配置可参考 sendmsg/send.conf
+# 2. admin_emails 支持多个邮箱,逗号分隔
+# 3. 敏感信息建议使用环境变量
+
+[smtp]
+# 阿里云 DirectMail SMTP 配置(与 sendmsg/send.conf 一致)
+smtp_host = smtpdm.aliyun.com
+smtp_port_ssl = 465
+smtp_username = support@mail.lyrria.com
+smtp_password = LRrria123456
+email_subject = Lyrria 新用户注册通知
+
+[database]
+# MySQL 数据库配置(与 sendmsg/send.conf 一致)
+mysql_host = 101.201.60.170
+mysql_port = 13306
+mysql_user = root
+mysql_password = Lyrria@Root@123456
+mysql_database = Lyrria
+
+[service]
+# 扫描间隔(秒),默认 600 即每 10 分钟
+poll_interval = 600
+# 上次扫描的结束时间(ISO 格式),脚本每次成功扫描后自动写入;重启后从该时间起扫,不丢数据。留空则按 poll_interval 回溯
+last_scan_end = 2026-02-13T09:20:22.054169
+
+[admin]
+# 管理员邮箱,多个用英文逗号分隔,收到新用户注册汇总邮件 keyu.liu.23@ucl.ac.uk,704541015@qq.com,tingw0525@gmail.com,liqun.he@ucl.ac.uk
+admin_emails = 704541015@qq.com
+

+ 334 - 0
sendEmailAsFeedBack/send_feedback_email.py

@@ -0,0 +1,334 @@
+"""用户反馈邮件通知服务
+
+每 N 分钟扫描 feedbacks 表,将本周期内新增的用户反馈信息汇总后,通过邮件发送给管理员。
+
+Prerequisites:
+- Python >= 3.7
+- 配置文件:send_feedback.conf(与脚本同目录)
+  - [smtp] 发件配置
+  - [database] 数据库配置
+  - [admin] admin_emails 收件人,多个逗号分隔
+
+运行: python send_feedback_email.py
+"""
+
+from __future__ import annotations
+
+import asyncio
+import configparser
+import logging
+import os
+import re
+import smtplib
+import sys
+from datetime import datetime, timedelta
+from email.header import Header
+from email.mime.multipart import MIMEMultipart
+from email.mime.text import MIMEText
+from email.utils import formataddr
+from pathlib import Path
+from typing import Optional
+
+from sqlalchemy import select
+
+config_file = Path(__file__).parent / "send_feedback.conf"
+config = configparser.ConfigParser()
+if config_file.exists():
+    config.read(config_file, encoding="utf-8")
+else:
+    raise FileNotFoundError(f"配置文件不存在: {config_file}")
+
+# backend 目录加入 sys.path,复用项目内 DatabaseManager 与模型定义
+backend_dir = Path(__file__).parent.parent / "backend"
+sys.path.insert(0, str(backend_dir))
+from database import DatabaseManager  # type: ignore  # noqa: E402
+from models_db import User, Feedback  # type: ignore  # noqa: E402
+
+logger = logging.getLogger(__name__)
+
+
+# ------- 从配置文件读取参数 -------
+SMTP_HOST = config.get("smtp", "smtp_host", fallback="smtpdm.aliyun.com")
+SMTP_PORT_SSL = int(config.get("smtp", "smtp_port_ssl", fallback="465"))
+SMTP_USERNAME = config.get("smtp", "smtp_username", fallback="").strip()
+SMTP_PASSWORD = config.get("smtp", "smtp_password", fallback="").strip()
+EMAIL_SUBJECT = config.get("smtp", "email_subject", fallback="Lyrria 新用户反馈通知")
+
+MYSQL_HOST = config.get("database", "mysql_host", fallback="").strip()
+MYSQL_PORT = config.get("database", "mysql_port", fallback="").strip()
+MYSQL_USER = config.get("database", "mysql_user", fallback="").strip()
+MYSQL_PASSWORD = config.get("database", "mysql_password", fallback="").strip()
+MYSQL_DATABASE = config.get("database", "mysql_database", fallback="").strip()
+
+POLL_INTERVAL = float(config.get("service", "poll_interval", fallback="60"))
+ADMIN_EMAILS_RAW = config.get("admin", "admin_emails", fallback="").strip()
+ADMIN_EMAILS = [e.strip() for e in ADMIN_EMAILS_RAW.split(",") if e.strip()]
+
+
+def build_message(
+    sender: str,
+    recipients: list[str],
+    subject: str,
+    body_text: str,
+) -> MIMEMultipart:
+    """构建纯文本邮件消息"""
+    msg = MIMEMultipart("alternative")
+    msg["Subject"] = Header(subject, "utf-8")
+    msg["From"] = formataddr(["Lyrria 管理员通知", sender])
+    msg["To"] = ",".join(recipients)
+    msg.attach(MIMEText(body_text, "plain", "utf-8"))
+    return msg
+
+
+def format_feedback_line(idx: int, fb: Feedback, user: Optional[User]) -> str:
+    """将单条反馈+用户信息格式化为一行中文说明"""
+    created = fb.created_at.strftime("%Y-%m-%d %H:%M:%S") if fb.created_at else "-"
+    username = user.username if user and getattr(user, "username", None) else "-"
+    email = user.email if user and getattr(user, "email", None) else "-"
+    phone = user.phone if user and getattr(user, "phone", None) else "-"
+    parts = [
+        f"反馈{idx}:",
+        f"反馈ID {fb.feedback_id}",
+        f"用户ID {fb.user_id}",
+        f"用户名 {username}",
+        f"邮箱 {email or '-'}",
+        f"手机 {phone or '-'}",
+        f"状态 {fb.status}",
+        f"创建时间 {created}",
+        f"内容:{fb.content.strip() if fb.content else '-'}",
+    ]
+    return ",".join(parts)
+
+
+def build_body(
+    feedback_items: list[tuple[Feedback, Optional[User]]],
+    period_start: datetime,
+    period_end: datetime,
+) -> str:
+    """组合反馈信息为邮件正文(汉字)"""
+    start_str = period_start.strftime("%Y-%m-%d %H:%M:%S")
+    end_str = period_end.strftime("%Y-%m-%d %H:%M:%S")
+    lines: list[str] = [
+        "【Lyrria 用户反馈通知】",
+        "",
+        f"统计周期:{start_str} 至 {end_str}",
+        f"本周期新增用户反馈数:{len(feedback_items)}",
+        "",
+    ]
+    if not feedback_items:
+        lines.append("本周期暂无新的用户反馈。")
+        return "\n".join(lines)
+
+    for idx, (fb, user) in enumerate(feedback_items, 1):
+        lines.append(format_feedback_line(idx, fb, user))
+
+    lines.append("")
+    lines.append("本邮件由系统自动发送,请勿直接回复。")
+    return "\n".join(lines)
+
+
+def send_email_sync(
+    recipients: list[str],
+    subject: str,
+    body: str,
+    smtp_username: str,
+    smtp_password: str,
+) -> tuple[bool, Optional[str]]:
+    """同步发送邮件。返回 (成功与否, 错误信息)。"""
+    if not recipients:
+        return False, "收件人列表为空"
+    if not smtp_username or not smtp_password:
+        return False, "SMTP 未配置 smtp_username / smtp_password"
+    try:
+        msg = build_message(smtp_username, recipients, subject, body)
+        with smtplib.SMTP_SSL(SMTP_HOST, SMTP_PORT_SSL) as client:
+            client.login(smtp_username, smtp_password)
+            client.sendmail(smtp_username, recipients, msg.as_string())
+        logger.info("✅ 用户反馈通知邮件已发送,收件人: %s", recipients)
+        return True, None
+    except smtplib.SMTPAuthenticationError as e:
+        err = f"SMTP 认证失败: {e}"
+        logger.error("❌ %s", err)
+        return False, err
+    except smtplib.SMTPException as e:
+        err = f"SMTP 错误: {e}"
+        logger.error("❌ %s", err)
+        return False, err
+    except Exception as e:
+        err = f"发送异常: {e}"
+        logger.error("❌ %s", err)
+        return False, err
+
+
+async def fetch_new_feedbacks(
+    db_manager: DatabaseManager,
+    period_start: datetime,
+    period_end: datetime,
+) -> list[tuple[Feedback, Optional[User]]]:
+    """查询 (period_start, period_end] 内创建的反馈,附带用户信息"""
+    async with db_manager.get_session() as session:
+        result = await session.execute(
+            select(Feedback, User)
+            .join(User, Feedback.user_id == User.user_id, isouter=True)
+            .where(
+                Feedback.created_at > period_start,
+                Feedback.created_at <= period_end,
+            )
+            .order_by(Feedback.created_at.asc())
+        )
+        rows = list(result.all())
+        # SQLAlchemy 返回 Row 对象,转换为 (Feedback, User|None)
+        feedback_items: list[tuple[Feedback, Optional[User]]] = []
+        for row in rows:
+            # row 可能是 (Feedback, User) 或具有属性 .Feedback / .User
+            if isinstance(row, tuple) and len(row) >= 2:
+                fb, user = row[0], row[1]
+            else:
+                fb = getattr(row, "Feedback", None)
+                user = getattr(row, "User", None)
+            if fb is None:
+                continue
+            feedback_items.append((fb, user))
+        return feedback_items
+
+
+def _parse_last_scan_end() -> Optional[datetime]:
+    """从配置文件读取并解析 last_scan_end,无效或为空返回 None"""
+    raw = config.get("service", "last_scan_end", fallback="").strip()
+    if not raw:
+        return None
+    try:
+        return datetime.fromisoformat(raw.replace("Z", "+00:00"))
+    except (ValueError, TypeError):
+        return None
+
+
+def _write_last_scan_end(period_end: datetime) -> None:
+    """将本次扫描结束时间写回配置文件(仅修改 last_scan_end 行,保留注释)"""
+    try:
+        value = period_end.isoformat()
+        path = Path(config_file)
+        text = path.read_text(encoding="utf-8")
+        new_text = re.sub(
+            r"^(\s*last_scan_end\s*=\s*).*$",
+            rf"\g<1>{value}",
+            text,
+            count=1,
+            flags=re.MULTILINE,
+        )
+        if new_text != text:
+            path.write_text(new_text, encoding="utf-8")
+            logger.debug("已更新 last_scan_end = %s", value)
+    except Exception as e:
+        logger.warning("写回 last_scan_end 失败: %s", e)
+
+
+async def run_once(db_manager: DatabaseManager) -> None:
+    """执行一次:按 last_scan_end 确定扫描范围,查新反馈并发邮件,成功后写回 last_scan_end"""
+    if not ADMIN_EMAILS:
+        logger.warning("⚠️ 未配置 admin_emails,跳过发送")
+        return
+
+    # 每次运行前重新读取配置,支持动态调整
+    config.read(config_file, encoding="utf-8")
+
+    period_end = datetime.now()
+    last_end = _parse_last_scan_end()
+    if last_end is not None:
+        period_start = last_end
+        logger.info("📅 从上次扫描结束时间继续: %s", last_end.strftime("%Y-%m-%d %H:%M:%S"))
+    else:
+        period_start = period_end - timedelta(seconds=POLL_INTERVAL)
+
+    feedback_items = await fetch_new_feedbacks(db_manager, period_start, period_end)
+    # 无新反馈时不发邮件,仅更新扫描时间
+    if not feedback_items:
+        _write_last_scan_end(period_end)
+        logger.info("本周期无新用户反馈,跳过发邮件,已更新 last_scan_end")
+        return
+
+    body = build_body(feedback_items, period_start, period_end)
+    logger.info("📋 本周期新增用户反馈数: %d,正在发送通知邮件", len(feedback_items))
+    ok, _ = await asyncio.to_thread(
+        send_email_sync,
+        ADMIN_EMAILS,
+        EMAIL_SUBJECT,
+        body,
+        SMTP_USERNAME,
+        SMTP_PASSWORD,
+    )
+    if ok:
+        _write_last_scan_end(period_end)
+
+
+async def main_loop() -> None:
+    """主循环:每 POLL_INTERVAL 秒扫描反馈表并发邮件"""
+    if MYSQL_HOST:
+        os.environ["MYSQL_HOST"] = MYSQL_HOST
+    if MYSQL_PORT:
+        os.environ["MYSQL_PORT"] = MYSQL_PORT
+    if MYSQL_USER:
+        os.environ["MYSQL_USER"] = MYSQL_USER
+    if MYSQL_PASSWORD:
+        os.environ["MYSQL_PASSWORD"] = MYSQL_PASSWORD
+    if MYSQL_DATABASE:
+        os.environ["MYSQL_DATABASE"] = MYSQL_DATABASE
+
+    db_manager = DatabaseManager()
+    try:
+        await db_manager.connect()
+        logger.info("✅ 数据库连接成功")
+        logger.info("🚀 用户反馈通知服务已启动,扫描间隔: %s 秒", POLL_INTERVAL)
+        while True:
+            try:
+                await run_once(db_manager)
+            except Exception as e:
+                logger.error("❌ 本周期处理异常: %s", e, exc_info=True)
+            await asyncio.sleep(POLL_INTERVAL)
+    except KeyboardInterrupt:
+        logger.info("🛑 收到中断信号,正在关闭...")
+    except Exception as e:
+        logger.error("❌ 服务异常: %s", e, exc_info=True)
+        sys.exit(1)
+    finally:
+        await db_manager.disconnect()
+        logger.info("✅ 服务已关闭")
+
+
+def setup_logging() -> None:
+    """日志:正常 -> send_feedback_email.log,错误 -> send_feedback_email_error.log"""
+    log_dir = Path(__file__).parent
+    log_file = log_dir / "send_feedback_email.log"
+    error_log_file = log_dir / "send_feedback_email_error.log"
+
+    formatter = logging.Formatter(
+        "%(asctime)s - %(name)s - %(levelname)s - %(message)s",
+        datefmt="%Y-%m-%d %H:%M:%S",
+    )
+
+    root = logging.getLogger()
+    root.setLevel(logging.INFO)
+    root.handlers.clear()
+
+    console = logging.StreamHandler(sys.stdout)
+    console.setLevel(logging.INFO)
+    console.setFormatter(formatter)
+    root.addHandler(console)
+
+    fh = logging.FileHandler(log_file, encoding="utf-8")
+    fh.setLevel(logging.INFO)
+    fh.setFormatter(formatter)
+    root.addHandler(fh)
+
+    efh = logging.FileHandler(error_log_file, encoding="utf-8")
+    efh.setLevel(logging.ERROR)
+    efh.setFormatter(formatter)
+    root.addHandler(efh)
+
+    logger.info("✅ 日志已配置: %s, %s", log_file, error_log_file)
+
+
+if __name__ == "__main__":
+    setup_logging()
+    asyncio.run(main_loop())
+

Một số tệp đã không được hiển thị bởi vì quá nhiều tập tin thay đổi trong này khác