zlwu 8 месяцев назад
Родитель
Сommit
128cf424a8

+ 15 - 2
frontend/src/pages/ChatBotPage.tsx

@@ -3029,15 +3029,28 @@ export const ChatBotPage = () => {
           })}
         </div>
 
-        {/* 对话能量石数量:顶部 24,宽 55,左右居中,样式参考 ProfilePage */}
+        {/* 对话能量石数量:顶部 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%)',
-            // 设计宽度 55(按 V2 设计稿)
             width: px2vmin(55, 'v2'),
+            cursor: 'pointer',
           }}
         >
           <img

+ 25 - 0
sendEmailAsNewUser/README.md

@@ -0,0 +1,25 @@
+# 新用户注册邮件通知
+
+每 10 分钟扫描 user 表,将本周期内新注册用户信息汇总后发邮件给管理员。
+
+## 配置
+
+| 配置项 | 说明 |
+|--------|------|
+| `send_new_user.conf` | 独立配置文件,可参考 `sendmsg/send.conf` 填写 [smtp]、[database] |
+| `[admin] admin_emails` | 收件人邮箱,多个用英文逗号分隔 |
+
+## 运行
+
+```bash
+# 填写 send_new_user.conf 后
+./start_prod.sh    # 启动
+./stop_prod.sh     # 停止
+./status_prod.sh   # 状态
+# 或直接
+python3 send_new_user_email.py
+```
+
+## 依赖
+
+需能导入 `backend` 下的 `database`、`models_db`(与 sendmsg 一致,依赖 backend 环境)。

+ 33 - 0
sendEmailAsNewUser/send_new_user.conf

@@ -0,0 +1,33 @@
+# 新用户注册邮件通知服务配置文件
+# 用于 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-12T12:18:33.575924
+
+[admin]
+# 管理员邮箱,多个用英文逗号分隔,收到新用户注册汇总邮件
+admin_emails = 704541015@qq.com

+ 295 - 0
sendEmailAsNewUser/send_new_user_email.py

@@ -0,0 +1,295 @@
+"""新用户注册邮件通知服务
+
+每 N 分钟扫描 user 表,将本周期内新注册用户信息汇总后通过邮件发送给管理员。
+
+Prerequisites:
+- Python >= 3.7
+- 配置文件:send_new_user.conf(与脚本同目录)
+  - [smtp] 发件配置
+  - [database] 数据库配置
+  - [admin] admin_emails 收件人,多个逗号分隔
+
+运行: python send_new_user_email.py
+"""
+
+import asyncio
+import configparser
+import logging
+import os
+import re
+import smtplib
+import sys
+from datetime import datetime, timedelta
+from pathlib import Path
+from typing import Optional
+
+from email.header import Header
+from email.mime.multipart import MIMEMultipart
+from email.mime.text import MIMEText
+from email.utils import formataddr
+from sqlalchemy import select
+
+config_file = Path(__file__).parent / "send_new_user.conf"
+config = configparser.ConfigParser()
+if config_file.exists():
+    config.read(config_file, encoding="utf-8")
+else:
+    raise FileNotFoundError(f"配置文件不存在: {config_file}")
+
+backend_dir = Path(__file__).parent.parent / "backend"
+sys.path.insert(0, str(backend_dir))
+from database import DatabaseManager
+from models_db import User
+
+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="600"))
+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_user_line(idx: int, u: User) -> str:
+    """将单条用户格式化为一行中文说明"""
+    created = u.created_at.strftime("%Y-%m-%d %H:%M:%S") if u.created_at else "-"
+    reg = u.registered_at.strftime("%Y-%m-%d %H:%M:%S") if u.registered_at else "-"
+    parts = [
+        f"用户{idx}:",
+        f"昵称 {u.username or '-'}",
+        f"用户ID {u.user_id}",
+        f"类型 {u.user_type or 'guest'}",
+        f"邮箱 {u.email or '-'}",
+        f"手机 {u.phone or '-'}",
+        f"区号 {u.area_code or '-'}",
+        f"注册时间 {reg}",
+        f"创建时间 {created}",
+    ]
+    return ",".join(parts)
+
+
+def build_body(users: list[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 = [
+        "【Lyrria 新用户注册通知】",
+        "",
+        f"统计周期:{start_str} 至 {end_str}",
+        f"本周期新注册用户数:{len(users)}",
+        "",
+    ]
+    if not users:
+        lines.append("本周期暂无新用户。")
+        return "\n".join(lines)
+    for i, u in enumerate(users, 1):
+        lines.append(format_user_line(i, u))
+    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_users(
+    db_manager: DatabaseManager,
+    period_start: datetime,
+    period_end: datetime,
+) -> list[User]:
+    """查询 (period_start, period_end] 内创建的用户"""
+    async with db_manager.get_session() as session:
+        result = await session.execute(
+            select(User)
+            .where(
+                User.created_at > period_start,
+                User.created_at <= period_end,
+            )
+            .order_by(User.created_at.asc())
+        )
+        return list(result.scalars().all())
+
+
+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)
+    users = await fetch_new_users(db_manager, period_start, period_end)
+    if not users:
+        _write_last_scan_end(period_end)
+        logger.debug("本周期无新用户,未发邮件,已更新 last_scan_end")
+        return
+    body = build_body(users, period_start, period_end)
+    logger.info("📋 本周期新用户数: %d,正在发送通知邮件", len(users))
+    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_new_user_email.log,错误 -> send_new_user_email_error.log"""
+    log_dir = Path(__file__).parent
+    log_file = log_dir / "send_new_user_email.log"
+    error_log_file = log_dir / "send_new_user_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())

+ 36 - 0
sendEmailAsNewUser/start_prod.sh

@@ -0,0 +1,36 @@
+#!/bin/bash
+# 新用户注册邮件通知服务 - 启动脚本
+
+set -e
+SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+cd "$SCRIPT_DIR"
+
+GREEN='\033[0;32m'
+YELLOW='\033[1;33m'
+RED='\033[0;31m'
+BLUE='\033[0;34m'
+NC='\033[0m'
+
+CONF_FILE="$SCRIPT_DIR/send_new_user.conf"
+if [ ! -f "$CONF_FILE" ]; then
+    echo -e "${RED}❌ 未找到配置文件 $CONF_FILE${NC}"
+    exit 1
+fi
+echo -e "${GREEN}✅ 配置文件: $CONF_FILE${NC}"
+
+if ! command -v python3 &> /dev/null; then
+    echo -e "${RED}❌ 未找到 python3${NC}"
+    exit 1
+fi
+
+if [ -d "venv" ]; then
+    source venv/bin/activate
+elif [ -d ".venv" ]; then
+    source .venv/bin/activate
+fi
+
+echo -e "${BLUE}📧 启动新用户注册邮件通知服务...${NC}"
+nohup python3 send_new_user_email.py > send_new_user_email_nohup.log 2>&1 &
+echo $! > send_new_user_email.pid
+echo -e "${GREEN}✅ 服务已启动,PID: $(cat send_new_user_email.pid)${NC}"
+echo -e "${YELLOW}日志: send_new_user_email.log, send_new_user_email_error.log${NC}"

+ 39 - 0
sendEmailAsNewUser/status_prod.sh

@@ -0,0 +1,39 @@
+#!/bin/bash
+# 新用户注册邮件通知服务 - 状态脚本
+
+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'
+
+echo -e "${BLUE}📊 新用户注册邮件通知服务状态${NC}"
+echo "=================================="
+
+PID_FILE="$SCRIPT_DIR/send_new_user_email.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 etime= 2>/dev/null | xargs -I {} echo "  运行时间: {}"
+    else
+        echo -e "${RED}❌ 服务未运行 (PID 文件存在但进程不存在)${NC}"
+    fi
+else
+    PIDS=$(pgrep -f "send_new_user_email.py" || true)
+    if [ -n "$PIDS" ]; then
+        echo -e "${YELLOW}⚠️ 服务运行中 (无 PID 文件)${NC}"
+        echo "  PIDs: $PIDS"
+    else
+        echo -e "${RED}❌ 服务未运行${NC}"
+    fi
+fi
+
+if [ -f "$SCRIPT_DIR/send_new_user_email.log" ]; then
+    LOG_SIZE=$(ls -lh "$SCRIPT_DIR/send_new_user_email.log" | awk '{print $5}')
+    echo "  日志: send_new_user_email.log ($LOG_SIZE)"
+fi
+echo "=================================="
+echo -e "${BLUE}💡 启动: ./start_prod.sh  停止: ./stop_prod.sh${NC}"

+ 45 - 0
sendEmailAsNewUser/stop_prod.sh

@@ -0,0 +1,45 @@
+#!/bin/bash
+# 新用户注册邮件通知服务 - 停止脚本
+
+set -e
+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'
+
+PID_FILE="$SCRIPT_DIR/send_new_user_email.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
+            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 "send_new_user_email.py" || true)
+    if [ -n "$PIDS" ]; then
+        echo -e "${YELLOW}🛑 正在停止服务...${NC}"
+        echo "$PIDS" | xargs kill 2>/dev/null || true
+        sleep 2
+        echo "$PIDS" | xargs kill -9 2>/dev/null || true
+        echo -e "${GREEN}✅ 服务已停止${NC}"
+    else
+        echo -e "${YELLOW}⚠️ 未找到运行中的服务${NC}"
+    fi
+fi