瀏覽代碼

将短信服务移至redis

lxylxy123321 2 周之前
父節點
當前提交
4b89b72132

+ 48 - 21
backend/app/main.py

@@ -13,8 +13,9 @@ from app.database import async_session
 from app.services.license import _notify_on_expired, _notify_on_warning
 from app.services.license import _notify_on_expired, _notify_on_warning
 from app.services.domain_fetch import fetch_domain_transactions
 from app.services.domain_fetch import fetch_domain_transactions
 from app.models.license import SuperAdminLicense
 from app.models.license import SuperAdminLicense
+from app.models.monitoring import FetchScheduleConfig, FetchLog
 from app.models.domain import MonitoredDomain
 from app.models.domain import MonitoredDomain
-from app.redis import get_redis, close_redis
+from app.redis import close_redis
 from sqlalchemy import select, text
 from sqlalchemy import select, text
 
 
 logger = logging.getLogger(__name__)
 logger = logging.getLogger(__name__)
@@ -41,10 +42,11 @@ async def _check_licenses():
                     await session.commit()
                     await session.commit()
                     await _notify_on_expired(session, lic)
                     await _notify_on_expired(session, lic)
 
 
-                # 2. 检查剩余 7 天预警(_notify_on_warning 内部通过 Redis 去重
+                # 2. 检查剩余 7 天预警(未发送过的
                 warning_result = await session.execute(
                 warning_result = await session.execute(
                     select(SuperAdminLicense).where(
                     select(SuperAdminLicense).where(
                         SuperAdminLicense.status == "active",
                         SuperAdminLicense.status == "active",
+                        SuperAdminLicense.warning_sent == False,
                         SuperAdminLicense.expires_at > text("NOW()"),
                         SuperAdminLicense.expires_at > text("NOW()"),
                         SuperAdminLicense.expires_at <= text("NOW() + INTERVAL '7 days'"),
                         SuperAdminLicense.expires_at <= text("NOW() + INTERVAL '7 days'"),
                     )
                     )
@@ -53,6 +55,8 @@ async def _check_licenses():
                     days_left = (lic.expires_at - datetime.now(timezone.utc)).days
                     days_left = (lic.expires_at - datetime.now(timezone.utc)).days
                     logger.info("检测到 License #%d 剩余 %d 天,发送预警短信", lic.id, days_left)
                     logger.info("检测到 License #%d 剩余 %d 天,发送预警短信", lic.id, days_left)
                     await _notify_on_warning(session, lic, days_left)
                     await _notify_on_warning(session, lic, days_left)
+                    lic.warning_sent = True
+                    await session.commit()
         except Exception:
         except Exception:
             logger.exception("定时检查 License 异常")
             logger.exception("定时检查 License 异常")
 
 
@@ -60,34 +64,54 @@ async def _check_licenses():
 
 
 
 
 async def _daily_fetch():
 async def _daily_fetch():
-    """定时爬取:每分钟检查 Redis 配置,到了目标时间就爬取当天流水"""
+    """定时爬取:每分钟检查 DB 配置,到了目标时间就爬取当天流水"""
+    last_fetch_date = None
     while True:
     while True:
         try:
         try:
-            r = await get_redis()
-            enabled = await r.get("fetch_schedule:enabled")
-            schedule_time = await r.get("fetch_schedule:time")
-
-            if enabled == "true" and schedule_time:
-                h, m = map(int, schedule_time.split(":"))
-                now = datetime.now(CST)
-                today = now.strftime("%Y-%m-%d")
-                last_fetch = await r.get("fetch:last_date")
-
-                # 已过目标时间且今天还没爬过
-                if now.hour * 60 + now.minute >= h * 60 + m and last_fetch != today:
-                    logger.info("开始定时爬取当天流水: %s", today)
-                    async with async_session() as session:
+            async with async_session() as session:
+                result = await session.execute(select(FetchScheduleConfig).limit(1))
+                config = result.scalar_one_or_none()
+
+                if config and config.enabled:
+                    h, m = map(int, config.schedule_time.split(":"))
+                    now = datetime.now(CST)
+                    today = now.strftime("%Y-%m-%d")
+
+                    # 已过目标时间且今天还没爬过
+                    if now.hour * 60 + now.minute >= h * 60 + m and last_fetch_date != today:
+                        logger.info("开始定时爬取当天流水: %s", today)
+
+                        # 查询今日已失败的域名(跳过)
+                        failed_result = await session.execute(
+                            select(FetchLog.domain).where(
+                                FetchLog.status == "failed",
+                                FetchLog.created_at >= text(f"'{today} 00:00:00+08'"),
+                            ).distinct()
+                        )
+                        failed_domains = {r[0] for r in failed_result.all()}
+
                         domain_result = await session.execute(
                         domain_result = await session.execute(
                             select(MonitoredDomain).where(MonitoredDomain.is_active == True)
                             select(MonitoredDomain).where(MonitoredDomain.is_active == True)
                         )
                         )
                         for d in domain_result.scalars().all():
                         for d in domain_result.scalars().all():
+                            if d.domain in failed_domains:
+                                logger.info("域名 %s 今日已失败,跳过", d.domain)
+                                continue
                             try:
                             try:
                                 await fetch_domain_transactions(d.domain, session, fetch_date=today)
                                 await fetch_domain_transactions(d.domain, session, fetch_date=today)
                                 logger.info("域名 %s 当天流水爬取完成", d.domain)
                                 logger.info("域名 %s 当天流水爬取完成", d.domain)
-                            except Exception:
+                                session.add(FetchLog(
+                                    domain=d.domain, status="success", message="当天流水爬取完成"
+                                ))
+                            except Exception as e:
+                                error_msg = str(e)[:500]
                                 logger.exception("域名 %s 当天流水爬取失败", d.domain)
                                 logger.exception("域名 %s 当天流水爬取失败", d.domain)
-                    await r.set("fetch:last_date", today)
-                    logger.info("当天定时爬取全部完成")
+                                session.add(FetchLog(
+                                    domain=d.domain, status="failed", message=error_msg
+                                ))
+                        await session.commit()
+                        last_fetch_date = today
+                        logger.info("当天定时爬取全部完成")
         except Exception:
         except Exception:
             logger.exception("定时爬取异常")
             logger.exception("定时爬取异常")
 
 
@@ -112,11 +136,12 @@ async def lifespan(app: FastAPI):
                 await session.commit()
                 await session.commit()
                 await _notify_on_expired(session, lic)
                 await _notify_on_expired(session, lic)
 
 
-            # 2. 检查剩余 7 天预警(_notify_on_warning 内部通过 Redis 去重
+            # 2. 检查剩余 7 天预警(未发送过的
             from datetime import timedelta
             from datetime import timedelta
             warning_result = await session.execute(
             warning_result = await session.execute(
                 select(SuperAdminLicense).where(
                 select(SuperAdminLicense).where(
                     SuperAdminLicense.status == "active",
                     SuperAdminLicense.status == "active",
+                    SuperAdminLicense.warning_sent == False,
                     SuperAdminLicense.expires_at > text("NOW()"),
                     SuperAdminLicense.expires_at > text("NOW()"),
                     SuperAdminLicense.expires_at <= text("NOW() + INTERVAL '7 days'"),
                     SuperAdminLicense.expires_at <= text("NOW() + INTERVAL '7 days'"),
                 )
                 )
@@ -125,6 +150,8 @@ async def lifespan(app: FastAPI):
                 days_left = (lic.expires_at - datetime.now(timezone.utc)).days
                 days_left = (lic.expires_at - datetime.now(timezone.utc)).days
                 logger.info("启动时检测到 License #%d 剩余 %d 天,发送预警短信", lic.id, days_left)
                 logger.info("启动时检测到 License #%d 剩余 %d 天,发送预警短信", lic.id, days_left)
                 await _notify_on_warning(session, lic, days_left)
                 await _notify_on_warning(session, lic, days_left)
+                lic.warning_sent = True
+                await session.commit()
 
 
             logger.info("启动检查完成")
             logger.info("启动检查完成")
     except Exception:
     except Exception:

+ 3 - 1
backend/app/models/license.py

@@ -1,4 +1,4 @@
-from sqlalchemy import Column, Integer, String, Text, DateTime, Numeric, func
+from sqlalchemy import Column, Integer, String, Text, DateTime, Numeric, Boolean, func
 from app.models import Base
 from app.models import Base
 
 
 
 
@@ -15,5 +15,7 @@ class SuperAdminLicense(Base):
     max_tenants = Column(Integer)
     max_tenants = Column(Integer)
     max_users_per_tenant = Column(Integer)
     max_users_per_tenant = Column(Integer)
     remark = Column(Text)
     remark = Column(Text)
+    warning_sent = Column(Boolean, server_default="false", default=False)
+    expired_sent = Column(Boolean, server_default="false", default=False)
     created_at = Column(DateTime(timezone=True), server_default=func.now())
     created_at = Column(DateTime(timezone=True), server_default=func.now())
     updated_at = Column(DateTime(timezone=True), onupdate=func.now())
     updated_at = Column(DateTime(timezone=True), onupdate=func.now())

+ 23 - 1
backend/app/models/monitoring.py

@@ -1,7 +1,29 @@
-from sqlalchemy import Column, Integer, String, Boolean, DateTime, Numeric, func
+from sqlalchemy import Column, Integer, String, Boolean, DateTime, Numeric, func, Text
 from app.models import Base
 from app.models import Base
 
 
 
 
+class FetchLog(Base):
+    """域名爬取日志表(每次尝试记录一条)"""
+    __tablename__ = "fetch_logs"
+    __table_args__ = {"schema": "domain_monitor"}
+
+    id = Column(Integer, primary_key=True, autoincrement=True)
+    domain = Column(String, nullable=False, index=True)
+    status = Column(String, nullable=False)  # success / failed / skipped
+    message = Column(Text)  # 成功/失败详情
+    created_at = Column(DateTime(timezone=True), server_default=func.now())
+
+
+class FetchScheduleConfig(Base):
+    """定时爬取配置表"""
+    __tablename__ = "fetch_schedule_config"
+    __table_args__ = {"schema": "domain_monitor"}
+
+    id = Column(Integer, primary_key=True, autoincrement=True)
+    enabled = Column(Boolean, server_default="false", default=False)
+    schedule_time = Column(String(5), server_default="02:00")  # HH:MM
+
+
 class SuperAdmin(Base):
 class SuperAdmin(Base):
     """超级管理员表"""
     """超级管理员表"""
     __tablename__ = "super_admin"
     __tablename__ = "super_admin"

+ 101 - 21
backend/app/routers/domains.py

@@ -1,16 +1,18 @@
 from fastapi import APIRouter, Depends, HTTPException, Query
 from fastapi import APIRouter, Depends, HTTPException, Query
 from pydantic import BaseModel
 from pydantic import BaseModel
-from sqlalchemy import select, func
+from sqlalchemy import select, func, desc
 from sqlalchemy.ext.asyncio import AsyncSession
 from sqlalchemy.ext.asyncio import AsyncSession
 from app.database import get_db
 from app.database import get_db
 from app.models.domain import MonitoredDomain
 from app.models.domain import MonitoredDomain
-from app.models.monitoring import SuperAdmin
+from app.models.monitoring import SuperAdmin, FetchScheduleConfig, FetchLog
 from app.schemas.domain import (
 from app.schemas.domain import (
     MonitoredDomainCreate,
     MonitoredDomainCreate,
     MonitoredDomainResponse,
     MonitoredDomainResponse,
 )
 )
 from app.services.domain_fetch import fetch_domain_transactions
 from app.services.domain_fetch import fetch_domain_transactions
-from app.redis import get_redis
+import logging
+
+logger = logging.getLogger(__name__)
 
 
 router = APIRouter(prefix="/api/domains", tags=["domains"])
 router = APIRouter(prefix="/api/domains", tags=["domains"])
 
 
@@ -115,8 +117,29 @@ async def get_domain_transactions(
     if not record:
     if not record:
         raise HTTPException(status_code=404, detail="域名不存在")
         raise HTTPException(status_code=404, detail="域名不存在")
 
 
-    data = await fetch_domain_transactions(record.domain, db, fetch_date=fetch_date)
-    return {"status": "ok", "domain": record.domain, "data": data}
+async def get_domain_transactions(
+    domain_id: int,
+    fetch_date: str | None = Query(None, description="爬取指定日期,格式 YYYY-MM-DD,不传则查全部"),
+    db: AsyncSession = Depends(get_db),
+):
+    """爬取指定域名的监控数据并入库"""
+    result = await db.execute(
+        select(MonitoredDomain).where(MonitoredDomain.id == domain_id)
+    )
+    record = result.scalar_one_or_none()
+    if not record:
+        raise HTTPException(status_code=404, detail="域名不存在")
+
+    try:
+        data = await fetch_domain_transactions(record.domain, db, fetch_date=fetch_date)
+        db.add(FetchLog(domain=record.domain, status="success", message="手动爬取成功"))
+        await db.commit()
+        return {"status": "ok", "domain": record.domain, "data": data}
+    except Exception as e:
+        error_msg = str(e)[:500]
+        db.add(FetchLog(domain=record.domain, status="failed", message=error_msg))
+        await db.commit()
+        raise HTTPException(status_code=500, detail=error_msg)
 
 
 
 
 @router.post("/fetch-all", status_code=202)
 @router.post("/fetch-all", status_code=202)
@@ -137,8 +160,12 @@ async def fetch_all_transactions(
     for d in domains:
     for d in domains:
         try:
         try:
             await fetch_domain_transactions(d.domain, db, fetch_date=fetch_date)
             await fetch_domain_transactions(d.domain, db, fetch_date=fetch_date)
+            db.add(FetchLog(domain=d.domain, status="success", message="手动批量爬取成功"))
         except Exception as e:
         except Exception as e:
-            errors.append({"domain": d.domain, "error": str(e)})
+            error_msg = str(e)[:500]
+            db.add(FetchLog(domain=d.domain, status="failed", message=error_msg))
+            errors.append({"domain": d.domain, "error": error_msg})
+    await db.commit()
     return {"status": "ok", "total": len(domains), "errors": errors}
     return {"status": "ok", "total": len(domains), "errors": errors}
 
 
 
 
@@ -149,21 +176,74 @@ class ScheduleConfigUpdate(BaseModel):
 
 
 
 
 @router.get("/schedule")
 @router.get("/schedule")
-async def get_schedule_config():
-    """获取定时爬取配置(Redis)"""
-    r = await get_redis()
-    enabled = await r.get("fetch_schedule:enabled")
-    schedule_time = await r.get("fetch_schedule:time")
-    return {
-        "enabled": enabled == "true",
-        "schedule_time": schedule_time or "02:00",
-    }
+async def get_schedule_config(db: AsyncSession = Depends(get_db)):
+    """获取定时爬取配置"""
+    result = await db.execute(select(FetchScheduleConfig).limit(1))
+    config = result.scalar_one_or_none()
+    if not config:
+        config = FetchScheduleConfig(enabled=False, schedule_time="02:00")
+        db.add(config)
+        await db.commit()
+        await db.refresh(config)
+    return {"enabled": config.enabled, "schedule_time": config.schedule_time}
 
 
 
 
 @router.put("/schedule")
 @router.put("/schedule")
-async def update_schedule_config(payload: ScheduleConfigUpdate):
-    """更新定时爬取配置(Redis)"""
-    r = await get_redis()
-    await r.set("fetch_schedule:enabled", "true" if payload.enabled else "false")
-    await r.set("fetch_schedule:time", payload.schedule_time)
-    return {"message": "配置已保存", "enabled": payload.enabled, "schedule_time": payload.schedule_time}
+async def update_schedule_config(
+    payload: ScheduleConfigUpdate,
+    db: AsyncSession = Depends(get_db),
+):
+    """更新定时爬取配置"""
+    result = await db.execute(select(FetchScheduleConfig).limit(1))
+    config = result.scalar_one_or_none()
+    if not config:
+        config = FetchScheduleConfig()
+        db.add(config)
+    config.enabled = payload.enabled
+    config.schedule_time = payload.schedule_time
+    await db.commit()
+    return {"message": "配置已保存", "enabled": config.enabled, "schedule_time": config.schedule_time}
+
+
+@router.get("/fetch-logs")
+async def list_fetch_logs(
+    domain: str | None = Query(None, description="按域名筛选"),
+    status: str | None = Query(None, description="按状态筛选: success / failed / skipped"),
+    page: int = Query(1, ge=1),
+    size: int = Query(20, ge=1, le=100),
+    db: AsyncSession = Depends(get_db),
+):
+    """获取爬取日志列表"""
+    conditions = []
+    if domain:
+        conditions.append(FetchLog.domain == domain)
+    if status:
+        conditions.append(FetchLog.status == status)
+
+    count_query = select(func.count()).select_from(FetchLog)
+    if conditions:
+        count_query = count_query.where(*conditions)
+    total = await db.scalar(count_query) or 0
+
+    logs_query = select(FetchLog)
+    if conditions:
+        logs_query = logs_query.where(*conditions)
+    logs_query = logs_query.order_by(desc(FetchLog.created_at)).offset((page - 1) * size).limit(size)
+    logs_result = await db.execute(logs_query)
+    logs = logs_result.scalars().all()
+
+    return {
+        "items": [
+            {
+                "id": log.id,
+                "domain": log.domain,
+                "status": log.status,
+                "message": log.message,
+                "created_at": log.created_at.isoformat() if log.created_at else None,
+            }
+            for log in logs
+        ],
+        "total": total,
+        "page": page,
+        "size": size,
+    }

+ 4 - 0
backend/app/routers/monitoring.py

@@ -35,6 +35,8 @@ async def list_consumption_details(
     end_date: str | None = Query(None, description="查询结束日期,格式 YYYY-MM-DD"),
     end_date: str | None = Query(None, description="查询结束日期,格式 YYYY-MM-DD"),
     super_admin_name: str | None = Query(None, description="按超级管理员名称筛选"),
     super_admin_name: str | None = Query(None, description="按超级管理员名称筛选"),
     tenant_name: str | None = Query(None, description="按租户名称筛选"),
     tenant_name: str | None = Query(None, description="按租户名称筛选"),
+    page: int = Query(1, ge=1, description="页码"),
+    page_size: int = Query(20, ge=1, le=100, description="每页数量"),
 ):
 ):
     """
     """
     获取原始消费明细列表(每条记录一行,不聚合)
     获取原始消费明细列表(每条记录一行,不聚合)
@@ -46,6 +48,8 @@ async def list_consumption_details(
         end_date=end_date,
         end_date=end_date,
         super_admin_name=super_admin_name,
         super_admin_name=super_admin_name,
         tenant_name=tenant_name,
         tenant_name=tenant_name,
+        page=page,
+        page_size=page_size,
     )
     )
 
 
 
 

+ 44 - 5
backend/app/services/domain_fetch.py

@@ -32,14 +32,32 @@ async def fetch_domain_transactions(domain: str, db: AsyncSession, fetch_date: s
         try:
         try:
             resp = await client.get(url)
             resp = await client.get(url)
             resp.raise_for_status()
             resp.raise_for_status()
-        except httpx.HTTPStatusError:
-            # 服务器返回了 HTTP 错误(如 404/500),不降级,直接抛出
-            raise
+        except httpx.HTTPStatusError as e:
+            # 如果带了日期参数报 404/500,去掉参数重试
+            if fetch_date and e.response.status_code in (404, 500):
+                url = f"https://{domain}/api/public/monitoring/dashboard"
+                resp = await client.get(url)
+                resp.raise_for_status()
+                # 不带参数时全量返回,由 _save_dashboard_data 按日期过滤
+                data = resp.json()
+                return _filter_by_date(data, fetch_date)
+            else:
+                raise
         except Exception:
         except Exception:
             # HTTPS 连接失败(SSL/网络错误),降级到 HTTP
             # HTTPS 连接失败(SSL/网络错误),降级到 HTTP
             url = f"http://{domain}/api/public/monitoring/dashboard{query_str}"
             url = f"http://{domain}/api/public/monitoring/dashboard{query_str}"
-            resp = await client.get(url)
-            resp.raise_for_status()
+            try:
+                resp = await client.get(url)
+                resp.raise_for_status()
+            except httpx.HTTPStatusError as e:
+                if fetch_date and e.response.status_code in (404, 500):
+                    url = f"http://{domain}/api/public/monitoring/dashboard"
+                    resp = await client.get(url)
+                    resp.raise_for_status()
+                    data = resp.json()
+                    return _filter_by_date(data, fetch_date)
+                else:
+                    raise
         data = resp.json()
         data = resp.json()
 
 
     # 将爬取到的数据存入本地数据库
     # 将爬取到的数据存入本地数据库
@@ -47,6 +65,27 @@ async def fetch_domain_transactions(domain: str, db: AsyncSession, fetch_date: s
     return data
     return data
 
 
 
 
+def _filter_by_date(data: dict, fetch_date: str | None) -> dict:
+    """按日期过滤返回数据,只保留 fetch_date 当天的消费记录"""
+    if not fetch_date:
+        return data
+
+    for sa in data.get("super_admins", []):
+        for t in sa.get("tenants", []):
+            filtered_users = []
+            for u in t.get("users", []):
+                filtered_records = []
+                for m in u.get("consumption_records", []):
+                    created_at = m.get("created_at", "")
+                    if created_at and created_at.startswith(fetch_date):
+                        filtered_records.append(m)
+                if filtered_records:
+                    u["consumption_records"] = filtered_records
+                    filtered_users.append(u)
+            t["users"] = filtered_users
+    return data
+
+
 async def _save_dashboard_data(domain: str, data: dict, db: AsyncSession, fetch_date: str | None = None):
 async def _save_dashboard_data(domain: str, data: dict, db: AsyncSession, fetch_date: str | None = None):
     """
     """
     将监控大屏数据存入本地数据库
     将监控大屏数据存入本地数据库

+ 20 - 21
backend/app/services/license.py

@@ -19,7 +19,6 @@ from app.schemas.license import (
     LicenseListResponse,
     LicenseListResponse,
 )
 )
 from app.services.sms import send_license_expired, send_license_restored, send_license_warning
 from app.services.sms import send_license_expired, send_license_restored, send_license_warning
-from app.redis import get_redis
 import logging
 import logging
 
 
 logger = logging.getLogger(__name__)
 logger = logging.getLogger(__name__)
@@ -64,8 +63,7 @@ async def _get_contact_for_license(db: AsyncSession, lic: SuperAdminLicense) ->
 async def _notify_on_expired(db: AsyncSession, lic: SuperAdminLicense) -> str:
 async def _notify_on_expired(db: AsyncSession, lic: SuperAdminLicense) -> str:
     """License valid 从 true 变为 false 时发送过期短信。返回: sent | skipped | failed"""
     """License valid 从 true 变为 false 时发送过期短信。返回: sent | skipped | failed"""
     try:
     try:
-        r = await get_redis()
-        if await r.get(f"sms:expired_sent:{lic.id}"):
+        if lic.expired_sent:
             logger.info("License #%d 已发送过过期短信,跳过", lic.id)
             logger.info("License #%d 已发送过过期短信,跳过", lic.id)
             return "skipped"
             return "skipped"
         phone, company = await _get_contact_for_license(db, lic)
         phone, company = await _get_contact_for_license(db, lic)
@@ -76,7 +74,8 @@ async def _notify_on_expired(db: AsyncSession, lic: SuperAdminLicense) -> str:
         ok, reason = await send_license_expired(phone, company)
         ok, reason = await send_license_expired(phone, company)
         if ok:
         if ok:
             logger.info("License 预警短信发送成功: license_id=%d", lic.id)
             logger.info("License 预警短信发送成功: license_id=%d", lic.id)
-            await r.setex(f"sms:expired_sent:{lic.id}", 86400 * 30, "1")  # 30 天过期
+            lic.expired_sent = True
+            await db.commit()
             return "sent"
             return "sent"
         logger.error("License 预警短信发送失败: license_id=%d, 原因=%s", lic.id, reason)
         logger.error("License 预警短信发送失败: license_id=%d, 原因=%s", lic.id, reason)
         return "failed"
         return "failed"
@@ -96,9 +95,9 @@ async def _notify_on_restored(db: AsyncSession, lic: SuperAdminLicense) -> str:
         ok, reason = await send_license_restored(phone, company)
         ok, reason = await send_license_restored(phone, company)
         if ok:
         if ok:
             logger.info("License 恢复短信发送成功: license_id=%d", lic.id)
             logger.info("License 恢复短信发送成功: license_id=%d", lic.id)
-            r = await get_redis()
-            await r.delete(f"sms:expired_sent:{lic.id}")
-            await r.delete(f"sms:warning_sent:{lic.id}")
+            lic.expired_sent = False
+            lic.warning_sent = False
+            await db.commit()
             return "sent"
             return "sent"
         logger.error("License 恢复短信发送失败: license_id=%d, 原因=%s", lic.id, reason)
         logger.error("License 恢复短信发送失败: license_id=%d, 原因=%s", lic.id, reason)
         return "failed"
         return "failed"
@@ -110,8 +109,7 @@ async def _notify_on_restored(db: AsyncSession, lic: SuperAdminLicense) -> str:
 async def _notify_on_warning(db: AsyncSession, lic: SuperAdminLicense, days_left: int) -> str:
 async def _notify_on_warning(db: AsyncSession, lic: SuperAdminLicense, days_left: int) -> str:
     """License 即将过期(剩余 7 天)预警短信。返回: sent | skipped | failed"""
     """License 即将过期(剩余 7 天)预警短信。返回: sent | skipped | failed"""
     try:
     try:
-        r = await get_redis()
-        if await r.get(f"sms:warning_sent:{lic.id}"):
+        if lic.warning_sent:
             logger.info("License #%d 已发送过预警短信,跳过", lic.id)
             logger.info("License #%d 已发送过预警短信,跳过", lic.id)
             return "skipped"
             return "skipped"
         phone, company = await _get_contact_for_license(db, lic)
         phone, company = await _get_contact_for_license(db, lic)
@@ -122,7 +120,8 @@ async def _notify_on_warning(db: AsyncSession, lic: SuperAdminLicense, days_left
         ok, reason = await send_license_warning(phone, company, days_left)
         ok, reason = await send_license_warning(phone, company, days_left)
         if ok:
         if ok:
             logger.info("License 即将过期预警短信发送成功: license_id=%d", lic.id)
             logger.info("License 即将过期预警短信发送成功: license_id=%d", lic.id)
-            await r.setex(f"sms:warning_sent:{lic.id}", 86400 * 30, "1")
+            lic.warning_sent = True
+            await db.commit()
             return "sent"
             return "sent"
         logger.error("License 即将过期预警短信发送失败: license_id=%d, 原因=%s", lic.id, reason)
         logger.error("License 即将过期预警短信发送失败: license_id=%d, 原因=%s", lic.id, reason)
         return "failed"
         return "failed"
@@ -340,21 +339,23 @@ async def update_license(
         raise ValueError("没有需要更新的字段")
         raise ValueError("没有需要更新的字段")
     # 修改过期时间后已超出 7 天窗口,重置预警标记
     # 修改过期时间后已超出 7 天窗口,重置预警标记
     if payload.expires_at is not None and _calc_days_left(lic.expires_at) > 7:
     if payload.expires_at is not None and _calc_days_left(lic.expires_at) > 7:
-        r = await get_redis()
-        await r.delete(f"sms:warning_sent:{lic.id}")
-        await r.delete(f"sms:expired_sent:{lic.id}")
+        lic.warning_sent = False
+        lic.expired_sent = False
     elif old_status != "active" and lic.status == "active":
     elif old_status != "active" and lic.status == "active":
-        r = await get_redis()
-        await r.delete(f"sms:warning_sent:{lic.id}")
-        await r.delete(f"sms:expired_sent:{lic.id}")
+        lic.warning_sent = False
+        lic.expired_sent = False
     await db.commit()
     await db.commit()
 
 
     # valid 变化时发送短信
     # valid 变化时发送短信
     if old_status == "active" and lic.status != "active":
     if old_status == "active" and lic.status != "active":
         sms_status = await _notify_on_expired(db, lic)
         sms_status = await _notify_on_expired(db, lic)
+        sms_type = "expired"
     elif old_status != "active" and lic.status == "active":
     elif old_status != "active" and lic.status == "active":
         sms_status = await _notify_on_restored(db, lic)
         sms_status = await _notify_on_restored(db, lic)
-    return {"message": "License已更新", "license_id": lic.id, "sms_status": sms_status}
+        sms_type = "restored"
+    else:
+        sms_type = None
+    return {"message": "License已更新", "license_id": lic.id, "sms_status": sms_status, "sms_type": sms_type}
 
 
 
 
 async def restore_license(
 async def restore_license(
@@ -370,11 +371,9 @@ async def restore_license(
     if lic.status != "revoked":
     if lic.status != "revoked":
         raise ValueError("仅可恢复已吊销的 License")
         raise ValueError("仅可恢复已吊销的 License")
     lic.status = "active"
     lic.status = "active"
+    lic.warning_sent = False
+    lic.expired_sent = False
     await db.commit()
     await db.commit()
-    # 清除 Redis 去重标记
-    r = await get_redis()
-    await r.delete(f"sms:warning_sent:{lic.id}")
-    await r.delete(f"sms:expired_sent:{lic.id}")
     sms_status = await _notify_on_restored(db, lic)
     sms_status = await _notify_on_restored(db, lic)
     return {"message": "License已恢复", "sms_status": sms_status}
     return {"message": "License已恢复", "sms_status": sms_status}
 
 

+ 21 - 7
backend/app/services/monitoring.py

@@ -200,6 +200,8 @@ async def get_consumption_details(
     end_date: str | None = None,
     end_date: str | None = None,
     super_admin_name: str | None = None,
     super_admin_name: str | None = None,
     tenant_name: str | None = None,
     tenant_name: str | None = None,
+    page: int = 1,
+    page_size: int = 20,
 ) -> ConsumptionDetailResponse:
 ) -> ConsumptionDetailResponse:
     """
     """
     查询原始消费明细表,每条记录一行,不做任何聚合
     查询原始消费明细表,每条记录一行,不做任何聚合
@@ -241,25 +243,37 @@ async def get_consumption_details(
     tenant_result = await db.execute(tenant_stmt)
     tenant_result = await db.execute(tenant_stmt)
     tenant_map: dict[int, Tenant] = {t.id: t for t in tenant_result.scalars().all()}
     tenant_map: dict[int, Tenant] = {t.id: t for t in tenant_result.scalars().all()}
 
 
-    # 2. 查原始消费明细
-    stmt = select(UserConsumptionDetail)
+    # 2. 构建筛选条件(复用)
+    conditions = []
     start_dt = date.fromisoformat(start_date) if start_date else None
     start_dt = date.fromisoformat(start_date) if start_date else None
     end_dt = date.fromisoformat(end_date) if end_date else None
     end_dt = date.fromisoformat(end_date) if end_date else None
     if start_dt:
     if start_dt:
-        stmt = stmt.where(cast(UserConsumptionDetail.consumption_date, Date) >= start_dt)
+        conditions.append(cast(UserConsumptionDetail.consumption_date, Date) >= start_dt)
     if end_dt:
     if end_dt:
-        stmt = stmt.where(cast(UserConsumptionDetail.consumption_date, Date) <= end_dt)
+        conditions.append(cast(UserConsumptionDetail.consumption_date, Date) <= end_dt)
     if super_admin_name:
     if super_admin_name:
         matched_tenant_ids = set()
         matched_tenant_ids = set()
         for sid in [sa.id for sa in all_sas]:
         for sid in [sa.id for sa in all_sas]:
             matched_tenant_ids.update(sa_tenant_map.get(sid, []))
             matched_tenant_ids.update(sa_tenant_map.get(sid, []))
         if matched_tenant_ids:
         if matched_tenant_ids:
-            stmt = stmt.where(UserConsumptionDetail.tenant_id.in_(matched_tenant_ids))
+            conditions.append(UserConsumptionDetail.tenant_id.in_(matched_tenant_ids))
     if tenant_name:
     if tenant_name:
         filtered_tenant_ids = set(tenant_map.keys())
         filtered_tenant_ids = set(tenant_map.keys())
         if filtered_tenant_ids:
         if filtered_tenant_ids:
-            stmt = stmt.where(UserConsumptionDetail.tenant_id.in_(filtered_tenant_ids))
+            conditions.append(UserConsumptionDetail.tenant_id.in_(filtered_tenant_ids))
+
+    # 3. 查总数
+    count_stmt = select(func.count(UserConsumptionDetail.id)).select_from(UserConsumptionDetail)
+    if conditions:
+        count_stmt = count_stmt.where(*conditions)
+    total = await db.scalar(count_stmt) or 0
+
+    # 4. 分页查询
+    stmt = select(UserConsumptionDetail)
+    if conditions:
+        stmt = stmt.where(*conditions)
     stmt = stmt.order_by(UserConsumptionDetail.consumption_date.desc())
     stmt = stmt.order_by(UserConsumptionDetail.consumption_date.desc())
+    stmt = stmt.offset((page - 1) * page_size).limit(page_size)
     result = await db.execute(stmt)
     result = await db.execute(stmt)
     records = result.scalars().all()
     records = result.scalars().all()
 
 
@@ -286,7 +300,7 @@ async def get_consumption_details(
             super_admin_actual_price=f"{float(c.super_admin_actual_price or 0):.4f}",
             super_admin_actual_price=f"{float(c.super_admin_actual_price or 0):.4f}",
         ))
         ))
 
 
-    return ConsumptionDetailResponse(total=len(items), records=items)
+    return ConsumptionDetailResponse(total=total, records=items)
 
 
 
 
 async def get_daily_stats(
 async def get_daily_stats(

+ 12 - 12
backend/app/services/sms.py

@@ -11,15 +11,12 @@ from urllib.parse import quote
 
 
 import httpx
 import httpx
 from app.config import settings
 from app.config import settings
+from app.redis import get_redis
 
 
 logger = logging.getLogger(__name__)
 logger = logging.getLogger(__name__)
 
 
 ALIYUN_ENDPOINT = "https://dysmsapi.aliyuncs.com/"
 ALIYUN_ENDPOINT = "https://dysmsapi.aliyuncs.com/"
 
 
-# 内存防重:记录每个手机号的最后一次发送时间,60 秒内不发第二次
-_send_times: dict[str, float] = {}
-_SMS_COOLDOWN = 60  # 秒
-
 
 
 def _percent_encode(s: str) -> str:
 def _percent_encode(s: str) -> str:
     """RFC 3986 percent encoding"""
     """RFC 3986 percent encoding"""
@@ -39,15 +36,18 @@ def _build_signature(method: str, params: dict[str, str], secret: str) -> str:
 async def _send_sms(phone: str, template_code: str, template_params: dict) -> tuple[bool, str]:
 async def _send_sms(phone: str, template_code: str, template_params: dict) -> tuple[bool, str]:
     """调用阿里云 SendSms 接口。返回 (success, reason)"""
     """调用阿里云 SendSms 接口。返回 (success, reason)"""
     if not settings.sms_access_key_id or not settings.sms_access_key_secret:
     if not settings.sms_access_key_id or not settings.sms_access_key_secret:
-        logger.warning("阿里云短信配置未设置(AK/SK为空),跳过短信发送")
+        logger.warning("阿里云短信 AK/SK 未配置,跳过发送")
         return False, "AK/SK未配置"
         return False, "AK/SK未配置"
 
 
-    now_ts = datetime.now(timezone.utc).timestamp()
-    last = _send_times.get(phone, 0)
-    if now_ts - last < _SMS_COOLDOWN:
-        logger.info("手机号 %s 距离上次发送不足 %d 秒,跳过防重", phone, _SMS_COOLDOWN)
+    # Redis 冷却期:30 秒内不发第二次
+    r = await get_redis()
+    cooldown_key = f"sms:cooldown:{phone}"
+    if await r.exists(cooldown_key):
+        logger.info("手机号 %s 在 30 秒冷却期内,跳过发送", phone)
         return False, "冷却期内,跳过发送"
         return False, "冷却期内,跳过发送"
-    _send_times[phone] = now_ts
+
+    await r.setex(cooldown_key, 30, "1")
+
     now = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
     now = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
     params = {
     params = {
         "AccessKeyId": settings.sms_access_key_id,
         "AccessKeyId": settings.sms_access_key_id,
@@ -73,7 +73,7 @@ async def _send_sms(phone: str, template_code: str, template_params: dict) -> tu
             resp = await client.post(ALIYUN_ENDPOINT, data=params)
             resp = await client.post(ALIYUN_ENDPOINT, data=params)
             result = resp.json()
             result = resp.json()
             if result.get("Code") == "OK":
             if result.get("Code") == "OK":
-                logger.info("短信发送成功: %s", phone)
+                logger.info("短信发送成功: phone=%s", phone)
                 return True, "发送成功"
                 return True, "发送成功"
             else:
             else:
                 code = result.get("Code")
                 code = result.get("Code")
@@ -81,7 +81,7 @@ async def _send_sms(phone: str, template_code: str, template_params: dict) -> tu
                 logger.error("短信发送失败: phone=%s, code=%s, msg=%s", phone, code, msg)
                 logger.error("短信发送失败: phone=%s, code=%s, msg=%s", phone, code, msg)
                 return False, f"阿里云错误: {code} - {msg}"
                 return False, f"阿里云错误: {code} - {msg}"
     except Exception as e:
     except Exception as e:
-        logger.exception("短信发送异常: %s", phone)
+        logger.exception("短信发送异常: phone=%s", phone)
         return False, f"异常: {e}"
         return False, f"异常: {e}"
 
 
 
 

+ 15 - 33
backend/migrations/001_initial.sql → backend/migrations/001_initial_tables.sql

@@ -1,19 +1,15 @@
 -- ============================================================
 -- ============================================================
--- 域名流水监控 - 初始建表
--- 文件: 001_initial.sql
--- 日期: 2026-05-12
+-- 初始建表
 -- ============================================================
 -- ============================================================
 
 
--- 创建业务 schema(如不存在)
 CREATE SCHEMA IF NOT EXISTS domain_monitor;
 CREATE SCHEMA IF NOT EXISTS domain_monitor;
 
 
--- ============================================================
--- 监控域名表(用于配置需要监控的域名)
--- ============================================================
-
+-- 监控域名表
 CREATE TABLE IF NOT EXISTS domain_monitor.monitored_domains (
 CREATE TABLE IF NOT EXISTS domain_monitor.monitored_domains (
     id SERIAL PRIMARY KEY,
     id SERIAL PRIMARY KEY,
     domain VARCHAR NOT NULL,
     domain VARCHAR NOT NULL,
+    remark VARCHAR(500),
+    super_admin_id INTEGER,
     is_active BOOLEAN DEFAULT TRUE,
     is_active BOOLEAN DEFAULT TRUE,
     created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
     created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
     updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
     updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
@@ -22,21 +18,16 @@ CREATE TABLE IF NOT EXISTS domain_monitor.monitored_domains (
 CREATE UNIQUE INDEX IF NOT EXISTS ix_monitored_domains_domain ON domain_monitor.monitored_domains (domain);
 CREATE UNIQUE INDEX IF NOT EXISTS ix_monitored_domains_domain ON domain_monitor.monitored_domains (domain);
 CREATE INDEX IF NOT EXISTS ix_monitored_domains_id ON domain_monitor.monitored_domains (id);
 CREATE INDEX IF NOT EXISTS ix_monitored_domains_id ON domain_monitor.monitored_domains (id);
 
 
--- ============================================================
--- 监控大屏 - 超级管理员表
--- ============================================================
-
+-- 超级管理员表
 CREATE TABLE IF NOT EXISTS domain_monitor.super_admin (
 CREATE TABLE IF NOT EXISTS domain_monitor.super_admin (
     id SERIAL PRIMARY KEY,
     id SERIAL PRIMARY KEY,
     username VARCHAR NOT NULL,
     username VARCHAR NOT NULL,
     nickname VARCHAR,
     nickname VARCHAR,
+    remark VARCHAR(500),
     created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
     created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
 );
 );
 
 
--- ============================================================
--- 监控大屏 - 超级管理员与租户关联表
--- ============================================================
-
+-- 超级管理员与租户关联表
 CREATE TABLE IF NOT EXISTS domain_monitor.super_admin_tenant (
 CREATE TABLE IF NOT EXISTS domain_monitor.super_admin_tenant (
     id SERIAL PRIMARY KEY,
     id SERIAL PRIMARY KEY,
     super_admin_id INTEGER NOT NULL,
     super_admin_id INTEGER NOT NULL,
@@ -46,10 +37,7 @@ CREATE TABLE IF NOT EXISTS domain_monitor.super_admin_tenant (
 CREATE INDEX IF NOT EXISTS ix_sat_super_admin ON domain_monitor.super_admin_tenant (super_admin_id);
 CREATE INDEX IF NOT EXISTS ix_sat_super_admin ON domain_monitor.super_admin_tenant (super_admin_id);
 CREATE INDEX IF NOT EXISTS ix_sat_tenant ON domain_monitor.super_admin_tenant (tenant_id);
 CREATE INDEX IF NOT EXISTS ix_sat_tenant ON domain_monitor.super_admin_tenant (tenant_id);
 
 
--- ============================================================
--- 监控大屏 - 租户(企业)表
--- ============================================================
-
+-- 租户表
 CREATE TABLE IF NOT EXISTS domain_monitor.tenant (
 CREATE TABLE IF NOT EXISTS domain_monitor.tenant (
     id SERIAL PRIMARY KEY,
     id SERIAL PRIMARY KEY,
     company_name VARCHAR,
     company_name VARCHAR,
@@ -61,10 +49,7 @@ CREATE TABLE IF NOT EXISTS domain_monitor.tenant (
 
 
 CREATE UNIQUE INDEX IF NOT EXISTS ix_tenant_subdomain ON domain_monitor.tenant (subdomain);
 CREATE UNIQUE INDEX IF NOT EXISTS ix_tenant_subdomain ON domain_monitor.tenant (subdomain);
 
 
--- ============================================================
--- 监控大屏 - 模型信息参考表
--- ============================================================
-
+-- 模型信息参考表
 CREATE TABLE IF NOT EXISTS domain_monitor.models (
 CREATE TABLE IF NOT EXISTS domain_monitor.models (
     id SERIAL PRIMARY KEY,
     id SERIAL PRIMARY KEY,
     model_code VARCHAR NOT NULL,
     model_code VARCHAR NOT NULL,
@@ -74,29 +59,26 @@ CREATE TABLE IF NOT EXISTS domain_monitor.models (
 
 
 CREATE UNIQUE INDEX IF NOT EXISTS ix_model_code ON domain_monitor.models (model_code);
 CREATE UNIQUE INDEX IF NOT EXISTS ix_model_code ON domain_monitor.models (model_code);
 
 
--- ============================================================
--- 监控大屏 - 用户模型消费明细表
--- 记录每次模型调用的消费数据,包含平台原价、企业折扣、用户折扣等信息
--- ============================================================
-
+-- 用户消费明细表
 CREATE TABLE IF NOT EXISTS domain_monitor.user_consumption_detail (
 CREATE TABLE IF NOT EXISTS domain_monitor.user_consumption_detail (
     id SERIAL PRIMARY KEY,
     id SERIAL PRIMARY KEY,
     user_id VARCHAR NOT NULL,
     user_id VARCHAR NOT NULL,
+    username VARCHAR,
     tenant_id INTEGER NOT NULL,
     tenant_id INTEGER NOT NULL,
     model_code VARCHAR NOT NULL,
     model_code VARCHAR NOT NULL,
     call_count INTEGER DEFAULT 0,
     call_count INTEGER DEFAULT 0,
-    -- 用户侧:用户实际支付
     user_actual_total NUMERIC(20, 4) DEFAULT 0,
     user_actual_total NUMERIC(20, 4) DEFAULT 0,
     user_discount NUMERIC(10, 4) DEFAULT 1.0000,
     user_discount NUMERIC(10, 4) DEFAULT 1.0000,
     user_actual_price NUMERIC(20, 4),
     user_actual_price NUMERIC(20, 4),
-    -- 租户侧:平台向企业收取
     tenant_actual_total NUMERIC(20, 4) DEFAULT 0,
     tenant_actual_total NUMERIC(20, 4) DEFAULT 0,
     tenant_discount NUMERIC(10, 4) DEFAULT 1.0000,
     tenant_discount NUMERIC(10, 4) DEFAULT 1.0000,
     tenant_actual_price NUMERIC(20, 4),
     tenant_actual_price NUMERIC(20, 4),
-    -- 平台原价
+    super_admin_discount NUMERIC(10, 4) DEFAULT 1.0000,
+    super_admin_actual_price NUMERIC(20, 4),
     original_price NUMERIC(20, 4),
     original_price NUMERIC(20, 4),
-    -- 消费时间(用于时间范围过滤)
     consumption_date TIMESTAMP WITH TIME ZONE NOT NULL,
     consumption_date TIMESTAMP WITH TIME ZONE NOT NULL,
+    order_no VARCHAR,
+    invoiced BOOLEAN DEFAULT FALSE,
     created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
     created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
 );
 );
 
 

+ 48 - 0
backend/migrations/002_new_tables.sql

@@ -0,0 +1,48 @@
+-- ============================================================
+-- 域名流水监控 - 新增业务表
+-- License 授权 / 访客信息 / 爬取调度配置
+-- ============================================================
+
+-- License 授权表(含短信去重字段)
+CREATE TABLE IF NOT EXISTS domain_monitor.super_admin_license (
+    id SERIAL PRIMARY KEY,
+    super_admin_id INTEGER NOT NULL,
+    license_key VARCHAR(200) NOT NULL,
+    expires_at TIMESTAMP WITH TIME ZONE NOT NULL,
+    status VARCHAR(20) NOT NULL DEFAULT 'active',
+    max_tenants INTEGER,
+    max_users_per_tenant INTEGER,
+    remark TEXT,
+    warning_sent BOOLEAN DEFAULT FALSE,
+    expired_sent BOOLEAN DEFAULT FALSE,
+    created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
+    updated_at TIMESTAMP WITH TIME ZONE
+);
+
+CREATE INDEX IF NOT EXISTS idx_sa_license_sa_id ON domain_monitor.super_admin_license(super_admin_id);
+CREATE INDEX IF NOT EXISTS idx_sa_license_status ON domain_monitor.super_admin_license(status);
+CREATE INDEX IF NOT EXISTS idx_sa_license_expires ON domain_monitor.super_admin_license(expires_at);
+
+-- 访客信息表
+CREATE TABLE IF NOT EXISTS domain_monitor.visitor_info (
+    id SERIAL PRIMARY KEY,
+    domain_id INT NOT NULL UNIQUE,
+    name VARCHAR(100),
+    phone VARCHAR(50),
+    email VARCHAR(200),
+    created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
+    updated_at TIMESTAMP WITH TIME ZONE,
+    CONSTRAINT fk_visitor_domain FOREIGN KEY (domain_id) REFERENCES domain_monitor.monitored_domains(id) ON DELETE CASCADE
+);
+
+-- 爬取调度配置表
+CREATE TABLE IF NOT EXISTS domain_monitor.fetch_schedule_config (
+    id SERIAL PRIMARY KEY,
+    enabled BOOLEAN DEFAULT FALSE,
+    schedule_time VARCHAR(5) DEFAULT '02:00'
+);
+
+-- 初始化默认配置
+INSERT INTO domain_monitor.fetch_schedule_config (enabled, schedule_time)
+SELECT FALSE, '02:00'
+WHERE NOT EXISTS (SELECT 1 FROM domain_monitor.fetch_schedule_config);

+ 0 - 21
backend/migrations/002_super_admin_model_discount.sql

@@ -1,21 +0,0 @@
--- ============================================================
--- 监控大屏 - 超级管理员模型折扣表
--- 文件: 002_super_admin_model_discount.sql
--- 日期: 2026-05-12
---
--- 超管折扣存储在 super_admin_model_discount 表中,通过爬虫同步服务自动更新:
--- - 全局折扣:model_code = "*"
--- - 模型级折扣:model_code = 具体模型标识
--- - 优先匹配具体模型,无匹配则使用全局折扣 "*"
--- ============================================================
-
--- 超级管理员模型折扣表
-CREATE TABLE IF NOT EXISTS domain_monitor.super_admin_model_discount (
-    id SERIAL PRIMARY KEY,
-    model_code VARCHAR NOT NULL,
-    discount_rate NUMERIC(10, 4) NOT NULL DEFAULT 1.0000,
-    created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
-    updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
-);
-
-CREATE UNIQUE INDEX IF NOT EXISTS ix_samd_model_code ON domain_monitor.super_admin_model_discount (model_code);

+ 33 - 0
backend/migrations/003_add_columns.sql

@@ -0,0 +1,33 @@
+-- ============================================================
+-- 存量表补充字段(安全幂等,已存在的列会自动跳过)
+-- ============================================================
+
+-- monitored_domains 补充 remark / super_admin_id
+ALTER TABLE domain_monitor.monitored_domains
+ADD COLUMN IF NOT EXISTS remark VARCHAR(500);
+
+ALTER TABLE domain_monitor.monitored_domains
+ADD COLUMN IF NOT EXISTS super_admin_id INTEGER;
+
+-- super_admin 补充 remark / source_domain
+ALTER TABLE domain_monitor.super_admin
+ADD COLUMN IF NOT EXISTS remark VARCHAR(500);
+
+ALTER TABLE domain_monitor.super_admin
+ADD COLUMN IF NOT EXISTS source_domain VARCHAR(200);
+
+-- user_consumption_detail 补充字段
+ALTER TABLE domain_monitor.user_consumption_detail
+ADD COLUMN IF NOT EXISTS username VARCHAR;
+
+ALTER TABLE domain_monitor.user_consumption_detail
+ADD COLUMN IF NOT EXISTS order_no VARCHAR;
+
+ALTER TABLE domain_monitor.user_consumption_detail
+ADD COLUMN IF NOT EXISTS invoiced BOOLEAN DEFAULT FALSE;
+
+ALTER TABLE domain_monitor.user_consumption_detail
+ADD COLUMN IF NOT EXISTS super_admin_discount NUMERIC(10, 4) DEFAULT 1.0000;
+
+ALTER TABLE domain_monitor.user_consumption_detail
+ADD COLUMN IF NOT EXISTS super_admin_actual_price NUMERIC(20, 4);

+ 0 - 14
backend/migrations/003_ucd_order_fields.sql

@@ -1,14 +0,0 @@
--- ============================================================
--- 监控大屏 - user_consumption_detail 增加订单号和开票字段
--- 文件: 003_ucd_order_fields.sql
--- 日期: 2026-05-12
---
--- 消费记录流水新增 order_no(关联 balance_log.biz_order_no)和 invoiced(是否已开票)字段
--- ============================================================
-
-ALTER TABLE domain_monitor.user_consumption_detail
-    ADD COLUMN IF NOT EXISTS order_no VARCHAR,
-    ADD COLUMN IF NOT EXISTS invoiced BOOLEAN DEFAULT FALSE;
-
-COMMENT ON COLUMN domain_monitor.user_consumption_detail.order_no IS '订单号,对应 balance_log.biz_order_no';
-COMMENT ON COLUMN domain_monitor.user_consumption_detail.invoiced IS '是否已开票';

+ 9 - 0
backend/migrations/004_license_sms_dedup.sql

@@ -0,0 +1,9 @@
+-- ============================================================
+-- License 短信去重字段(存量库增量,新库建表时已包含)
+-- ============================================================
+
+ALTER TABLE domain_monitor.super_admin_license
+ADD COLUMN IF NOT EXISTS warning_sent BOOLEAN DEFAULT FALSE;
+
+ALTER TABLE domain_monitor.super_admin_license
+ADD COLUMN IF NOT EXISTS expired_sent BOOLEAN DEFAULT FALSE;

+ 0 - 8
backend/migrations/004_super_admin_discount_fields.sql

@@ -1,8 +0,0 @@
--- 为 user_consumption_detail 表添加超管折扣和超管实际金额字段
--- 解决爬取数据时超管折扣丢失的问题
-
-ALTER TABLE domain_monitor.user_consumption_detail
-    ADD COLUMN super_admin_discount NUMERIC(10, 4) DEFAULT 1.0000;
-
-ALTER TABLE domain_monitor.user_consumption_detail
-    ADD COLUMN super_admin_actual_price NUMERIC(20, 4);

+ 14 - 0
backend/migrations/005_fetch_log.sql

@@ -0,0 +1,14 @@
+-- ============================================================
+-- 域名爬取日志表
+-- ============================================================
+
+CREATE TABLE domain_monitor.fetch_logs (
+    id SERIAL PRIMARY KEY,
+    domain VARCHAR NOT NULL,
+    status VARCHAR NOT NULL,
+    message TEXT,
+    created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
+);
+
+CREATE INDEX idx_fetch_logs_domain ON domain_monitor.fetch_logs(domain);
+CREATE INDEX idx_fetch_logs_created_at ON domain_monitor.fetch_logs(created_at);

+ 0 - 4
backend/migrations/005_user_fields.sql

@@ -1,4 +0,0 @@
--- 为 user_consumption_detail 表添加 username 字段
--- 用于在用户页面显示真实用户名,而不是 user_id
-ALTER TABLE domain_monitor.user_consumption_detail
-    ADD COLUMN username VARCHAR;

+ 0 - 17
backend/migrations/006_license_table.sql

@@ -1,17 +0,0 @@
--- 创建 License 授权表
-CREATE TABLE IF NOT EXISTS domain_monitor.super_admin_license (
-    id SERIAL PRIMARY KEY,
-    super_admin_id INTEGER NOT NULL,
-    license_key VARCHAR(200) NOT NULL,
-    expires_at TIMESTAMP WITH TIME ZONE NOT NULL,
-    status VARCHAR(20) NOT NULL DEFAULT 'active',
-    max_tenants INTEGER,
-    max_users_per_tenant INTEGER,
-    remark TEXT,
-    created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
-    updated_at TIMESTAMP WITH TIME ZONE
-);
-
-CREATE INDEX idx_sa_license_sa_id ON domain_monitor.super_admin_license(super_admin_id);
-CREATE INDEX idx_sa_license_status ON domain_monitor.super_admin_license(status);
-CREATE INDEX idx_sa_license_expires ON domain_monitor.super_admin_license(expires_at);

+ 0 - 3
backend/migrations/007_domain_remark.sql

@@ -1,3 +0,0 @@
--- 为监控域名表添加备注字段
-ALTER TABLE domain_monitor.monitored_domains
-ADD COLUMN remark VARCHAR(500);

+ 0 - 6
backend/migrations/008_super_admin_remark.sql

@@ -1,6 +0,0 @@
--- 为超级管理员表添加备注和来源域名字段
-ALTER TABLE domain_monitor.super_admin
-ADD COLUMN IF NOT EXISTS remark VARCHAR(500);
-
-ALTER TABLE domain_monitor.super_admin
-ADD COLUMN IF NOT EXISTS source_domain VARCHAR(200);

+ 0 - 20
backend/migrations/009_domain_super_admin_id.sql

@@ -1,20 +0,0 @@
--- 为域名表添加 super_admin_id 关联字段
-ALTER TABLE domain_monitor.monitored_domains
-ADD COLUMN IF NOT EXISTS super_admin_id INTEGER;
-
--- 根据 username 反查,将已有的 super_admin_id 回填到域名表
--- 注意:这只在 username 与域名相同时生效
-UPDATE domain_monitor.monitored_domains md
-SET super_admin_id = sa.id
-FROM domain_monitor.super_admin sa
-WHERE sa.username = md.domain
-  AND md.super_admin_id IS NULL;
-
--- 将域名备注同步到超管备注(基于 domain -> super_admin_id 映射)
-UPDATE domain_monitor.super_admin sa
-SET remark = md.remark
-FROM domain_monitor.monitored_domains md
-WHERE md.super_admin_id = sa.id
-  AND md.remark IS NOT NULL
-  AND md.remark != ''
-  AND (sa.remark IS NULL OR sa.remark = '');

+ 0 - 13
backend/migrations/010_sync_domain_remarks.sql

@@ -1,13 +0,0 @@
--- 直接从域名表同步备注到超管表
--- 将第一个有备注的域名备注,同步到所有还没有有效备注的超管
--- (适用于"一个域名对应一组超管"的场景)
-UPDATE domain_monitor.super_admin sa
-SET remark = (
-    SELECT md.remark
-    FROM domain_monitor.monitored_domains md
-    WHERE md.remark IS NOT NULL
-      AND md.remark != ''
-    ORDER BY md.id ASC
-    LIMIT 1
-)
-WHERE sa.remark IS NULL OR sa.remark = '' OR sa.remark = sa.username;

+ 0 - 11
backend/migrations/011_visitor_info.sql

@@ -1,11 +0,0 @@
--- 新建 visitor_info 表,存储域名联系人信息
-CREATE TABLE IF NOT EXISTS domain_monitor.visitor_info (
-    id SERIAL PRIMARY KEY,
-    domain_id INT NOT NULL UNIQUE,
-    name VARCHAR(100),
-    phone VARCHAR(50),
-    email VARCHAR(200),
-    created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
-    updated_at TIMESTAMP WITH TIME ZONE,
-    CONSTRAINT fk_visitor_domain FOREIGN KEY (domain_id) REFERENCES domain_monitor.monitored_domains(id) ON DELETE CASCADE
-);

+ 0 - 4
backend/migrations/012_add_warning_sent.sql

@@ -1,4 +0,0 @@
--- 为 super_admin_license 表添加 warning_sent 字段
--- 用于标记 7 天预警短信是否已发送,避免重复发送
-ALTER TABLE domain_monitor.super_admin_license
-ADD COLUMN IF NOT EXISTS warning_sent BOOLEAN DEFAULT FALSE;

+ 149 - 31
frontend/src/App.tsx

@@ -91,6 +91,7 @@ function Sidebar({ page, setPage }: { page: string; setPage: (p: string) => void
     { key: "tenants", label: "租户", icon: "M12 7V3H2v18h20V7H12zM6 19H4v-2h2v2zm0-4H4v-2h2v2zm0-4H4V9h2v2zm0-4H4V5h2v2zm4 12H8v-2h2v2zm0-4H8v-2h2v2zm0-4H8V9h2v2zm0-4H8V5h2v2zm10 12h-8v-2h2v-2h-2v-2h2v-2h-2V9h8v10zm-2-8h-2v2h2v-2zm0 4h-2v2h2v-2z" },
     { key: "tenants", label: "租户", icon: "M12 7V3H2v18h20V7H12zM6 19H4v-2h2v2zm0-4H4v-2h2v2zm0-4H4V9h2v2zm0-4H4V5h2v2zm4 12H8v-2h2v2zm0-4H8v-2h2v2zm0-4H8V9h2v2zm0-4H8V5h2v2zm10 12h-8v-2h2v-2h-2v-2h2v-2h-2V9h8v10zm-2-8h-2v2h2v-2zm0 4h-2v2h2v-2z" },
     { key: "users", label: "用户", icon: "M16 11c1.66 0 2.99-1.34 2.99-3S17.66 5 16 5c-1.66 0-3 1.34-3 3s1.34 3 3 3zm-8 0c1.66 0 2.99-1.34 2.99-3S9.66 5 8 5C6.34 5 5 6.34 5 8s1.34 3 3 3zm0 2c-2.33 0-7 1.17-7 3.5V19h14v-2.5c0-2.33-4.67-3.5-7-3.5zm8 0c-.29 0-.62.02-.97.05 1.16.84 1.97 1.97 1.97 3.45V19h6v-2.5c0-2.33-4.67-3.5-7-3.5z" },
     { key: "users", label: "用户", icon: "M16 11c1.66 0 2.99-1.34 2.99-3S17.66 5 16 5c-1.66 0-3 1.34-3 3s1.34 3 3 3zm-8 0c1.66 0 2.99-1.34 2.99-3S9.66 5 8 5C6.34 5 5 6.34 5 8s1.34 3 3 3zm0 2c-2.33 0-7 1.17-7 3.5V19h14v-2.5c0-2.33-4.67-3.5-7-3.5zm8 0c-.29 0-.62.02-.97.05 1.16.84 1.97 1.97 1.97 3.45V19h6v-2.5c0-2.33-4.67-3.5-7-3.5z" },
     { key: "license", label: "License 许可", icon: "M18 8h-1V6c0-2.76-2.24-5-5-5S7 3.24 7 6v2H6c-1.1 0-2 .9-2 2v10c0 1.1.9 2 2 2h12c1.1 0 2-.9 2-2V10c0-1.1-.9-2-2-2zm-6 9c-1.1 0-2-.9-2-2s.9-2 2-2 2 .9 2 2-.9 2-2 2zm3.1-9H8.9V6c0-1.71 1.39-3.1 3.1-3.1 1.71 0 3.1 1.39 3.1 3.1v2z" },
     { key: "license", label: "License 许可", icon: "M18 8h-1V6c0-2.76-2.24-5-5-5S7 3.24 7 6v2H6c-1.1 0-2 .9-2 2v10c0 1.1.9 2 2 2h12c1.1 0 2-.9 2-2V10c0-1.1-.9-2-2-2zm-6 9c-1.1 0-2-.9-2-2s.9-2 2-2 2 .9 2 2-.9 2-2 2zm3.1-9H8.9V6c0-1.71 1.39-3.1 3.1-3.1 1.71 0 3.1 1.39 3.1 3.1v2z" },
+    { key: "fetch_logs", label: "爬取日志", icon: "M14 2H6c-1.1 0-1.99.9-1.99 2L4 20c0 1.1.89 2 1.99 2H18c1.1 0 2-.9 2-2V8l-6-6zm2 16H8v-2h8v2zm0-4H8v-2h8v2zm-3-5V3.5L18.5 9H13z" },
   ];
   ];
   return (
   return (
     <div style={{ width: 220, minHeight: "100vh", background: T.sidebar, padding: "24px 12px", position: "fixed", top: 0, left: 0, zIndex: 100 }}>
     <div style={{ width: 220, minHeight: "100vh", background: T.sidebar, padding: "24px 12px", position: "fixed", top: 0, left: 0, zIndex: 100 }}>
@@ -211,6 +212,8 @@ function DomainsPage() {
   const [newDomain, setNewDomain] = useState("");
   const [newDomain, setNewDomain] = useState("");
   const [newRemark, setNewRemark] = useState("");
   const [newRemark, setNewRemark] = useState("");
   const [fetchingId, setFetchingId] = useState<number | null>(null);
   const [fetchingId, setFetchingId] = useState<number | null>(null);
+  const [fetchDate, setFetchDate] = useState("");
+  const [showDatePicker, setShowDatePicker] = useState<number | null>(null);
   const [editingRemarkId, setEditingRemarkId] = useState<number | null>(null);
   const [editingRemarkId, setEditingRemarkId] = useState<number | null>(null);
   const [editingRemarkValue, setEditingRemarkValue] = useState("");
   const [editingRemarkValue, setEditingRemarkValue] = useState("");
   const [toast, setToast] = useState<{ message: string; type: "success" | "error" } | null>(null);
   const [toast, setToast] = useState<{ message: string; type: "success" | "error" } | null>(null);
@@ -222,9 +225,9 @@ function DomainsPage() {
   const { data: domains, isLoading } = useQuery({ queryKey: ["domains"], queryFn: () => domainApi.list().then((r) => r.data) });
   const { data: domains, isLoading } = useQuery({ queryKey: ["domains"], queryFn: () => domainApi.list().then((r) => r.data) });
   const addMutation = useMutation({ mutationFn: (data: MonitoredDomainCreate) => domainApi.create(data), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ["domains"] }); setNewDomain(""); setNewRemark(""); showToast("域名添加成功", "success"); }, onError: () => { showToast("添加失败,请检查域名格式", "error"); } });
   const addMutation = useMutation({ mutationFn: (data: MonitoredDomainCreate) => domainApi.create(data), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ["domains"] }); setNewDomain(""); setNewRemark(""); showToast("域名添加成功", "success"); }, onError: () => { showToast("添加失败,请检查域名格式", "error"); } });
   const deleteMutation = useMutation({ mutationFn: (id: number) => domainApi.remove(id), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ["domains"] }); showToast("域名已删除", "success"); }, onError: () => { showToast("删除失败", "error"); } });
   const deleteMutation = useMutation({ mutationFn: (id: number) => domainApi.remove(id), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ["domains"] }); showToast("域名已删除", "success"); }, onError: () => { showToast("删除失败", "error"); } });
-  const fetchMutation = useMutation({ mutationFn: (id: number) => domainApi.fetchTransactions(id), onSuccess: (res) => { queryClient.invalidateQueries({ queryKey: ["dashboard"] }); setFetchingId(null); showToast(`${res.data.domain} 爬取成功`, "success"); }, onError: () => { setFetchingId(null); showToast("爬取失败,请检查域名是否可达", "error"); } });
+  const fetchMutation = useMutation({ mutationFn: ({ id, date }: { id: number; date?: string }) => domainApi.fetchTransactions(id, date), onSuccess: (res) => { queryClient.invalidateQueries({ queryKey: ["dashboard"] }); setFetchingId(null); setShowDatePicker(null); showToast(`${res.data.domain} 爬取成功`, "success"); }, onError: () => { setFetchingId(null); showToast("爬取失败,请检查域名是否可达", "error"); } });
   const updateRemarkMutation = useMutation({ mutationFn: ({ id, remark }: { id: number; remark: string }) => domainApi.updateRemark(id, { remark }), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ["domains"] }); setEditingRemarkId(null); showToast("备注更新成功", "success"); }, onError: () => { showToast("备注更新失败", "error"); } });
   const updateRemarkMutation = useMutation({ mutationFn: ({ id, remark }: { id: number; remark: string }) => domainApi.updateRemark(id, { remark }), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ["domains"] }); setEditingRemarkId(null); showToast("备注更新成功", "success"); }, onError: () => { showToast("备注更新失败", "error"); } });
-  const handleFetch = (id: number) => { setFetchingId(id); fetchMutation.mutate(id); };
+  const handleFetch = (id: number) => { setFetchingId(id); fetchMutation.mutate({ id, date: fetchDate || undefined }); };
   const handleSubmit = (e: React.FormEvent) => { e.preventDefault(); if (!newDomain.trim()) return; addMutation.mutate({ domain: newDomain.trim(), remark: newRemark.trim() }); };
   const handleSubmit = (e: React.FormEvent) => { e.preventDefault(); if (!newDomain.trim()) return; addMutation.mutate({ domain: newDomain.trim(), remark: newRemark.trim() }); };
   const startEditRemark = (d: MonitoredDomain) => { setEditingRemarkId(d.id); setEditingRemarkValue(d.remark || ""); };
   const startEditRemark = (d: MonitoredDomain) => { setEditingRemarkId(d.id); setEditingRemarkValue(d.remark || ""); };
   const saveRemark = (id: number) => { updateRemarkMutation.mutate({ id, remark: editingRemarkValue }); };
   const saveRemark = (id: number) => { updateRemarkMutation.mutate({ id, remark: editingRemarkValue }); };
@@ -272,10 +275,25 @@ function DomainsPage() {
                   </td>
                   </td>
                   <td style={{ padding: "12px 14px" }}><Badge active={d.is_active} text={d.is_active ? "启用" : "停用"} /></td>
                   <td style={{ padding: "12px 14px" }}><Badge active={d.is_active} text={d.is_active ? "启用" : "停用"} /></td>
                   <td style={{ padding: "12px 14px", fontSize: 13, color: T.textSec }}>{fmtDate(d.created_at)}</td>
                   <td style={{ padding: "12px 14px", fontSize: 13, color: T.textSec }}>{fmtDate(d.created_at)}</td>
-                  <td style={{ padding: "12px 14px", display: "flex", gap: 6 }}>
-                    <button onClick={() => handleFetch(d.id)} disabled={fetchingId === d.id} style={{ padding: "5px 10px", borderRadius: 6, fontSize: 12, cursor: "pointer", border: `1px solid ${T.primary}`, background: "transparent", color: T.primary, fontWeight: 500 }}>
-                      {fetchingId === d.id ? "爬取中" : "爬取流水"}
-                    </button>
+                  <td style={{ padding: "12px 14px", display: "flex", gap: 6, alignItems: "center" }}>
+                    {showDatePicker === d.id ? (
+                      <>
+                        <input type="date" value={fetchDate} onChange={(e) => setFetchDate(e.target.value)} style={{ padding: "3px 6px", borderRadius: 4, border: `1px solid ${T.border}`, fontSize: 12, outline: "none", width: 140 }} />
+                        <button onClick={() => handleFetch(d.id)} disabled={fetchingId === d.id} style={{ padding: "5px 10px", borderRadius: 6, fontSize: 12, cursor: "pointer", border: "none", background: T.primary, color: "#fff", fontWeight: 500 }}>
+                          {fetchingId === d.id ? "爬取中" : "爬取"}
+                        </button>
+                        <button onClick={() => { setShowDatePicker(null); setFetchDate(""); }} style={{ padding: "3px 8px", borderRadius: 4, fontSize: 12, cursor: "pointer", border: "none", background: T.border, color: T.text }}>取消</button>
+                      </>
+                    ) : (
+                      <>
+                        <button onClick={() => setShowDatePicker(d.id)} style={{ padding: "5px 10px", borderRadius: 6, fontSize: 12, cursor: "pointer", border: `1px solid ${T.primary}`, background: "transparent", color: T.primary, fontWeight: 500 }}>
+                          按日期爬取
+                        </button>
+                        <button onClick={() => handleFetch(d.id)} disabled={fetchingId === d.id} style={{ padding: "5px 10px", borderRadius: 6, fontSize: 12, cursor: "pointer", border: `1px solid ${T.border}`, background: "transparent", color: T.text, fontWeight: 500 }}>
+                          {fetchingId === d.id ? "爬取中" : "爬取当天"}
+                        </button>
+                      </>
+                    )}
                     <button onClick={() => deleteMutation.mutate(d.id)} disabled={deleteMutation.isPending} style={{ padding: "5px 10px", borderRadius: 6, fontSize: 12, cursor: "pointer", border: "none", background: "transparent", color: T.danger, fontWeight: 500 }}>删除</button>
                     <button onClick={() => deleteMutation.mutate(d.id)} disabled={deleteMutation.isPending} style={{ padding: "5px 10px", borderRadius: 6, fontSize: 12, cursor: "pointer", border: "none", background: "transparent", color: T.danger, fontWeight: 500 }}>删除</button>
                   </td>
                   </td>
                 </tr>
                 </tr>
@@ -674,48 +692,70 @@ function TenantsPage() {
 /* ===== 用户页面 ===== */
 /* ===== 用户页面 ===== */
 function UsersPage() {
 function UsersPage() {
   const [query, setQuery] = useState({ startDate: "", endDate: "", saName: "", tenantName: "" });
   const [query, setQuery] = useState({ startDate: "", endDate: "", saName: "", tenantName: "" });
+  const [page, setPage] = useState(1);
+  const [pageSize, setPageSize] = useState(20);
 
 
-  const params: { start_date?: string; end_date?: string; super_admin_name?: string; tenant_name?: string } = {};
+  const params: { start_date?: string; end_date?: string; super_admin_name?: string; tenant_name?: string; page?: number; page_size?: number } = {};
   if (query.startDate) params.start_date = query.startDate;
   if (query.startDate) params.start_date = query.startDate;
   if (query.endDate) params.end_date = query.endDate;
   if (query.endDate) params.end_date = query.endDate;
   if (query.saName) params.super_admin_name = query.saName;
   if (query.saName) params.super_admin_name = query.saName;
   if (query.tenantName) params.tenant_name = query.tenantName;
   if (query.tenantName) params.tenant_name = query.tenantName;
+  params.page = page;
+  params.page_size = pageSize;
 
 
   const { data: details, isLoading } = useQuery({
   const { data: details, isLoading } = useQuery({
     queryKey: ["consumption-details", params],
     queryKey: ["consumption-details", params],
-    queryFn: () => monitoringApi.getConsumptionDetails(Object.keys(params).length ? params : undefined).then((r) => r.data),
+    queryFn: () => monitoringApi.getConsumptionDetails(params).then((r) => r.data),
   });
   });
 
 
   if (isLoading) return <PageLayout title="用户" subtitle="逐笔消费明细,用于对账"><div style={{ textAlign: "center", padding: 60, color: T.textSec }}>加载中...</div></PageLayout>;
   if (isLoading) return <PageLayout title="用户" subtitle="逐笔消费明细,用于对账"><div style={{ textAlign: "center", padding: 60, color: T.textSec }}>加载中...</div></PageLayout>;
   if (!details) return <PageLayout title="用户" subtitle="逐笔消费明细,用于对账"><Card title="数据"><div style={{ textAlign: "center", padding: 40, color: T.textSec }}><p>暂无数据,请先爬取域名流水</p></div></Card></PageLayout>;
   if (!details) return <PageLayout title="用户" subtitle="逐笔消费明细,用于对账"><Card title="数据"><div style={{ textAlign: "center", padding: 40, color: T.textSec }}><p>暂无数据,请先爬取域名流水</p></div></Card></PageLayout>;
 
 
   const records = details.records || [];
   const records = details.records || [];
+  const total = details.total ?? 0;
+  const totalPages = Math.max(1, Math.ceil(total / pageSize));
 
 
   return (
   return (
     <PageLayout title="用户" subtitle="逐笔消费明细,用于对账">
     <PageLayout title="用户" subtitle="逐笔消费明细,用于对账">
-      <FilterBar currentQuery={query} onSearch={setQuery} showTenantFilter />
-      <Card title={`消费明细(共 ${details.total} 笔)`}>
+      <FilterBar currentQuery={query} onSearch={(q) => { setQuery(q); setPage(1); }} showTenantFilter />
+      <Card title={`消费明细(共 ${total} 笔)`}>
         {records.length === 0 ? <div style={{ textAlign: "center", padding: 40, color: T.textSec }}>暂无消费记录</div> : (
         {records.length === 0 ? <div style={{ textAlign: "center", padding: 40, color: T.textSec }}>暂无消费记录</div> : (
-          <table style={{ width: "100%", borderCollapse: "collapse", fontSize: 13 }}>
-            <thead><tr>{["用户", "所属租户", "订单号", "模型", "消费时间", "用户折扣", "用户金额(元)", "租户折扣", "租户金额(元)", "超管折扣", "超管金额(元)"].map((h) => <th key={h} style={{ padding: "8px 10px", textAlign: "left", fontSize: 11, color: T.textSec, borderBottom: `1px solid ${T.border}` }}>{h}</th>)}</tr></thead>
-            <tbody>
-              {records.map((r: any, idx: number) => (
-                <tr key={idx} style={{ borderBottom: `1px solid ${T.border}` }}>
-                  <td style={{ padding: "8px 10px", fontWeight: 500 }}>{r.user_name}</td>
-                  <td style={{ padding: "8px 10px", fontWeight: 500 }}>{r.tenant_name}</td>
-                  <td style={{ padding: "8px 10px", color: T.textSec }}>{r.order_no || "-"}</td>
-                  <td style={{ padding: "8px 10px", fontWeight: 500 }}>{r.model_code}</td>
-                  <td style={{ padding: "8px 10px", color: T.textSec }}>{r.consumption_date ? r.consumption_date.split(".")[0] : "-"}</td>
-                  <td style={{ padding: "8px 10px", color: T.textSec }}>{r.user_discount}</td>
-                  <td style={{ padding: "8px 10px" }}>{r.user_consumed}</td>
-                  <td style={{ padding: "8px 10px", color: T.textSec }}>{r.tenant_discount}</td>
-                  <td style={{ padding: "8px 10px" }}>{r.tenant_actual_price}</td>
-                  <td style={{ padding: "8px 10px", color: T.textSec }}>{r.super_admin_discount}</td>
-                  <td style={{ padding: "8px 10px" }}>{r.super_admin_actual_price}</td>
-                </tr>
-              ))}
-            </tbody>
-          </table>
+          <>
+            <table style={{ width: "100%", borderCollapse: "collapse", fontSize: 13 }}>
+              <thead><tr>{["用户", "所属租户", "订单号", "模型", "消费时间", "用户折扣", "用户金额(元)", "租户折扣", "租户金额(元)", "超管折扣", "超管金额(元)"].map((h) => <th key={h} style={{ padding: "8px 10px", textAlign: "left", fontSize: 11, color: T.textSec, borderBottom: `1px solid ${T.border}` }}>{h}</th>)}</tr></thead>
+              <tbody>
+                {records.map((r: any, idx: number) => (
+                  <tr key={idx} style={{ borderBottom: `1px solid ${T.border}` }}>
+                    <td style={{ padding: "8px 10px", fontWeight: 500 }}>{r.user_name}</td>
+                    <td style={{ padding: "8px 10px", fontWeight: 500 }}>{r.tenant_name}</td>
+                    <td style={{ padding: "8px 10px", color: T.textSec }}>{r.order_no || "-"}</td>
+                    <td style={{ padding: "8px 10px", fontWeight: 500 }}>{r.model_code}</td>
+                    <td style={{ padding: "8px 10px", color: T.textSec }}>{r.consumption_date ? r.consumption_date.split(".")[0] : "-"}</td>
+                    <td style={{ padding: "8px 10px", color: T.textSec }}>{r.user_discount}</td>
+                    <td style={{ padding: "8px 10px" }}>{r.user_consumed}</td>
+                    <td style={{ padding: "8px 10px", color: T.textSec }}>{r.tenant_discount}</td>
+                    <td style={{ padding: "8px 10px" }}>{r.tenant_actual_price}</td>
+                    <td style={{ padding: "8px 10px", color: T.textSec }}>{r.super_admin_discount}</td>
+                    <td style={{ padding: "8px 10px" }}>{r.super_admin_actual_price}</td>
+                  </tr>
+                ))}
+              </tbody>
+            </table>
+            <div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", marginTop: 16, fontSize: 13 }}>
+              <div style={{ display: "flex", alignItems: "center", gap: 8, color: T.textSec }}>
+                <span>每页</span>
+                <select value={pageSize} onChange={(e) => { setPageSize(Number(e.target.value)); setPage(1); }} style={{ padding: "4px 8px", borderRadius: 6, border: `1px solid ${T.border}`, fontSize: 13, outline: "none", background: "#fff" }}>
+                  {[10, 20, 50, 100].map((n) => <option key={n} value={n}>{n} 条</option>)}
+                </select>
+                <span>,共 {total} 条</span>
+              </div>
+              <div style={{ display: "flex", alignItems: "center", gap: 8 }}>
+                <button disabled={page <= 1} onClick={() => setPage(page - 1)} style={{ padding: "4px 12px", borderRadius: 6, fontSize: 13, cursor: page <= 1 ? "default" : "pointer", border: `1px solid ${T.border}`, background: "#fff", color: page <= 1 ? T.textSec : T.text }}>上一页</button>
+                <span style={{ color: T.textSec }}>第 {page} / {totalPages} 页</span>
+                <button disabled={page >= totalPages} onClick={() => setPage(page + 1)} style={{ padding: "4px 12px", borderRadius: 6, fontSize: 13, cursor: page >= totalPages ? "default" : "pointer", border: `1px solid ${T.border}`, background: "#fff", color: page >= totalPages ? T.textSec : T.text }}>下一页</button>
+              </div>
+            </div>
+          </>
         )}
         )}
       </Card>
       </Card>
     </PageLayout>
     </PageLayout>
@@ -791,7 +831,7 @@ function LicensePage() {
   const updateMutation = useMutation({
   const updateMutation = useMutation({
     mutationFn: ({ id, data }: { id: number; data: { license_key?: string; expires_at?: string } }) =>
     mutationFn: ({ id, data }: { id: number; data: { license_key?: string; expires_at?: string } }) =>
       licenseApi.update(id, data),
       licenseApi.update(id, data),
-    onSuccess: (res) => { queryClient.invalidateQueries({ queryKey: ["licenses"] }); setEditTarget(null); showToast("License 已更新", "success"); showSmsToast(res.data?.sms_status); },
+    onSuccess: (res) => { queryClient.invalidateQueries({ queryKey: ["licenses"] }); setEditTarget(null); showToast("License 已更新", "success"); showSmsToast(res.data?.sms_status, res.data?.sms_type ?? "restored"); },
     onError: () => { showToast("更新失败", "error"); },
     onError: () => { showToast("更新失败", "error"); },
   });
   });
 
 
@@ -950,6 +990,83 @@ function LicensePage() {
   );
   );
 }
 }
 
 
+/* ===== 爬取日志页面 ===== */
+function FetchLogsPage() {
+  const [filters, setFilters] = useState({ domain: "", status: "" });
+  const [page, setPage] = useState(1);
+  const size = 20;
+
+  const params: Record<string, string | number> = { page, size };
+  if (filters.domain) params.domain = filters.domain;
+  if (filters.status) params.status = filters.status;
+
+  const { data, isLoading } = useQuery({
+    queryKey: ["fetch-logs", params],
+    queryFn: () => domainApi.getFetchLogs(params).then((r) => r.data),
+  });
+
+  const statusBadge = (status: string) => {
+    if (status === "success") return <span style={{ display: "inline-flex", alignItems: "center", gap: 4, padding: "2px 10px", borderRadius: 99, fontSize: 12, background: "#f0fdf4", color: "#16a34a" }}>成功</span>;
+    if (status === "failed") return <span style={{ display: "inline-flex", alignItems: "center", gap: 4, padding: "2px 10px", borderRadius: 99, fontSize: 12, background: "#fef2f2", color: "#dc2626" }}>失败</span>;
+    if (status === "skipped") return <span style={{ display: "inline-flex", alignItems: "center", gap: 4, padding: "2px 10px", borderRadius: 99, fontSize: 12, background: "#fefce8", color: "#ca8a04" }}>跳过</span>;
+    return <span style={{ color: T.textSec }}>{status}</span>;
+  };
+
+  const total = data?.total ?? 0;
+  const totalPages = Math.max(1, Math.ceil(total / size));
+
+  return (
+    <PageLayout title="爬取日志" subtitle="查看域名爬取历史记录">
+      <Card title="筛选">
+        <div style={{ display: "flex", gap: 16, alignItems: "flex-end", flexWrap: "wrap" }}>
+          <div>
+            <label style={{ display: "block", fontSize: 12, color: T.textSec, marginBottom: 4 }}>域名</label>
+            <input type="text" value={filters.domain} onChange={(e) => setFilters({ ...filters, domain: e.target.value })} placeholder="输入域名筛选" style={{ padding: "8px 12px", border: `1px solid ${T.border}`, borderRadius: 6, fontSize: 14, outline: "none", width: 200 }} />
+          </div>
+          <div>
+            <label style={{ display: "block", fontSize: 12, color: T.textSec, marginBottom: 4 }}>状态</label>
+            <select value={filters.status} onChange={(e) => setFilters({ ...filters, status: e.target.value })} style={{ padding: "8px 12px", border: `1px solid ${T.border}`, borderRadius: 6, fontSize: 14, outline: "none", background: "#fff" }}>
+              <option value="">全部</option>
+              <option value="success">成功</option>
+              <option value="failed">失败</option>
+              <option value="skipped">跳过</option>
+            </select>
+          </div>
+          <button onClick={() => { setPage(1); }} style={{ padding: "8px 20px", borderRadius: 6, fontSize: 14, cursor: "pointer", border: "none", background: T.primary, color: "#fff", fontWeight: 500, whiteSpace: "nowrap" }}>查询</button>
+        </div>
+      </Card>
+
+      <Card title={`日志记录(共 ${total} 条)`}>
+        {isLoading ? <div style={{ textAlign: "center", padding: 40, color: T.textSec }}>加载中...</div> : !data?.items?.length ? <div style={{ textAlign: "center", padding: 48, color: T.textSec }}>暂无爬取日志</div> : (
+          <>
+            <table style={{ width: "100%", borderCollapse: "collapse", fontSize: 13 }}>
+              <thead><tr>{["ID", "域名", "状态", "信息", "时间"].map((h) => <th key={h} style={{ padding: "8px 10px", textAlign: "left", fontSize: 11, color: T.textSec, borderBottom: `1px solid ${T.border}` }}>{h}</th>)}</tr></thead>
+              <tbody>
+                {data.items.map((log: any) => (
+                  <tr key={log.id} style={{ borderBottom: `1px solid ${T.border}` }}>
+                    <td style={{ padding: "8px 10px", color: T.textSec }}>{log.id}</td>
+                    <td style={{ padding: "8px 10px", fontWeight: 500 }}>{log.domain}</td>
+                    <td style={{ padding: "8px 10px" }}>{statusBadge(log.status)}</td>
+                    <td style={{ padding: "8px 10px", color: log.status === "failed" ? T.danger : T.text, maxWidth: 400, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }} title={log.message}>{log.message}</td>
+                    <td style={{ padding: "8px 10px", color: T.textSec, whiteSpace: "nowrap" }}>{log.created_at ? new Date(log.created_at).toLocaleString("zh-CN") : "-"}</td>
+                  </tr>
+                ))}
+              </tbody>
+            </table>
+            {totalPages > 1 && (
+              <div style={{ display: "flex", alignItems: "center", justifyContent: "center", gap: 8, marginTop: 16 }}>
+                <button disabled={page <= 1} onClick={() => setPage(page - 1)} style={{ padding: "4px 12px", borderRadius: 6, fontSize: 13, cursor: page <= 1 ? "default" : "pointer", border: `1px solid ${T.border}`, background: "#fff", color: page <= 1 ? T.textSec : T.text }}>上一页</button>
+                <span style={{ fontSize: 13, color: T.textSec }}>第 {page} / {totalPages} 页</span>
+                <button disabled={page >= totalPages} onClick={() => setPage(page + 1)} style={{ padding: "4px 12px", borderRadius: 6, fontSize: 13, cursor: page >= totalPages ? "default" : "pointer", border: `1px solid ${T.border}`, background: "#fff", color: page >= totalPages ? T.textSec : T.text }}>下一页</button>
+              </div>
+            )}
+          </>
+        )}
+      </Card>
+    </PageLayout>
+  );
+}
+
 /* ===== App ===== */
 /* ===== App ===== */
 function App() {
 function App() {
   const [page, setPage] = useState("domains");
   const [page, setPage] = useState("domains");
@@ -960,6 +1077,7 @@ function App() {
     tenants: <TenantsPage />,
     tenants: <TenantsPage />,
     users: <UsersPage />,
     users: <UsersPage />,
     license: <LicensePage />,
     license: <LicensePage />,
+    fetch_logs: <FetchLogsPage />,
   };
   };
   return (
   return (
     <>
     <>

+ 4 - 0
frontend/src/api/domains.ts

@@ -26,4 +26,8 @@ export const domainApi = {
 
 
   /** 保存定时爬取配置 */
   /** 保存定时爬取配置 */
   saveSchedule: (data: { enabled: boolean; schedule_time: string }) => api.put("/api/domains/schedule", data),
   saveSchedule: (data: { enabled: boolean; schedule_time: string }) => api.put("/api/domains/schedule", data),
+
+  /** 获取爬取日志 */
+  getFetchLogs: (params?: { domain?: string; status?: string; page?: number; size?: number }) =>
+    api.get("/api/domains/fetch-logs", { params }),
 };
 };

+ 4 - 2
frontend/src/api/monitoring.ts

@@ -13,13 +13,15 @@ export const monitoringApi = {
     return api.get(`/api/public/monitoring/dashboard${query ? `?${query}` : ""}`);
     return api.get(`/api/public/monitoring/dashboard${query ? `?${query}` : ""}`);
   },
   },
 
 
-  /** 获取原始消费明细列表(不聚合,每条记录一行) */
-  getConsumptionDetails: (params?: { start_date?: string; end_date?: string; super_admin_name?: string; tenant_name?: string }) => {
+  /** 获取原始消费明细列表(不聚合,每条记录一行,支持分页) */
+  getConsumptionDetails: (params?: { start_date?: string; end_date?: string; super_admin_name?: string; tenant_name?: string; page?: number; page_size?: number }) => {
     const qs = new URLSearchParams();
     const qs = new URLSearchParams();
     if (params?.start_date) qs.set("start_date", params.start_date);
     if (params?.start_date) qs.set("start_date", params.start_date);
     if (params?.end_date) qs.set("end_date", params.end_date);
     if (params?.end_date) qs.set("end_date", params.end_date);
     if (params?.super_admin_name) qs.set("super_admin_name", params.super_admin_name);
     if (params?.super_admin_name) qs.set("super_admin_name", params.super_admin_name);
     if (params?.tenant_name) qs.set("tenant_name", params.tenant_name);
     if (params?.tenant_name) qs.set("tenant_name", params.tenant_name);
+    if (params?.page) qs.set("page", String(params.page));
+    if (params?.page_size) qs.set("page_size", String(params.page_size));
     const query = qs.toString();
     const query = qs.toString();
     return api.get(`/api/public/monitoring/consumption-details${query ? `?${query}` : ""}`);
     return api.get(`/api/public/monitoring/consumption-details${query ? `?${query}` : ""}`);
   },
   },