docs(uiux): UIUX 设计方案大改 + 5 份作业指导书对齐 + 开发任务文档
- UIUX 文档:填充 19 个缺口(多主体画像/健康度/AI+看板/增长域/洞察域/创始人端/OODA/助推/商密) - UIUX 文档:插入 6 个新章节(十四~十九),旧章节重编号为二十~三十一,更新目录和交叉引用 - 作业指导书 x5:导航改为 6 域分组,新增 Context Bar/工作模式/Insight Rail/决策线程/多工作区等 UI 概念 - 新建 docs/2-task-uiux.md:50 个代码落地开发任务,按 P0-P6 分优先级 + 8 Sprint 规划 - 后端/前端:大量新增模型、路由、组件(来自之前 Phase 开发)
This commit is contained in:
@@ -0,0 +1,47 @@
|
||||
"""审计日志装饰器。
|
||||
|
||||
自动记录 CREATE/UPDATE/DELETE 操作。
|
||||
"""
|
||||
|
||||
import functools
|
||||
import logging
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models.audit import AuditLog
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def log_audit(
|
||||
db: AsyncSession,
|
||||
user_id: str,
|
||||
action: str,
|
||||
target_type: str,
|
||||
target_id: str,
|
||||
detail: dict | None = None,
|
||||
tenant_id: str | None = None,
|
||||
) -> None:
|
||||
"""记录审计日志。
|
||||
|
||||
Args:
|
||||
db: 数据库会话
|
||||
user_id: 操作用户 ID
|
||||
action: 操作类型(login/view/create/update/delete/export/ai_call)
|
||||
target_type: 资源类型(映射到 resource_type 字段)
|
||||
target_id: 资源 ID(映射到 resource_id 字段)
|
||||
detail: 操作详情字典
|
||||
tenant_id: 租户 ID
|
||||
"""
|
||||
audit = AuditLog(
|
||||
tenant_id=tenant_id or "",
|
||||
user_id=user_id,
|
||||
action=action,
|
||||
resource_type=target_type,
|
||||
resource_id=target_id,
|
||||
detail_json=detail,
|
||||
created_at=datetime.now(timezone.utc),
|
||||
)
|
||||
db.add(audit)
|
||||
await db.flush()
|
||||
@@ -0,0 +1,49 @@
|
||||
"""缓存装饰器。
|
||||
|
||||
为热点接口自动添加 Redis 缓存。
|
||||
"""
|
||||
|
||||
import functools
|
||||
import hashlib
|
||||
import logging
|
||||
from typing import Any, Callable
|
||||
|
||||
from app.core.redis import cache_get, cache_set
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def cached(prefix: str, ttl: int = 300):
|
||||
"""缓存装饰器 — 自动缓存函数返回值。
|
||||
|
||||
Args:
|
||||
prefix: 缓存键前缀
|
||||
ttl: 缓存过期时间(秒)
|
||||
"""
|
||||
|
||||
def decorator(func: Callable) -> Callable:
|
||||
@functools.wraps(func)
|
||||
async def wrapper(*args, **kwargs) -> Any:
|
||||
# 生成缓存键
|
||||
key_parts = [prefix]
|
||||
for arg in args[1:]: # 跳过 self/db
|
||||
key_parts.append(str(arg))
|
||||
for k, v in sorted(kwargs.items()):
|
||||
key_parts.append(f"{k}={v}")
|
||||
cache_key = hashlib.md5(":".join(key_parts).encode()).hexdigest()
|
||||
|
||||
# 尝试获取缓存
|
||||
cached = await cache_get(f"{prefix}:{cache_key}")
|
||||
if cached is not None:
|
||||
return cached
|
||||
|
||||
# 执行函数
|
||||
result = await func(*args, **kwargs)
|
||||
|
||||
# 写入缓存
|
||||
await cache_set(f"{prefix}:{cache_key}", result, ttl)
|
||||
return result
|
||||
|
||||
return wrapper
|
||||
|
||||
return decorator
|
||||
@@ -0,0 +1,37 @@
|
||||
"""PII 脱敏服务 — 手机/邮箱/身份证 日志脱敏。"""
|
||||
|
||||
import re
|
||||
|
||||
|
||||
def mask_phone(phone: str) -> str:
|
||||
"""手机号脱敏:138****1234"""
|
||||
if len(phone) >= 11:
|
||||
return phone[:3] + "****" + phone[-4:]
|
||||
return phone
|
||||
|
||||
|
||||
def mask_email(email: str) -> str:
|
||||
"""邮箱脱敏:z***@example.com"""
|
||||
if "@" in email:
|
||||
name, domain = email.split("@", 1)
|
||||
if len(name) > 1:
|
||||
return name[0] + "***@" + domain
|
||||
return email
|
||||
|
||||
|
||||
def mask_id_card(id_card: str) -> str:
|
||||
"""身份证脱敏:110***********1234"""
|
||||
if len(id_card) >= 18:
|
||||
return id_card[:3] + "*" * 11 + id_card[-4:]
|
||||
return id_card
|
||||
|
||||
|
||||
def mask_pii(text: str) -> str:
|
||||
"""自动识别并脱敏文本中的 PII 信息。"""
|
||||
# 手机号
|
||||
text = re.sub(r"1[3-9]\d{9}", lambda m: mask_phone(m.group()), text)
|
||||
# 邮箱
|
||||
text = re.sub(r"[\w.+-]+@[\w-]+\.[\w.-]+", lambda m: mask_email(m.group()), text)
|
||||
# 身份证(18位)
|
||||
text = re.sub(r"\d{17}[\dXx]", lambda m: mask_id_card(m.group()), text)
|
||||
return text
|
||||
@@ -0,0 +1,23 @@
|
||||
"""敏感字段加密存储。"""
|
||||
|
||||
import base64
|
||||
import hashlib
|
||||
|
||||
from app.core.config import settings
|
||||
|
||||
|
||||
def encrypt_field(value: str) -> str:
|
||||
"""加密敏感字段(简化版 — 实际应使用 KMS/Vault)。"""
|
||||
key = settings.jwt_secret_key.encode()
|
||||
data = value.encode()
|
||||
# XOR 加密(简化版,生产环境应使用 AES)
|
||||
encrypted = bytes(b ^ key[i % len(key)] for i, b in enumerate(data))
|
||||
return base64.b64encode(encrypted).decode()
|
||||
|
||||
|
||||
def decrypt_field(encrypted: str) -> str:
|
||||
"""解密敏感字段。"""
|
||||
key = settings.jwt_secret_key.encode()
|
||||
data = base64.b64decode(encrypted)
|
||||
decrypted = bytes(b ^ key[i % len(key)] for i, b in enumerate(data))
|
||||
return decrypted.decode()
|
||||
@@ -0,0 +1,41 @@
|
||||
"""限流中间件 — 登录 5 次/min + 写接口按用户限流。"""
|
||||
|
||||
import time
|
||||
from collections import defaultdict
|
||||
|
||||
from fastapi import HTTPException, Request, status
|
||||
|
||||
|
||||
class RateLimiter:
|
||||
"""简单的内存限流器。"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._requests: dict[str, list[float]] = defaultdict(list)
|
||||
|
||||
def check(self, key: str, max_requests: int, window_seconds: int) -> bool:
|
||||
"""检查是否超过限流阈值。"""
|
||||
now = time.time()
|
||||
window_start = now - window_seconds
|
||||
|
||||
# 清理过期记录
|
||||
self._requests[key] = [t for t in self._requests[key] if t > window_start]
|
||||
|
||||
if len(self._requests[key]) >= max_requests:
|
||||
return False
|
||||
|
||||
self._requests[key].append(now)
|
||||
return True
|
||||
|
||||
|
||||
rate_limiter = RateLimiter()
|
||||
|
||||
|
||||
async def login_rate_limit(request: Request) -> None:
|
||||
"""登录接口限流 — 5 次/min/IP。"""
|
||||
client_ip = request.client.host if request.client else "unknown"
|
||||
if not rate_limiter.check(f"login:{client_ip}", max_requests=5, window_seconds=60):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
|
||||
detail="登录尝试过于频繁,请稍后再试",
|
||||
headers={"Retry-After": "60"},
|
||||
)
|
||||
@@ -0,0 +1,47 @@
|
||||
"""Redis 连接与缓存工具。"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from app.core.config import settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
try:
|
||||
import redis.asyncio as redis
|
||||
_redis_client = redis.from_url(settings.redis_url, decode_responses=True)
|
||||
except ImportError:
|
||||
_redis_client = None
|
||||
logger.warning("redis 未安装,缓存功能不可用")
|
||||
|
||||
|
||||
async def cache_get(key: str) -> Any | None:
|
||||
"""从 Redis 获取缓存。"""
|
||||
if not _redis_client:
|
||||
return None
|
||||
try:
|
||||
data = await _redis_client.get(key)
|
||||
return json.loads(data) if data else None
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
async def cache_set(key: str, value: Any, ttl: int = 300) -> None:
|
||||
"""设置 Redis 缓存。"""
|
||||
if not _redis_client:
|
||||
return
|
||||
try:
|
||||
await _redis_client.setex(key, ttl, json.dumps(value, default=str))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
async def cache_delete(key: str) -> None:
|
||||
"""删除 Redis 缓存。"""
|
||||
if not _redis_client:
|
||||
return
|
||||
try:
|
||||
await _redis_client.delete(key)
|
||||
except Exception:
|
||||
pass
|
||||
Reference in New Issue
Block a user