Ver código fonte

新增lincense,完善功能

lxylxy123321 2 semanas atrás
pai
commit
07003a8188
42 arquivos alterados com 2509 adições e 328 exclusões
  1. 2 1
      .gitignore
  2. 4 1
      backend/app/main.py
  3. BIN
      backend/app/models/__pycache__/domain.cpython-311.pyc
  4. BIN
      backend/app/models/__pycache__/license.cpython-311.pyc
  5. BIN
      backend/app/models/__pycache__/monitoring.cpython-311.pyc
  6. 3 1
      backend/app/models/domain.py
  7. 21 0
      backend/app/models/license.py
  8. 19 0
      backend/app/models/monitoring.py
  9. BIN
      backend/app/routers/__pycache__/domains.cpython-311.pyc
  10. BIN
      backend/app/routers/__pycache__/license.cpython-311.pyc
  11. BIN
      backend/app/routers/__pycache__/monitoring.cpython-311.pyc
  12. 60 7
      backend/app/routers/domains.py
  13. 111 0
      backend/app/routers/license.py
  14. 48 5
      backend/app/routers/monitoring.py
  15. BIN
      backend/app/schemas/__pycache__/domain.cpython-311.pyc
  16. BIN
      backend/app/schemas/__pycache__/license.cpython-311.pyc
  17. BIN
      backend/app/schemas/__pycache__/monitoring.cpython-311.pyc
  18. 4 0
      backend/app/schemas/domain.py
  19. 63 0
      backend/app/schemas/license.py
  20. 67 28
      backend/app/schemas/monitoring.py
  21. BIN
      backend/app/services/__pycache__/domain_fetch.cpython-311.pyc
  22. BIN
      backend/app/services/__pycache__/license.cpython-311.pyc
  23. BIN
      backend/app/services/__pycache__/monitoring.cpython-311.pyc
  24. 46 13
      backend/app/services/domain_fetch.py
  25. 342 0
      backend/app/services/license.py
  26. 297 99
      backend/app/services/monitoring.py
  27. 21 0
      backend/migrations/002_super_admin_model_discount.sql
  28. 14 0
      backend/migrations/003_ucd_order_fields.sql
  29. 8 0
      backend/migrations/004_super_admin_discount_fields.sql
  30. 4 0
      backend/migrations/005_user_fields.sql
  31. 17 0
      backend/migrations/006_license_table.sql
  32. 3 0
      backend/migrations/007_domain_remark.sql
  33. 6 0
      backend/migrations/008_super_admin_remark.sql
  34. 20 0
      backend/migrations/009_domain_super_admin_id.sql
  35. 13 0
      backend/migrations/010_sync_domain_remarks.sql
  36. 713 59
      frontend/src/App.tsx
  37. 6 3
      frontend/src/api/domains.ts
  38. 48 0
      frontend/src/api/license.ts
  39. 25 3
      frontend/src/api/monitoring.ts
  40. 7 0
      frontend/src/types/domain.ts
  41. 368 0
      license_api.md
  42. 149 108
      monitoring_api.md

+ 2 - 1
.gitignore

@@ -8,7 +8,8 @@ backend/app/__pycache__/
 frontend/node_modules/
 frontend/node_modules/
 frontend/dist/
 frontend/dist/
 frontend/.env
 frontend/.env
-
+# claude
+.claude
 # Editor
 # Editor
 .vscode/
 .vscode/
 .idea/
 .idea/

+ 4 - 1
backend/app/main.py

@@ -1,7 +1,8 @@
 from fastapi import FastAPI
 from fastapi import FastAPI
 from fastapi.middleware.cors import CORSMiddleware
 from fastapi.middleware.cors import CORSMiddleware
 from app.config import settings
 from app.config import settings
-from app.routers import domains, monitoring
+from app.routers import domains, monitoring, license as license_router
+from app.routers.license import public_router as public_license_router
 
 
 app = FastAPI(
 app = FastAPI(
     title="域名流水监控",
     title="域名流水监控",
@@ -21,6 +22,8 @@ app.add_middleware(
 # 注册路由
 # 注册路由
 app.include_router(domains.router)
 app.include_router(domains.router)
 app.include_router(monitoring.router)
 app.include_router(monitoring.router)
+app.include_router(license_router.router)
+app.include_router(public_license_router)
 
 
 
 
 @app.get("/health")
 @app.get("/health")

BIN
backend/app/models/__pycache__/domain.cpython-311.pyc


BIN
backend/app/models/__pycache__/license.cpython-311.pyc


BIN
backend/app/models/__pycache__/monitoring.cpython-311.pyc


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

@@ -1,4 +1,4 @@
-from sqlalchemy import Column, Integer, String, Boolean, DateTime, func
+from sqlalchemy import Column, Integer, String, Boolean, DateTime, ForeignKey, func
 from sqlalchemy.orm import declarative_base
 from sqlalchemy.orm import declarative_base
 
 
 Base = declarative_base()
 Base = declarative_base()
@@ -12,6 +12,8 @@ class MonitoredDomain(Base):
 
 
     id = Column(Integer, primary_key=True, index=True)  # 主键
     id = Column(Integer, primary_key=True, index=True)  # 主键
     domain = Column(String, unique=True, nullable=False, index=True)  # 域名
     domain = Column(String, unique=True, nullable=False, index=True)  # 域名
+    remark = Column(String(500))  # 备注
+    super_admin_id = Column(Integer)  # 关联的超管ID
     is_active = Column(Boolean, default=True)  # 是否启用
     is_active = Column(Boolean, default=True)  # 是否启用
     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())  # 更新时间

+ 21 - 0
backend/app/models/license.py

@@ -0,0 +1,21 @@
+from sqlalchemy import Column, Integer, String, Text, DateTime, Numeric, func
+from sqlalchemy.orm import declarative_base
+
+Base = declarative_base()
+
+
+class SuperAdminLicense(Base):
+    """超级管理员 License 授权表"""
+    __tablename__ = "super_admin_license"
+    __table_args__ = {"schema": "domain_monitor"}
+
+    id = Column(Integer, primary_key=True, autoincrement=True)
+    super_admin_id = Column(Integer, nullable=False, index=True)
+    license_key = Column(String(200), nullable=False)
+    expires_at = Column(DateTime(timezone=True), nullable=False, index=True)
+    status = Column(String(20), nullable=False, server_default="active", index=True)
+    max_tenants = Column(Integer)
+    max_users_per_tenant = Column(Integer)
+    remark = Column(Text)
+    created_at = Column(DateTime(timezone=True), server_default=func.now())
+    updated_at = Column(DateTime(timezone=True), onupdate=func.now())

+ 19 - 0
backend/app/models/monitoring.py

@@ -12,6 +12,7 @@ class SuperAdmin(Base):
     id = Column(Integer, primary_key=True, autoincrement=True)
     id = Column(Integer, primary_key=True, autoincrement=True)
     username = Column(String, nullable=False)
     username = Column(String, nullable=False)
     nickname = Column(String)
     nickname = Column(String)
+    remark = Column(String)  # 域名备注,作为超管显示名称
     created_at = Column(DateTime(timezone=True), server_default=func.now())
     created_at = Column(DateTime(timezone=True), server_default=func.now())
 
 
 
 
@@ -49,6 +50,18 @@ class Model(Base):
     original_price = Column(Numeric(20, 4))
     original_price = Column(Numeric(20, 4))
 
 
 
 
+class SuperAdminModelDiscount(Base):
+    """超级管理员模型折扣表(从 crawler 同步)"""
+    __tablename__ = "super_admin_model_discount"
+    __table_args__ = {"schema": "domain_monitor"}
+
+    id = Column(Integer, primary_key=True, autoincrement=True)
+    model_code = Column(String, unique=True, nullable=False, index=True)
+    discount_rate = Column(Numeric(10, 4), server_default="1.0000", nullable=False)
+    created_at = Column(DateTime(timezone=True), server_default=func.now())
+    updated_at = Column(DateTime(timezone=True), onupdate=func.now())
+
+
 class UserConsumptionDetail(Base):
 class UserConsumptionDetail(Base):
     """用户模型消费明细表(每次调用的记录)"""
     """用户模型消费明细表(每次调用的记录)"""
     __tablename__ = "user_consumption_detail"
     __tablename__ = "user_consumption_detail"
@@ -56,6 +69,7 @@ class UserConsumptionDetail(Base):
 
 
     id = Column(Integer, primary_key=True, autoincrement=True)
     id = Column(Integer, primary_key=True, autoincrement=True)
     user_id = Column(String, nullable=False, index=True)
     user_id = Column(String, nullable=False, index=True)
+    username = Column(String)
     tenant_id = Column(Integer, nullable=False, index=True)
     tenant_id = Column(Integer, nullable=False, index=True)
     model_code = Column(String, nullable=False, index=True)
     model_code = Column(String, nullable=False, index=True)
     call_count = Column(Integer, server_default="0")
     call_count = Column(Integer, server_default="0")
@@ -67,6 +81,11 @@ class UserConsumptionDetail(Base):
     tenant_actual_total = Column(Numeric(20, 4), server_default="0")
     tenant_actual_total = Column(Numeric(20, 4), server_default="0")
     tenant_discount = Column(Numeric(10, 4), server_default="1.0000")
     tenant_discount = Column(Numeric(10, 4), server_default="1.0000")
     tenant_actual_price = Column(Numeric(20, 4))
     tenant_actual_price = Column(Numeric(20, 4))
+    # 超管侧:平台向超管收取
+    super_admin_discount = Column(Numeric(10, 4), server_default="1.0000")
+    super_admin_actual_price = Column(Numeric(20, 4))
     original_price = Column(Numeric(20, 4))
     original_price = Column(Numeric(20, 4))
     consumption_date = Column(DateTime(timezone=True), nullable=False, index=True)
     consumption_date = Column(DateTime(timezone=True), nullable=False, index=True)
+    order_no = Column(String)
+    invoiced = Column(Boolean, server_default="false")
     created_at = Column(DateTime(timezone=True), server_default=func.now())
     created_at = Column(DateTime(timezone=True), server_default=func.now())

BIN
backend/app/routers/__pycache__/domains.cpython-311.pyc


BIN
backend/app/routers/__pycache__/license.cpython-311.pyc


BIN
backend/app/routers/__pycache__/monitoring.cpython-311.pyc


+ 60 - 7
backend/app/routers/domains.py

@@ -1,8 +1,10 @@
-from fastapi import APIRouter, Depends, HTTPException
-from sqlalchemy import select
+from fastapi import APIRouter, Depends, HTTPException, Query
+from pydantic import BaseModel
+from sqlalchemy import select, func
 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.schemas.domain import (
 from app.schemas.domain import (
     MonitoredDomainCreate,
     MonitoredDomainCreate,
     MonitoredDomainResponse,
     MonitoredDomainResponse,
@@ -17,14 +19,29 @@ async def add_domain(
     payload: MonitoredDomainCreate,
     payload: MonitoredDomainCreate,
     db: AsyncSession = Depends(get_db),
     db: AsyncSession = Depends(get_db),
 ):
 ):
-    """添加需要监控的域名"""
+    """添加需要监控的域名,同时创建对应的超管记录"""
     existing = await db.execute(
     existing = await db.execute(
         select(MonitoredDomain).where(MonitoredDomain.domain == payload.domain)
         select(MonitoredDomain).where(MonitoredDomain.domain == payload.domain)
     )
     )
     if existing.scalar_one_or_none():
     if existing.scalar_one_or_none():
         raise HTTPException(status_code=409, detail="域名已在监控中")
         raise HTTPException(status_code=409, detail="域名已在监控中")
 
 
-    record = MonitoredDomain(domain=payload.domain)
+    # 如果未指定超管,自动创建一条超管记录
+    sa_id = payload.super_admin_id
+    if sa_id is None:
+        max_id_result = await db.execute(select(func.max(SuperAdmin.id)))
+        max_id = max_id_result.scalar() or 0
+        new_sa = SuperAdmin(
+            id=max_id + 1,
+            username=payload.domain,
+            nickname=payload.domain,
+            remark=payload.remark or None,
+        )
+        db.add(new_sa)
+        await db.flush()
+        sa_id = new_sa.id
+
+    record = MonitoredDomain(domain=payload.domain, remark=payload.remark or None, super_admin_id=sa_id)
     db.add(record)
     db.add(record)
     await db.commit()
     await db.commit()
     await db.refresh(record)
     await db.refresh(record)
@@ -38,6 +55,38 @@ async def list_domains(db: AsyncSession = Depends(get_db)):
     return result.scalars().all()
     return result.scalars().all()
 
 
 
 
+class MonitoredDomainUpdate(BaseModel):
+    """更新域名备注"""
+    remark: str = ""
+
+
+@router.patch("/{domain_id}", response_model=MonitoredDomainResponse)
+async def update_domain_remark(
+    domain_id: int,
+    payload: MonitoredDomainUpdate,
+    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="域名不存在")
+    record.remark = payload.remark or None
+    # 同步到关联的超管
+    if record.super_admin_id:
+        sa_result = await db.execute(
+            select(SuperAdmin).where(SuperAdmin.id == record.super_admin_id)
+        )
+        sa = sa_result.scalar_one_or_none()
+        if sa:
+            sa.remark = payload.remark or None
+    await db.commit()
+    await db.refresh(record)
+    return record
+
+
 @router.delete("/{domain_id}", status_code=204)
 @router.delete("/{domain_id}", status_code=204)
 async def remove_domain(domain_id: int, db: AsyncSession = Depends(get_db)):
 async def remove_domain(domain_id: int, db: AsyncSession = Depends(get_db)):
     """移除指定 ID 的监控域名"""
     """移除指定 ID 的监控域名"""
@@ -54,6 +103,7 @@ async def remove_domain(domain_id: int, db: AsyncSession = Depends(get_db)):
 @router.get("/{domain_id}/transactions")
 @router.get("/{domain_id}/transactions")
 async def get_domain_transactions(
 async def get_domain_transactions(
     domain_id: int,
     domain_id: int,
+    fetch_date: str | None = Query(None, description="爬取指定日期,格式 YYYY-MM-DD,不传则查全部"),
     db: AsyncSession = Depends(get_db),
     db: AsyncSession = Depends(get_db),
 ):
 ):
     """爬取指定域名的监控数据并入库"""
     """爬取指定域名的监控数据并入库"""
@@ -64,12 +114,15 @@ 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)
+    data = await fetch_domain_transactions(record.domain, db, fetch_date=fetch_date)
     return {"status": "ok", "domain": record.domain, "data": data}
     return {"status": "ok", "domain": record.domain, "data": data}
 
 
 
 
 @router.post("/fetch-all", status_code=202)
 @router.post("/fetch-all", status_code=202)
-async def fetch_all_transactions(db: AsyncSession = Depends(get_db)):
+async def fetch_all_transactions(
+    fetch_date: str | None = Query(None, description="爬取指定日期,格式 YYYY-MM-DD,不传则查全部"),
+    db: AsyncSession = Depends(get_db),
+):
     """批量爬取所有已启用域名的监控数据"""
     """批量爬取所有已启用域名的监控数据"""
     result = await db.execute(
     result = await db.execute(
         select(MonitoredDomain).where(MonitoredDomain.is_active == True)
         select(MonitoredDomain).where(MonitoredDomain.is_active == True)
@@ -78,7 +131,7 @@ async def fetch_all_transactions(db: AsyncSession = Depends(get_db)):
     errors = []
     errors = []
     for d in domains:
     for d in domains:
         try:
         try:
-            await fetch_domain_transactions(d.domain, db)
+            await fetch_domain_transactions(d.domain, db, fetch_date=fetch_date)
         except Exception as e:
         except Exception as e:
             errors.append({"domain": d.domain, "error": str(e)})
             errors.append({"domain": d.domain, "error": str(e)})
     return {"status": "ok", "total": len(domains), "errors": errors}
     return {"status": "ok", "total": len(domains), "errors": errors}

+ 111 - 0
backend/app/routers/license.py

@@ -0,0 +1,111 @@
+from fastapi import APIRouter, Depends, Header, HTTPException, Query
+from sqlalchemy.ext.asyncio import AsyncSession
+from app.database import get_db
+from app.services.license import (
+    get_super_admins,
+    create_license,
+    list_licenses,
+    get_license_status,
+    revoke_license,
+    restore_license,
+    update_license,
+    delete_license,
+    check_license_by_referer,
+)
+from app.schemas.license import LicenseCreate, LicenseUpdate
+
+router = APIRouter(prefix="/api/license", tags=["License许可"])
+
+# 公开接口,不带 /api/license 前缀,放在 /api/public 下
+public_router = APIRouter(prefix="/api/public", tags=["License公开接口"])
+
+
+@router.get("/super-admins", summary="超级管理员列表(下拉选项)")
+async def handle_get_super_admins(
+    db: AsyncSession = Depends(get_db),
+):
+    return await get_super_admins(db)
+
+
+@router.post("/", summary="创建/更新 License")
+async def handle_create_license(
+    payload: LicenseCreate,
+    db: AsyncSession = Depends(get_db),
+):
+    return await create_license(db, payload)
+
+
+@router.get("/list", summary="License 列表")
+async def handle_list_licenses(
+    super_admin_id: int | None = Query(None),
+    status: str | None = Query(None),
+    page: int = Query(1, ge=1),
+    size: int = Query(20, ge=1, le=100),
+    db: AsyncSession = Depends(get_db),
+):
+    return await list_licenses(db, super_admin_id, status, page, size)
+
+
+@router.get("/{license_id}", summary="License 状态详情")
+async def handle_get_license(
+    license_id: int,
+    db: AsyncSession = Depends(get_db),
+):
+    try:
+        return await get_license_status(db, license_id)
+    except ValueError as e:
+        raise HTTPException(status_code=404, detail=str(e))
+
+
+@router.put("/{license_id}", summary="更新 License(key 或过期时间)")
+async def handle_update_license(
+    license_id: int,
+    payload: LicenseUpdate,
+    db: AsyncSession = Depends(get_db),
+):
+    try:
+        return await update_license(db, license_id, payload)
+    except ValueError as e:
+        raise HTTPException(status_code=400, detail=str(e))
+
+
+@router.post("/{license_id}/revoke", summary="吊销 License")
+async def handle_revoke_license(
+    license_id: int,
+    db: AsyncSession = Depends(get_db),
+):
+    try:
+        return await revoke_license(db, license_id)
+    except ValueError as e:
+        raise HTTPException(status_code=404, detail=str(e))
+
+
+@router.post("/{license_id}/restore", summary="恢复已吊销的 License")
+async def handle_restore_license(
+    license_id: int,
+    db: AsyncSession = Depends(get_db),
+):
+    try:
+        return await restore_license(db, license_id)
+    except ValueError as e:
+        raise HTTPException(status_code=400, detail=str(e))
+
+
+@router.delete("/{license_id}", summary="删除 License")
+async def handle_delete_license(
+    license_id: int,
+    db: AsyncSession = Depends(get_db),
+):
+    try:
+        return await delete_license(db, license_id)
+    except ValueError as e:
+        raise HTTPException(status_code=404, detail=str(e))
+
+
+@public_router.get("/license/check", summary="公开 License 校验接口(通过 Referer 匹配域名)")
+async def handle_license_check(
+    referer: str | None = Header(None),
+    db: AsyncSession = Depends(get_db),
+):
+    """第三方系统通过请求携带的 Referer 头,匹配对应的监控域名,检查 License 状态。"""
+    return await check_license_by_referer(db, referer)

+ 48 - 5
backend/app/routers/monitoring.py

@@ -1,7 +1,7 @@
 from fastapi import APIRouter, Depends, Query
 from fastapi import APIRouter, Depends, Query
 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.services.monitoring import get_dashboard
+from app.services.monitoring import get_dashboard, get_consumption_details, get_daily_stats
 
 
 router = APIRouter(prefix="/api/public/monitoring", tags=["监控大屏"])
 router = APIRouter(prefix="/api/public/monitoring", tags=["监控大屏"])
 
 
@@ -9,19 +9,62 @@ router = APIRouter(prefix="/api/public/monitoring", tags=["监控大屏"])
 @router.get("/dashboard")
 @router.get("/dashboard")
 async def get_monitoring_dashboard(
 async def get_monitoring_dashboard(
     db: AsyncSession = Depends(get_db),
     db: AsyncSession = Depends(get_db),
-    start_date: str | None = Query(None, description="查询开始日期,格式 YYYY-MM-DD"),
-    end_date: str | None = Query(None, description="查询结束日期,格式 YYYY-MM-DD"),
-    super_admin_id: int | None = Query(None, description="指定超级管理员ID"),
+    start_date: str | None = Query(None, description="查询开始日期,格式 YYYY-MM-DD,不传则查全部"),
+    end_date: str | None = Query(None, description="查询结束日期,格式 YYYY-MM-DD,不传则查全部"),
+    super_admin_id: int | None = Query(None, description="指定某个超级管理员ID,不传则查全部"),
+    super_admin_name: str | None = Query(None, description="按超级管理员名称模糊匹配,不传则查全部"),
 ):
 ):
     """
     """
     获取监控大屏数据
     获取监控大屏数据
 
 
     层级结构:平台 → 超级管理员 → 租户 → 用户
     层级结构:平台 → 超级管理员 → 租户 → 用户
-    每个节点包含消费金额、折扣信息、模型维度明细。
     """
     """
     return await get_dashboard(
     return await get_dashboard(
         db,
         db,
         start_date=start_date,
         start_date=start_date,
         end_date=end_date,
         end_date=end_date,
         super_admin_id=super_admin_id,
         super_admin_id=super_admin_id,
+        super_admin_name=super_admin_name,
+    )
+
+
+@router.get("/consumption-details")
+async def list_consumption_details(
+    db: AsyncSession = Depends(get_db),
+    start_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="按超级管理员名称筛选"),
+    tenant_name: str | None = Query(None, description="按租户名称筛选"),
+):
+    """
+    获取原始消费明细列表(每条记录一行,不聚合)
+    用于对账场景
+    """
+    return await get_consumption_details(
+        db,
+        start_date=start_date,
+        end_date=end_date,
+        super_admin_name=super_admin_name,
+        tenant_name=tenant_name,
+    )
+
+
+@router.get("/daily-stats")
+async def get_daily_consumption_stats(
+    db: AsyncSession = Depends(get_db),
+    start_date: str = Query(..., description="开始日期,格式 YYYY-MM-DD"),
+    end_date: str = Query(..., description="结束日期,格式 YYYY-MM-DD"),
+    super_admin_name: str | None = Query(None, description="按超级管理员名称筛选"),
+    tenant_name: str | None = Query(None, description="按租户名称筛选"),
+):
+    """
+    获取按日聚合的消费统计
+    当筛选了日期范围时,展示每日的消费汇总(超级管理员 → 租户 → 每日金额)
+    """
+    return await get_daily_stats(
+        db,
+        start_date=start_date,
+        end_date=end_date,
+        super_admin_name=super_admin_name,
+        tenant_name=tenant_name,
     )
     )

BIN
backend/app/schemas/__pycache__/domain.cpython-311.pyc


BIN
backend/app/schemas/__pycache__/license.cpython-311.pyc


BIN
backend/app/schemas/__pycache__/monitoring.cpython-311.pyc


+ 4 - 0
backend/app/schemas/domain.py

@@ -5,12 +5,16 @@ from datetime import datetime
 class MonitoredDomainCreate(BaseModel):
 class MonitoredDomainCreate(BaseModel):
     """添加监控域名的请求体"""
     """添加监控域名的请求体"""
     domain: str
     domain: str
+    remark: str = ""
+    super_admin_id: int | None = None
 
 
 
 
 class MonitoredDomainResponse(BaseModel):
 class MonitoredDomainResponse(BaseModel):
     """监控域名响应体"""
     """监控域名响应体"""
     id: int
     id: int
     domain: str
     domain: str
+    remark: str | None = None
+    super_admin_id: int | None = None
     is_active: bool
     is_active: bool
     created_at: datetime | None
     created_at: datetime | None
 
 

+ 63 - 0
backend/app/schemas/license.py

@@ -0,0 +1,63 @@
+from pydantic import BaseModel
+from typing import Optional
+from datetime import datetime
+
+
+class LicenseCreate(BaseModel):
+    """创建/更新 License 请求"""
+    super_admin_id: int
+    license_key: str
+    expires_at: str  # ISO 8601 string
+    max_tenants: Optional[int] = None
+    max_users_per_tenant: Optional[int] = None
+    remark: Optional[str] = None
+
+
+class LicenseResponse(BaseModel):
+    """单个 License 响应"""
+    id: int
+    super_admin_id: int
+    super_admin_name: str
+    license_key: str
+    expires_at: str
+    status: str
+    max_tenants: Optional[int] = None
+    max_users_per_tenant: Optional[int] = None
+    remark: Optional[str] = None
+    created_at: Optional[str] = None
+    updated_at: Optional[str] = None
+    domain: Optional[str] = None
+
+
+class LicenseStatusResponse(BaseModel):
+    """License 状态查询响应"""
+    id: int
+    super_admin_id: int
+    super_admin_name: str
+    license_key: str
+    expires_at: str
+    status: str
+    days_left: int
+    max_tenants: Optional[int] = None
+    max_users_per_tenant: Optional[int] = None
+    remark: Optional[str] = None
+
+
+class SuperAdminOption(BaseModel):
+    """超级管理员下拉选项"""
+    id: int
+    username: str
+    nickname: Optional[str] = None
+    remark: Optional[str] = None
+
+
+class LicenseListResponse(BaseModel):
+    """License 列表响应"""
+    total: int
+    items: list[LicenseResponse]
+
+
+class LicenseUpdate(BaseModel):
+    """更新 License 请求(仅更新指定字段)"""
+    license_key: Optional[str] = None
+    expires_at: Optional[str] = None

+ 67 - 28
backend/app/schemas/monitoring.py

@@ -1,19 +1,25 @@
 from pydantic import BaseModel
 from pydantic import BaseModel
 from typing import Optional
 from typing import Optional
-from decimal import Decimal
+from datetime import datetime
 
 
 
 
-class ModelDetail(BaseModel):
-    """模型维度消费明细"""
-    model_code: str
+class ConsumptionRecord(BaseModel):
+    """单条消费记录流水"""
+    user_id: str
+    username: str
+    tenant_name: Optional[str] = None
+    order_no: str
     model_name: str
     model_name: str
-    original_price: str
-    tenant_discount: str
-    tenant_actual_price: str
+    model_code: str
+    amount: str
+    created_at: str
+    invoiced: bool
     user_discount: str
     user_discount: str
     user_actual_price: str
     user_actual_price: str
-    total_amount: str
-    call_count: int
+    tenant_discount: str
+    tenant_actual_price: str
+    super_admin_discount: str
+    super_admin_actual_price: str
 
 
 
 
 class UserConsumption(BaseModel):
 class UserConsumption(BaseModel):
@@ -21,22 +27,10 @@ class UserConsumption(BaseModel):
     user_id: str
     user_id: str
     username: str
     username: str
     nickname: Optional[str] = None
     nickname: Optional[str] = None
-    total_consumption: str  # 用户实际支付的总额
-    tenant_actual_total: str  # 平台向企业收取的该用户总额
-    model_details: list[ModelDetail]
-
-
-class TenantModelSummary(BaseModel):
-    """租户级模型消费汇总"""
-    model_code: str
-    model_name: str
-    original_price: str
-    tenant_discount: str
-    tenant_actual_price: str
-    user_discount: str
-    user_actual_price: str
-    total_amount: str
-    call_count: int
+    total_consumption: str
+    tenant_actual_total: str
+    tenant_name: Optional[str] = None
+    consumption_records: list[ConsumptionRecord]
 
 
 
 
 class TenantData(BaseModel):
 class TenantData(BaseModel):
@@ -44,12 +38,11 @@ class TenantData(BaseModel):
     tenant_id: int
     tenant_id: int
     company_name: Optional[str] = None
     company_name: Optional[str] = None
     subdomain: str
     subdomain: str
-    total_consumption: str  # 该租户所有用户消费之和
-    total_tenant_charged: str  # 平台向该租户收取的总额
+    total_consumption: str
+    total_tenant_charged: str
     balance: str
     balance: str
     user_count: int
     user_count: int
     users: list[UserConsumption]
     users: list[UserConsumption]
-    model_summary: list[TenantModelSummary]
 
 
 
 
 class SuperAdminData(BaseModel):
 class SuperAdminData(BaseModel):
@@ -57,6 +50,7 @@ class SuperAdminData(BaseModel):
     super_admin_id: int
     super_admin_id: int
     username: str
     username: str
     nickname: Optional[str] = None
     nickname: Optional[str] = None
+    remark: Optional[str] = None
     tenant_count: int
     tenant_count: int
     total_consumption: str
     total_consumption: str
     total_tenant_charged: str
     total_tenant_charged: str
@@ -73,6 +67,51 @@ class Overview(BaseModel):
     total_balance: str
     total_balance: str
 
 
 
 
+class ConsumptionDetailRecord(BaseModel):
+    """单条消费明细记录(不聚合)"""
+    user_id: str
+    user_name: str
+    tenant_name: str
+    order_no: str
+    model_code: str
+    consumption_date: str     # 消费时间
+    tenant_consumed: str      # 企业实际支付金额
+    user_discount: str        # 用户折扣率
+    user_consumed: str        # 用户实际支付金额
+    tenant_discount: str      # 企业折扣率
+    tenant_actual_price: str  # 企业实际被收取金额
+    super_admin_discount: str # 超管折扣率
+    super_admin_actual_price: str  # 平台向超管收取金额
+
+
+class DailyTenantStat(BaseModel):
+    """租户按日消费统计"""
+    tenant_name: str
+    date: str
+    consumption: str
+    charged: str
+
+
+class DailySAStat(BaseModel):
+    """超级管理员按日消费统计"""
+    sa_name: str
+    date: str
+    consumption: str
+    charged: str
+    tenants: list[DailyTenantStat]
+
+
+class DailyStatsResponse(BaseModel):
+    """按日消费统计响应"""
+    sa_stats: list[DailySAStat]
+
+
+class ConsumptionDetailResponse(BaseModel):
+    """消费明细列表响应"""
+    total: int
+    records: list[ConsumptionDetailRecord]
+
+
 class DashboardResponse(BaseModel):
 class DashboardResponse(BaseModel):
     """监控大屏响应"""
     """监控大屏响应"""
     overview: Overview
     overview: Overview

BIN
backend/app/services/__pycache__/domain_fetch.cpython-311.pyc


BIN
backend/app/services/__pycache__/license.cpython-311.pyc


BIN
backend/app/services/__pycache__/monitoring.cpython-311.pyc


+ 46 - 13
backend/app/services/domain_fetch.py

@@ -12,17 +12,23 @@ from app.models.monitoring import (
 from datetime import datetime
 from datetime import datetime
 
 
 
 
-async def fetch_domain_transactions(domain: str, db: AsyncSession) -> dict:
+async def fetch_domain_transactions(domain: str, db: AsyncSession, fetch_date: str | None = None) -> dict:
     """
     """
     爬取指定域名的监控大屏数据
     爬取指定域名的监控大屏数据
 
 
     请求地址: {domain}/api/public/monitoring/dashboard
     请求地址: {domain}/api/public/monitoring/dashboard
     先尝试 HTTPS,失败后自动降级到 HTTP
     先尝试 HTTPS,失败后自动降级到 HTTP
+    fetch_date: 指定爬取日期(YYYY-MM-DD),不传则查全部
     爬取后将数据存入本地数据库
     爬取后将数据存入本地数据库
     """
     """
+    qs = []
+    if fetch_date:
+        qs.append(f"start_date={fetch_date}&end_date={fetch_date}")
+    query_str = ("?" + "&".join(qs)) if qs else ""
+
     async with httpx.AsyncClient(timeout=30, verify=False) as client:
     async with httpx.AsyncClient(timeout=30, verify=False) as client:
         # 先尝试 HTTPS
         # 先尝试 HTTPS
-        url = f"https://{domain}/api/public/monitoring/dashboard"
+        url = f"https://{domain}/api/public/monitoring/dashboard{query_str}"
         try:
         try:
             resp = await client.get(url)
             resp = await client.get(url)
             resp.raise_for_status()
             resp.raise_for_status()
@@ -31,22 +37,29 @@ async def fetch_domain_transactions(domain: str, db: AsyncSession) -> dict:
             raise
             raise
         except Exception:
         except Exception:
             # HTTPS 连接失败(SSL/网络错误),降级到 HTTP
             # HTTPS 连接失败(SSL/网络错误),降级到 HTTP
-            url = f"http://{domain}/api/public/monitoring/dashboard"
+            url = f"http://{domain}/api/public/monitoring/dashboard{query_str}"
             resp = await client.get(url)
             resp = await client.get(url)
             resp.raise_for_status()
             resp.raise_for_status()
         data = resp.json()
         data = resp.json()
 
 
     # 将爬取到的数据存入本地数据库
     # 将爬取到的数据存入本地数据库
-    await _save_dashboard_data(domain, data, db)
+    await _save_dashboard_data(domain, data, db, fetch_date=fetch_date)
     return data
     return data
 
 
 
 
-async def _save_dashboard_data(domain: str, data: dict, db: AsyncSession):
+async def _save_dashboard_data(domain: str, data: dict, db: AsyncSession, fetch_date: str | None = None):
     """
     """
     将监控大屏数据存入本地数据库
     将监控大屏数据存入本地数据库
 
 
     数据包含超级管理员 → 租户 → 用户 → 模型消费明细
     数据包含超级管理员 → 租户 → 用户 → 模型消费明细
     """
     """
+    # 获取域名备注
+    domain_result = await db.execute(
+        select(MonitoredDomain).where(MonitoredDomain.domain == domain)
+    )
+    domain_record = domain_result.scalar_one_or_none()
+    domain_remark = domain_record.remark if domain_record else None
+
     super_admins = data.get("super_admins", [])
     super_admins = data.get("super_admins", [])
     for sa_data in super_admins:
     for sa_data in super_admins:
         sa_id = sa_data.get("super_admin_id")
         sa_id = sa_data.get("super_admin_id")
@@ -63,11 +76,20 @@ async def _save_dashboard_data(domain: str, data: dict, db: AsyncSession):
                 id=sa_id,
                 id=sa_id,
                 username=sa_data.get("username") or f"unassigned_{sa_id}",
                 username=sa_data.get("username") or f"unassigned_{sa_id}",
                 nickname=sa_data.get("nickname"),
                 nickname=sa_data.get("nickname"),
+                remark=domain_remark,
             )
             )
             db.add(sa)
             db.add(sa)
         else:
         else:
             sa.username = sa_data.get("username") or sa.username
             sa.username = sa_data.get("username") or sa.username
             sa.nickname = sa_data.get("nickname") or sa.nickname
             sa.nickname = sa_data.get("nickname") or sa.nickname
+            # 始终用域名备注同步到超管备注
+            if domain_remark:
+                sa.remark = domain_remark
+
+        # 同步 super_admin_id 到域名记录
+        if domain_record:
+            domain_record.super_admin_id = sa_id
+            await db.flush()
 
 
         # 2. 遍历租户
         # 2. 遍历租户
         for t_data in sa_data.get("tenants", []):
         for t_data in sa_data.get("tenants", []):
@@ -109,8 +131,8 @@ async def _save_dashboard_data(domain: str, data: dict, db: AsyncSession):
                 if not u_id:
                 if not u_id:
                     continue
                     continue
 
 
-                # 4. 遍历模型消费明细
-                for m_data in u_data.get("model_details", []):
+                # 4. 遍历消费明细(远程 API 返回字段为 consumption_records)
+                for m_data in u_data.get("consumption_records", []):
                     m_code = m_data.get("model_code")
                     m_code = m_data.get("model_code")
                     if not m_code:
                     if not m_code:
                         continue
                         continue
@@ -124,24 +146,35 @@ async def _save_dashboard_data(domain: str, data: dict, db: AsyncSession):
                         model = Model(
                         model = Model(
                             model_code=m_code,
                             model_code=m_code,
                             model_name=m_data.get("model_name", ""),
                             model_name=m_data.get("model_name", ""),
-                            original_price=m_data.get("original_price"),
+                            original_price=None,
                         )
                         )
                         db.add(model)
                         db.add(model)
 
 
                     # 保存消费明细
                     # 保存消费明细
-                    consumption_date = datetime.now()
+                    if fetch_date:
+                        consumption_date = datetime.strptime(fetch_date, "%Y-%m-%d")
+                    else:
+                        created_at_str = m_data.get("created_at", "")
+                        if created_at_str:
+                            consumption_date = datetime.fromisoformat(created_at_str)
+                        else:
+                            consumption_date = datetime.now()
                     db.add(UserConsumptionDetail(
                     db.add(UserConsumptionDetail(
                         user_id=u_id,
                         user_id=u_id,
+                        username=u_data.get("username"),
                         tenant_id=t_id,
                         tenant_id=t_id,
                         model_code=m_code,
                         model_code=m_code,
-                        call_count=m_data.get("call_count", 0),
-                        user_actual_total=m_data.get("total_amount"),
+                        call_count=1,
+                        order_no=m_data.get("order_no"),
+                        user_actual_total=m_data.get("amount"),
                         user_discount=m_data.get("user_discount"),
                         user_discount=m_data.get("user_discount"),
                         user_actual_price=m_data.get("user_actual_price"),
                         user_actual_price=m_data.get("user_actual_price"),
-                        tenant_actual_total=m_data.get("total_amount"),
+                        tenant_actual_total=m_data.get("tenant_actual_price"),
                         tenant_discount=m_data.get("tenant_discount"),
                         tenant_discount=m_data.get("tenant_discount"),
                         tenant_actual_price=m_data.get("tenant_actual_price"),
                         tenant_actual_price=m_data.get("tenant_actual_price"),
-                        original_price=m_data.get("original_price"),
+                        super_admin_discount=m_data.get("super_admin_discount"),
+                        super_admin_actual_price=m_data.get("super_admin_actual_price"),
+                        original_price=None,
                         consumption_date=consumption_date,
                         consumption_date=consumption_date,
                     ))
                     ))
 
 

+ 342 - 0
backend/app/services/license.py

@@ -0,0 +1,342 @@
+import math
+from datetime import datetime, timezone
+from urllib.parse import urlparse
+
+from sqlalchemy import select
+from sqlalchemy.ext.asyncio import AsyncSession
+from app.models.license import SuperAdminLicense
+from app.models.monitoring import SuperAdmin
+from app.models.domain import MonitoredDomain
+from app.schemas.license import (
+    LicenseCreate,
+    LicenseResponse,
+    LicenseStatusResponse,
+    LicenseUpdate,
+    SuperAdminOption,
+    LicenseListResponse,
+)
+
+
+def _to_str(val) -> str:
+    if val is None:
+        return ""
+    if isinstance(val, datetime):
+        return val.isoformat()
+    return str(val)
+
+
+def _calc_days_left(expires_at: datetime) -> int:
+    now = datetime.now(timezone.utc) if expires_at.tzinfo else datetime.now()
+    delta = expires_at - now
+    return math.ceil(delta.total_seconds() / (60 * 60 * 24))
+
+
+async def get_super_admins(db: AsyncSession) -> list[SuperAdminOption]:
+    """获取所有超级管理员,供下拉选择"""
+    result = await db.execute(select(SuperAdmin).order_by(SuperAdmin.id))
+    sas = result.scalars().all()
+    return [SuperAdminOption(id=sa.id, username=sa.username, nickname=sa.nickname, remark=sa.remark) for sa in sas]
+
+
+async def create_license(
+    db: AsyncSession, payload: LicenseCreate
+) -> dict:
+    """创建或更新 License。同一超管只保留一个 active 的 License,已存在则更新。"""
+    existing = await db.execute(
+        select(SuperAdminLicense).where(
+            SuperAdminLicense.super_admin_id == payload.super_admin_id,
+            SuperAdminLicense.status == "active",
+        )
+    )
+    exist_lic = existing.scalar_one_or_none()
+
+    expires_at = datetime.fromisoformat(payload.expires_at)
+
+    if exist_lic:
+        exist_lic.license_key = payload.license_key
+        exist_lic.expires_at = expires_at
+        exist_lic.max_tenants = payload.max_tenants
+        exist_lic.max_users_per_tenant = payload.max_users_per_tenant
+        exist_lic.remark = payload.remark
+        await db.flush()
+        await db.commit()
+        return {"message": "License已更新", "license_id": exist_lic.id}
+
+    new_lic = SuperAdminLicense(
+        super_admin_id=payload.super_admin_id,
+        license_key=payload.license_key,
+        expires_at=expires_at,
+        status="active",
+        max_tenants=payload.max_tenants,
+        max_users_per_tenant=payload.max_users_per_tenant,
+        remark=payload.remark,
+    )
+    db.add(new_lic)
+    await db.commit()
+    await db.refresh(new_lic)
+    return {"message": "License已创建", "license_id": new_lic.id}
+
+
+async def list_licenses(
+    db: AsyncSession,
+    super_admin_id: int | None = None,
+    status: str | None = None,
+    page: int = 1,
+    size: int = 20,
+) -> LicenseListResponse:
+    """查询 License 列表"""
+    stmt = select(SuperAdminLicense)
+    if super_admin_id is not None:
+        stmt = stmt.where(SuperAdminLicense.super_admin_id == super_admin_id)
+    if status:
+        stmt = stmt.where(SuperAdminLicense.status == status)
+    stmt = stmt.order_by(SuperAdminLicense.created_at.desc())
+
+    total_result = await db.execute(stmt)
+    all_rows = total_result.scalars().all()
+    total = len(all_rows)
+
+    offset = (page - 1) * size
+    paged = all_rows[offset: offset + size]
+
+    items = []
+    for r in paged:
+        sa_result = await db.execute(
+            select(SuperAdmin).where(SuperAdmin.id == r.super_admin_id)
+        )
+        sa = sa_result.scalar_one_or_none()
+        sa_name = sa.remark or sa.username if sa else str(r.super_admin_id)
+
+        # 查询关联的域名
+        domain_result = await db.execute(
+            select(MonitoredDomain.domain).where(
+                MonitoredDomain.super_admin_id == r.super_admin_id,
+                MonitoredDomain.is_active == True,
+            ).limit(1)
+        )
+        domain_name = domain_result.scalar_one_or_none()
+
+        items.append(LicenseResponse(
+            id=r.id,
+            super_admin_id=r.super_admin_id,
+            super_admin_name=sa_name,
+            license_key=r.license_key,
+            expires_at=_to_str(r.expires_at),
+            status=r.status,
+            max_tenants=r.max_tenants,
+            max_users_per_tenant=r.max_users_per_tenant,
+            remark=r.remark,
+            created_at=_to_str(r.created_at),
+            updated_at=_to_str(r.updated_at),
+            domain=domain_name,
+        ))
+
+    return LicenseListResponse(total=total, items=items)
+
+
+async def get_license_status(
+    db: AsyncSession, license_id: int
+) -> LicenseStatusResponse:
+    """获取单个 License 的详细状态"""
+    result = await db.execute(
+        select(SuperAdminLicense).where(SuperAdminLicense.id == license_id)
+    )
+    lic = result.scalar_one_or_none()
+    if not lic:
+        raise ValueError("License 不存在")
+
+    days_left = _calc_days_left(lic.expires_at)
+    if days_left <= 0:
+        lic.status = "expired"
+        await db.commit()
+
+    sa_result = await db.execute(
+        select(SuperAdmin).where(SuperAdmin.id == lic.super_admin_id)
+    )
+    sa = sa_result.scalar_one_or_none()
+
+    return LicenseStatusResponse(
+        id=lic.id,
+        super_admin_id=lic.super_admin_id,
+        super_admin_name=sa.remark or sa.username if sa else str(lic.super_admin_id),
+        license_key=lic.license_key,
+        expires_at=_to_str(lic.expires_at),
+        status=lic.status,
+        days_left=days_left,
+        max_tenants=lic.max_tenants,
+        max_users_per_tenant=lic.max_users_per_tenant,
+        remark=lic.remark,
+    )
+
+
+async def revoke_license(
+    db: AsyncSession, license_id: int
+) -> dict:
+    """吊销 License"""
+    result = await db.execute(
+        select(SuperAdminLicense).where(SuperAdminLicense.id == license_id)
+    )
+    lic = result.scalar_one_or_none()
+    if not lic:
+        raise ValueError("License 不存在")
+    lic.status = "revoked"
+    await db.commit()
+    return {"message": "License已吊销"}
+
+
+async def update_license(
+    db: AsyncSession, license_id: int, payload: LicenseUpdate
+) -> dict:
+    """更新 License 的 key 或过期时间"""
+    result = await db.execute(
+        select(SuperAdminLicense).where(SuperAdminLicense.id == license_id)
+    )
+    lic = result.scalar_one_or_none()
+    if not lic:
+        raise ValueError("License 不存在")
+    changed = False
+    if payload.license_key is not None:
+        lic.license_key = payload.license_key
+        changed = True
+    if payload.expires_at is not None:
+        lic.expires_at = datetime.fromisoformat(payload.expires_at)
+        if _calc_days_left(lic.expires_at) > 0:
+            lic.status = "active"
+        else:
+            lic.status = "expired"
+        changed = True
+    if not changed:
+        raise ValueError("没有需要更新的字段")
+    await db.commit()
+    return {"message": "License已更新", "license_id": lic.id}
+
+
+async def restore_license(
+    db: AsyncSession, license_id: int
+) -> dict:
+    """恢复已吊销的 License"""
+    result = await db.execute(
+        select(SuperAdminLicense).where(SuperAdminLicense.id == license_id)
+    )
+    lic = result.scalar_one_or_none()
+    if not lic:
+        raise ValueError("License 不存在")
+    if lic.status != "revoked":
+        raise ValueError("仅可恢复已吊销的 License")
+    lic.status = "active"
+    await db.commit()
+    return {"message": "License已恢复"}
+
+
+async def delete_license(
+    db: AsyncSession, license_id: int
+) -> dict:
+    """删除 License 记录"""
+    result = await db.execute(
+        select(SuperAdminLicense).where(SuperAdminLicense.id == license_id)
+    )
+    lic = result.scalar_one_or_none()
+    if not lic:
+        raise ValueError("License 不存在")
+    await db.delete(lic)
+    await db.commit()
+    return {"message": "License已删除"}
+
+
+async def check_license_by_referer(
+    db: AsyncSession, referer: str | None
+) -> dict:
+    """通过 Referer 头匹配域名,检查对应超管的 License 状态。
+
+    流程:
+    1. 从 Referer 提取域名(host)
+    2. 在 MonitoredDomain 中匹配 domain 字段
+    3. 获取关联的 super_admin_id
+    4. 查询该超管当前 active 的 License 并返回状态
+    """
+    if not referer:
+        return {
+            "valid": False,
+            "status": "unknown",
+            "message": "缺少 Referer 头",
+        }
+
+    # 从 Referer URL 提取域名
+    try:
+        parsed = urlparse(referer)
+        referer_host = parsed.hostname
+        # 当 Referer 缺少 scheme(如 "127.0.0.1" 或 "example.com")时,
+        # urlparse 会将其解析为 path 而非 netloc,导致 hostname 为 None。
+        # 此时回退为使用原始字符串作为域名。
+        if not referer_host:
+            referer_host = referer.strip().rstrip("/")
+            if not referer_host:
+                return {
+                    "valid": False,
+                    "status": "unknown",
+                    "message": "Referer 格式无效",
+                }
+        # netloc 包含端口(如 127.0.0.1:8010),hostname 不包含
+        referer_netloc = parsed.netloc if parsed.netloc else referer_host
+    except Exception:
+        return {
+            "valid": False,
+            "status": "unknown",
+            "message": "Referer 解析失败",
+        }
+
+    # 匹配监控域名(支持多种存储格式:纯域名、带端口、带 http(s):// 前缀)
+    result = await db.execute(
+        select(MonitoredDomain).where(
+            MonitoredDomain.is_active == True,
+            MonitoredDomain.domain.in_([
+                referer_netloc,            # 例: 127.0.0.1:8010
+                referer_host,              # 例: 127.0.0.1
+                f"http://{referer_netloc}",  # 例: http://127.0.0.1:8010
+                f"https://{referer_netloc}", # 例: https://127.0.0.1:8010
+            ]),
+        )
+    )
+    domain = result.scalars().first()
+    if not domain or not domain.super_admin_id:
+        return {
+            "valid": False,
+            "status": "unknown",
+            "message": "域名未注册或无关联超管",
+        }
+
+    # 查询该超管最新的 license(不限 status,取最近创建的一条)
+    license_result = await db.execute(
+        select(SuperAdminLicense)
+        .where(SuperAdminLicense.super_admin_id == domain.super_admin_id)
+        .order_by(SuperAdminLicense.created_at.desc())
+    )
+    lic = license_result.scalars().first()
+    if not lic:
+        return {
+            "valid": False,
+            "status": "not_found",
+            "message": "未找到有效 License",
+        }
+
+    days_left = _calc_days_left(lic.expires_at)
+    if lic.status == "active" and days_left <= 0:
+        lic.status = "expired"
+        await db.commit()
+
+    sa_result = await db.execute(
+        select(SuperAdmin).where(SuperAdmin.id == lic.super_admin_id)
+    )
+    sa = sa_result.scalar_one_or_none()
+
+    return {
+        "valid": lic.status == "active",
+        "status": lic.status,
+        "super_admin_name": sa.remark or sa.username if sa else str(lic.super_admin_id),
+        "license_key": lic.license_key,
+        "expires_at": _to_str(lic.expires_at),
+        "days_left": days_left,
+        "max_tenants": lic.max_tenants,
+        "max_users_per_tenant": lic.max_users_per_tenant,
+        "remark": lic.remark,
+    }

+ 297 - 99
backend/app/services/monitoring.py

@@ -1,4 +1,5 @@
-from sqlalchemy import select
+from datetime import date
+from sqlalchemy import select, func, cast, Date
 from sqlalchemy.ext.asyncio import AsyncSession
 from sqlalchemy.ext.asyncio import AsyncSession
 from app.models.monitoring import (
 from app.models.monitoring import (
     SuperAdmin,
     SuperAdmin,
@@ -12,9 +13,13 @@ from app.schemas.monitoring import (
     Overview,
     Overview,
     SuperAdminData,
     SuperAdminData,
     TenantData,
     TenantData,
-    TenantModelSummary,
     UserConsumption,
     UserConsumption,
-    ModelDetail,
+    ConsumptionRecord,
+    ConsumptionDetailResponse,
+    ConsumptionDetailRecord,
+    DailyStatsResponse,
+    DailySAStat,
+    DailyTenantStat,
 )
 )
 
 
 
 
@@ -23,31 +28,34 @@ async def get_dashboard(
     start_date: str | None = None,
     start_date: str | None = None,
     end_date: str | None = None,
     end_date: str | None = None,
     super_admin_id: int | None = None,
     super_admin_id: int | None = None,
+    super_admin_name: str | None = None,
 ) -> DashboardResponse:
 ) -> DashboardResponse:
     """
     """
     获取监控大屏数据
     获取监控大屏数据
 
 
     层级结构:平台 → 超级管理员 → 租户 → 用户
     层级结构:平台 → 超级管理员 → 租户 → 用户
-    每个节点包含消费金额、折扣信息、模型维度明细。
-
-    金额关系:
-    - total_consumption:用户实际掏的钱(用户余额扣减)
-    - total_tenant_charged:平台从企业账户扣的钱(企业余额扣减)
-    - 两者之差 = 平台的折扣让利空间
+    每个用户节点包含消费记录流水(扁平列表)。
     """
     """
-    sa_filter = super_admin_id if super_admin_id else None
-
-    # 1. 查询所有超级管理员
+    # 1. 查询所有超级管理员(按ID或名称过滤)
     sa_stmt = select(SuperAdmin)
     sa_stmt = select(SuperAdmin)
-    if sa_filter:
-        sa_stmt = sa_stmt.where(SuperAdmin.id == sa_filter)
+    if super_admin_id is not None:
+        sa_stmt = sa_stmt.where(SuperAdmin.id == super_admin_id)
+    if super_admin_name:
+        sa_stmt = sa_stmt.where(
+            (SuperAdmin.nickname.ilike(f"%{super_admin_name}%")) |
+            (SuperAdmin.username.ilike(f"%{super_admin_name}%"))
+        )
     sa_result = await db.execute(sa_stmt)
     sa_result = await db.execute(sa_stmt)
     super_admins = sa_result.scalars().all()
     super_admins = sa_result.scalars().all()
 
 
     # 2. 查询超级管理员-租户关联关系
     # 2. 查询超级管理员-租户关联关系
     sat_stmt = select(SuperAdminTenant)
     sat_stmt = select(SuperAdminTenant)
-    if sa_filter:
-        sat_stmt = sat_stmt.where(SuperAdminTenant.super_admin_id == sa_filter)
+    if super_admin_id is not None:
+        sat_stmt = sat_stmt.where(SuperAdminTenant.super_admin_id == super_admin_id)
+    if super_admin_name:
+        sa_ids = [sa.id for sa in super_admins]
+        if sa_ids:
+            sat_stmt = sat_stmt.where(SuperAdminTenant.super_admin_id.in_(sa_ids))
     sat_result = await db.execute(sat_stmt)
     sat_result = await db.execute(sat_stmt)
     sa_tenant_map: dict[int, list[int]] = {}
     sa_tenant_map: dict[int, list[int]] = {}
     all_tenant_ids: set[int] = set()
     all_tenant_ids: set[int] = set()
@@ -64,42 +72,55 @@ async def get_dashboard(
     model_map: dict[str, Model] = {m.model_code: m for m in model_result.scalars().all()}
     model_map: dict[str, Model] = {m.model_code: m for m in model_result.scalars().all()}
 
 
     # 5. 查询消费明细(按时间范围过滤)
     # 5. 查询消费明细(按时间范围过滤)
+    start_dt = date.fromisoformat(start_date) if start_date else None
+    end_dt = date.fromisoformat(end_date) if end_date else None
+
     base_stmt = select(UserConsumptionDetail)
     base_stmt = select(UserConsumptionDetail)
-    if start_date:
-        base_stmt = base_stmt.where(UserConsumptionDetail.consumption_date >= start_date)
-    if end_date:
-        base_stmt = base_stmt.where(UserConsumptionDetail.consumption_date <= end_date)
-    if sa_filter:
-        tenant_ids = sa_tenant_map.get(sa_filter, [])
-        if tenant_ids:
-            base_stmt = base_stmt.where(UserConsumptionDetail.tenant_id.in_(tenant_ids))
+    if start_dt:
+        base_stmt = base_stmt.where(
+            cast(UserConsumptionDetail.consumption_date, Date) >= start_dt
+        )
+    if end_dt:
+        base_stmt = base_stmt.where(
+            cast(UserConsumptionDetail.consumption_date, Date) <= end_dt
+        )
+    if super_admin_id is not None:
+        matched_tenant_ids = sa_tenant_map.get(super_admin_id, [])
+        if matched_tenant_ids:
+            base_stmt = base_stmt.where(UserConsumptionDetail.tenant_id.in_(matched_tenant_ids))
 
 
     consumption_result = await db.execute(base_stmt.order_by(UserConsumptionDetail.created_at))
     consumption_result = await db.execute(base_stmt.order_by(UserConsumptionDetail.created_at))
     consumptions = consumption_result.scalars().all()
     consumptions = consumption_result.scalars().all()
 
 
-    # 6. 按 租户 → 用户 → 模型 聚合数据
-    # 结构: {tenant_id: {user_id: {model_code: {聚合数据}}}}
-    agg: dict[int, dict[str, dict[str, dict]]] = {}
+    # 7. 按 租户 → 用户 聚合,每条消费记录作为独立条目
+    # 结构: {tenant_id: {user_id: [consumption_records]}}
+    agg: dict[int, dict[str, list]] = {}
     for c in consumptions:
     for c in consumptions:
         agg.setdefault(c.tenant_id, {})
         agg.setdefault(c.tenant_id, {})
-        agg[c.tenant_id].setdefault(c.user_id, {})
-        if c.model_code not in agg[c.tenant_id][c.user_id]:
-            agg[c.tenant_id][c.user_id][c.model_code] = {
-                "user_actual_total": 0.0,
-                "tenant_actual_total": 0.0,
-                "call_count": 0,
-                "user_discount": float(c.user_discount or 1),
-                "tenant_discount": float(c.tenant_discount or 1),
-                "user_actual_price": float(c.user_actual_price or 0),
-                "tenant_actual_price": float(c.tenant_actual_price or 0),
-                "original_price": float(c.original_price or 0),
-            }
-        d = agg[c.tenant_id][c.user_id][c.model_code]
-        d["user_actual_total"] += float(c.user_actual_total or 0)
-        d["tenant_actual_total"] += float(c.tenant_actual_total or 0)
-        d["call_count"] += c.call_count or 0
-
-    # 7. 构建超级管理员数据
+        agg[c.tenant_id].setdefault(c.user_id, [])
+        tenant = tenant_map.get(c.tenant_id)
+        model_info = model_map.get(c.model_code)
+        user_discount_val = float(c.user_discount or 1)
+        sa_discount_val = float(c.super_admin_discount or 1)
+        agg[c.tenant_id][c.user_id].append(ConsumptionRecord(
+            user_id=c.user_id,
+            username=c.user_id,
+            tenant_name=tenant.company_name if tenant else None,
+            order_no=c.order_no or "",
+            model_name=model_info.model_name if model_info else c.model_code,
+            model_code=c.model_code,
+            amount=f"{float(c.user_actual_total or 0):.4f}",
+            created_at=str(c.created_at) if c.created_at else "",
+            invoiced=bool(c.invoiced),
+            user_discount=f"{user_discount_val:.4f}",
+            user_actual_price=f"{float(c.user_actual_price or 0):.4f}",
+            tenant_discount=f"{float(c.tenant_discount or 1):.4f}",
+            tenant_actual_price=f"{float(c.tenant_actual_price or 0):.4f}",
+            super_admin_discount=f"{sa_discount_val:.4f}",
+            super_admin_actual_price=f"{float(c.super_admin_actual_price or 0):.4f}",
+        ))
+
+    # 8. 构建超级管理员数据
     sa_data_list: list[SuperAdminData] = []
     sa_data_list: list[SuperAdminData] = []
     for sa in super_admins:
     for sa in super_admins:
         tenant_ids = sa_tenant_map.get(sa.id, [])
         tenant_ids = sa_tenant_map.get(sa.id, [])
@@ -111,64 +132,19 @@ async def get_dashboard(
 
 
             users_map = agg.get(tid, {})
             users_map = agg.get(tid, {})
             user_list: list[UserConsumption] = []
             user_list: list[UserConsumption] = []
-            for uid, models in users_map.items():
-                model_details: list[ModelDetail] = []
-                for mcode, mdata in models.items():
-                    model_info = model_map.get(mcode)
-                    model_details.append(ModelDetail(
-                        model_code=mcode,
-                        model_name=model_info.model_name if model_info else mcode,
-                        original_price=f"{mdata['original_price']:.4f}",
-                        tenant_discount=f"{mdata['tenant_discount']:.4f}",
-                        tenant_actual_price=f"{mdata['tenant_actual_price']:.4f}",
-                        user_discount=f"{mdata['user_discount']:.4f}",
-                        user_actual_price=f"{mdata['user_actual_price']:.4f}",
-                        total_amount=f"{mdata['user_actual_total']:.4f}",
-                        call_count=mdata["call_count"],
-                    ))
+            for uid, records in users_map.items():
                 user_list.append(UserConsumption(
                 user_list.append(UserConsumption(
                     user_id=uid,
                     user_id=uid,
                     username=uid,
                     username=uid,
                     nickname=None,
                     nickname=None,
-                    total_consumption=f"{sum(m['user_actual_total'] for m in models.values()):.4f}",
-                    tenant_actual_total=f"{sum(m['tenant_actual_total'] for m in models.values()):.4f}",
-                    model_details=model_details,
-                ))
-
-            # 租户级模型汇总(聚合该租户下所有用户的同模型数据)
-            tenant_model_agg: dict[str, dict] = {}
-            for models in users_map.values():
-                for mcode, mdata in models.items():
-                    if mcode not in tenant_model_agg:
-                        tenant_model_agg[mcode] = {
-                            "total_amount": 0.0,
-                            "call_count": 0,
-                            **{k: v for k, v in mdata.items() if k not in ("total_amount", "call_count", "user_actual_total", "tenant_actual_total")},
-                        }
-                    tenant_model_agg[mcode]["total_amount"] += mdata["user_actual_total"]
-                    tenant_model_agg[mcode]["call_count"] += mdata["call_count"]
-
-            model_summary: list[TenantModelSummary] = []
-            for mcode, mdata in tenant_model_agg.items():
-                model_info = model_map.get(mcode)
-                model_summary.append(TenantModelSummary(
-                    model_code=mcode,
-                    model_name=model_info.model_name if model_info else mcode,
-                    original_price=f"{mdata['original_price']:.4f}",
-                    tenant_discount=f"{mdata['tenant_discount']:.4f}",
-                    tenant_actual_price=f"{mdata['tenant_actual_price']:.4f}",
-                    user_discount=f"{mdata['user_discount']:.4f}",
-                    user_actual_price=f"{mdata['user_actual_price']:.4f}",
-                    total_amount=f"{mdata['total_amount']:.4f}",
-                    call_count=mdata["call_count"],
+                    total_consumption=f"{sum(float(r.amount) for r in records):.4f}",
+                    tenant_actual_total=f"{sum(float(r.tenant_actual_price) for r in records):.4f}",
+                    tenant_name=tenant.company_name if tenant else None,
+                    consumption_records=records,
                 ))
                 ))
 
 
-            tenant_total_consumption = sum(
-                m["user_actual_total"] for models in users_map.values() for m in models.values()
-            )
-            tenant_total_charged = sum(
-                m["tenant_actual_total"] for models in users_map.values() for m in models.values()
-            )
+            tenant_total_consumption = sum(float(r.amount) for records in users_map.values() for r in records)
+            tenant_total_charged = sum(float(r.tenant_actual_price) for records in users_map.values() for r in records)
 
 
             tenant_data_list.append(TenantData(
             tenant_data_list.append(TenantData(
                 tenant_id=tid,
                 tenant_id=tid,
@@ -179,7 +155,6 @@ async def get_dashboard(
                 balance=f"{float(tenant.balance or 0):.4f}",
                 balance=f"{float(tenant.balance or 0):.4f}",
                 user_count=len(users_map),
                 user_count=len(users_map),
                 users=user_list,
                 users=user_list,
-                model_summary=model_summary,
             ))
             ))
 
 
         sa_total_consumption = sum(float(t.total_consumption) for t in tenant_data_list)
         sa_total_consumption = sum(float(t.total_consumption) for t in tenant_data_list)
@@ -189,13 +164,14 @@ async def get_dashboard(
             super_admin_id=sa.id,
             super_admin_id=sa.id,
             username=sa.username,
             username=sa.username,
             nickname=sa.nickname,
             nickname=sa.nickname,
+            remark=sa.remark,
             tenant_count=len(tenant_data_list),
             tenant_count=len(tenant_data_list),
             total_consumption=f"{sa_total_consumption:.4f}",
             total_consumption=f"{sa_total_consumption:.4f}",
             total_tenant_charged=f"{sa_total_charged:.4f}",
             total_tenant_charged=f"{sa_total_charged:.4f}",
             tenants=tenant_data_list,
             tenants=tenant_data_list,
         ))
         ))
 
 
-    # 8. 构建平台汇总
+    # 9. 构建平台汇总
     total_super_admins = len(sa_data_list)
     total_super_admins = len(sa_data_list)
     total_tenants = sum(sa.tenant_count for sa in sa_data_list)
     total_tenants = sum(sa.tenant_count for sa in sa_data_list)
     total_users = sum(t.user_count for sa in sa_data_list for t in sa.tenants)
     total_users = sum(t.user_count for sa in sa_data_list for t in sa.tenants)
@@ -216,3 +192,225 @@ async def get_dashboard(
         start_date=start_date,
         start_date=start_date,
         end_date=end_date,
         end_date=end_date,
     )
     )
+
+
+async def get_consumption_details(
+    db: AsyncSession,
+    start_date: str | None = None,
+    end_date: str | None = None,
+    super_admin_name: str | None = None,
+    tenant_name: str | None = None,
+) -> ConsumptionDetailResponse:
+    """
+    查询原始消费明细表,每条记录一行,不做任何聚合
+    用于对账场景
+    """
+    # 1. 查出所有 SA、关联关系、租户
+    sa_stmt = select(SuperAdmin)
+    if super_admin_name:
+        sa_stmt = sa_stmt.where(
+            (SuperAdmin.nickname.ilike(f"%{super_admin_name}%")) |
+            (SuperAdmin.username.ilike(f"%{super_admin_name}%"))
+        )
+    sa_result = await db.execute(sa_stmt)
+    all_sas = sa_result.scalars().all()
+    sa_name_map = {sa.id: sa.nickname or sa.username for sa in all_sas}
+
+    sat_stmt = select(SuperAdminTenant)
+    if super_admin_name:
+        sa_ids = [sa.id for sa in all_sas]
+        if sa_ids:
+            sat_stmt = sat_stmt.where(SuperAdminTenant.super_admin_id.in_(sa_ids))
+    sat_result = await db.execute(sat_stmt)
+    sa_tenant_map: dict[int, list[int]] = {}
+    all_tenant_ids: set[int] = set()
+    for row in sat_result.scalars().all():
+        sa_tenant_map.setdefault(row.super_admin_id, []).append(row.tenant_id)
+        all_tenant_ids.add(row.tenant_id)
+
+    # 查询所有租户(tenant_name 筛选时独立过滤,不受 SA 关联限制)
+    tenant_stmt = select(Tenant)
+    tenant_conditions = []
+    if tenant_name:
+        tenant_conditions.append(
+            (Tenant.company_name.ilike(f"%{tenant_name}%")) |
+            (Tenant.subdomain.ilike(f"%{tenant_name}%"))
+        )
+    if tenant_conditions:
+        tenant_stmt = tenant_stmt.where(*tenant_conditions)
+    tenant_result = await db.execute(tenant_stmt)
+    tenant_map: dict[int, Tenant] = {t.id: t for t in tenant_result.scalars().all()}
+
+    # 2. 查原始消费明细
+    stmt = select(UserConsumptionDetail)
+    start_dt = date.fromisoformat(start_date) if start_date else None
+    end_dt = date.fromisoformat(end_date) if end_date else None
+    if start_dt:
+        stmt = stmt.where(cast(UserConsumptionDetail.consumption_date, Date) >= start_dt)
+    if end_dt:
+        stmt = stmt.where(cast(UserConsumptionDetail.consumption_date, Date) <= end_dt)
+    if super_admin_name:
+        matched_tenant_ids = set()
+        for sid in [sa.id for sa in all_sas]:
+            matched_tenant_ids.update(sa_tenant_map.get(sid, []))
+        if matched_tenant_ids:
+            stmt = stmt.where(UserConsumptionDetail.tenant_id.in_(matched_tenant_ids))
+    if tenant_name:
+        filtered_tenant_ids = set(tenant_map.keys())
+        if filtered_tenant_ids:
+            stmt = stmt.where(UserConsumptionDetail.tenant_id.in_(filtered_tenant_ids))
+    stmt = stmt.order_by(UserConsumptionDetail.consumption_date.desc())
+    result = await db.execute(stmt)
+    records = result.scalars().all()
+
+    # 3. 拼装返回
+    items: list[ConsumptionDetailRecord] = []
+    for c in records:
+        tenant = tenant_map.get(c.tenant_id)
+        t_name = tenant.company_name or tenant.subdomain if tenant else "-"
+        t_discount = float(c.tenant_discount or 1)
+        sa_discount = float(c.super_admin_discount or 1)
+        items.append(ConsumptionDetailRecord(
+            user_id=c.user_id,
+            user_name=c.username or c.user_id,
+            tenant_name=t_name,
+            order_no=c.order_no or "",
+            model_code=c.model_code,
+            consumption_date=str(c.consumption_date) if c.consumption_date else "",
+            tenant_consumed=f"{float(c.tenant_actual_total or 0):.4f}",
+            user_discount=f"{float(c.user_discount or 1):.4f}",
+            user_consumed=f"{float(c.user_actual_total or 0):.4f}",
+            tenant_discount=f"{t_discount:.4f}",
+            tenant_actual_price=f"{float(c.tenant_actual_price or 0):.4f}",
+            super_admin_discount=f"{sa_discount:.4f}",
+            super_admin_actual_price=f"{float(c.super_admin_actual_price or 0):.4f}",
+        ))
+
+    return ConsumptionDetailResponse(total=len(items), records=items)
+
+
+async def get_daily_stats(
+    db: AsyncSession,
+    start_date: str,
+    end_date: str,
+    super_admin_name: str | None = None,
+    tenant_name: str | None = None,
+) -> DailyStatsResponse:
+    """
+    查询按日聚合的消费统计:超级管理员 → 租户 → 每日消费金额
+    按 consumption_date 的日期部分分组,汇总每个租户每天的消费和收取金额
+    """
+    # 1. 查出所有 SA、关联关系、租户
+    sa_stmt = select(SuperAdmin)
+    if super_admin_name:
+        sa_stmt = sa_stmt.where(
+            (SuperAdmin.nickname.ilike(f"%{super_admin_name}%")) |
+            (SuperAdmin.username.ilike(f"%{super_admin_name}%"))
+        )
+    sa_result = await db.execute(sa_stmt)
+    all_sas = sa_result.scalars().all()
+    sa_name_map = {sa.id: sa.nickname or sa.username for sa in all_sas}
+
+    sat_stmt = select(SuperAdminTenant)
+    if super_admin_name:
+        sa_ids = [sa.id for sa in all_sas]
+        if sa_ids:
+            sat_stmt = sat_stmt.where(SuperAdminTenant.super_admin_id.in_(sa_ids))
+    sat_result = await db.execute(sat_stmt)
+    sa_tenant_map: dict[int, list[int]] = {}
+    all_tenant_ids: set[int] = set()
+    for row in sat_result.scalars().all():
+        sa_tenant_map.setdefault(row.super_admin_id, []).append(row.tenant_id)
+        all_tenant_ids.add(row.tenant_id)
+
+    tenant_stmt = select(Tenant).where(Tenant.id.in_(all_tenant_ids))
+    if tenant_name:
+        tenant_stmt = tenant_stmt.where(
+            (Tenant.company_name.ilike(f"%{tenant_name}%")) |
+            (Tenant.subdomain.ilike(f"%{tenant_name}%"))
+        )
+    tenant_result = await db.execute(tenant_stmt)
+    tenant_map: dict[int, Tenant] = {t.id: t for t in tenant_result.scalars().all()}
+    filtered_tenant_ids = set(tenant_map.keys())
+
+    # 2. 按日 + 租户聚合消费明细
+    start_dt = date.fromisoformat(start_date)
+    end_dt = date.fromisoformat(end_date)
+    stmt = select(
+        cast(UserConsumptionDetail.consumption_date, Date).label("stat_date"),
+        UserConsumptionDetail.tenant_id,
+        func.sum(UserConsumptionDetail.user_actual_total).label("total_consumption"),
+        func.sum(UserConsumptionDetail.tenant_actual_total).label("total_charged"),
+    ).where(
+        cast(UserConsumptionDetail.consumption_date, Date) >= start_dt,
+        cast(UserConsumptionDetail.consumption_date, Date) <= end_dt,
+    )
+    if super_admin_name:
+        matched_tenant_ids = set()
+        for sid in [sa.id for sa in all_sas]:
+            matched_tenant_ids.update(sa_tenant_map.get(sid, []))
+        if matched_tenant_ids:
+            stmt = stmt.where(UserConsumptionDetail.tenant_id.in_(matched_tenant_ids))
+    if tenant_name and filtered_tenant_ids:
+        stmt = stmt.where(UserConsumptionDetail.tenant_id.in_(filtered_tenant_ids))
+    stmt = stmt.group_by("stat_date", UserConsumptionDetail.tenant_id).order_by("stat_date")
+    result = await db.execute(stmt)
+    rows = result.fetchall()
+
+    # 3. 组装数据:按 SA → 租户 → 日期分组
+    # daily[sa_id][tenant_id] = {date: {consumption, charged}}
+    daily: dict[int, dict[int, dict[str, dict]]] = {}
+    for row in rows:
+        stat_date = str(row.stat_date)
+        tid = row.tenant_id
+        # 找到该租户所属的 SA
+        sa_id = None
+        for sid, tids in sa_tenant_map.items():
+            if tid in tids:
+                sa_id = sid
+                break
+        if sa_id is None:
+            continue
+        daily.setdefault(sa_id, {})
+        daily[sa_id].setdefault(tid, {})
+        daily[sa_id][tid][stat_date] = {
+            "consumption": f"{float(row.total_consumption):.4f}",
+            "charged": f"{float(row.total_charged):.4f}",
+        }
+
+    # 4. 构建返回
+    sa_stats: list[DailySAStat] = []
+    for sa in all_sas:
+        sa_id = sa.id
+        tenant_ids = sa_tenant_map.get(sa_id, [])
+        # 收集所有日期
+        all_dates: set[str] = set()
+        for tid in tenant_ids:
+            if tid in daily.get(sa_id, {}):
+                all_dates.update(daily[sa_id][tid].keys())
+        sorted_dates = sorted(all_dates)
+
+        tenant_stats: list[DailyTenantStat] = []
+        for tid in tenant_ids:
+            tenant = tenant_map.get(tid)
+            if not tenant:
+                continue
+            tname = tenant.company_name or tenant.subdomain
+            for d in sorted_dates:
+                stat = daily.get(sa_id, {}).get(tid, {}).get(d)
+                tenant_stats.append(DailyTenantStat(
+                    tenant_name=tname,
+                    date=d,
+                    consumption=stat["consumption"] if stat else "0.0000",
+                    charged=stat["charged"] if stat else "0.0000",
+                ))
+
+        sa_stats.append(DailySAStat(
+            sa_name=sa.remark or sa.username,
+            date="",
+            consumption="0",
+            charged="0",
+            tenants=tenant_stats,
+        ))
+
+    return DailyStatsResponse(sa_stats=sa_stats)

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

@@ -0,0 +1,21 @@
+-- ============================================================
+-- 监控大屏 - 超级管理员模型折扣表
+-- 文件: 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);

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

@@ -0,0 +1,14 @@
+-- ============================================================
+-- 监控大屏 - 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 '是否已开票';

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

@@ -0,0 +1,8 @@
+-- 为 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);

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

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

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

@@ -0,0 +1,17 @@
+-- 创建 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);

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

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

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

@@ -0,0 +1,6 @@
+-- 为超级管理员表添加备注和来源域名字段
+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);

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

@@ -0,0 +1,20 @@
+-- 为域名表添加 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 = '');

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

@@ -0,0 +1,13 @@
+-- 直接从域名表同步备注到超管表
+-- 将第一个有备注的域名备注,同步到所有还没有有效备注的超管
+-- (适用于"一个域名对应一组超管"的场景)
+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;

+ 713 - 59
frontend/src/App.tsx

@@ -2,6 +2,7 @@ import { useState, useEffect, useRef } from "react";
 import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
 import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
 import { domainApi } from "./api/domains";
 import { domainApi } from "./api/domains";
 import { monitoringApi } from "./api/monitoring";
 import { monitoringApi } from "./api/monitoring";
+import { licenseApi } from "./api/license";
 import type { MonitoredDomain, MonitoredDomainCreate } from "./types/domain";
 import type { MonitoredDomain, MonitoredDomainCreate } from "./types/domain";
 
 
 /* ===== 颜色主题 ===== */
 /* ===== 颜色主题 ===== */
@@ -26,6 +27,7 @@ const globalStyle = `
   *, *::before, *::after { margin:0; padding:0; box-sizing:border-box; }
   *, *::before, *::after { margin:0; padding:0; box-sizing:border-box; }
   html, body, #root { width:100%; height:100%; overflow:auto; font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif; background:${T.bg}; color:${T.text}; font-size:14px; }
   html, body, #root { width:100%; height:100%; overflow:auto; font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif; background:${T.bg}; color:${T.text}; font-size:14px; }
   @keyframes spin { to { transform:rotate(360deg); } }
   @keyframes spin { to { transform:rotate(360deg); } }
+  @keyframes fadeIn { from { opacity:0; transform:translateY(-10px); } to { opacity:1; transform:translateY(0); } }
 `;
 `;
 function GlobalStyle() { return <style>{globalStyle}</style>; }
 function GlobalStyle() { return <style>{globalStyle}</style>; }
 
 
@@ -66,11 +68,29 @@ function Toggle({ checked, onChange, label }: { checked: boolean; onChange: (v:
   );
   );
 }
 }
 
 
+/** Toast 提示 — 页面中上部固定位置 */
+function Toast({ message, type }: { message: string; type: "success" | "error" }) {
+  return (
+    <div style={{
+      position: "fixed", top: 20, left: "50%", transform: "translateX(-50%)",
+      zIndex: 9999, padding: "12px 24px", borderRadius: 8, fontSize: 14,
+      fontWeight: 500, color: "#fff",
+      background: type === "success" ? T.success : T.danger,
+      boxShadow: "0 4px 16px rgba(0,0,0,0.2)",
+      animation: "fadeIn 0.3s ease",
+    }}>{message}</div>
+  );
+}
+
 /* ===== 侧边栏 ===== */
 /* ===== 侧边栏 ===== */
 function Sidebar({ page, setPage }: { page: string; setPage: (p: string) => void }) {
 function Sidebar({ page, setPage }: { page: string; setPage: (p: string) => void }) {
   const items = [
   const items = [
     { key: "domains", label: "域名管理", icon: "M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-1 17.93c-3.95-.49-7-3.85-7-7.93 0-.62.08-1.21.21-1.79L9 15v1c0 1.1.9 2 2 2v1.93zm6.9-2.54c-.26-.81-1-1.39-1.9-1.39h-1v-3c0-.55-.45-1-1-1H8v-2h2c.55 0 1-.45 1-1V7h2c1.1 0 2-.9 2-2v-.41c2.93 1.19 5 4.06 5 7.41 0 2.08-.8 3.97-2.1 5.39z" },
     { key: "domains", label: "域名管理", icon: "M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-1 17.93c-3.95-.49-7-3.85-7-7.93 0-.62.08-1.21.21-1.79L9 15v1c0 1.1.9 2 2 2v1.93zm6.9-2.54c-.26-.81-1-1.39-1.9-1.39h-1v-3c0-.55-.45-1-1-1H8v-2h2c.55 0 1-.45 1-1V7h2c1.1 0 2-.9 2-2v-.41c2.93 1.19 5 4.06 5 7.41 0 2.08-.8 3.97-2.1 5.39z" },
     { key: "monitoring", label: "监控大屏", icon: "M3 13h8V3H3v10zm0 8h8v-6H3v6zm10 0h8V11h-8v10zm0-18v6h8V3h-8z" },
     { key: "monitoring", label: "监控大屏", icon: "M3 13h8V3H3v10zm0 8h8v-6H3v6zm10 0h8V11h-8v10zm0-18v6h8V3h-8z" },
+    { key: "super_admins", label: "超级管理员", icon: "M12 12c2.21 0 4-1.79 4-4s-1.79-4-4-4-4 1.79-4 4 1.79 4 4 4zm0 2c-2.67 0-8 1.34-8 4v2h16v-2c0-2.66-5.33-4-8-4z" },
+    { 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: "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" },
   ];
   ];
   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 }}>
@@ -91,60 +111,130 @@ function Sidebar({ page, setPage }: { page: string; setPage: (p: string) => void
 function FetchControls() {
 function FetchControls() {
   const queryClient = useQueryClient();
   const queryClient = useQueryClient();
   const [autoFetch, setAutoFetch] = useState(false);
   const [autoFetch, setAutoFetch] = useState(false);
-  const [intervalSec, setIntervalSec] = useState(300);
-  const timerRef = useRef<ReturnType<typeof setInterval> | null>(null);
+  const [scheduleTime, setScheduleTime] = useState("02:00");
+  const [fetchDate, setFetchDate] = useState("");
+  const [fetchingByDate, setFetchingByDate] = useState(false);
+  const [toast, setToast] = useState<{ message: string; type: "success" | "error" } | null>(null);
+  const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
+
+  const showToast = (message: string, type: "success" | "error") => {
+    setToast({ message, type });
+    setTimeout(() => setToast(null), 4000);
+  };
 
 
   const batchMutation = useMutation({
   const batchMutation = useMutation({
     mutationFn: () => domainApi.fetchAll(),
     mutationFn: () => domainApi.fetchAll(),
-    onSuccess: () => { queryClient.invalidateQueries({ queryKey: ["dashboard"] }); },
+    onSuccess: (res) => {
+      queryClient.invalidateQueries({ queryKey: ["dashboard"] });
+      const data = res.data;
+      if (data.errors && data.errors.length > 0) {
+        showToast(`部分失败: ${data.errors.length}/${data.total} 个域名出错`, "error");
+      } else if (data.total === 0) {
+        showToast("没有启用中的域名", "error");
+      } else {
+        showToast(`全部爬取成功,共 ${data.total} 个域名`, "success");
+      }
+    },
+    onError: () => { showToast("爬取请求失败,请稍后重试", "error"); },
   });
   });
 
 
-  // 自动爬取定时器
+  const fetchByDateMutation = useMutation({
+    mutationFn: (date: string) => domainApi.fetchAll(date),
+    onSuccess: (res) => {
+      queryClient.invalidateQueries({ queryKey: ["dashboard"] });
+      setFetchingByDate(false);
+      const data = res.data;
+      if (data.errors && data.errors.length > 0) {
+        showToast(`部分失败: ${data.errors.length}/${data.total} 个域名出错`, "error");
+      } else if (data.total === 0) {
+        showToast("没有启用中的域名", "error");
+      } else {
+        showToast(`按日期爬取成功,共 ${data.total} 个域名`, "success");
+      }
+    },
+    onError: () => { setFetchingByDate(false); showToast("爬取请求失败,请稍后重试", "error"); },
+  });
+
+  // 定时爬取:计算到下一个目标时间点的延迟
   useEffect(() => {
   useEffect(() => {
-    if (autoFetch) {
-      const fn = () => batchMutation.mutate();
-      fn();
-      timerRef.current = setInterval(fn, intervalSec * 1000);
+    if (!autoFetch) {
+      if (timerRef.current) clearTimeout(timerRef.current);
+      return;
     }
     }
-    return () => { if (timerRef.current) clearInterval(timerRef.current); };
-  }, [autoFetch, intervalSec]);
+    const [h, m] = scheduleTime.split(":").map(Number);
+    const now = new Date();
+    let next = new Date(now);
+    next.setHours(h, m, 0, 0);
+    if (next <= now) next.setDate(next.getDate() + 1);
+    const delay = next.getTime() - now.getTime();
+    const fn = () => {
+      batchMutation.mutate();
+      timerRef.current = setTimeout(fn, 24 * 3600 * 1000);
+    };
+    timerRef.current = setTimeout(fn, delay);
+    return () => { if (timerRef.current) clearTimeout(timerRef.current); };
+  }, [autoFetch, scheduleTime]);
+
+  const handleFetchByDate = () => {
+    if (!fetchDate) return;
+    setFetchingByDate(true);
+    fetchByDateMutation.mutate(fetchDate);
+  };
 
 
   return (
   return (
-    <div style={{ display: "flex", alignItems: "center", gap: 20, flexWrap: "wrap" }}>
-      <Toggle checked={autoFetch} onChange={setAutoFetch} label="自动爬取" />
-      {autoFetch && (
+    <>
+      {toast && <Toast message={toast.message} type={toast.type} />}
+      <div style={{ display: "flex", alignItems: "center", gap: 16, flexWrap: "wrap" }}>
+        <Toggle checked={autoFetch} onChange={setAutoFetch} label="每日定时爬取" />
+        {autoFetch && (
+          <div style={{ display: "flex", alignItems: "center", gap: 6 }}>
+            <span style={{ fontSize: 13, color: T.textSec }}>时间</span>
+            <input type="time" value={scheduleTime} onChange={(e) => setScheduleTime(e.target.value)} style={{ padding: "4px 8px", borderRadius: 6, border: `1px solid ${T.border}`, fontSize: 13, outline: "none" }} />
+          </div>
+        )}
+        <button onClick={() => batchMutation.mutate()} disabled={batchMutation.isPending} style={{ padding: "6px 14px", borderRadius: 6, fontSize: 13, cursor: "pointer", border: "none", background: batchMutation.isPending ? "#f1f5f9" : T.primary, color: batchMutation.isPending ? T.textSec : "#fff", fontWeight: 500, whiteSpace: "nowrap" }}>
+          {batchMutation.isPending ? "爬取中..." : "全部爬取"}
+        </button>
         <div style={{ display: "flex", alignItems: "center", gap: 6 }}>
         <div style={{ display: "flex", alignItems: "center", gap: 6 }}>
-          <span style={{ fontSize: 13, color: T.textSec }}>间隔</span>
-          <select value={intervalSec} onChange={(e) => setIntervalSec(Number(e.target.value))} style={{ padding: "4px 8px", borderRadius: 6, border: `1px solid ${T.border}`, fontSize: 13, outline: "none" }}>
-            <option value={60}>1分钟</option>
-            <option value={300}>5分钟</option>
-            <option value={600}>10分钟</option>
-            <option value={1800}>30分钟</option>
-          </select>
+          <span style={{ fontSize: 13, color: T.textSec }}>按日期爬取</span>
+          <input type="date" value={fetchDate} onChange={(e) => setFetchDate(e.target.value)} style={{ padding: "4px 8px", borderRadius: 6, border: `1px solid ${T.border}`, fontSize: 13, outline: "none" }} />
+          <button onClick={handleFetchByDate} disabled={fetchingByDate || !fetchDate} style={{ padding: "6px 14px", borderRadius: 6, fontSize: 13, cursor: fetchingByDate || !fetchDate ? "default" : "pointer", border: `1px solid ${T.primary}`, background: fetchingByDate || !fetchDate ? "#f1f5f9" : "transparent", color: fetchingByDate || !fetchDate ? T.textSec : T.primary, fontWeight: 500, whiteSpace: "nowrap" }}>
+            {fetchingByDate ? "爬取中..." : "爬取"}
+          </button>
         </div>
         </div>
-      )}
-      <button onClick={() => batchMutation.mutate()} disabled={batchMutation.isPending} style={{ padding: "6px 14px", borderRadius: 6, fontSize: 13, cursor: "pointer", border: "none", background: batchMutation.isPending ? "#f1f5f9" : T.primary, color: batchMutation.isPending ? T.textSec : "#fff", fontWeight: 500, whiteSpace: "nowrap" }}>
-        {batchMutation.isPending ? "爬取中..." : "全部爬取"}
-      </button>
-    </div>
+      </div>
+    </>
   );
   );
 }
 }
 
 
 /* ===== 域名管理页面 ===== */
 /* ===== 域名管理页面 ===== */
 function DomainsPage() {
 function DomainsPage() {
   const [newDomain, setNewDomain] = useState("");
   const [newDomain, setNewDomain] = useState("");
+  const [newRemark, setNewRemark] = useState("");
   const [fetchingId, setFetchingId] = useState<number | null>(null);
   const [fetchingId, setFetchingId] = useState<number | null>(null);
+  const [editingRemarkId, setEditingRemarkId] = useState<number | null>(null);
+  const [editingRemarkValue, setEditingRemarkValue] = useState("");
+  const [toast, setToast] = useState<{ message: string; type: "success" | "error" } | null>(null);
   const queryClient = useQueryClient();
   const queryClient = useQueryClient();
+  const showToast = (message: string, type: "success" | "error") => {
+    setToast({ message, type });
+    setTimeout(() => setToast(null), 4000);
+  };
   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(""); } });
-  const deleteMutation = useMutation({ mutationFn: (id: number) => domainApi.remove(id), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ["domains"] }); } });
-  const fetchMutation = useMutation({ mutationFn: (id: number) => domainApi.fetchTransactions(id), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ["dashboard"] }); setFetchingId(null); }, onError: () => { setFetchingId(null); } });
+  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 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 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); };
-  const handleSubmit = (e: React.FormEvent) => { e.preventDefault(); if (!newDomain.trim()) return; addMutation.mutate({ domain: newDomain.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 saveRemark = (id: number) => { updateRemarkMutation.mutate({ id, remark: editingRemarkValue }); };
   const fmtDate = (s: string | null) => s ? new Date(s).toLocaleString("zh-CN") : "-";
   const fmtDate = (s: string | null) => s ? new Date(s).toLocaleString("zh-CN") : "-";
   const activeCount = domains?.filter((d: MonitoredDomain) => d.is_active).length ?? 0;
   const activeCount = domains?.filter((d: MonitoredDomain) => d.is_active).length ?? 0;
   return (
   return (
-    <div style={{ marginLeft: 220, padding: 32 }}>
+    <>
+      {toast && <Toast message={toast.message} type={toast.type} />}
+      <div style={{ marginLeft: 220, padding: 32 }}>
       <div style={{ marginBottom: 28 }}><h1 style={{ fontSize: 24, fontWeight: 600, color: T.heading, marginBottom: 4 }}>域名管理</h1><p style={{ color: T.textSec, fontSize: 14 }}>管理需要爬取流水的域名列表</p></div>
       <div style={{ marginBottom: 28 }}><h1 style={{ fontSize: 24, fontWeight: 600, color: T.heading, marginBottom: 4 }}>域名管理</h1><p style={{ color: T.textSec, fontSize: 14 }}>管理需要爬取流水的域名列表</p></div>
       <div style={{ display: "grid", gridTemplateColumns: "repeat(4, 1fr)", gap: 16, marginBottom: 24 }}>
       <div style={{ display: "grid", gridTemplateColumns: "repeat(4, 1fr)", gap: 16, marginBottom: 24 }}>
         {[{ label: "域名总数", value: domains?.length ?? 0 }, { label: "启用中", value: activeCount }, { label: "已停用", value: (domains?.length ?? 0) - activeCount }, { label: "今日新增", value: 0 }].map((s) => (
         {[{ label: "域名总数", value: domains?.length ?? 0 }, { label: "启用中", value: activeCount }, { label: "已停用", value: (domains?.length ?? 0) - activeCount }, { label: "今日新增", value: 0 }].map((s) => (
@@ -157,18 +247,30 @@ function DomainsPage() {
       <Card title="添加域名">
       <Card title="添加域名">
         <form onSubmit={handleSubmit} style={{ display: "flex", gap: 12 }}>
         <form onSubmit={handleSubmit} style={{ display: "flex", gap: 12 }}>
           <input value={newDomain} onChange={(e) => setNewDomain(e.target.value)} placeholder="输入域名,如 tenant.example.com" style={{ flex: 1, padding: "9px 14px", border: `1px solid ${T.border}`, borderRadius: 8, fontSize: 14, color: T.text, background: "#fff", outline: "none" }} />
           <input value={newDomain} onChange={(e) => setNewDomain(e.target.value)} placeholder="输入域名,如 tenant.example.com" style={{ flex: 1, padding: "9px 14px", border: `1px solid ${T.border}`, borderRadius: 8, fontSize: 14, color: T.text, background: "#fff", outline: "none" }} />
+          <input value={newRemark} onChange={(e) => setNewRemark(e.target.value)} placeholder="备注(用于标识超管)" style={{ width: 180, padding: "9px 14px", border: `1px solid ${T.border}`, borderRadius: 8, fontSize: 14, color: T.text, background: "#fff", outline: "none" }} />
           <button type="submit" disabled={addMutation.isPending} style={{ padding: "9px 20px", borderRadius: 8, fontSize: 14, fontWeight: 500, cursor: "pointer", border: "none", background: addMutation.isPending ? "#a5b4fc" : T.primary, color: "#fff" }}>{addMutation.isPending ? "添加中..." : "添加域名"}</button>
           <button type="submit" disabled={addMutation.isPending} style={{ padding: "9px 20px", borderRadius: 8, fontSize: 14, fontWeight: 500, cursor: "pointer", border: "none", background: addMutation.isPending ? "#a5b4fc" : T.primary, color: "#fff" }}>{addMutation.isPending ? "添加中..." : "添加域名"}</button>
         </form>
         </form>
       </Card>
       </Card>
       <Card title="域名列表" extra={<FetchControls />}>
       <Card title="域名列表" extra={<FetchControls />}>
         {isLoading ? <div style={{ textAlign: "center", padding: 40, color: T.textSec }}>加载中...</div> : !domains?.length ? <div style={{ textAlign: "center", padding: 48, color: T.textSec }}><div style={{ fontSize: 40, marginBottom: 12 }}>🌐</div><p>暂无域名</p></div> : (
         {isLoading ? <div style={{ textAlign: "center", padding: 40, color: T.textSec }}>加载中...</div> : !domains?.length ? <div style={{ textAlign: "center", padding: 48, color: T.textSec }}><div style={{ fontSize: 40, marginBottom: 12 }}>🌐</div><p>暂无域名</p></div> : (
           <table style={{ width: "100%", borderCollapse: "collapse" }}>
           <table style={{ width: "100%", borderCollapse: "collapse" }}>
-            <thead><tr>{["ID", "域名", "状态", "创建时间", "操作"].map((h) => <th key={h} style={{ padding: "10px 14px", textAlign: "left", fontSize: 12, fontWeight: 600, color: T.textSec, textTransform: "uppercase", background: T.bg, borderBottom: `1px solid ${T.border}` }}>{h}</th>)}</tr></thead>
+            <thead><tr>{["ID", "域名", "备注", "状态", "创建时间", "操作"].map((h) => <th key={h} style={{ padding: "10px 14px", textAlign: "left", fontSize: 12, fontWeight: 600, color: T.textSec, textTransform: "uppercase", background: T.bg, borderBottom: `1px solid ${T.border}` }}>{h}</th>)}</tr></thead>
             <tbody>
             <tbody>
               {domains.map((d: MonitoredDomain) => (
               {domains.map((d: MonitoredDomain) => (
                 <tr key={d.id} style={{ borderBottom: `1px solid ${T.border}` }}>
                 <tr key={d.id} style={{ borderBottom: `1px solid ${T.border}` }}>
                   <td style={{ padding: "12px 14px", fontSize: 13, color: T.textSec }}>{d.id}</td>
                   <td style={{ padding: "12px 14px", fontSize: 13, color: T.textSec }}>{d.id}</td>
                   <td style={{ padding: "12px 14px", fontWeight: 500 }}>{d.domain}</td>
                   <td style={{ padding: "12px 14px", fontWeight: 500 }}>{d.domain}</td>
+                  <td style={{ padding: "12px 14px" }}>
+                    {editingRemarkId === d.id ? (
+                      <div style={{ display: "flex", gap: 4, alignItems: "center" }}>
+                        <input value={editingRemarkValue} onChange={(e) => setEditingRemarkValue(e.target.value)} onKeyDown={(e) => { if (e.key === "Enter") saveRemark(d.id); if (e.key === "Escape") setEditingRemarkId(null); }} style={{ padding: "4px 8px", borderRadius: 6, border: `1px solid ${T.primary}`, fontSize: 13, outline: "none", width: 160 }} autoFocus />
+                        <button onClick={() => saveRemark(d.id)} style={{ padding: "3px 8px", borderRadius: 4, fontSize: 12, cursor: "pointer", border: "none", background: T.success, color: "#fff" }}>保存</button>
+                        <button onClick={() => setEditingRemarkId(null)} style={{ padding: "3px 8px", borderRadius: 4, fontSize: 12, cursor: "pointer", border: "none", background: T.border, color: T.text }}>取消</button>
+                      </div>
+                    ) : (
+                      <span onDoubleClick={() => startEditRemark(d)} style={{ cursor: "pointer", fontSize: 13, color: d.remark ? T.text : T.textSec, padding: "2px 4px", borderRadius: 4 }} title="双击编辑">{d.remark || "双击添加备注"}</span>
+                    )}
+                  </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 }}>
                   <td style={{ padding: "12px 14px", display: "flex", gap: 6 }}>
@@ -184,6 +286,7 @@ function DomainsPage() {
         )}
         )}
       </Card>
       </Card>
     </div>
     </div>
+    </>
   );
   );
 }
 }
 
 
@@ -206,14 +309,12 @@ function CollapsePanel({ title, children, defaultOpen = false, badge }: { title:
 
 
 /* ===== 监控大屏页面 ===== */
 /* ===== 监控大屏页面 ===== */
 function MonitoringPage() {
 function MonitoringPage() {
-  const [startDate, setStartDate] = useState("");
-  const [endDate, setEndDate] = useState("");
-  const [saId, setSaId] = useState("");
+  const [query, setQuery] = useState({ startDate: "", endDate: "", saName: "", tenantName: "" });
 
 
-  const params: { start_date?: string; end_date?: string; super_admin_id?: number } = {};
-  if (startDate) params.start_date = startDate;
-  if (endDate) params.end_date = endDate;
-  if (saId) params.super_admin_id = parseInt(saId);
+  const params: { start_date?: string; end_date?: string; super_admin_name?: string } = {};
+  if (query.startDate) params.start_date = query.startDate;
+  if (query.endDate) params.end_date = query.endDate;
+  if (query.saName) params.super_admin_name = query.saName;
 
 
   const { data: dashboard, isLoading } = useQuery({
   const { data: dashboard, isLoading } = useQuery({
     queryKey: ["dashboard", params],
     queryKey: ["dashboard", params],
@@ -224,13 +325,7 @@ function MonitoringPage() {
     <div style={{ marginLeft: 220, padding: 32 }}>
     <div style={{ marginLeft: 220, padding: 32 }}>
       <div style={{ marginBottom: 28 }}><h1 style={{ fontSize: 24, fontWeight: 600, color: T.heading, marginBottom: 4 }}>监控大屏</h1><p style={{ color: T.textSec, fontSize: 14 }}>平台消费数据树状层级汇总</p></div>
       <div style={{ marginBottom: 28 }}><h1 style={{ fontSize: 24, fontWeight: 600, color: T.heading, marginBottom: 4 }}>监控大屏</h1><p style={{ color: T.textSec, fontSize: 14 }}>平台消费数据树状层级汇总</p></div>
 
 
-      <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="date" value={startDate} onChange={(e) => setStartDate(e.target.value)} style={{ padding: "8px 12px", border: `1px solid ${T.border}`, borderRadius: 6, fontSize: 14, outline: "none" }} /></div>
-          <div><label style={{ display: "block", fontSize: 12, color: T.textSec, marginBottom: 4 }}>结束日期</label><input type="date" value={endDate} onChange={(e) => setEndDate(e.target.value)} style={{ padding: "8px 12px", border: `1px solid ${T.border}`, borderRadius: 6, fontSize: 14, outline: "none" }} /></div>
-          <div><label style={{ display: "block", fontSize: 12, color: T.textSec, marginBottom: 4 }}>超级管理员ID</label><input type="number" value={saId} onChange={(e) => setSaId(e.target.value)} placeholder="留空查全部" style={{ padding: "8px 12px", border: `1px solid ${T.border}`, borderRadius: 6, fontSize: 14, outline: "none", width: 140 }} /></div>
-        </div>
-      </Card>
+      <FilterBar currentQuery={query} onSearch={setQuery} />
 
 
       {isLoading ? <div style={{ textAlign: "center", padding: 60, color: T.textSec }}>加载中...</div> : !dashboard ? (
       {isLoading ? <div style={{ textAlign: "center", padding: 60, color: T.textSec }}>加载中...</div> : !dashboard ? (
         <Card title="数据"><div style={{ textAlign: "center", padding: 40, color: T.textSec }}><p>暂无数据,请先爬取域名流水</p></div></Card>
         <Card title="数据"><div style={{ textAlign: "center", padding: 40, color: T.textSec }}><p>暂无数据,请先爬取域名流水</p></div></Card>
@@ -255,30 +350,22 @@ function MonitoringPage() {
           </Card>
           </Card>
 
 
           {dashboard.super_admins.map((sa) => (
           {dashboard.super_admins.map((sa) => (
-            <Card key={sa.super_admin_id} title={`${sa.nickname || sa.username}`} extra={<Badge active={true} text={`${sa.tenant_count} 个租户`} />}>
+            <Card key={sa.super_admin_id} title={`${sa.remark || sa.source_domain || sa.username}`} extra={<Badge active={true} text={`${sa.tenant_count} 个租户`} />}>
               <div style={{ marginBottom: 12, fontSize: 13, color: T.textSec }}>消费 ¥{sa.total_consumption} · 收取 ¥{sa.total_tenant_charged}</div>
               <div style={{ marginBottom: 12, fontSize: 13, color: T.textSec }}>消费 ¥{sa.total_consumption} · 收取 ¥{sa.total_tenant_charged}</div>
               {sa.tenants.map((tenant) => (
               {sa.tenants.map((tenant) => (
                 <CollapsePanel key={tenant.tenant_id} title={tenant.company_name || tenant.subdomain} badge={`${tenant.user_count} 用户 · ¥${tenant.total_consumption}`}>
                 <CollapsePanel key={tenant.tenant_id} title={tenant.company_name || tenant.subdomain} badge={`${tenant.user_count} 用户 · ¥${tenant.total_consumption}`}>
                   <div style={{ fontSize: 13, color: T.textSec, marginBottom: 12 }}>余额 ¥{tenant.balance} · 收取 ¥{tenant.total_tenant_charged}</div>
                   <div style={{ fontSize: 13, color: T.textSec, marginBottom: 12 }}>余额 ¥{tenant.balance} · 收取 ¥{tenant.total_tenant_charged}</div>
                   {tenant.users.map((u) => (
                   {tenant.users.map((u) => (
                     <CollapsePanel key={u.user_id} title={u.nickname || u.username} badge={`消费 ¥${u.total_consumption}`} defaultOpen={false}>
                     <CollapsePanel key={u.user_id} title={u.nickname || u.username} badge={`消费 ¥${u.total_consumption}`} defaultOpen={false}>
-                      <div style={{ fontSize: 12, color: T.textSec, marginBottom: 10 }}>平台收取该用户 ¥{u.tenant_actual_total}</div>
-                      {u.model_details.length > 0 && (
+                      <div style={{ fontSize: 12, color: T.textSec, marginBottom: 10 }}>企业实际支付 ¥{u.tenant_actual_total}</div>
+                      {u.consumption_records.length > 0 && (
                         <table style={{ width: "100%", borderCollapse: "collapse", fontSize: 13 }}>
                         <table style={{ width: "100%", borderCollapse: "collapse", fontSize: 13 }}>
-                          <thead><tr>{["模型", "原价", "企业折扣", "用户折扣", "金额(元)", "调用次数"].map((h) => <th key={h} style={{ padding: "6px 10px", textAlign: "left", fontSize: 11, color: T.textSec, borderBottom: `1px solid ${T.border}` }}>{h}</th>)}</tr></thead>
-                          <tbody>{u.model_details.map((m) => (<tr key={m.model_code}><td style={{ padding: "6px 10px", fontWeight: 500 }}>{m.model_name}</td><td style={{ padding: "6px 10px", color: T.textSec }}>{m.original_price}</td><td style={{ padding: "6px 10px", color: T.textSec }}>{m.tenant_discount}</td><td style={{ padding: "6px 10px", color: T.textSec }}>{m.user_discount}</td><td style={{ padding: "6px 10px", fontWeight: 500 }}>{m.total_amount}</td><td style={{ padding: "6px 10px", color: T.textSec }}>{m.call_count.toLocaleString()}</td></tr>))}</tbody>
+                          <thead><tr>{["订单号", "模型", "金额(元)", "用户折扣", "企业折扣", "超管折扣", "时间", "已开票"].map((h) => <th key={h} style={{ padding: "6px 10px", textAlign: "left", fontSize: 11, color: T.textSec, borderBottom: `1px solid ${T.border}` }}>{h}</th>)}</tr></thead>
+                          <tbody>{u.consumption_records.map((r: any, idx: number) => (<tr key={idx}><td style={{ padding: "6px 10px", color: T.textSec }}>{r.order_no || "-"}</td><td style={{ padding: "6px 10px", fontWeight: 500 }}>{r.model_name}</td><td style={{ padding: "6px 10px", fontWeight: 500 }}>{r.amount}</td><td style={{ padding: "6px 10px", color: T.textSec }}>{r.user_discount}</td><td style={{ padding: "6px 10px", color: T.textSec }}>{r.tenant_discount}</td><td style={{ padding: "6px 10px", color: T.textSec }}>{r.super_admin_discount}</td><td style={{ padding: "6px 10px", color: T.textSec }}>{r.created_at}</td><td style={{ padding: "6px 10px", color: T.textSec }}>{r.invoiced ? "是" : "否"}</td></tr>))}</tbody>
                         </table>
                         </table>
                       )}
                       )}
                     </CollapsePanel>
                     </CollapsePanel>
                   ))}
                   ))}
-                  {tenant.model_summary.length > 0 && (
-                    <CollapsePanel title="模型汇总" defaultOpen={true}>
-                      <table style={{ width: "100%", borderCollapse: "collapse", fontSize: 13 }}>
-                        <thead><tr>{["模型", "总金额(元)", "总调用次数"].map((h) => <th key={h} style={{ padding: "6px 10px", textAlign: "left", fontSize: 11, color: T.textSec, borderBottom: `1px solid ${T.border}` }}>{h}</th>)}</tr></thead>
-                        <tbody>{tenant.model_summary.map((m) => (<tr key={m.model_code}><td style={{ padding: "6px 10px", fontWeight: 500 }}>{m.model_name}</td><td style={{ padding: "6px 10px", fontWeight: 500 }}>{m.total_amount}</td><td style={{ padding: "6px 10px", color: T.textSec }}>{m.call_count.toLocaleString()}</td></tr>))}</tbody>
-                      </table>
-                    </CollapsePanel>
-                  )}
                 </CollapsePanel>
                 </CollapsePanel>
               ))}
               ))}
             </Card>
             </Card>
@@ -289,14 +376,581 @@ function MonitoringPage() {
   );
   );
 }
 }
 
 
+/* ===== 通用页面布局 ===== */
+function PageLayout({ title, subtitle, children }: { title: string; subtitle: string; children: React.ReactNode }) {
+  return (
+    <div style={{ marginLeft: 220, padding: 32 }}>
+      <div style={{ marginBottom: 28 }}>
+        <h1 style={{ fontSize: 24, fontWeight: 600, color: T.heading, marginBottom: 4 }}>{title}</h1>
+        <p style={{ color: T.textSec, fontSize: 14 }}>{subtitle}</p>
+      </div>
+      {children}
+    </div>
+  );
+}
+
+/* 筛选条件组件(复用)— 输入与查询解耦,点击"查询"才触发请求,支持查询历史管理 */
+function FilterBar({ currentQuery, onSearch, showTenantFilter }: {
+  currentQuery: { startDate: string; endDate: string; saName: string; tenantName: string };
+  onSearch: (q: { startDate: string; endDate: string; saName: string; tenantName: string }) => void;
+  showTenantFilter?: boolean;
+}) {
+  const [input, setInput] = useState({ startDate: "", endDate: "", saName: "", tenantName: "" });
+
+  useEffect(() => {
+    setInput({ startDate: currentQuery.startDate, endDate: currentQuery.endDate, saName: currentQuery.saName, tenantName: currentQuery.tenantName });
+  }, [currentQuery]);
+
+  const handleSearch = () => {
+    onSearch({ startDate: input.startDate, endDate: input.endDate, saName: input.saName, tenantName: input.tenantName });
+  };
+
+  const hasActiveFilter = currentQuery.startDate || currentQuery.endDate || currentQuery.saName || currentQuery.tenantName;
+
+  const removeCondition = (key: string) => {
+    const next = { ...currentQuery, [key]: "" };
+    onSearch(next);
+  };
+
+  const clearAll = () => {
+    onSearch({ startDate: "", endDate: "", saName: "", tenantName: "" });
+  };
+
+  return (
+    <Card title="筛选条件">
+      <div style={{ display: "flex", gap: 16, alignItems: "flex-end", flexWrap: "wrap", marginBottom: hasActiveFilter ? 12 : 0 }}>
+        <div><label style={{ display: "block", fontSize: 12, color: T.textSec, marginBottom: 4 }}>开始日期</label><input type="date" value={input.startDate} onChange={(e) => setInput({ ...input, startDate: e.target.value })} style={{ padding: "8px 12px", border: `1px solid ${T.border}`, borderRadius: 6, fontSize: 14, outline: "none" }} /></div>
+        <div><label style={{ display: "block", fontSize: 12, color: T.textSec, marginBottom: 4 }}>结束日期</label><input type="date" value={input.endDate} onChange={(e) => setInput({ ...input, endDate: e.target.value })} style={{ padding: "8px 12px", border: `1px solid ${T.border}`, borderRadius: 6, fontSize: 14, outline: "none" }} /></div>
+        <div><label style={{ display: "block", fontSize: 12, color: T.textSec, marginBottom: 4 }}>超级管理员名称</label><input type="text" value={input.saName} onChange={(e) => setInput({ ...input, saName: e.target.value })} style={{ padding: "8px 12px", border: `1px solid ${T.border}`, borderRadius: 6, fontSize: 14, outline: "none", width: 160 }} /></div>
+        {showTenantFilter && (
+          <div><label style={{ display: "block", fontSize: 12, color: T.textSec, marginBottom: 4 }}>租户名称</label><input type="text" value={input.tenantName} onChange={(e) => setInput({ ...input, tenantName: e.target.value })} style={{ padding: "8px 12px", border: `1px solid ${T.border}`, borderRadius: 6, fontSize: 14, outline: "none", width: 160 }} /></div>
+        )}
+        <button onClick={handleSearch} style={{ padding: "8px 20px", borderRadius: 6, fontSize: 14, cursor: "pointer", border: "none", background: T.primary, color: "#fff", fontWeight: 500, whiteSpace: "nowrap" }}>查询</button>
+      </div>
+      {hasActiveFilter && (
+        <div style={{ display: "flex", gap: 8, flexWrap: "wrap", paddingTop: 12, borderTop: `1px solid ${T.border}` }}>
+          {currentQuery.startDate && <Tag label={`开始: ${currentQuery.startDate}`} onRemove={() => removeCondition("startDate")} />}
+          {currentQuery.endDate && <Tag label={`结束: ${currentQuery.endDate}`} onRemove={() => removeCondition("endDate")} />}
+          {currentQuery.saName && <Tag label={`超管: ${currentQuery.saName}`} onRemove={() => removeCondition("saName")} />}
+          {currentQuery.tenantName && <Tag label={`租户: ${currentQuery.tenantName}`} onRemove={() => removeCondition("tenantName")} />}
+          <button onClick={clearAll} style={{ padding: "2px 10px", borderRadius: 99, fontSize: 12, cursor: "pointer", border: "none", background: "#fef2f2", color: T.danger, fontWeight: 500 }}>清空全部</button>
+        </div>
+      )}
+    </Card>
+  );
+}
+
+function Tag({ label, onRemove }: { label: string; onRemove: () => void }) {
+  return (
+    <span style={{ display: "inline-flex", alignItems: "center", gap: 6, padding: "4px 12px", borderRadius: 99, fontSize: 12, background: "#eff6ff", color: T.primary, fontWeight: 500 }}>
+      {label}
+      <span onClick={onRemove} style={{ cursor: "pointer", fontWeight: 700, opacity: 0.6 }} title="移除该条件">✕</span>
+    </span>
+  );
+}
+
+/** 带提示的表头 */
+function TooltipTh({ text, tip }: { text: string; tip: string }) {
+  return (
+    <th style={{ padding: "8px 12px", textAlign: "left", fontSize: 12, color: T.textSec, borderBottom: `1px solid ${T.border}` }} title={tip}>
+      {text}
+      <span style={{ marginLeft: 3, fontSize: 10, opacity: 0.5, cursor: "help" }}>?</span>
+    </th>
+  );
+}
+
+/* ===== 超级管理员页面 ===== */
+function SuperAdminsPage() {
+  const [query, setQuery] = useState({ startDate: "", endDate: "", saName: "", tenantName: "" });
+
+  const params: { start_date?: string; end_date?: string; super_admin_name?: string } = {};
+  if (query.startDate) params.start_date = query.startDate;
+  if (query.endDate) params.end_date = query.endDate;
+  if (query.saName) params.super_admin_name = query.saName;
+
+  const hasDate = Boolean(query.startDate && query.endDate);
+
+  const { data: dashboard, isLoading: dashLoading } = useQuery({
+    queryKey: ["dashboard", params],
+    queryFn: () => monitoringApi.getDashboard(Object.keys(params).length ? params : undefined).then((r) => r.data),
+    enabled: !hasDate,
+  });
+
+  const { data: dailyStats, isLoading: dailyLoading } = useQuery({
+    queryKey: ["daily-stats", params],
+    queryFn: () => monitoringApi.getDailyStats({ start_date: query.startDate!, end_date: query.endDate!, super_admin_name: query.saName || undefined }).then((r) => r.data),
+    enabled: hasDate,
+  });
+
+  if (dashLoading || dailyLoading) return <PageLayout title="超级管理员" subtitle="查看各超级管理员消费数据"><div style={{ textAlign: "center", padding: 60, color: T.textSec }}>加载中...</div></PageLayout>;
+
+  if (hasDate) {
+    const saStats = dailyStats?.sa_stats || [];
+    return (
+      <PageLayout title="超级管理员" subtitle={`${query.startDate} ~ ${query.endDate} 每日消费统计`}>
+        <FilterBar currentQuery={query} onSearch={setQuery} />
+        {saStats.length === 0 ? <Card title="数据"><div style={{ textAlign: "center", padding: 40, color: T.textSec }}>该筛选条件下暂无每日消费数据</div></Card> : saStats.map((sa: any, saIdx: number) => (
+          <Card key={saIdx} title={`${sa.sa_name} 每日消费明细`}>
+            <table style={{ width: "100%", borderCollapse: "collapse", fontSize: 13 }}>
+              <thead><tr>
+                <th style={{ padding: "8px 12px", textAlign: "left", fontSize: 12, color: T.textSec, borderBottom: `1px solid ${T.border}` }}>日期</th>
+                <th style={{ padding: "8px 12px", textAlign: "left", fontSize: 12, color: T.textSec, borderBottom: `1px solid ${T.border}` }}>租户</th>
+                <th style={{ padding: "8px 12px", textAlign: "left", fontSize: 12, color: T.textSec, borderBottom: `1px solid ${T.border}` }}>消费(元)</th>
+                <TooltipTh text="收取(元)" tip="企业实际支付金额 = 原价 × 租户折扣率" />
+              </tr></thead>
+              <tbody>
+                {(sa.tenants || []).map((t: any, idx: number) => (
+                  <tr key={idx} style={{ borderBottom: `1px solid ${T.border}` }}>
+                    <td style={{ padding: "8px 12px", fontWeight: 500 }}>{t.date}</td>
+                    <td style={{ padding: "8px 12px", color: T.textSec }}>{t.tenant_name || "-"}</td>
+                    <td style={{ padding: "8px 12px" }}>{t.consumption}</td>
+                    <td style={{ padding: "8px 12px" }}>{t.charged}</td>
+                  </tr>
+                ))}
+              </tbody>
+            </table>
+          </Card>
+        ))}
+      </PageLayout>
+    );
+  }
+
+  if (!dashboard) return <PageLayout title="超级管理员" subtitle="查看各超级管理员消费数据"><Card title="数据"><div style={{ textAlign: "center", padding: 40, color: T.textSec }}><p>暂无数据,请先爬取域名流水</p></div></Card></PageLayout>;
+
+  const totalCount = dashboard.super_admins.length;
+  const totalConsumption = dashboard.super_admins.reduce((s: number, sa: any) => s + parseFloat(sa.total_consumption || 0), 0).toFixed(4);
+  const totalCharged = dashboard.super_admins.reduce((s: number, sa: any) => s + parseFloat(sa.total_tenant_charged || 0), 0).toFixed(4);
+
+  return (
+    <PageLayout title="超级管理员" subtitle="查看各超级管理员消费数据">
+      <FilterBar currentQuery={query} onSearch={setQuery} />
+      <Card title="统计">
+        <div style={{ display: "grid", gridTemplateColumns: "repeat(3, 1fr)", gap: 16 }}>
+          <div style={{ background: T.bg, borderRadius: 8, padding: "14px 18px" }}><div style={{ fontSize: 12, color: T.textSec, marginBottom: 4 }}>管理员数</div><div style={{ fontSize: 20, fontWeight: 700, color: T.heading }}>{totalCount}</div></div>
+          <div style={{ background: T.bg, borderRadius: 8, padding: "14px 18px" }}><div style={{ fontSize: 12, color: T.textSec, marginBottom: 4 }}>总消费(元)</div><div style={{ fontSize: 20, fontWeight: 700, color: T.heading }}>{totalConsumption}</div></div>
+          <div style={{ background: T.bg, borderRadius: 8, padding: "14px 18px" }}><div style={{ fontSize: 12, color: T.textSec, marginBottom: 4 }}>总收取(元)</div><div style={{ fontSize: 20, fontWeight: 700, color: T.heading }}>{totalCharged}</div></div>
+        </div>
+      </Card>
+      {dashboard.super_admins.map((sa: any) => (
+        <Card key={sa.super_admin_id} title={`${sa.nickname || sa.username}`} extra={<Badge active={true} text={`${sa.tenant_count} 个租户`} />}>
+          <div style={{ marginBottom: 12, fontSize: 13, color: T.textSec }}>消费 ¥{sa.total_consumption} · 收取 ¥{sa.total_tenant_charged}</div>
+          {sa.tenants.length === 0 ? <div style={{ textAlign: "center", padding: 20, color: T.textSec, fontSize: 13 }}>暂无租户数据</div> : (
+            <table style={{ width: "100%", borderCollapse: "collapse" }}>
+              <thead><tr>
+                <th style={{ padding: "8px 12px", textAlign: "left", fontSize: 12, color: T.textSec, borderBottom: `1px solid ${T.border}` }}>租户</th>
+                <th style={{ padding: "8px 12px", textAlign: "left", fontSize: 12, color: T.textSec, borderBottom: `1px solid ${T.border}` }}>用户数</th>
+                <th style={{ padding: "8px 12px", textAlign: "left", fontSize: 12, color: T.textSec, borderBottom: `1px solid ${T.border}` }}>消费(元)</th>
+                <TooltipTh text="收取(元)" tip="企业实际支付金额 = 原价 × 租户折扣率" />
+                <th style={{ padding: "8px 12px", textAlign: "left", fontSize: 12, color: T.textSec, borderBottom: `1px solid ${T.border}` }}>余额(元)</th>
+              </tr></thead>
+              <tbody>
+                {sa.tenants.map((t: any) => (
+                  <tr key={t.tenant_id} style={{ borderBottom: `1px solid ${T.border}` }}>
+                    <td style={{ padding: "10px 12px", fontWeight: 500 }}>{t.company_name || t.subdomain}</td>
+                    <td style={{ padding: "10px 12px" }}>{t.user_count}</td>
+                    <td style={{ padding: "10px 12px" }}>{t.total_consumption}</td>
+                    <td style={{ padding: "10px 12px" }}>{t.total_tenant_charged}</td>
+                    <td style={{ padding: "10px 12px", color: T.textSec }}>{t.balance}</td>
+                  </tr>
+                ))}
+              </tbody>
+            </table>
+          )}
+        </Card>
+      ))}
+    </PageLayout>
+  );
+}
+
+/* ===== 租户页面 ===== */
+function TenantsPage() {
+  const [query, setQuery] = useState({ startDate: "", endDate: "", saName: "", tenantName: "" });
+
+  const params: { start_date?: string; end_date?: string; super_admin_name?: string } = {};
+  if (query.startDate) params.start_date = query.startDate;
+  if (query.endDate) params.end_date = query.endDate;
+  if (query.saName) params.super_admin_name = query.saName;
+
+  const hasDate = Boolean(query.startDate && query.endDate);
+
+  const { data: dashboard, isLoading: dashLoading } = useQuery({
+    queryKey: ["dashboard", params],
+    queryFn: () => monitoringApi.getDashboard(Object.keys(params).length ? params : undefined).then((r) => r.data),
+    enabled: !hasDate,
+  });
+
+  const { data: dailyStats, isLoading: dailyLoading } = useQuery({
+    queryKey: ["daily-stats", params],
+    queryFn: () => monitoringApi.getDailyStats({ start_date: query.startDate!, end_date: query.endDate!, super_admin_name: query.saName || undefined, tenant_name: query.tenantName || undefined }).then((r) => r.data),
+    enabled: hasDate,
+  });
+
+  if (dashLoading || dailyLoading) return <PageLayout title="租户" subtitle="查看各租户消费数据"><div style={{ textAlign: "center", padding: 60, color: T.textSec }}>加载中...</div></PageLayout>;
+
+  if (hasDate) {
+    const saStats = dailyStats?.sa_stats || [];
+    const rows: any[] = [];
+    saStats.forEach((sa: any) => {
+      (sa.tenants || []).forEach((t: any) => {
+        rows.push({ sa_name: sa.sa_name, tenant_name: t.tenant_name || "-", date: t.date, consumption: t.consumption, charged: t.charged });
+      });
+    });
+    return (
+      <PageLayout title="租户" subtitle={`${query.startDate} ~ ${query.endDate} 每日消费统计`}>
+        <FilterBar currentQuery={query} onSearch={setQuery} showTenantFilter />
+        <Card title={`每日明细(共 ${rows.length} 条)`}>
+          {rows.length === 0 ? <div style={{ textAlign: "center", padding: 40, color: T.textSec }}>该筛选条件下暂无每日消费数据</div> : (
+            <table style={{ width: "100%", borderCollapse: "collapse", fontSize: 13 }}>
+              <thead><tr>
+                <th style={{ padding: "8px 12px", textAlign: "left", fontSize: 12, color: T.textSec, borderBottom: `1px solid ${T.border}` }}>管理员</th>
+                <th style={{ padding: "8px 12px", textAlign: "left", fontSize: 12, color: T.textSec, borderBottom: `1px solid ${T.border}` }}>日期</th>
+                <th style={{ padding: "8px 12px", textAlign: "left", fontSize: 12, color: T.textSec, borderBottom: `1px solid ${T.border}` }}>租户</th>
+                <th style={{ padding: "8px 12px", textAlign: "left", fontSize: 12, color: T.textSec, borderBottom: `1px solid ${T.border}` }}>消费(元)</th>
+                <TooltipTh text="收取(元)" tip="企业实际支付金额 = 原价 × 租户折扣率" />
+              </tr></thead>
+              <tbody>
+                {rows.map((r: any, idx: number) => (
+                  <tr key={idx} style={{ borderBottom: `1px solid ${T.border}` }}>
+                    <td style={{ padding: "8px 12px", color: T.textSec }}>{r.sa_name}</td>
+                    <td style={{ padding: "8px 12px", fontWeight: 500 }}>{r.date}</td>
+                    <td style={{ padding: "8px 12px" }}>{r.tenant_name}</td>
+                    <td style={{ padding: "8px 12px" }}>{r.consumption}</td>
+                    <td style={{ padding: "8px 12px" }}>{r.charged}</td>
+                  </tr>
+                ))}
+              </tbody>
+            </table>
+          )}
+        </Card>
+      </PageLayout>
+    );
+  }
+
+  if (!dashboard) return <PageLayout title="租户" subtitle="查看各租户消费数据"><Card title="数据"><div style={{ textAlign: "center", padding: 40, color: T.textSec }}><p>暂无数据,请先爬取域名流水</p></div></Card></PageLayout>;
+
+  const allTenants: any[] = [];
+  dashboard.super_admins.forEach((sa: any) => {
+    sa.tenants.forEach((t: any) => {
+      allTenants.push({ ...t, sa_name: sa.remark || sa.source_domain || sa.username });
+    });
+  });
+
+  const filteredTenants = query.tenantName
+    ? allTenants.filter((t: any) => t.company_name?.toLowerCase().includes(query.tenantName.toLowerCase()) || t.subdomain?.toLowerCase().includes(query.tenantName.toLowerCase()))
+    : allTenants;
+
+  return (
+    <PageLayout title="租户" subtitle="查看各租户消费数据">
+      <FilterBar currentQuery={query} onSearch={setQuery} showTenantFilter />
+      <Card title={`租户列表(共 ${filteredTenants.length} 个)`}>
+        {filteredTenants.length === 0 ? <div style={{ textAlign: "center", padding: 40, color: T.textSec }}>暂无匹配的租户数据</div> : (
+          <table style={{ width: "100%", borderCollapse: "collapse" }}>
+            <thead><tr>
+              <th style={{ padding: "8px 12px", textAlign: "left", fontSize: 12, color: T.textSec, borderBottom: `1px solid ${T.border}` }}>管理员</th>
+              <th style={{ padding: "8px 12px", textAlign: "left", fontSize: 12, color: T.textSec, borderBottom: `1px solid ${T.border}` }}>租户</th>
+              <th style={{ padding: "8px 12px", textAlign: "left", fontSize: 12, color: T.textSec, borderBottom: `1px solid ${T.border}` }}>用户数</th>
+              <th style={{ padding: "8px 12px", textAlign: "left", fontSize: 12, color: T.textSec, borderBottom: `1px solid ${T.border}` }}>消费(元)</th>
+              <TooltipTh text="收取(元)" tip="企业实际支付金额 = 原价 × 租户折扣率" />
+              <th style={{ padding: "8px 12px", textAlign: "left", fontSize: 12, color: T.textSec, borderBottom: `1px solid ${T.border}` }}>余额(元)</th>
+            </tr></thead>
+            <tbody>
+              {filteredTenants.map((t: any) => (
+                <tr key={`${t.sa_name}-${t.tenant_id}`} style={{ borderBottom: `1px solid ${T.border}` }}>
+                  <td style={{ padding: "10px 12px", color: T.textSec }}>{t.sa_name}</td>
+                  <td style={{ padding: "10px 12px", fontWeight: 500 }}>{t.company_name || t.subdomain}</td>
+                  <td style={{ padding: "10px 12px" }}>{t.user_count}</td>
+                  <td style={{ padding: "10px 12px" }}>{t.total_consumption}</td>
+                  <td style={{ padding: "10px 12px" }}>{t.total_tenant_charged}</td>
+                  <td style={{ padding: "10px 12px", color: T.textSec }}>{t.balance}</td>
+                </tr>
+              ))}
+            </tbody>
+          </table>
+        )}
+      </Card>
+    </PageLayout>
+  );
+}
+
+/* ===== 用户页面 ===== */
+function UsersPage() {
+  const [query, setQuery] = useState({ startDate: "", endDate: "", saName: "", tenantName: "" });
+
+  const params: { start_date?: string; end_date?: string; super_admin_name?: string; tenant_name?: string } = {};
+  if (query.startDate) params.start_date = query.startDate;
+  if (query.endDate) params.end_date = query.endDate;
+  if (query.saName) params.super_admin_name = query.saName;
+  if (query.tenantName) params.tenant_name = query.tenantName;
+
+  const { data: details, isLoading } = useQuery({
+    queryKey: ["consumption-details", params],
+    queryFn: () => monitoringApi.getConsumptionDetails(Object.keys(params).length ? params : undefined).then((r) => r.data),
+  });
+
+  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>;
+
+  const records = details.records || [];
+
+  return (
+    <PageLayout title="用户" subtitle="逐笔消费明细,用于对账">
+      <FilterBar currentQuery={query} onSearch={setQuery} showTenantFilter />
+      <Card title={`消费明细(共 ${details.total} 笔)`}>
+        {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>
+        )}
+      </Card>
+    </PageLayout>
+  );
+}
+
+/* ===== License 许可页面 ===== */
+function LicensePage() {
+  const queryClient = useQueryClient();
+  const [toast, setToast] = useState<{ message: string; type: "success" | "error" } | null>(null);
+  const showToast = (message: string, type: "success" | "error") => {
+    setToast({ message, type });
+    setTimeout(() => setToast(null), 4000);
+  };
+
+  // 域名列表(仅统计卡片用)
+  const { data: domains, isLoading: domainsLoading } = useQuery({
+    queryKey: ["license-domains"],
+    queryFn: () => domainApi.list().then((r) => r.data),
+  });
+
+  // 超级管理员下拉选项
+  const { data: saOptions, isLoading: saLoading } = useQuery({
+    queryKey: ["sa-options"],
+    queryFn: () => licenseApi.getSuperAdmins().then((r) => r.data),
+  });
+
+  // License 列表
+  const { data: licenses, isLoading: licensesLoading } = useQuery({
+    queryKey: ["licenses"],
+    queryFn: () => licenseApi.list({ size: 100 }).then((r) => r.data),
+  });
+
+  // 创建表单
+  const [createForm, setCreateForm] = useState({ super_admin_id: "", license_key: "", expires_at: "", max_tenants: "", max_users: "", remark: "" });
+
+  const createMutation = useMutation({
+    mutationFn: (data: any) => licenseApi.create(data),
+    onSuccess: () => {
+      queryClient.invalidateQueries({ queryKey: ["licenses"] });
+      setCreateForm({ super_admin_id: "", license_key: "", expires_at: "", max_tenants: "", max_users: "", remark: "" });
+      showToast("License 创建成功", "success");
+    },
+    onError: (err: any) => { showToast(err.response?.data?.detail || "创建失败", "error"); },
+  });
+
+  const revokeMutation = useMutation({
+    mutationFn: (id: number) => licenseApi.revoke(id),
+    onSuccess: () => { queryClient.invalidateQueries({ queryKey: ["licenses"] }); showToast("License 已吊销", "success"); },
+    onError: () => { showToast("吊销失败", "error"); },
+  });
+
+  const deleteMutation = useMutation({
+    mutationFn: (id: number) => licenseApi.delete(id),
+    onSuccess: () => { queryClient.invalidateQueries({ queryKey: ["licenses"] }); showToast("License 已删除", "success"); },
+    onError: () => { showToast("删除失败", "error"); },
+  });
+
+  const restoreMutation = useMutation({
+    mutationFn: (id: number) => licenseApi.restore(id),
+    onSuccess: () => { queryClient.invalidateQueries({ queryKey: ["licenses"] }); showToast("License 已恢复", "success"); },
+    onError: () => { showToast("恢复失败", "error"); },
+  });
+
+  const updateMutation = useMutation({
+    mutationFn: ({ id, data }: { id: number; data: { license_key?: string; expires_at?: string } }) =>
+      licenseApi.update(id, data),
+    onSuccess: () => { queryClient.invalidateQueries({ queryKey: ["licenses"] }); setEditTarget(null); showToast("License 已更新", "success"); },
+    onError: () => { showToast("更新失败", "error"); },
+  });
+
+  // 编辑弹窗
+  const [editTarget, setEditTarget] = useState<any | null>(null);
+  const [editForm, setEditForm] = useState({ license_key: "", expires_at: "" });
+
+  const handleSaveEdit = () => {
+    if (!editTarget) return;
+    const data: { license_key?: string; expires_at?: string } = {};
+    if (editForm.license_key && editForm.license_key !== editTarget.license_key) data.license_key = editForm.license_key;
+    if (editForm.expires_at && editForm.expires_at !== (editTarget.expires_at || "").split(".")[0]?.substring(0, 16)) data.expires_at = editForm.expires_at;
+    if (Object.keys(data).length === 0) { showToast("未做任何修改", "error"); return; }
+    updateMutation.mutate({ id: editTarget.id, data });
+  };
+
+  useEffect(() => {
+    if (editTarget) {
+      setEditForm({
+        license_key: editTarget.license_key || "",
+        expires_at: editTarget.expires_at ? editTarget.expires_at.split(".")[0].substring(0, 16) : "",
+      });
+    }
+  }, [editTarget?.id]);
+
+  const handleCreate = (e: React.FormEvent) => {
+    e.preventDefault();
+    if (!createForm.super_admin_id || !createForm.license_key || !createForm.expires_at) {
+      showToast("请填写必填项(超级管理员、License Key、过期时间)", "error");
+      return;
+    }
+    const data: any = {
+      super_admin_id: parseInt(createForm.super_admin_id),
+      license_key: createForm.license_key,
+      expires_at: createForm.expires_at,
+    };
+    if (createForm.max_tenants) data.max_tenants = parseInt(createForm.max_tenants);
+    if (createForm.max_users) data.max_users_per_tenant = parseInt(createForm.max_users);
+    if (createForm.remark) data.remark = createForm.remark;
+    createMutation.mutate(data);
+  };
+
+  const statusColor = (status: string) => {
+    if (status === "active") return { bg: "#f0fdf4", color: "#16a34a", text: "有效" };
+    if (status === "expired") return { bg: "#fef2f2", color: "#dc2626", text: "已过期" };
+    if (status === "revoked") return { bg: "#f1f5f9", color: "#64748b", text: "已吊销" };
+    return { bg: "#f1f5f9", color: "#94a3b8", text: status };
+  };
+
+  if (domainsLoading || saLoading || licensesLoading) return <PageLayout title="License 许可" subtitle="管理系统授权许可"><div style={{ textAlign: "center", padding: 60, color: T.textSec }}>加载中...</div></PageLayout>;
+
+  return (
+    <>
+      {toast && <Toast message={toast.message} type={toast.type} />}
+      <PageLayout title="License 许可" subtitle="管理系统授权许可">
+        {/* 统计卡片 */}
+        <div style={{ display: "grid", gridTemplateColumns: "repeat(4, 1fr)", gap: 16, marginBottom: 24 }}>
+          {[
+            { label: "监控域名数", value: domains?.length ?? 0 },
+            { label: "有效 License", value: licenses?.items?.filter((l: any) => l.status === "active").length ?? 0 },
+            { label: "已过期", value: licenses?.items?.filter((l: any) => l.status === "expired").length ?? 0 },
+            { label: "已吊销", value: licenses?.items?.filter((l: any) => l.status === "revoked").length ?? 0 },
+          ].map((s) => (
+            <div key={s.label} style={{ background: T.card, borderRadius: 10, padding: "18px 22px", boxShadow: "0 1px 3px rgba(0,0,0,0.08)", border: `1px solid ${T.border}` }}>
+              <div style={{ fontSize: 13, color: T.textSec, marginBottom: 6 }}>{s.label}</div>
+              <div style={{ fontSize: 26, fontWeight: 700, color: T.heading }}>{s.value}</div>
+            </div>
+          ))}
+        </div>
+
+        {/* 创建 License */}
+        <Card title="创建 License">
+          <form onSubmit={handleCreate} style={{ display: "flex", gap: 12, flexWrap: "wrap", alignItems: "flex-end" }}>
+            <div><label style={{ display: "block", fontSize: 12, color: T.textSec, marginBottom: 4 }}>超级管理员 *</label>
+              <select value={createForm.super_admin_id} onChange={(e) => setCreateForm({ ...createForm, super_admin_id: e.target.value })} style={{ padding: "8px 12px", border: `1px solid ${T.border}`, borderRadius: 6, fontSize: 14, minWidth: 200, outline: "none", background: "#fff" }}>
+                <option value="">-- 请选择 --</option>
+                {saOptions?.map((sa: any) => <option key={sa.id} value={sa.id}>{sa.remark || sa.source_domain || sa.username} (ID: {sa.id})</option>)}
+              </select>
+            </div>
+            <div><label style={{ display: "block", fontSize: 12, color: T.textSec, marginBottom: 4 }}>License Key *</label><input value={createForm.license_key} onChange={(e) => setCreateForm({ ...createForm, license_key: e.target.value })} style={{ padding: "8px 12px", border: `1px solid ${T.border}`, borderRadius: 6, fontSize: 14, width: 220, outline: "none" }} /></div>
+            <div><label style={{ display: "block", fontSize: 12, color: T.textSec, marginBottom: 4 }}>过期时间 *</label><input type="datetime-local" value={createForm.expires_at} onChange={(e) => setCreateForm({ ...createForm, expires_at: e.target.value })} style={{ padding: "8px 12px", border: `1px solid ${T.border}`, borderRadius: 6, fontSize: 14, outline: "none" }} /></div>
+            <div><label style={{ display: "block", fontSize: 12, color: T.textSec, marginBottom: 4 }}>最大租户数</label><input type="number" value={createForm.max_tenants} onChange={(e) => setCreateForm({ ...createForm, max_tenants: e.target.value })} style={{ padding: "8px 12px", border: `1px solid ${T.border}`, borderRadius: 6, fontSize: 14, width: 100, outline: "none" }} /></div>
+            <div><label style={{ display: "block", fontSize: 12, color: T.textSec, marginBottom: 4 }}>每租户最大用户数</label><input type="number" value={createForm.max_users} onChange={(e) => setCreateForm({ ...createForm, max_users: e.target.value })} style={{ padding: "8px 12px", border: `1px solid ${T.border}`, borderRadius: 6, fontSize: 14, width: 120, outline: "none" }} /></div>
+            <div><label style={{ display: "block", fontSize: 12, color: T.textSec, marginBottom: 4 }}>备注</label><input value={createForm.remark} onChange={(e) => setCreateForm({ ...createForm, remark: e.target.value })} style={{ padding: "8px 12px", border: `1px solid ${T.border}`, borderRadius: 6, fontSize: 14, width: 150, outline: "none" }} /></div>
+            <button type="submit" disabled={createMutation.isPending} style={{ padding: "8px 20px", borderRadius: 6, fontSize: 14, cursor: "pointer", border: "none", background: createMutation.isPending ? "#a5b4fc" : T.primary, color: "#fff", fontWeight: 500, whiteSpace: "nowrap" }}>{createMutation.isPending ? "创建中..." : "创建"}</button>
+          </form>
+        </Card>
+
+        {/* License 列表 */}
+        <Card title={`License 列表(共 ${licenses?.total ?? 0} 条)`}>
+          {!licenses?.items?.length ? <div style={{ textAlign: "center", padding: 40, color: T.textSec }}>暂无 License 记录</div> : (
+            <table style={{ width: "100%", borderCollapse: "collapse", fontSize: 13 }}>
+              <thead><tr>{["超管", "关联域名", "License Key", "过期时间", "状态", "剩余天数", "最大租户", "备注", "操作"].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>
+                {licenses.items.map((l: any) => {
+                  const sc = statusColor(l.status);
+                  const daysLeft = Math.ceil((new Date(l.expires_at).getTime() - Date.now()) / (1000 * 60 * 60 * 24));
+                  return (
+                    <tr key={l.id} style={{ borderBottom: `1px solid ${T.border}` }}>
+                      <td style={{ padding: "8px 10px" }}>{l.super_admin_name || l.super_admin_id}</td>
+                      <td style={{ padding: "8px 10px", fontFamily: "monospace", fontSize: 12, color: T.textSec }}>{l.domain || "-"}</td>
+                      <td style={{ padding: "8px 10px", fontFamily: "monospace", fontWeight: 500, fontSize: 12 }}>{l.license_key}</td>
+                      <td style={{ padding: "8px 10px", fontSize: 12 }}>{l.expires_at ? l.expires_at.split(".")[0]?.replace("T", " ") : "-"}</td>
+                      <td style={{ padding: "8px 10px" }}>
+                        <span style={{ display: "inline-flex", alignItems: "center", gap: 4, padding: "2px 8px", borderRadius: 99, fontSize: 12, background: sc.bg, color: sc.color }}>{sc.text}</span>
+                      </td>
+                      <td style={{ padding: "8px 10px", color: daysLeft <= 7 && l.status === "active" ? T.danger : T.text }}>{l.status === "active" ? `${daysLeft} 天` : "-"}</td>
+                      <td style={{ padding: "8px 10px", color: T.textSec }}>{l.max_tenants ?? "-"}</td>
+                      <td style={{ padding: "8px 10px", color: T.textSec, maxWidth: 150, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }} title={l.remark || ""}>{l.remark || "-"}</td>
+                      <td style={{ padding: "8px 10px", display: "flex", gap: 6 }}>
+                        <button onClick={() => setEditTarget(l)} style={{ padding: "4px 8px", borderRadius: 6, fontSize: 12, cursor: "pointer", border: `1px solid ${T.primary}`, background: "transparent", color: T.primary }}>编辑</button>
+                        {l.status === "active" && <button onClick={() => revokeMutation.mutate(l.id)} style={{ padding: "4px 8px", borderRadius: 6, fontSize: 12, cursor: "pointer", border: `1px solid #f59e0b`, background: "transparent", color: "#f59e0b" }}>吊销</button>}
+                        {l.status === "revoked" && <button onClick={() => restoreMutation.mutate(l.id)} style={{ padding: "4px 8px", borderRadius: 6, fontSize: 12, cursor: "pointer", border: `1px solid #22c55e`, background: "transparent", color: "#22c55e" }}>恢复</button>}
+                        <button onClick={() => deleteMutation.mutate(l.id)} style={{ padding: "4px 8px", borderRadius: 6, fontSize: 12, cursor: "pointer", border: "none", background: "transparent", color: T.danger }}>删除</button>
+                      </td>
+                    </tr>
+                  );
+                })}
+              </tbody>
+            </table>
+          )}
+        </Card>
+
+        {/* 编辑弹窗 */}
+        {editTarget && (
+          <div style={{ position: "fixed", inset: 0, background: "rgba(0,0,0,0.4)", display: "flex", alignItems: "center", justifyContent: "center", zIndex: 1000 }} onClick={() => setEditTarget(null)}>
+            <div onClick={(e) => e.stopPropagation()} style={{ background: "#fff", borderRadius: 12, padding: 28, width: 420, boxShadow: "0 20px 60px rgba(0,0,0,0.2)" }}>
+              <h3 style={{ margin: "0 0 16px", fontSize: 16, color: T.heading }}>编辑 License #{editTarget.id}</h3>
+              <div style={{ display: "flex", flexDirection: "column", gap: 12 }}>
+                <div>
+                  <label style={{ display: "block", fontSize: 12, color: T.textSec, marginBottom: 4 }}>License Key</label>
+                  <input value={editForm.license_key} onChange={(e) => setEditForm({ ...editForm, license_key: e.target.value })} style={{ padding: "8px 12px", border: `1px solid ${T.border}`, borderRadius: 6, fontSize: 14, width: "100%", boxSizing: "border-box", outline: "none" }} />
+                </div>
+                <div>
+                  <label style={{ display: "block", fontSize: 12, color: T.textSec, marginBottom: 4 }}>过期时间</label>
+                  <input type="datetime-local" value={editForm.expires_at} onChange={(e) => setEditForm({ ...editForm, expires_at: e.target.value })} style={{ padding: "8px 12px", border: `1px solid ${T.border}`, borderRadius: 6, fontSize: 14, width: "100%", boxSizing: "border-box", outline: "none" }} />
+                </div>
+              </div>
+              <div style={{ display: "flex", gap: 8, justifyContent: "flex-end", marginTop: 20 }}>
+                <button onClick={() => setEditTarget(null)} style={{ padding: "6px 16px", borderRadius: 6, fontSize: 13, cursor: "pointer", border: `1px solid ${T.border}`, background: "#fff", color: T.text }}>取消</button>
+                <button onClick={handleSaveEdit} disabled={updateMutation.isPending} style={{ padding: "6px 16px", borderRadius: 6, fontSize: 13, cursor: "pointer", border: "none", background: updateMutation.isPending ? "#a5b4fc" : T.primary, color: "#fff", fontWeight: 500 }}>{updateMutation.isPending ? "保存中..." : "保存"}</button>
+              </div>
+            </div>
+          </div>
+        )}
+      </PageLayout>
+    </>
+  );
+}
+
 /* ===== App ===== */
 /* ===== App ===== */
 function App() {
 function App() {
   const [page, setPage] = useState("domains");
   const [page, setPage] = useState("domains");
+  const pageMap: Record<string, React.ReactNode> = {
+    domains: <DomainsPage />,
+    monitoring: <MonitoringPage />,
+    super_admins: <SuperAdminsPage />,
+    tenants: <TenantsPage />,
+    users: <UsersPage />,
+    license: <LicensePage />,
+  };
   return (
   return (
     <>
     <>
       <GlobalStyle />
       <GlobalStyle />
       <Sidebar page={page} setPage={setPage} />
       <Sidebar page={page} setPage={setPage} />
-      {page === "domains" ? <DomainsPage /> : <MonitoringPage />}
+      {pageMap[page] || <DomainsPage />}
     </>
     </>
   );
   );
 }
 }

+ 6 - 3
frontend/src/api/domains.ts

@@ -1,5 +1,5 @@
 import { api } from "./client";
 import { api } from "./client";
-import type { MonitoredDomain, MonitoredDomainCreate } from "../types/domain";
+import type { MonitoredDomain, MonitoredDomainCreate, MonitoredDomainUpdate } from "../types/domain";
 
 
 /** 域名相关 API 方法封装 */
 /** 域名相关 API 方法封装 */
 export const domainApi = {
 export const domainApi = {
@@ -9,12 +9,15 @@ export const domainApi = {
   /** 添加新的监控域名 */
   /** 添加新的监控域名 */
   create: (data: MonitoredDomainCreate) => api.post<MonitoredDomain>("/api/domains/", data),
   create: (data: MonitoredDomainCreate) => api.post<MonitoredDomain>("/api/domains/", data),
 
 
+  /** 更新域名备注 */
+  updateRemark: (id: number, data: MonitoredDomainUpdate) => api.patch<MonitoredDomain>(`/api/domains/${id}`, data),
+
   /** 删除指定 ID 的监控域名 */
   /** 删除指定 ID 的监控域名 */
   remove: (id: number) => api.delete(`/api/domains/${id}`),
   remove: (id: number) => api.delete(`/api/domains/${id}`),
 
 
   /** 爬取指定域名的监控数据 */
   /** 爬取指定域名的监控数据 */
-  fetchTransactions: (id: number) => api.get(`/api/domains/${id}/transactions`),
+  fetchTransactions: (id: number, date?: string) => api.get(`/api/domains/${id}/transactions${date ? `?fetch_date=${date}` : ""}`),
 
 
   /** 批量爬取所有已启用域名的数据 */
   /** 批量爬取所有已启用域名的数据 */
-  fetchAll: () => api.post("/api/domains/fetch-all"),
+  fetchAll: (date?: string) => api.post(`/api/domains/fetch-all${date ? `?fetch_date=${date}` : ""}`),
 };
 };

+ 48 - 0
frontend/src/api/license.ts

@@ -0,0 +1,48 @@
+import { api } from "./client";
+
+export const licenseApi = {
+  /** 超级管理员下拉选项 */
+  getSuperAdmins: () => api.get("/api/license/super-admins"),
+
+  /** 创建/更新 License */
+  create: (data: {
+    super_admin_id: number;
+    license_key: string;
+    expires_at: string;
+    max_tenants?: number;
+    max_users_per_tenant?: number;
+    remark?: string;
+  }) => api.post("/api/license/", data),
+
+  /** License 列表 */
+  list: (params?: {
+    super_admin_id?: number;
+    status?: string;
+    page?: number;
+    size?: number;
+  }) => {
+    const qs = new URLSearchParams();
+    if (params?.super_admin_id != null) qs.set("super_admin_id", String(params.super_admin_id));
+    if (params?.status) qs.set("status", params.status);
+    if (params?.page != null) qs.set("page", String(params.page));
+    if (params?.size != null) qs.set("size", String(params.size));
+    const query = qs.toString();
+    return api.get(`/api/license/list${query ? `?${query}` : ""}`);
+  },
+
+  /** License 状态详情 */
+  get: (licenseId: number) => api.get(`/api/license/${licenseId}`),
+
+  /** 吊销 License */
+  revoke: (licenseId: number) => api.post(`/api/license/${licenseId}/revoke`),
+
+  /** 恢复已吊销的 License */
+  restore: (licenseId: number) => api.post(`/api/license/${licenseId}/restore`),
+
+  /** 更新 License(key 或过期时间) */
+  update: (licenseId: number, data: { license_key?: string; expires_at?: string }) =>
+    api.put(`/api/license/${licenseId}`, data),
+
+  /** 删除 License */
+  delete: (licenseId: number) => api.delete(`/api/license/${licenseId}`),
+};

+ 25 - 3
frontend/src/api/monitoring.ts

@@ -2,13 +2,35 @@ import { api } from "./client";
 
 
 /** 监控大屏相关接口 */
 /** 监控大屏相关接口 */
 export const monitoringApi = {
 export const monitoringApi = {
-  /** 获取监控大屏数据 */
-  getDashboard: (params?: { start_date?: string; end_date?: string; super_admin_id?: number }) => {
+  /** 获取监控大屏数据(聚合视图) */
+  getDashboard: (params?: { start_date?: string; end_date?: string; super_admin_id?: number; super_admin_name?: string }) => {
     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_id) qs.set("super_admin_id", String(params.super_admin_id));
+    if (params?.super_admin_id != null) qs.set("super_admin_id", String(params.super_admin_id));
+    if (params?.super_admin_name) qs.set("super_admin_name", params.super_admin_name);
     const query = qs.toString();
     const query = qs.toString();
     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 }) => {
+    const qs = new URLSearchParams();
+    if (params?.start_date) qs.set("start_date", params.start_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?.tenant_name) qs.set("tenant_name", params.tenant_name);
+    const query = qs.toString();
+    return api.get(`/api/public/monitoring/consumption-details${query ? `?${query}` : ""}`);
+  },
+
+  /** 获取按日聚合的消费统计(超级管理员 → 租户 → 每日金额) */
+  getDailyStats: (params: { start_date: string; end_date: string; super_admin_name?: string; tenant_name?: string }) => {
+    const qs = new URLSearchParams();
+    qs.set("start_date", params.start_date);
+    qs.set("end_date", params.end_date);
+    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);
+    return api.get(`/api/public/monitoring/daily-stats?${qs.toString()}`);
+  },
 };
 };

+ 7 - 0
frontend/src/types/domain.ts

@@ -2,6 +2,7 @@
 export interface MonitoredDomain {
 export interface MonitoredDomain {
   id: number;
   id: number;
   domain: string;
   domain: string;
+  remark?: string | null;
   is_active: boolean;
   is_active: boolean;
   created_at: string | null;
   created_at: string | null;
 }
 }
@@ -9,4 +10,10 @@ export interface MonitoredDomain {
 /** 添加域名时的请求参数 */
 /** 添加域名时的请求参数 */
 export interface MonitoredDomainCreate {
 export interface MonitoredDomainCreate {
   domain: string;
   domain: string;
+  remark?: string;
+}
+
+/** 更新域名备注 */
+export interface MonitoredDomainUpdate {
+  remark: string;
 }
 }

+ 368 - 0
license_api.md

@@ -0,0 +1,368 @@
+# License 授权系统 API 文档
+
+## 1. 概述
+
+License 系统用于管理超级管理员级别的授权许可。
+
+**层级关系**:平台 → 超级管理员 (License) → 租户 → 用户
+
+**基础 URL**:`http://<host>:<port>`(开发环境默认 `http://localhost:8000`)
+
+---
+
+## 2. License 管理接口
+
+### 2.1 获取超级管理员列表
+
+```
+GET /api/license/super-admins
+```
+
+下拉选项接口,获取所有超级管理员,用于创建 License 时选择关联对象。
+
+**响应**:
+
+```json
+[
+  {
+    "id": 1,
+    "username": "admin1",
+    "nickname": "管理员A",
+    "remark": "客户A"
+  }
+]
+```
+
+---
+
+### 2.2 创建/更新 License
+
+```
+POST /api/license/
+Content-Type: application/json
+```
+
+**请求体**:
+
+| 字段 | 类型 | 必填 | 说明 |
+|------|------|------|------|
+| `super_admin_id` | integer | 是 | 超级管理员 ID |
+| `license_key` | string | 是 | License 密钥(1-200 字符) |
+| `expires_at` | string | 是 | 过期时间(ISO 8601 格式) |
+| `max_tenants` | integer | 否 | 可管理租户上限 |
+| `max_users_per_tenant` | integer | 否 | 每租户用户上限 |
+| `remark` | string | 否 | 备注 |
+
+**示例**:
+
+```bash
+curl -X POST "http://localhost:8000/api/license/" \
+  -H "Content-Type: application/json" \
+  -d '{
+    "super_admin_id": 1,
+    "license_key": "LICENSE-2026-ABCDEF",
+    "expires_at": "2027-12-31T23:59:59",
+    "max_tenants": 10,
+    "remark": "年度授权"
+  }'
+```
+
+**响应**:
+
+```json
+{
+  "message": "License已创建",
+  "license_id": 1
+}
+```
+
+> 同一 `super_admin_id` 只允许一个 `active` 状态的 License。重复创建会更新已有记录。
+
+---
+
+### 2.3 获取 License 列表
+
+```
+GET /api/license/list?super_admin_id=1&status=active&page=1&size=20
+```
+
+**查询参数**:
+
+| 参数 | 类型 | 必填 | 说明 |
+|------|------|------|------|
+| `super_admin_id` | integer | 否 | 按超管筛选 |
+| `status` | string | 否 | `active` / `expired` / `revoked` |
+| `page` | integer | 否 | 页码,默认 1 |
+| `size` | integer | 否 | 每页条数,默认 20,最大 100 |
+
+**响应**:
+
+```json
+{
+  "total": 1,
+  "items": [
+    {
+      "id": 1,
+      "super_admin_id": 1,
+      "super_admin_name": "客户A",
+      "license_key": "LICENSE-2026-ABCDEF",
+      "expires_at": "2027-12-31T23:59:59",
+      "status": "active",
+      "max_tenants": 10,
+      "max_users_per_tenant": null,
+      "remark": "年度授权",
+      "created_at": "2026-05-13T10:00:00",
+      "updated_at": null
+    }
+  ]
+}
+```
+
+---
+
+### 2.4 获取 License 详情
+
+```
+GET /api/license/{license_id}
+```
+
+**响应**:
+
+```json
+{
+  "id": 1,
+  "super_admin_id": 1,
+  "super_admin_name": "客户A",
+  "license_key": "LICENSE-2026-ABCDEF",
+  "expires_at": "2027-12-31T23:59:59",
+  "status": "active",
+  "days_left": 232,
+  "max_tenants": 10,
+  "max_users_per_tenant": null,
+  "remark": "年度授权"
+}
+```
+
+> 查询时如果 `days_left <= 0`,会自动将 License 状态更新为 `expired`。
+
+---
+
+### 2.5 吊销 License
+
+```
+POST /api/license/{license_id}/revoke
+```
+
+**响应**:
+
+```json
+{
+  "message": "License已吊销"
+}
+```
+
+---
+
+### 2.6 删除 License
+
+```
+DELETE /api/license/{license_id}
+```
+
+**响应**:
+
+```json
+{
+  "message": "License已删除"
+}
+```
+
+---
+
+## 3. 公开 License 校验接口(第三方系统集成)
+
+### 3.1 接口说明
+
+提供给第三方系统调用的公开接口,**无需认证**。通过请求的 `Referer` 头自动匹配对应的监控域名,并返回该域名关联超管的 License 状态。
+
+### 3.2 域名匹配流程
+
+```
+请求携带 Referer: https://example.com/page
+  → 提取域名 example.com
+  → 在 monitored_domains 表中匹配 domain = 'example.com'(需 is_active=true)
+  → 获取该记录关联的 super_admin_id
+  → 查询该超管当前 active 的 License
+  → 返回 License 状态
+```
+
+### 3.3 请求
+
+```
+GET /api/public/license/check
+```
+
+**请求头**:
+
+| 头 | 类型 | 必填 | 说明 |
+|------|------|------|------|
+| `Referer` | string | 是 | 请求来源页面的完整 URL,用于提取域名匹配 |
+
+**curl 示例**:
+
+```bash
+curl -H "Referer: https://example.com/dashboard" \
+  "http://localhost:8000/api/public/license/check"
+```
+
+### 3.4 响应
+
+#### License 有效
+
+```json
+{
+  "valid": true,
+  "status": "active",
+  "super_admin_name": "客户A",
+  "license_key": "LICENSE-2026-ABCDEF",
+  "expires_at": "2027-12-31T23:59:59",
+  "days_left": 232,
+  "max_tenants": 10,
+  "max_users_per_tenant": null,
+  "remark": "年度授权"
+}
+```
+
+#### 域名未注册或无关联超管
+
+```json
+{
+  "valid": false,
+  "status": "unknown",
+  "message": "域名未注册或无关联超管"
+}
+```
+
+#### 未找到有效 License
+
+```json
+{
+  "valid": false,
+  "status": "not_found",
+  "message": "未找到有效 License"
+}
+```
+
+#### 缺少 Referer 头
+
+```json
+{
+  "valid": false,
+  "status": "unknown",
+  "message": "缺少 Referer 头"
+}
+```
+
+### 3.5 响应字段说明
+
+| 字段 | 类型 | 出现条件 | 说明 |
+|------|------|----------|------|
+| `valid` | boolean | 总是 | License 是否有效 |
+| `status` | string | 总是 | `active`(有效)、`not_found`(未找到)、`unknown`(未知/错误) |
+| `super_admin_name` | string | `valid=true` | 超管显示名称(优先取 remark,否则取 username) |
+| `license_key` | string | `valid=true` | License 密钥 |
+| `expires_at` | string | `valid=true` | 过期时间(ISO 8601) |
+| `days_left` | integer | `valid=true` | 剩余天数,负数表示已过期 |
+| `max_tenants` | integer | `valid=true` | 可管理租户上限 |
+| `max_users_per_tenant` | integer | `valid=true` | 每租户用户上限 |
+| `remark` | string | `valid=true` | License 备注 |
+| `message` | string | `valid=false` | 失败原因说明 |
+
+### 3.6 调用示例
+
+**前端(浏览器自动携带 Referer)**:
+
+```javascript
+const res = await fetch('/api/public/license/check');
+const data = await res.json();
+
+if (data.valid) {
+  console.log(`License 有效,剩余 ${data.days_left} 天`);
+} else {
+  console.warn(`License 校验失败:${data.message}`);
+}
+```
+
+**Python 后端**:
+
+```python
+import requests
+
+resp = requests.get(
+    "https://license-server.example.com/api/public/license/check",
+    headers={"Referer": "https://example.com/dashboard"},
+)
+print(resp.json())
+```
+
+**Node.js**:
+
+```javascript
+const resp = await fetch("https://license-server.example.com/api/public/license/check", {
+  headers: { "Referer": "https://example.com/dashboard" },
+});
+console.log(await resp.json());
+```
+
+**curl**:
+
+```bash
+curl -H "Referer: https://example.com/dashboard" \
+  "https://license-server.example.com/api/public/license/check"
+```
+
+---
+
+## 4. 数据表
+
+### super_admin_license
+
+| 字段 | 类型 | 说明 |
+|------|------|------|
+| `id` | INTEGER PK | 主键 |
+| `super_admin_id` | INTEGER | 关联的超管 ID |
+| `license_key` | VARCHAR(200) | License 密钥 |
+| `expires_at` | TIMESTAMPTZ | 过期时间 |
+| `status` | VARCHAR(20) | `active` / `expired` / `revoked` |
+| `max_tenants` | INTEGER | 租户上限 |
+| `max_users_per_tenant` | INTEGER | 每租户用户上限 |
+| `remark` | TEXT | 备注 |
+| `created_at` | TIMESTAMPTZ | 创建时间 |
+| `updated_at` | TIMESTAMPTZ | 更新时间 |
+
+索引:`super_admin_id`, `status`, `expires_at`
+
+### monitored_domains(公开校验接口关联用)
+
+| 字段 | 类型 | 说明 |
+|------|------|------|
+| `id` | INTEGER PK | 主键 |
+| `domain` | VARCHAR (UNIQUE) | 域名 |
+| `super_admin_id` | INTEGER | 关联的超管 ID |
+| `is_active` | BOOLEAN | 是否启用 |
+
+---
+
+## 5. 常见问题
+
+**Q: 同一超管可以创建多个 License 吗?**
+A: 同一 `super_admin_id` 只允许一个 `active` 状态的 License。重复创建会更新已有记录。
+
+**Q: License 过期后会自动变为 expired 吗?**
+A: 会。每次查询 License 详情时会自动检查 `days_left`,如果 `<= 0` 则自动更新状态为 `expired`。
+
+**Q: 公开校验接口需要认证吗?**
+A: 不需要。`GET /api/public/license/check` 是公开接口,第三方系统可直接调用。
+
+**Q: 公开接口如果域名没有关联超管怎么办?**
+A: 返回 `"valid": false, "status": "unknown", "message": "域名未注册或无关联超管"`。

+ 149 - 108
monitoring_api.md

@@ -6,7 +6,7 @@
 
 
 **层级结构**:平台 → 超级管理员 → 租户 → 用户
 **层级结构**:平台 → 超级管理员 → 租户 → 用户
 
 
-每个节点包含消费金额、折扣信息、模型维度明细
+每条消费记录包含三级折扣及对应金额:**用户折扣**、**企业折扣**、**超管折扣**
 
 
 ---
 ---
 
 
@@ -17,6 +17,9 @@
 ```sql
 ```sql
 -- 迁移文件:migrations/060_create_super_admin_tenant_table.sql
 -- 迁移文件:migrations/060_create_super_admin_tenant_table.sql
 psql -d your_database -f migrations/060_create_super_admin_tenant_table.sql
 psql -d your_database -f migrations/060_create_super_admin_tenant_table.sql
+
+-- 迁移文件:migrations/063_create_super_admin_model_discount_table.sql
+psql -d your_database -f migrations/063_create_super_admin_model_discount_table.sql
 ```
 ```
 
 
 ### 2.2 初始化超级管理员-租户关联数据
 ### 2.2 初始化超级管理员-租户关联数据
@@ -38,7 +41,22 @@ INSERT INTO aigcspace.super_admin_tenant (super_admin_id, tenant_id) VALUES
 
 
 > **注意**:未关联到任何超级管理员的租户会归入"未分配租户"节点展示。
 > **注意**:未关联到任何超级管理员的租户会归入"未分配租户"节点展示。
 
 
-### 2.3 重启后端服务
+### 2.3 同步超管折扣
+
+超管折扣从 crawler API 同步到数据库,可通过爬虫自动同步或手动触发:
+
+```bash
+# 手动同步(爬虫同步时会自动写入超管折扣表)
+python -c "from app.services.crawler_sync_service import _fetch_crawler_discounts_for_sync; print(_fetch_crawler_discounts_for_sync(db))"
+```
+
+或通过超管后台接口手动同步:
+
+```
+POST /api/super/super-admin/discounts/sync
+```
+
+### 2.4 重启后端服务
 
 
 ```bash
 ```bash
 # 开发环境
 # 开发环境
@@ -55,16 +73,10 @@ gunicorn main:app -w 4 -k uvicorn.workers.UvicornWorker --bind 0.0.0.0:8010
 ### 3.1 获取监控大屏数据
 ### 3.1 获取监控大屏数据
 
 
 ```
 ```
-GET /api/admin/monitoring/dashboard
+GET /api/public/monitoring/dashboard
 ```
 ```
 
 
-#### 认证方式
-
-需要平台管理员 Token(`AdminUser` 角色),通过 `Authorization` Header 传递:
-
-```
-Authorization: Bearer <admin_token>
-```
+> **公共接口,无需鉴权**
 
 
 #### 请求参数
 #### 请求参数
 
 
@@ -78,16 +90,13 @@ Authorization: Bearer <admin_token>
 
 
 ```bash
 ```bash
 # 查询全部
 # 查询全部
-curl -X GET "http://localhost:8010/api/admin/monitoring/dashboard" \
-  -H "Authorization: Bearer <token>"
+curl -X GET "http://localhost:8010/api/public/monitoring/dashboard"
 
 
 # 按时间范围查询
 # 按时间范围查询
-curl -X GET "http://localhost:8010/api/admin/monitoring/dashboard?start_date=2026-01-01&end_date=2026-05-12" \
-  -H "Authorization: Bearer <token>"
+curl -X GET "http://localhost:8010/api/public/monitoring/dashboard?start_date=2026-01-01&end_date=2026-05-12"
 
 
 # 查询某个超级管理员
 # 查询某个超级管理员
-curl -X GET "http://localhost:8010/api/admin/monitoring/dashboard?super_admin_id=1" \
-  -H "Authorization: Bearer <token>"
+curl -X GET "http://localhost:8010/api/public/monitoring/dashboard?super_admin_id=1"
 ```
 ```
 
 
 #### 响应结构
 #### 响应结构
@@ -113,8 +122,8 @@ curl -X GET "http://localhost:8010/api/admin/monitoring/dashboard?super_admin_id
       "tenants": [
       "tenants": [
         {
         {
           "tenant_id": 1,
           "tenant_id": 1,
-          "company_name": "某某科技有限公司",
-          "subdomain": "techcorp",
+          "company_name": "成都网讯",
+          "subdomain": "wangxun",
           "total_consumption": "3000.0000",
           "total_consumption": "3000.0000",
           "total_tenant_charged": "2400.0000",
           "total_tenant_charged": "2400.0000",
           "balance": "10000.0000",
           "balance": "10000.0000",
@@ -126,33 +135,26 @@ curl -X GET "http://localhost:8010/api/admin/monitoring/dashboard?super_admin_id
               "nickname": "张三",
               "nickname": "张三",
               "total_consumption": "500.0000",
               "total_consumption": "500.0000",
               "tenant_actual_total": "400.0000",
               "tenant_actual_total": "400.0000",
-              "model_details": [
+              "consumption_records": [
                 {
                 {
-                  "model_code": "qwen-plus",
+                  "user_id": "u001",
+                  "username": "zhangsan",
+                  "tenant_name": "成都网讯",
+                  "order_no": "20260508194831_ai_conversation_xxx",
                   "model_name": "通义千问Plus",
                   "model_name": "通义千问Plus",
-                  "original_price": "0.0040",
-                  "tenant_discount": "0.8000",
-                  "tenant_actual_price": "0.0032",
+                  "model_code": "qwen-plus",
+                  "amount": "0.0040",
+                  "created_at": "2026-05-08T19:48:31",
+                  "invoiced": false,
                   "user_discount": "0.9000",
                   "user_discount": "0.9000",
-                  "user_actual_price": "0.0036",
-                  "total_amount": "500.0000",
-                  "call_count": 138889
+                  "user_actual_price": "0.0040",
+                  "tenant_discount": "0.8000",
+                  "tenant_actual_price": "0.0036",
+                  "super_admin_discount": "0.8000",
+                  "super_admin_actual_price": "0.0036"
                 }
                 }
               ]
               ]
             }
             }
-          ],
-          "model_summary": [
-            {
-              "model_code": "qwen-plus",
-              "model_name": "通义千问Plus",
-              "original_price": "0.0040",
-              "tenant_discount": "0.8000",
-              "tenant_actual_price": "0.0032",
-              "user_discount": "1.0000",
-              "user_actual_price": "0.0040",
-              "total_amount": "3000.0000",
-              "call_count": 833334
-            }
           ]
           ]
         }
         }
       ]
       ]
@@ -175,7 +177,7 @@ curl -X GET "http://localhost:8010/api/admin/monitoring/dashboard?super_admin_id
 | `total_tenants` | integer | 租户总数 |
 | `total_tenants` | integer | 租户总数 |
 | `total_users` | integer | 用户总数 |
 | `total_users` | integer | 用户总数 |
 | `total_consumption` | string(Decimal) | 平台总消费金额(元),即所有用户实际支付之和 |
 | `total_consumption` | string(Decimal) | 平台总消费金额(元),即所有用户实际支付之和 |
-| `total_tenant_charged` | string(Decimal) | 平台向企业收取总额(元),即原价 × 企业折扣后的金额 |
+| `total_tenant_charged` | string(Decimal) | 平台向企业收取总额(元),即用户实际支付 / 用户折扣 × 企业折扣 |
 | `total_balance` | string(Decimal) | 所有企业当前余额合计(元) |
 | `total_balance` | string(Decimal) | 所有企业当前余额合计(元) |
 
 
 ### 4.2 超级管理员节点 (`super_admins[]`)
 ### 4.2 超级管理员节点 (`super_admins[]`)
@@ -190,7 +192,7 @@ curl -X GET "http://localhost:8010/api/admin/monitoring/dashboard?super_admin_id
 | `total_tenant_charged` | string(Decimal) | 管辖范围内平台向企业收取总额 |
 | `total_tenant_charged` | string(Decimal) | 管辖范围内平台向企业收取总额 |
 | `tenants` | array | 管辖的租户列表 |
 | `tenants` | array | 管辖的租户列表 |
 
 
-> 当 `super_admin_id=0`、`nickname="未分配租户"` 时,表示未关联到任何超级管理员的租户
+> **过滤规则**:该超管管辖范围内没有任何有消费记录的租户时,该节点仍会返回但 `tenants` 为空数组
 
 
 ### 4.3 租户节点 (`tenants[]`)
 ### 4.3 租户节点 (`tenants[]`)
 
 
@@ -203,8 +205,9 @@ curl -X GET "http://localhost:8010/api/admin/monitoring/dashboard?super_admin_id
 | `total_tenant_charged` | string(Decimal) | 平台向该企业收取的总额 |
 | `total_tenant_charged` | string(Decimal) | 平台向该企业收取的总额 |
 | `balance` | string(Decimal) | 企业当前余额 |
 | `balance` | string(Decimal) | 企业当前余额 |
 | `user_count` | integer | 用户数量 |
 | `user_count` | integer | 用户数量 |
-| `users` | array | 用户消费列表 |
-| `model_summary` | array | 租户级模型消费汇总 |
+| `users` | array | 用户消费列表(仅包含有消费记录的用户) |
+
+> **过滤规则**:租户下所有用户在查询时间范围内均无消费记录时,该租户节点被过滤不返回。
 
 
 ### 4.4 用户节点 (`users[]`)
 ### 4.4 用户节点 (`users[]`)
 
 
@@ -215,50 +218,83 @@ curl -X GET "http://localhost:8010/api/admin/monitoring/dashboard?super_admin_id
 | `nickname` | string | 用户昵称 |
 | `nickname` | string | 用户昵称 |
 | `total_consumption` | string(Decimal) | 用户累计消费(用户实际支付金额) |
 | `total_consumption` | string(Decimal) | 用户累计消费(用户实际支付金额) |
 | `tenant_actual_total` | string(Decimal) | 企业为该用户实际被平台收取的总额 |
 | `tenant_actual_total` | string(Decimal) | 企业为该用户实际被平台收取的总额 |
-| `model_details` | array | 该用户按模型的消费明细 |
+| `consumption_records` | array | 该用户的消费记录流水(扁平列表) |
+
+> **过滤规则**:用户在查询时间范围内无消费记录时,该用户节点被过滤不返回。
+
+### 4.5 消费记录流水 (`consumption_records[]`)
 
 
-### 4.5 模型消费明细 (`model_details[]` / `model_summary[]`)
+每条记录包含用户信息、消费明细和**三级折扣及金额**:
 
 
 | 字段 | 类型 | 说明 |
 | 字段 | 类型 | 说明 |
 |------|------|------|
 |------|------|------|
-| `model_code` | string | 模型标识(如 `qwen-plus`) |
+| `user_id` | string | 用户ID |
+| `username` | string | 用户名 |
+| `tenant_name` | string | 所属租户名称(NULL表示平台直属用户) |
+| `order_no` | string | 订单号(对应 balance_log.biz_order_no) |
 | `model_name` | string | 模型显示名称 |
 | `model_name` | string | 模型显示名称 |
-| `original_price` | string(Decimal) | 平台原价(元/token 或 元/单位) |
-| `tenant_discount` | string(Decimal) | 超级管理员给企业的折扣率(0~1,如 0.8 表示8折) |
-| `tenant_actual_price` | string(Decimal) | 企业实际支付单价 = 原价 × 企业折扣率 |
-| `user_discount` | string(Decimal) | 企业给用户的折扣率(0~1,如 0.9 表示9折) |
-| `user_actual_price` | string(Decimal) | 用户实际支付单价 = 原价 × 用户折扣率 |
-| `total_amount` | string(Decimal) | 该模型的累计消费金额 |
-| `call_count` | integer | 调用次数 |
+| `model_code` | string | 模型标识(如 `qwen-plus`) |
+| `amount` | string(Decimal) | 消费金额(用户实际支付) |
+| `created_at` | string(datetime) | 消费时间 |
+| `invoiced` | boolean | 是否已开票 |
+| `user_discount` | string(Decimal) | 用户折扣率(0~1,如 0.9 表示9折) |
+| `user_actual_price` | string(Decimal) | 用户实际支付金额 |
+| `tenant_discount` | string(Decimal) | 企业折扣率(0~1,如 0.8 表示8折) |
+| `tenant_actual_price` | string(Decimal) | 企业实际支付金额 |
+| `super_admin_discount` | string(Decimal) | 超级管理员折扣率(从 crawler API 同步) |
+| `super_admin_actual_price` | string(Decimal) | 平台向超管收取金额 |
 
 
 ---
 ---
 
 
 ## 5. 金额关系说明
 ## 5. 金额关系说明
 
 
+### 5.1 三级折扣体系
+
 ```
 ```
-平台原价 (original_price)
+平台原价
-  ├── × 企业折扣率 (tenant_discount) = 企业实际支付单价 (tenant_actual_price)
-  │     └── 平台向企业收取 = Σ(企业实际支付单价 × 调用量)
+  ├── 超管折扣率 (super_admin_discount) → 平台向超管收取 (super_admin_actual_price)
+  │      = amount / user_discount × super_admin_discount
-  └── × 用户折扣率 (user_discount) = 用户实际支付单价 (user_actual_price)
-        └── 用户消费总额 = Σ(用户实际支付单价 × 调用量)
+  ├── 企业折扣率 (tenant_discount) → 企业实际支付 (tenant_actual_price)
+  │      = amount / user_discount × tenant_discount
+  │
+  └── 用户折扣率 (user_discount) → 用户实际支付 (user_actual_price)
+         = amount(user_consumption.amount 已为用户折扣后的价格)
 ```
 ```
 
 
+**计算公式**:
+
+| 层级 | 折扣率来源 | 实际支付计算 |
+|------|-----------|-------------|
+| 用户 | `user_model_discount` 表 | `user_actual_price = amount` |
+| 企业 | `tenant_model_discount` 表 | `tenant_actual_price = amount / user_discount × tenant_discount` |
+| 超管 | `super_admin_model_discount` 表(crawler 同步) | `super_admin_actual_price = amount / user_discount × super_admin_discount` |
+
 **关键区别**:
 **关键区别**:
-- `total_consumption`:用户实际掏的钱(用户余额扣减)
-- `total_tenant_charged`:平台从企业账户扣的钱(企业余额扣减)
-- 两者之差 = 平台的折扣让利空间
+
+- `user_actual_price`:用户实际掏的钱(用户余额扣减)
+- `tenant_actual_price`:平台从企业账户扣的钱(企业余额扣减)
+- `super_admin_actual_price`:平台向超管收取的金额
+- 三者之差 = 各层级的折扣让利空间
+
+### 5.2 超管折扣同步
+
+超管折扣存储在 `super_admin_model_discount` 表中,通过爬虫同步服务自动更新:
+
+- 全局折扣:`model_code = "*"`
+- 模型级折扣:`model_code = 具体模型标识`
+- 优先匹配具体模型,无匹配则使用全局折扣 `*`
+- 爬虫同步时写入,监控接口直接读库,响应速度快
 
 
 ---
 ---
 
 
 ## 6. 前端接入示例
 ## 6. 前端接入示例
 
 
-### 6.1 Vue 3 + ECharts
+### 6.1 Vue 3 数据获取
 
 
 ```javascript
 ```javascript
 import { ref, onMounted } from 'vue'
 import { ref, onMounted } from 'vue'
-import * as echarts from 'echarts'
 
 
 const dashboardData = ref(null)
 const dashboardData = ref(null)
 
 
@@ -267,66 +303,59 @@ async function fetchDashboard(startDate, endDate) {
   if (startDate) params.append('start_date', startDate)
   if (startDate) params.append('start_date', startDate)
   if (endDate) params.append('end_date', endDate)
   if (endDate) params.append('end_date', endDate)
 
 
-  const res = await fetch(`/api/admin/monitoring/dashboard?${params}`, {
-    headers: { 'Authorization': `Bearer ${token}` }
-  })
+  const res = await fetch(`/api/public/monitoring/dashboard?${params}`)
   dashboardData.value = await res.json()
   dashboardData.value = await res.json()
 }
 }
+```
 
 
-// 树状图渲染示例
-function renderTreeChart(data) {
-  const chart = echarts.init(document.getElementById('chart'))
-
-  const treeData = {
-    name: '平台',
-    children: data.super_admins.map(sa => ({
-      name: sa.nickname || sa.username,
-      value: sa.total_consumption,
-      children: sa.tenants.map(t => ({
-        name: t.company_name,
-        value: t.total_consumption,
-        children: t.users.map(u => ({
-          name: u.nickname || u.user_id,
-          value: u.total_consumption
-        }))
-      }))
-    }))
-  }
+### 6.2 消费记录表格渲染
 
 
-  chart.setOption({
-    series: [{
-      type: 'tree',
-      data: [treeData],
-      orient: 'TB',
-      expandAndCollapse: true,
-      label: {
-        formatter: (params) => `${params.name}\n¥${params.value}`
+```javascript
+// 扁平化所有用户的消费记录
+function flattenRecords(data) {
+  const records = []
+  for (const sa of data.super_admins) {
+    for (const tenant of sa.tenants) {
+      for (const user of tenant.users) {
+        for (const record of user.consumption_records) {
+          records.push(record)
+        }
       }
       }
-    }]
-  })
+    }
+  }
+  return records.sort((a, b) => new Date(b.created_at) - new Date(a.created_at))
 }
 }
 ```
 ```
 
 
-### 6.2 数据处理(按模型聚合)
+### 6.3 按模型聚合
 
 
 ```javascript
 ```javascript
-// 从树状数据中提取模型维度汇总
+// 从消费记录中按模型聚合
 function aggregateByModel(dashboardData) {
 function aggregateByModel(dashboardData) {
   const modelMap = {}
   const modelMap = {}
 
 
   for (const sa of dashboardData.super_admins) {
   for (const sa of dashboardData.super_admins) {
     for (const tenant of sa.tenants) {
     for (const tenant of sa.tenants) {
-      for (const model of tenant.model_summary) {
-        if (!modelMap[model.model_code]) {
-          modelMap[model.model_code] = {
-            model_code: model.model_code,
-            model_name: model.model_name,
-            total_amount: 0,
-            call_count: 0
+      for (const user of tenant.users) {
+        for (const record of user.consumption_records) {
+          const key = record.model_code
+          if (!modelMap[key]) {
+            modelMap[key] = {
+              model_code: record.model_code,
+              model_name: record.model_name,
+              total_amount: 0,
+              user_total: 0,
+              tenant_total: 0,
+              super_admin_total: 0,
+              count: 0
+            }
           }
           }
+          modelMap[key].total_amount += parseFloat(record.amount)
+          modelMap[key].user_total += parseFloat(record.user_actual_price)
+          modelMap[key].tenant_total += parseFloat(record.tenant_actual_price)
+          modelMap[key].super_admin_total += parseFloat(record.super_admin_actual_price)
+          modelMap[key].count += 1
         }
         }
-        modelMap[model.model_code].total_amount += parseFloat(model.total_amount)
-        modelMap[model.model_code].call_count += model.call_count
       }
       }
     }
     }
   }
   }
@@ -339,14 +368,26 @@ function aggregateByModel(dashboardData) {
 
 
 ## 7. 常见问题
 ## 7. 常见问题
 
 
+### Q: 为什么有些用户/租户不返回?
+
+A: 接口默认过滤 `total_consumption = 0` 的用户和租户。只有查询时间范围内有真实消费记录的节点才会返回。
+
 ### Q: 未分配租户是什么意思?
 ### Q: 未分配租户是什么意思?
+
 A: 如果某个租户没有在 `super_admin_tenant` 表中关联任何超级管理员,该租户会归入 `super_admin_id=0, nickname="未分配租户"` 的特殊节点。
 A: 如果某个租户没有在 `super_admin_tenant` 表中关联任何超级管理员,该租户会归入 `super_admin_id=0, nickname="未分配租户"` 的特殊节点。
 
 
 ### Q: 折扣率是 1.0 代表什么?
 ### Q: 折扣率是 1.0 代表什么?
+
 A: 折扣率 1.0 表示没有折扣(原价)。0.8 表示8折,用户只需支付原价的80%。
 A: 折扣率 1.0 表示没有折扣(原价)。0.8 表示8折,用户只需支付原价的80%。
 
 
+### Q: 超管折扣如何更新?
+
+A: 超管折扣通过爬虫服务 `sync_from_crawler` 自动同步。爬虫每次同步模型数据时,会将折扣写入 `super_admin_model_discount` 表。也可通过超管后台 `POST /api/super/super-admin/discounts/sync` 手动触发同步。
+
 ### Q: 时间范围如何影响数据?
 ### Q: 时间范围如何影响数据?
-A: `start_date` 和 `end_date` 只过滤用户消费明细(`user_consumption` 表),企业余额是实时快照不受时间过滤。
+
+A: `start_date` 和 `end_date` 过滤 `user_consumption` 表的 `created_at` 字段。企业余额是实时快照不受时间过滤。
 
 
 ### Q: 返回数据量很大怎么办?
 ### Q: 返回数据量很大怎么办?
-A: 可以通过 `super_admin_id` 参数只查某个管理员的数据,减少返回量。前端也可以做懒加载,点击超级管理员节点时再请求其下的租户详情(当前版本暂不支持,需前端二次开发)。
+
+A: 可以通过 `super_admin_id` 参数只查某个管理员的数据,减少返回量。