"""通用服务:版本检查 / 通知 / 举报(对应《03-接口文档》第 10 章)""" import json from datetime import datetime import redis.asyncio as aioredis from sqlalchemy import func, select, update from sqlalchemy.exc import IntegrityError from sqlalchemy.ext.asyncio import AsyncSession from app.core import errors, ratelimit from app.core.response import BizException from app.models.common import Notification, Report from app.services import config_service # 举报限流:10 次/天/用户 REPORT_DAILY_LIMIT = 10 def _fmt(dt: datetime | None) -> str | None: return dt.strftime("%Y-%m-%d %H:%M:%S") if dt else None # ---------- 版本检查 ---------- async def get_app_version(db: AsyncSession, redis: aioredis.Redis, platform: str, version_code: int) -> dict: """版本检查(10.1):配置存 system_configs key=app.version.{platform},JSON 值,后台可改""" raw = await config_service.get_config(db, redis, f"app.version.{platform}", "") cfg: dict = {} if raw: try: cfg = json.loads(raw) except (TypeError, ValueError): cfg = {} min_code = int(cfg.get("min_version_code", 0)) return { "latest_version": cfg.get("latest_version", "1.0.0"), "latest_version_code": int(cfg.get("latest_version_code", 1)), "force_update": version_code < min_code, "update_url": cfg.get("update_url", ""), "notice": cfg.get("notice", ""), } # ---------- 通知 ---------- async def notify( db: AsyncSession, redis: aioredis.Redis, user_id: int, type: int, title: str, content: str = "", biz_id: str = "", ) -> None: """写入通知并累加 Redis 未读数(审核/提现/公告/评论回复共用,供后续模块调用)""" db.add(Notification(user_id=user_id, type=type, title=title, content=content, biz_id=biz_id)) await db.flush() await redis.incr(f"notif:unread:{user_id}") async def list_notifications( db: AsyncSession, user_id: int, page: int, page_size: int, type: int | None ) -> dict: """通知列表(10.2)""" stmt = select(Notification).where(Notification.user_id == user_id) if type is not None: stmt = stmt.where(Notification.type == type) total = (await db.execute(select(func.count()).select_from(stmt.subquery()))).scalar() or 0 rows = ( await db.execute( stmt.order_by(Notification.id.desc()).offset((page - 1) * page_size).limit(page_size) ) ).scalars().all() return { "total": total, "list": [ { "id": n.id, "type": n.type, "title": n.title, "content": n.content, "biz_id": n.biz_id, "is_read": bool(n.is_read), "created_at": _fmt(n.created_at), } for n in rows ], } async def get_unread_count(db: AsyncSession, redis: aioredis.Redis, user_id: int) -> int: """未读数(10.3):直接读 Redis;缺失时从 DB 回填""" key = f"notif:unread:{user_id}" cached = await redis.get(key) if cached is not None: return max(int(cached), 0) count = (await db.execute( select(func.count(Notification.id)).where(Notification.user_id == user_id, Notification.is_read == 0) )).scalar() or 0 await redis.set(key, count) return count async def mark_read( db: AsyncSession, redis: aioredis.Redis, user_id: int, ids: list[int] | None, mark_all: bool ) -> None: """标记已读(10.4):ids 与 all 二选一;同步修正 Redis 未读数""" if mark_all: await db.execute( update(Notification).where(Notification.user_id == user_id, Notification.is_read == 0) .values(is_read=1) ) await db.commit() await redis.set(f"notif:unread:{user_id}", 0) return if not ids: raise BizException(errors.PARAM_ERROR, "ids 与 all 需传其一") result = await db.execute( update(Notification) .where(Notification.user_id == user_id, Notification.is_read == 0, Notification.id.in_(ids)) .values(is_read=1) ) await db.commit() affected = result.rowcount or 0 if affected > 0: # 未读数下限 0(DB 为准重算,避免并发脏读) remaining = (await db.execute( select(func.count(Notification.id)).where(Notification.user_id == user_id, Notification.is_read == 0) )).scalar() or 0 await redis.set(f"notif:unread:{user_id}", remaining) # ---------- 举报 ---------- async def create_report(db: AsyncSession, redis: aioredis.Redis, user_id: int, req) -> None: """提交举报(10.5):唯一索引 uk_user_target 防重复(2106);限流 10 次/天""" today = f"{datetime.now():%Y%m%d}" await ratelimit.hit(redis, f"rl:report:{user_id}:{today}", REPORT_DAILY_LIMIT, 24 * 3600) db.add(Report( user_id=user_id, target_type=req.target_type, target_id=req.target_id, reason_type=req.reason_type, remark=req.remark or "", )) try: await db.commit() except IntegrityError: await db.rollback() raise BizException(errors.REPORT_DUPLICATED)