1. 积分获取:用户通过发布优质内容、参与互动等方式获取积分," + "每日设有上限,防止刷分行为。
" + "2. 积分扣减:对于违反平台规范的行为,将扣除相应积分作为惩戒," + "严重违规将额外处理。
" + "3. 管理员奖励:不同层级的管理员根据其管理职责,每月可获得固定积分奖励。
" + "4. 积分用途:本平台积分会定期发送到协同办公平台,用于党群礼物兑换。
" + "5. 申诉机制:如对积分变动有异议,可在7个工作日内向管理员提出申诉。
" + ), + "sort_order": 1, + }, +] diff --git a/src/backend/bisheng/points/domain/constants/space_level_rules.py b/src/backend/bisheng/points/domain/constants/space_level_rules.py new file mode 100644 index 000000000..58a772961 --- /dev/null +++ b/src/backend/bisheng/points/domain/constants/space_level_rules.py @@ -0,0 +1,19 @@ +"""知识空间等级到入库类积分规则编码的映射。""" + +from bisheng.knowledge.domain.models.knowledge_space_scope import KnowledgeSpaceLevelEnum + +# personal / favorite 不计分;G5≈团队库,G6≈科室库(TEAM_KS)。 +SPACE_LEVEL_TO_EARN_RULE: dict[str, str] = { + KnowledgeSpaceLevelEnum.PUBLIC.value: "G1", + KnowledgeSpaceLevelEnum.DEPARTMENT.value: "G2", + KnowledgeSpaceLevelEnum.TEAM.value: "G5", + KnowledgeSpaceLevelEnum.TEAM_KS.value: "G6", +} + + +def earn_rule_for_space_level(space_level: str | None) -> str | None: + """返回入库成功应对应的 G* 编码;个人库等返回 None。""" + if not space_level: + return None + value = getattr(space_level, "value", space_level) + return SPACE_LEVEL_TO_EARN_RULE.get(str(value)) diff --git a/src/backend/bisheng/points/domain/models/__init__.py b/src/backend/bisheng/points/domain/models/__init__.py new file mode 100644 index 000000000..0417ef42a --- /dev/null +++ b/src/backend/bisheng/points/domain/models/__init__.py @@ -0,0 +1,17 @@ +"""积分领域数据模型导出。""" + +from bisheng.points.domain.models.points import ( + PointCopy, + PointFavoriteTierAward, + PointPendingDeduct, + PointRankSnapshot, + PointRule, + PointSyncOutbox, + UserPointAccount, + UserPointLog, +) + +__all__ = [ + "PointCopy", "PointFavoriteTierAward", "PointPendingDeduct", "PointRankSnapshot", + "PointRule", "PointSyncOutbox", "UserPointAccount", "UserPointLog", +] diff --git a/src/backend/bisheng/points/domain/models/points.py b/src/backend/bisheng/points/domain/models/points.py new file mode 100644 index 000000000..69d5c0c7c --- /dev/null +++ b/src/backend/bisheng/points/domain/models/points.py @@ -0,0 +1,592 @@ +"""积分模块 ORM 模型:流水只追加,余额与流水在同一事务维护。 + +库表字段均带中文 COMMENT,便于 DBA / 运营直接读库理解语义。 +""" + +from datetime import datetime + +from sqlalchemy import BigInteger, Column, DateTime, Index, Integer, String, UniqueConstraint, text +from sqlmodel import Field + +from bisheng.common.models.base import SQLModelSerializable +from bisheng.core.database.dialect_helpers import UPDATE_TIME_SERVER_DEFAULT, JsonType, LargeText + + +class UserPointAccount(SQLModelSerializable, table=True): + """用户积分账户的读模型;允许余额为负数。""" + + __tablename__ = "user_point_account" + id: int | None = Field( + default=None, + sa_column=Column(BigInteger, primary_key=True, autoincrement=True, comment="主键"), + ) + tenant_id: int = Field( + default=1, + sa_column=Column( + Integer, nullable=False, server_default=text("1"), comment="租户ID" + ), + ) + user_id: int = Field( + sa_column=Column(Integer, nullable=False, comment="用户ID") + ) + balance: int = Field( + default=0, + sa_column=Column( + Integer, nullable=False, server_default=text("0"), comment="当前积分余额(可为负)" + ), + ) + lifetime_earned: int = Field( + default=0, + sa_column=Column( + Integer, nullable=False, server_default=text("0"), comment="累计获得积分" + ), + ) + lifetime_deducted: int = Field( + default=0, + sa_column=Column( + Integer, nullable=False, server_default=text("0"), comment="累计扣减积分(绝对值合计)" + ), + ) + version: int = Field( + default=0, + sa_column=Column( + Integer, nullable=False, server_default=text("0"), comment="乐观锁版本号" + ), + ) + create_time: datetime | None = Field( + default=None, + sa_column=Column( + DateTime, + nullable=False, + server_default=text("CURRENT_TIMESTAMP"), + comment="创建时间", + ), + ) + update_time: datetime | None = Field( + default=None, + sa_column=Column( + DateTime, + nullable=False, + server_default=UPDATE_TIME_SERVER_DEFAULT, + comment="更新时间", + ), + ) + __table_args__ = ( + UniqueConstraint("tenant_id", "user_id", name="uk_upa_tenant_user"), + Index("ix_upa_tenant_balance", "tenant_id", "balance"), + ) + + +class UserPointLog(SQLModelSerializable, table=True): + """不可变积分账本流水;纠错只能通过追加反向流水完成。""" + + __tablename__ = "user_point_log" + id: int | None = Field( + default=None, + sa_column=Column(BigInteger, primary_key=True, autoincrement=True, comment="主键"), + ) + tenant_id: int = Field( + default=1, + sa_column=Column( + Integer, nullable=False, server_default=text("1"), comment="租户ID" + ), + ) + user_id: int = Field( + sa_column=Column(Integer, nullable=False, comment="用户ID") + ) + delta: int = Field( + sa_column=Column(Integer, nullable=False, comment="本次变动分值(正为获得,负为扣减)") + ) + balance_after: int = Field( + sa_column=Column(Integer, nullable=False, comment="变动后余额") + ) + direction: str = Field( + sa_column=Column( + String(16), nullable=False, comment="变动方向:earn/deduct 等" + ) + ) + rule_code: str | None = Field( + default=None, + sa_column=Column(String(32), comment="规则编码,如 G1/R1/M1"), + ) + title: str = Field( + sa_column=Column(String(64), nullable=False, comment="流水标题(展示用)") + ) + source: str = Field( + sa_column=Column( + String(32), + nullable=False, + comment="来源:auto/admin_adjust/monthly_reward 等", + ) + ) + biz_type: str | None = Field( + default=None, + sa_column=Column(String(32), comment="业务类型,如 answer/qa_question"), + ) + biz_id: str | None = Field( + default=None, + sa_column=Column(String(64), comment="业务主键(字符串)"), + ) + idempotency_key: str = Field( + sa_column=Column(String(128), nullable=False, comment="幂等键,租户内唯一") + ) + operator_id: int | None = Field( + default=None, + sa_column=Column(Integer, comment="操作人用户ID(人工调分/扣分时)"), + ) + remark: str | None = Field( + default=None, + sa_column=Column(String(200), comment="备注/原因"), + ) + score_snapshot: int | None = Field( + default=None, + sa_column=Column(Integer, comment="记账时规则分值快照"), + ) + beneficiary_role: str | None = Field( + default=None, + sa_column=Column(String(32), comment="受益人角色,如 answerer/uploader"), + ) + occurred_at: datetime | None = Field( + default=None, + sa_column=Column( + DateTime, + nullable=False, + server_default=text("CURRENT_TIMESTAMP"), + comment="业务发生时间(榜单/日 cap 统计窗口用)", + ), + ) + create_time: datetime | None = Field( + default=None, + sa_column=Column( + DateTime, + nullable=False, + server_default=text("CURRENT_TIMESTAMP"), + comment="记录写入时间", + ), + ) + # ix_upl_tenant_dir_time 末列带 delta:运营概览的 SUM(delta) 可走纯索引扫描免回表。 + # ix_upl_source_time:管理端审计列表按 source 过滤 + occurred_at 倒序分页。 + __table_args__ = ( + UniqueConstraint("tenant_id", "idempotency_key", name="uk_upl_tenant_idem"), + Index("ix_upl_user_time", "tenant_id", "user_id", "occurred_at", "id"), + Index("ix_upl_tenant_dir_time", "tenant_id", "direction", "occurred_at", "delta"), + Index("ix_upl_source_time", "tenant_id", "source", "occurred_at", "id"), + ) + + +class PointRule(SQLModelSerializable, table=True): + """租户积分规则;规则不能物理删除,只能停用。""" + + __tablename__ = "point_rule" + id: int | None = Field( + default=None, + sa_column=Column(Integer, primary_key=True, autoincrement=True, comment="主键"), + ) + tenant_id: int = Field( + default=1, + sa_column=Column( + Integer, nullable=False, server_default=text("1"), comment="租户ID" + ), + ) + rule_code: str = Field( + sa_column=Column(String(16), nullable=False, comment="规则编码,如 G1/R1/M1") + ) + rule_type: str = Field( + sa_column=Column( + String(32), + nullable=False, + comment="规则类型:earn/deduct/admin_reward", + ) + ) + name: str = Field( + sa_column=Column(String(40), nullable=False, comment="规则名称") + ) + score_expr: dict = Field( + default_factory=dict, + sa_column=Column(JsonType, nullable=False, comment="分值表达式 JSON"), + ) + daily_cap: int | None = Field( + default=None, + sa_column=Column(Integer, comment="每日上限;空表示不限制"), + ) + beneficiary: str | None = Field( + default=None, + sa_column=Column(String(32), comment="默认受益人角色"), + ) + status: str = Field( + default="enabled", + sa_column=Column( + String(16), + nullable=False, + server_default=text("'enabled'"), + comment="状态:enabled/disabled", + ), + ) + remark: str | None = Field( + default=None, + sa_column=Column(String(200), comment="备注"), + ) + sort_order: int = Field( + default=0, + sa_column=Column( + Integer, nullable=False, server_default=text("0"), comment="排序权重,越小越靠前" + ), + ) + create_time: datetime | None = Field( + default=None, + sa_column=Column( + DateTime, + nullable=False, + server_default=text("CURRENT_TIMESTAMP"), + comment="创建时间", + ), + ) + update_time: datetime | None = Field( + default=None, + sa_column=Column( + DateTime, + nullable=False, + server_default=UPDATE_TIME_SERVER_DEFAULT, + comment="更新时间", + ), + ) + __table_args__ = (UniqueConstraint("tenant_id", "rule_code", name="uk_pr_tenant_code"),) + + +class PointCopy(SQLModelSerializable, table=True): + """前台规则页的可配置说明文案。""" + + __tablename__ = "point_copy" + id: int | None = Field( + default=None, + sa_column=Column(Integer, primary_key=True, autoincrement=True, comment="主键"), + ) + tenant_id: int = Field( + default=1, + sa_column=Column( + Integer, nullable=False, server_default=text("1"), comment="租户ID" + ), + ) + copy_key: str = Field( + sa_column=Column(String(64), nullable=False, comment="文案键,如 guide") + ) + content: str = Field( + sa_column=Column(LargeText, nullable=False, comment="文案内容(富文本/纯文本)") + ) + sort_order: int = Field( + default=0, + sa_column=Column( + Integer, nullable=False, server_default=text("0"), comment="排序权重" + ), + ) + create_time: datetime | None = Field( + default=None, + sa_column=Column( + DateTime, + nullable=False, + server_default=text("CURRENT_TIMESTAMP"), + comment="创建时间", + ), + ) + update_time: datetime | None = Field( + default=None, + sa_column=Column( + DateTime, + nullable=False, + server_default=UPDATE_TIME_SERVER_DEFAULT, + comment="更新时间", + ), + ) + __table_args__ = (UniqueConstraint("tenant_id", "copy_key", name="uk_pc_tenant_key"),) + + +class PointRankSnapshot(SQLModelSerializable, table=True): + """小时刷新后的排行榜快照。""" + + __tablename__ = "point_rank_snapshot" + id: int | None = Field( + default=None, + sa_column=Column(BigInteger, primary_key=True, autoincrement=True, comment="主键"), + ) + tenant_id: int = Field( + default=1, + sa_column=Column( + Integer, nullable=False, server_default=text("1"), comment="租户ID" + ), + ) + period: str = Field( + sa_column=Column( + String(16), nullable=False, comment="周期类型:month/year/all" + ) + ) + scope: str = Field( + sa_column=Column( + String(16), nullable=False, comment="榜单范围:global(公司)/dept(部门桶)" + ) + ) + scope_id: int | None = Field( + default=None, + sa_column=Column(Integer, comment="范围ID:公司部门ID或部门桶ID"), + ) + period_key: str = Field( + sa_column=Column( + String(16), + nullable=False, + comment="周期键:YYYY-MM / YYYY / all", + ) + ) + user_id: int = Field( + sa_column=Column(Integer, nullable=False, comment="用户ID") + ) + rank_no: int = Field( + sa_column=Column(Integer, nullable=False, comment="名次(同分稠密并列)") + ) + period_score: int = Field( + sa_column=Column(Integer, nullable=False, comment="周期内积分净变动(all 为终身获得)") + ) + balance: int = Field( + sa_column=Column(Integer, nullable=False, comment="快照时账户余额") + ) + dept_id: int | None = Field( + default=None, + sa_column=Column(Integer, comment="用户所属部门桶ID(展示用)"), + ) + refreshed_at: datetime | None = Field( + default=None, + sa_column=Column( + DateTime, + nullable=False, + server_default=text("CURRENT_TIMESTAMP"), + comment="本批快照刷新时间", + ), + ) + create_time: datetime | None = Field( + default=None, + sa_column=Column( + DateTime, + nullable=False, + server_default=text("CURRENT_TIMESTAMP"), + comment="创建时间", + ), + ) + # 榜单每小时整桶重建,读/写/删都按 (period, scope, period_key, scope_id) 定位同一个桶。 + __table_args__ = ( + Index( + "ix_prs_bucket", + "tenant_id", + "period", + "scope", + "period_key", + "scope_id", + "rank_no", + ), + Index( + "ix_prs_user", + "tenant_id", + "period", + "period_key", + "user_id", + "scope", + "scope_id", + ), + Index("ix_prs_refresh", "tenant_id", "period", "period_key", "refreshed_at"), + ) + + +class PointFavoriteTierAward(SQLModelSerializable, table=True): + """记录 G3 已发最高档,防止取消收藏后重复发放。""" + + __tablename__ = "point_favorite_tier_award" + id: int | None = Field( + default=None, + sa_column=Column(BigInteger, primary_key=True, autoincrement=True, comment="主键"), + ) + tenant_id: int = Field( + default=1, + sa_column=Column( + Integer, nullable=False, server_default=text("1"), comment="租户ID" + ), + ) + file_id: int = Field( + sa_column=Column(Integer, nullable=False, comment="知识文件ID") + ) + highest_tier: int = Field( + sa_column=Column(Integer, nullable=False, comment="已发放的最高阶梯档位") + ) + points_granted_total: int = Field( + sa_column=Column(Integer, nullable=False, comment="该文件累计已发 G3 积分") + ) + create_time: datetime | None = Field( + default=None, + sa_column=Column( + DateTime, + nullable=False, + server_default=text("CURRENT_TIMESTAMP"), + comment="创建时间", + ), + ) + update_time: datetime | None = Field( + default=None, + sa_column=Column( + DateTime, + nullable=False, + server_default=UPDATE_TIME_SERVER_DEFAULT, + comment="更新时间", + ), + ) + __table_args__ = (UniqueConstraint("tenant_id", "file_id", name="uk_pfta_tenant_file"),) + + +class PointPendingDeduct(SQLModelSerializable, table=True): + """违规删内容后扣分失败的补扣队列;幂等键与正式流水一致,可安全重试。""" + + __tablename__ = "point_pending_deduct" + id: int | None = Field( + default=None, + sa_column=Column(BigInteger, primary_key=True, autoincrement=True, comment="主键"), + ) + tenant_id: int = Field( + default=1, + sa_column=Column( + Integer, nullable=False, server_default=text("1"), comment="租户ID" + ), + ) + user_id: int = Field( + sa_column=Column(Integer, nullable=False, comment="待扣分用户ID") + ) + rule_code: str = Field( + sa_column=Column(String(32), nullable=False, comment="扣减规则编码,如 R1") + ) + biz_type: str = Field( + sa_column=Column( + String(32), nullable=False, comment="业务类型,如 qa_question/qa_answer" + ) + ) + biz_id: str = Field( + sa_column=Column(String(64), nullable=False, comment="业务主键") + ) + idempotency_key: str = Field( + sa_column=Column(String(128), nullable=False, comment="幂等键,与正式扣分一致") + ) + operator_id: int | None = Field( + default=None, + sa_column=Column(Integer, comment="发起违规删除的操作人ID"), + ) + remark: str | None = Field( + default=None, + sa_column=Column(String(200), comment="扣减原因备注"), + ) + # pending / done / dead + status: str = Field( + default="pending", + sa_column=Column( + String(16), + nullable=False, + server_default=text("'pending'"), + comment="状态:pending/done/dead", + ), + ) + retry_count: int = Field( + default=0, + sa_column=Column( + Integer, nullable=False, server_default=text("0"), comment="已重试次数" + ), + ) + next_retry_at: datetime | None = Field( + default=None, + sa_column=Column(DateTime, comment="下次重试时间"), + ) + last_error: str | None = Field( + default=None, + sa_column=Column(LargeText, comment="最近一次失败错误信息"), + ) + create_time: datetime | None = Field( + default=None, + sa_column=Column( + DateTime, + nullable=False, + server_default=text("CURRENT_TIMESTAMP"), + comment="创建时间", + ), + ) + update_time: datetime | None = Field( + default=None, + sa_column=Column( + DateTime, + nullable=False, + server_default=UPDATE_TIME_SERVER_DEFAULT, + comment="更新时间", + ), + ) + __table_args__ = ( + UniqueConstraint("tenant_id", "idempotency_key", name="uk_ppd_tenant_idem"), + Index("ix_ppd_due", "status", "next_retry_at", "id"), + ) + + +class PointSyncOutbox(SQLModelSerializable, table=True): + """积分流水的外部同步发件箱;外部失败不阻塞记账。""" + + __tablename__ = "point_sync_outbox" + id: int | None = Field( + default=None, + sa_column=Column(BigInteger, primary_key=True, autoincrement=True, comment="主键"), + ) + tenant_id: int = Field( + default=1, + sa_column=Column( + Integer, nullable=False, server_default=text("1"), comment="租户ID" + ), + ) + log_id: int = Field( + sa_column=Column(BigInteger, nullable=False, comment="关联 user_point_log.id") + ) + payload: dict = Field( + default_factory=dict, + sa_column=Column(JsonType, nullable=False, comment="同步载荷 JSON"), + ) + status: str = Field( + default="pending", + sa_column=Column( + String(16), + nullable=False, + server_default=text("'pending'"), + comment="状态:pending/sent/dead 等", + ), + ) + retry_count: int = Field( + default=0, + sa_column=Column( + Integer, nullable=False, server_default=text("0"), comment="已重试次数" + ), + ) + next_retry_at: datetime | None = Field( + default=None, + sa_column=Column(DateTime, comment="下次重试时间"), + ) + last_error: str | None = Field( + default=None, + sa_column=Column(LargeText, comment="最近一次失败错误信息"), + ) + sent_at: datetime | None = Field( + default=None, + sa_column=Column(DateTime, comment="成功发送时间"), + ) + create_time: datetime | None = Field( + default=None, + sa_column=Column( + DateTime, + nullable=False, + server_default=text("CURRENT_TIMESTAMP"), + comment="创建时间", + ), + ) + update_time: datetime | None = Field( + default=None, + sa_column=Column( + DateTime, + nullable=False, + server_default=UPDATE_TIME_SERVER_DEFAULT, + comment="更新时间", + ), + ) diff --git a/src/backend/bisheng/points/domain/repositories/points_repository.py b/src/backend/bisheng/points/domain/repositories/points_repository.py new file mode 100644 index 000000000..edcd24cd6 --- /dev/null +++ b/src/backend/bisheng/points/domain/repositories/points_repository.py @@ -0,0 +1,713 @@ +"""积分仓储:集中持有 ORM 读写,服务层不直接拼装查询。""" + +from datetime import datetime + +from sqlalchemy import and_, delete, func, insert, or_ +from sqlmodel import select + +from bisheng.points.domain.models import ( + PointCopy, + PointFavoriteTierAward, + PointPendingDeduct, + PointRankSnapshot, + PointRule, + PointSyncOutbox, + UserPointAccount, + UserPointLog, +) + +# 排行快照批量插入的分片大小,避免单条语句超过 max_allowed_packet。 +RANK_SNAPSHOT_INSERT_CHUNK = 2000 + + +class PointsRepository: + """在调用方事务中执行积分账户、流水、规则与快照读写。""" + + def __init__(self, session): + self.session = session + + async def lock_or_create_account(self, tenant_id: int, user_id: int) -> UserPointAccount: + """锁定用户账户;并发首建通过嵌套事务吸收唯一键竞争。""" + row = ( + await self.session.exec( + select(UserPointAccount) + .where(UserPointAccount.tenant_id == tenant_id, UserPointAccount.user_id == user_id) + .with_for_update() + ) + ).first() + if row: + return row + try: + async with self.session.begin_nested(): + row = UserPointAccount(tenant_id=tenant_id, user_id=user_id) + self.session.add(row) + await self.session.flush() + except Exception: + # 并发首建撞唯一键时回读并重新加锁。 + row = ( + await self.session.exec( + select(UserPointAccount) + .where(UserPointAccount.tenant_id == tenant_id, UserPointAccount.user_id == user_id) + .with_for_update() + ) + ).one() + return row + + async def find_account(self, tenant_id: int, user_id: int) -> UserPointAccount | None: + """按租户与用户读取账户;无账户时返回 None。""" + return ( + await self.session.exec( + select(UserPointAccount).where( + UserPointAccount.tenant_id == tenant_id, + UserPointAccount.user_id == user_id, + ) + ) + ).first() + + async def get_log_by_idempotency(self, tenant_id: int, key: str) -> UserPointLog | None: + """按幂等键获取已写入流水。""" + return ( + await self.session.exec( + select(UserPointLog).where( + UserPointLog.tenant_id == tenant_id, + UserPointLog.idempotency_key == key, + ) + ) + ).first() + + async def get_log_by_id(self, log_id: int) -> UserPointLog | None: + """按主键读取流水。""" + return (await self.session.exec(select(UserPointLog).where(UserPointLog.id == log_id))).first() + + async def sum_earn_today(self, tenant_id: int, user_id: int, rule_code: str, start: datetime) -> int: + """汇总上海业务日内同规则已获得分数。""" + value = ( + await self.session.exec( + select(func.coalesce(func.sum(UserPointLog.delta), 0)).where( + UserPointLog.tenant_id == tenant_id, + UserPointLog.user_id == user_id, + UserPointLog.rule_code == rule_code, + UserPointLog.direction == "earn", + UserPointLog.occurred_at >= start, + ) + ) + ).one() + return int(value[0] if isinstance(value, tuple) else value or 0) + + async def sum_user_delta( + self, + tenant_id: int, + user_id: int, + *, + direction: str, + start: datetime, + end: datetime, + ) -> int: + """汇总用户在时间窗内某方向的 delta 合计。""" + value = ( + await self.session.exec( + select(func.coalesce(func.sum(UserPointLog.delta), 0)).where( + UserPointLog.tenant_id == tenant_id, + UserPointLog.user_id == user_id, + UserPointLog.direction == direction, + UserPointLog.occurred_at >= start, + UserPointLog.occurred_at < end, + ) + ) + ).one() + return int(value[0] if isinstance(value, tuple) else value or 0) + + async def append_log(self, log: UserPointLog) -> UserPointLog: + """追加账本流水并刷新主键。""" + self.session.add(log) + await self.session.flush() + return log + + async def add_outbox(self, tenant_id: int, log_id: int, payload: dict) -> None: + """为已写流水建立待同步记录。""" + self.session.add(PointSyncOutbox(tenant_id=tenant_id, log_id=log_id, payload=payload)) + + async def list_logs( + self, + tenant_id: int, + user_id: int, + direction: str | None, + page: int, + page_size: int, + *, + from_time: datetime | None = None, + to_time: datetime | None = None, + ): + """分页查询用户流水,按发生时间倒序。""" + filters = [ + UserPointLog.tenant_id == tenant_id, + UserPointLog.user_id == user_id, + ] + if direction: + filters.append(UserPointLog.direction == direction) + if from_time is not None: + filters.append(UserPointLog.occurred_at >= from_time) + if to_time is not None: + filters.append(UserPointLog.occurred_at < to_time) + total = ( + await self.session.exec(select(func.count()).select_from(UserPointLog).where(*filters)) + ).one() + rows = ( + await self.session.exec( + select(UserPointLog) + .where(*filters) + .order_by(UserPointLog.occurred_at.desc(), UserPointLog.id.desc()) + .offset((page - 1) * page_size) + .limit(page_size) + ) + ).all() + return list(rows), int(total[0] if isinstance(total, tuple) else total) + + async def get_rule(self, tenant_id: int, rule_code: str) -> PointRule | None: + """按编码读取规则。""" + return ( + await self.session.exec( + select(PointRule).where(PointRule.tenant_id == tenant_id, PointRule.rule_code == rule_code) + ) + ).first() + + async def get_rule_by_id(self, rule_id: int) -> PointRule | None: + """按主键读取规则。""" + return (await self.session.exec(select(PointRule).where(PointRule.id == rule_id))).first() + + async def list_rules( + self, + tenant_id: int, + *, + rule_type: str | None = None, + status: str | None = None, + ) -> list[PointRule]: + """列出租户规则,按 sort_order、id 排序。""" + stmt = select(PointRule).where(PointRule.tenant_id == tenant_id) + if rule_type: + stmt = stmt.where(PointRule.rule_type == rule_type) + if status: + stmt = stmt.where(PointRule.status == status) + rows = (await self.session.exec(stmt.order_by(PointRule.sort_order, PointRule.id))).all() + return list(rows) + + async def save_rule(self, rule: PointRule) -> PointRule: + """持久化规则并刷新。""" + self.session.add(rule) + await self.session.flush() + await self.session.refresh(rule) + return rule + + async def list_copies(self, tenant_id: int) -> list[PointCopy]: + """列出租户说明文案。""" + rows = ( + await self.session.exec( + select(PointCopy) + .where(PointCopy.tenant_id == tenant_id) + .order_by(PointCopy.sort_order, PointCopy.id) + ) + ).all() + return list(rows) + + async def upsert_copies(self, tenant_id: int, items: list[dict]) -> list[PointCopy]: + """按 copy_key 批量更新文案内容;不存在则创建。 + + Keys present for the tenant but missing from ``items`` are deleted + (replace-set semantics — product keeps a single ``guide`` row). + """ + result: list[PointCopy] = [] + keep_keys: set[str] = set() + for item in items: + key = item["copy_key"] + keep_keys.add(key) + row = ( + await self.session.exec( + select(PointCopy).where(PointCopy.tenant_id == tenant_id, PointCopy.copy_key == key) + ) + ).first() + if row is None: + row = PointCopy( + tenant_id=tenant_id, + copy_key=key, + content=item["content"], + sort_order=int(item.get("sort_order") or 0), + ) + else: + row.content = item["content"] + if "sort_order" in item and item["sort_order"] is not None: + row.sort_order = int(item["sort_order"]) + self.session.add(row) + result.append(row) + + existing = ( + await self.session.exec(select(PointCopy).where(PointCopy.tenant_id == tenant_id)) + ).all() + for row in existing: + if row.copy_key not in keep_keys: + await self.session.delete(row) + + await self.session.flush() + return result + + async def sum_total_issued(self, tenant_id: int) -> int: + """租户累计发放(earn 方向 delta 之和)。""" + value = ( + await self.session.exec( + select(func.coalesce(func.sum(UserPointLog.delta), 0)).where( + UserPointLog.tenant_id == tenant_id, + UserPointLog.direction == "earn", + ) + ) + ).one() + return int(value[0] if isinstance(value, tuple) else value or 0) + + async def sum_total_balance(self, tenant_id: int) -> int: + """租户当前余额合计。""" + value = ( + await self.session.exec( + select(func.coalesce(func.sum(UserPointAccount.balance), 0)).where( + UserPointAccount.tenant_id == tenant_id + ) + ) + ).one() + return int(value[0] if isinstance(value, tuple) else value or 0) + + async def sum_violation_deducted(self, tenant_id: int) -> int: + """违规扣减合计:manual_deduct + 负向 manual_adjust 的绝对值。""" + value = ( + await self.session.exec( + select(func.coalesce(func.sum(UserPointLog.delta), 0)).where( + UserPointLog.tenant_id == tenant_id, + UserPointLog.direction == "deduct", + or_( + UserPointLog.source == "manual_deduct", + and_(UserPointLog.source == "manual_adjust", UserPointLog.delta < 0), + ), + ) + ) + ).one() + raw = int(value[0] if isinstance(value, tuple) else value or 0) + return abs(raw) + + async def find_user_rank( + self, + tenant_id: int, + period: str, + scope: str, + scope_id: int | None, + period_key: str, + user_id: int, + ) -> PointRankSnapshot | None: + """读取用户在指定榜单桶中的排名快照。""" + stmt = select(PointRankSnapshot).where( + PointRankSnapshot.tenant_id == tenant_id, + PointRankSnapshot.period == period, + PointRankSnapshot.scope == scope, + PointRankSnapshot.period_key == period_key, + PointRankSnapshot.user_id == user_id, + ) + if scope_id is None: + stmt = stmt.where(PointRankSnapshot.scope_id.is_(None)) + else: + stmt = stmt.where(PointRankSnapshot.scope_id == scope_id) + return (await self.session.exec(stmt)).first() + + async def list_top_ranks( + self, + tenant_id: int, + period: str, + scope: str, + scope_id: int | None, + period_key: str, + *, + limit: int = 10, + ) -> list[PointRankSnapshot]: + """读取 TOP N 排名快照。""" + stmt = select(PointRankSnapshot).where( + PointRankSnapshot.tenant_id == tenant_id, + PointRankSnapshot.period == period, + PointRankSnapshot.scope == scope, + PointRankSnapshot.period_key == period_key, + ) + if scope_id is None: + stmt = stmt.where(PointRankSnapshot.scope_id.is_(None)) + else: + stmt = stmt.where(PointRankSnapshot.scope_id == scope_id) + # TOP N 按人数截断:同分同名次时仍按分值降序、user_id 升序取前 N 人。 + rows = ( + await self.session.exec( + stmt.order_by( + PointRankSnapshot.period_score.desc(), + PointRankSnapshot.user_id.asc(), + ).limit(limit) + ) + ).all() + return list(rows) + + async def latest_rank_refreshed_at( + self, tenant_id: int, period: str, period_key: str + ) -> datetime | None: + """返回指定榜单最近刷新时间。""" + value = ( + await self.session.exec( + select(func.max(PointRankSnapshot.refreshed_at)).where( + PointRankSnapshot.tenant_id == tenant_id, + PointRankSnapshot.period == period, + PointRankSnapshot.period_key == period_key, + ) + ) + ).one() + if value is None: + return None + return value[0] if isinstance(value, tuple) else value + + async def get_favorite_tier_award( + self, tenant_id: int, file_id: int + ) -> PointFavoriteTierAward | None: + """读取文档已发放的 G3 最高档记录。""" + return ( + await self.session.exec( + select(PointFavoriteTierAward).where( + PointFavoriteTierAward.tenant_id == tenant_id, + PointFavoriteTierAward.file_id == file_id, + ) + ) + ).first() + + async def upsert_favorite_tier_award( + self, + tenant_id: int, + file_id: int, + *, + highest_tier: int, + points_granted_total: int, + ) -> PointFavoriteTierAward: + """更新或创建 G3 档位发放进度,取消收藏后也不回退。""" + row = await self.get_favorite_tier_award(tenant_id, file_id) + if row is None: + row = PointFavoriteTierAward( + tenant_id=tenant_id, + file_id=file_id, + highest_tier=highest_tier, + points_granted_total=points_granted_total, + ) + else: + # 仅抬升已授分数/档位,避免收藏人数回落后被重复补发。 + row.highest_tier = max(int(row.highest_tier), highest_tier) + row.points_granted_total = max(int(row.points_granted_total), points_granted_total) + self.session.add(row) + await self.session.flush() + return row + + async def list_accounts(self, tenant_id: int) -> list[UserPointAccount]: + """列出租户全部积分账户。""" + rows = ( + await self.session.exec( + select(UserPointAccount).where(UserPointAccount.tenant_id == tenant_id) + ) + ).all() + return list(rows) + + async def list_accounts_page( + self, + tenant_id: int, + *, + page: int, + page_size: int, + user_ids: list[int] | None = None, + ) -> tuple[list[UserPointAccount], int]: + """分页列出积分账户;可按 user_ids 过滤(关键词预解析后传入)。""" + filters = [UserPointAccount.tenant_id == tenant_id] + if user_ids is not None: + if not user_ids: + return [], 0 + filters.append(UserPointAccount.user_id.in_(user_ids)) + total = ( + await self.session.exec(select(func.count()).select_from(UserPointAccount).where(*filters)) + ).one() + rows = ( + await self.session.exec( + select(UserPointAccount) + .where(*filters) + .order_by(UserPointAccount.balance.desc(), UserPointAccount.user_id.asc()) + .offset(max(page - 1, 0) * page_size) + .limit(page_size) + ) + ).all() + return list(rows), int(total[0] if isinstance(total, tuple) else total) + + async def list_audit_logs( + self, + tenant_id: int, + *, + page: int, + page_size: int, + sources: list[str] | None = None, + user_id: int | None = None, + ) -> tuple[list[UserPointLog], int]: + """管理端审计:默认看 manual/deduct;可扩 source。""" + filters = [UserPointLog.tenant_id == tenant_id] + if sources: + filters.append(UserPointLog.source.in_(sources)) + if user_id is not None: + filters.append(UserPointLog.user_id == int(user_id)) + total = ( + await self.session.exec(select(func.count()).select_from(UserPointLog).where(*filters)) + ).one() + rows = ( + await self.session.exec( + select(UserPointLog) + .where(*filters) + .order_by(UserPointLog.occurred_at.desc(), UserPointLog.id.desc()) + .offset(max(page - 1, 0) * page_size) + .limit(page_size) + ) + ).all() + return list(rows), int(total[0] if isinstance(total, tuple) else total) + + async def list_tenant_ids_with_accounts(self) -> list[int]: + """返回存在积分账户的租户 id(Beat 扫租户用)。""" + rows = (await self.session.exec(select(UserPointAccount.tenant_id).distinct())).all() + return sorted({int(r[0] if isinstance(r, tuple) else r) for r in rows}) + + async def sum_deltas_by_user( + self, + tenant_id: int, + *, + start: datetime, + end: datetime, + ) -> dict[int, int]: + """按用户汇总时间窗内全部 delta(月/年净变动)。""" + rows = ( + await self.session.exec( + select(UserPointLog.user_id, func.coalesce(func.sum(UserPointLog.delta), 0)).where( + UserPointLog.tenant_id == tenant_id, + UserPointLog.occurred_at >= start, + UserPointLog.occurred_at < end, + ).group_by(UserPointLog.user_id) + ) + ).all() + result: dict[int, int] = {} + for row in rows: + user_id, total = row[0], row[1] + result[int(user_id)] = int(total or 0) + return result + + async def sum_deltas_by_users( + self, + tenant_id: int, + user_ids: list[int], + *, + start: datetime, + end: datetime, + ) -> dict[int, int]: + """按给定用户集合汇总时间窗内 delta。 + + 供管理端列表按页取值:只聚合当页用户,避免为 20 行数据扫全租户整月流水。 + user_ids 为空时直接返回空字典,不发查询。 + """ + if not user_ids: + return {} + rows = ( + await self.session.exec( + select(UserPointLog.user_id, func.coalesce(func.sum(UserPointLog.delta), 0)) + .where( + UserPointLog.tenant_id == tenant_id, + UserPointLog.user_id.in_(user_ids), + UserPointLog.occurred_at >= start, + UserPointLog.occurred_at < end, + ) + .group_by(UserPointLog.user_id) + ) + ).all() + return {int(row[0]): int(row[1] or 0) for row in rows} + + async def sum_lifetime_deltas_by_user(self, tenant_id: int) -> dict[int, int]: + """按用户汇总全部流水 delta(对账期望余额)。""" + rows = ( + await self.session.exec( + select(UserPointLog.user_id, func.coalesce(func.sum(UserPointLog.delta), 0)) + .where(UserPointLog.tenant_id == tenant_id) + .group_by(UserPointLog.user_id) + ) + ).all() + result: dict[int, int] = {} + for row in rows: + user_id, total = row[0], row[1] + result[int(user_id)] = int(total or 0) + return result + + async def list_due_sync_outbox( + self, + *, + limit: int = 100, + now: datetime | None = None, + ) -> list[PointSyncOutbox]: + """列出到期可投递的 pending/failed outbox(跨租户时需 bypass)。""" + current = now or datetime.utcnow() + rows = ( + await self.session.exec( + select(PointSyncOutbox) + .where( + PointSyncOutbox.status.in_(["pending", "failed"]), + or_( + PointSyncOutbox.next_retry_at.is_(None), + PointSyncOutbox.next_retry_at <= current, + ), + ) + .order_by(PointSyncOutbox.id) + .limit(limit) + ) + ).all() + return list(rows) + + async def save_outbox(self, row: PointSyncOutbox) -> PointSyncOutbox: + """持久化 outbox 状态变更。""" + self.session.add(row) + await self.session.flush() + return row + + async def clear_dept_rank_snapshots(self, tenant_id: int, period: str, period_key: str) -> None: + """删除某 period_key 下全部部门桶快照(刷新前清僵尸桶)。""" + await self.session.exec( + delete(PointRankSnapshot).where( + PointRankSnapshot.tenant_id == tenant_id, + PointRankSnapshot.period == period, + PointRankSnapshot.scope == "dept", + PointRankSnapshot.period_key == period_key, + ) + ) + + async def clear_period_rank_snapshots(self, tenant_id: int, period: str, period_key: str) -> None: + """删除某 period_key 下全部快照(含旧全租户 global 与各公司/部门桶)。""" + await self.session.exec( + delete(PointRankSnapshot).where( + PointRankSnapshot.tenant_id == tenant_id, + PointRankSnapshot.period == period, + PointRankSnapshot.period_key == period_key, + ) + ) + + @staticmethod + def _snapshot_values(rows: list[PointRankSnapshot]) -> list[dict]: + """快照 ORM 对象 → 批量插入用的字典列表。 + + 故意不带 id 与 create_time:分别交给自增主键与库端默认值, + 与逐行 ORM 插入时的落库结果保持一致。 + """ + return [ + { + "tenant_id": row.tenant_id, + "period": row.period, + "scope": row.scope, + "scope_id": row.scope_id, + "period_key": row.period_key, + "user_id": row.user_id, + "rank_no": row.rank_no, + "period_score": row.period_score, + "balance": row.balance, + "dept_id": row.dept_id, + "refreshed_at": row.refreshed_at, + } + for row in rows + ] + + async def bulk_insert_rank_snapshots(self, rows: list[PointRankSnapshot]) -> int: + """批量写入排行快照(不含删除);返回写入行数。 + + 用 Core 批量 insert 而非逐行 ``session.add()``:MySQL 无 RETURNING,ORM flush + 为回填自增主键会退化成一行一条 INSERT(实测 4.2 万行约 12s,批量后约 0.5s)。 + 这些行在 ``build_ranked_rows`` 里已显式带上 tenant_id,因此绕过 before_flush + 的租户回填不影响正确性。 + """ + if not rows: + return 0 + values = self._snapshot_values(rows) + for start in range(0, len(values), RANK_SNAPSHOT_INSERT_CHUNK): + await self.session.execute( + insert(PointRankSnapshot), values[start : start + RANK_SNAPSHOT_INSERT_CHUNK] + ) + return len(rows) + + async def replace_rank_snapshots( + self, + tenant_id: int, + period: str, + scope: str, + scope_id: int | None, + period_key: str, + rows: list[PointRankSnapshot], + ) -> int: + """删除同一榜单维度后写入新快照;返回写入行数。""" + stmt = delete(PointRankSnapshot).where( + PointRankSnapshot.tenant_id == tenant_id, + PointRankSnapshot.period == period, + PointRankSnapshot.scope == scope, + PointRankSnapshot.period_key == period_key, + ) + if scope_id is None: + stmt = stmt.where(PointRankSnapshot.scope_id.is_(None)) + else: + stmt = stmt.where(PointRankSnapshot.scope_id == scope_id) + await self.session.exec(stmt) + return await self.bulk_insert_rank_snapshots(rows) + + async def get_pending_deduct_by_key( + self, tenant_id: int, idempotency_key: str + ) -> PointPendingDeduct | None: + """按幂等键读取补扣行。""" + return ( + await self.session.exec( + select(PointPendingDeduct).where( + PointPendingDeduct.tenant_id == tenant_id, + PointPendingDeduct.idempotency_key == idempotency_key, + ) + ) + ).first() + + async def upsert_pending_deduct(self, row: PointPendingDeduct) -> PointPendingDeduct: + """插入补扣行;同幂等键已存在则返回已有行(并发安全)。""" + existing = await self.get_pending_deduct_by_key(int(row.tenant_id), row.idempotency_key) + if existing is not None: + return existing + self.session.add(row) + try: + await self.session.flush() + return row + except Exception: + # 唯一键冲突:回滚本次 flush 后读已有行(独立 session 场景下安全)。 + await self.session.rollback() + existing = await self.get_pending_deduct_by_key(int(row.tenant_id), row.idempotency_key) + if existing is not None: + return existing + raise + + async def list_due_pending_deducts( + self, + *, + limit: int = 100, + now: datetime | None = None, + ) -> list[PointPendingDeduct]: + """列出到期可重试的补扣任务。""" + current = now or datetime.utcnow() + rows = ( + await self.session.exec( + select(PointPendingDeduct) + .where( + PointPendingDeduct.status == "pending", + or_( + PointPendingDeduct.next_retry_at.is_(None), + PointPendingDeduct.next_retry_at <= current, + ), + ) + .order_by(PointPendingDeduct.id) + .limit(limit) + ) + ).all() + return list(rows) + + async def save_pending_deduct(self, row: PointPendingDeduct) -> PointPendingDeduct: + """持久化补扣行状态。""" + self.session.add(row) + await self.session.flush() + return row diff --git a/src/backend/bisheng/points/domain/schemas/points_schema.py b/src/backend/bisheng/points/domain/schemas/points_schema.py new file mode 100644 index 000000000..89b81e444 --- /dev/null +++ b/src/backend/bisheng/points/domain/schemas/points_schema.py @@ -0,0 +1,178 @@ +"""积分接口的请求与响应模式。""" + +from datetime import datetime + +from pydantic import BaseModel, Field + + +class PointAdjustRequest(BaseModel): + """管理员手动调分请求。""" + + user_id: int + delta: int + remark: str = Field(min_length=5, max_length=100) + + +class PointDeductRequest(BaseModel): + """按扣减规则执行积分扣减。""" + + user_id: int + rule_code: str + biz_type: str | None = None + biz_id: str | None = None + remark: str | None = None + + +class PointRuleRequest(BaseModel): + """创建或更新积分规则的可变字段。""" + + rule_code: str | None = None + rule_type: str | None = None + name: str | None = None + score_expr: dict | None = None + daily_cap: int | None = None + beneficiary: str | None = None + status: str | None = None + remark: str | None = None + sort_order: int | None = None + + +class PointCopyItem(BaseModel): + """单条说明文案。""" + + copy_key: str + content: str + sort_order: int = 0 + + +class PointCopiesUpdateRequest(BaseModel): + """批量更新说明文案。""" + + items: list[PointCopyItem] + + +class PointLogResponse(BaseModel): + """前台流水展示字段。""" + + id: int + title: str + delta: int + balance_after: int + direction: str + rule_code: str | None + source: str + remark: str | None + occurred_at: datetime | None + + +class PointSummaryResponse(BaseModel): + """我的积分摘要。""" + + balance: int + month_earned: int + month_deducted: int + dept_rank: int | None = None + global_rank: int | None = None + global_rank_display: str = "-" + rank_refreshed_at: datetime | None = None + + +class PointOverviewResponse(BaseModel): + """运营概览三绝对数。""" + + total_issued: int + total_balance: int + total_violation_deducted: int + + +class PointAdminUserItem(BaseModel): + """管理端用户积分列表行。""" + + user_id: int + user_name: str = "" + dept_name: str = "—" + balance: int + month_score: int = 0 + + +class PointAuditLogItem(BaseModel): + """管理端操作/审计流水行。""" + + id: int + user_id: int + user_name: str = "" + title: str + delta: int + balance_after: int + direction: str + rule_code: str | None = None + source: str + operator_id: int | None = None + remark: str | None = None + occurred_at: datetime | None = None + + +class PointRuleResponse(BaseModel): + """规则列表项。""" + + id: int + rule_code: str + rule_type: str + name: str + score_expr: dict + daily_cap: int | None + beneficiary: str | None + beneficiary_options: list[str] = Field(default_factory=list) + status: str + remark: str | None + sort_order: int + + +class PointLeaderboardItem(BaseModel): + """排行榜条目。""" + + rank: int + user_id: int + user_name: str = "" + dept_name: str = "" + balance: int + period_score: int + + +class PointLeaderboardResponse(BaseModel): + """排行榜响应。""" + + period: str + refreshed_at: datetime | None + items: list[PointLeaderboardItem] + + +class DepartmentOrgLevelItem(BaseModel): + """部门组织四级标签只读项。""" + + id: int + dept_id: str | None = None + name: str | None = None + parent_id: int | None = None + path: str | None = None + org_level: str | None = None + + +class SetCompanyRootRequest(BaseModel): + """指定公司根请求;confirm 预留给二次确认 UI。""" + + confirm: bool = True + + +class SetCompanyRootResponse(BaseModel): + """公司根级联打标结果。""" + + company_id: int + labeled_count: int + levels: dict[str, int] + + +class ClearCompanyRootResponse(BaseModel): + """取消公司根并清空组织层级标签的结果。""" + + cleared_count: int diff --git a/src/backend/bisheng/points/domain/services/department_org_level_service.py b/src/backend/bisheng/points/domain/services/department_org_level_service.py new file mode 100644 index 000000000..417ca764b --- /dev/null +++ b/src/backend/bisheng/points/domain/services/department_org_level_service.py @@ -0,0 +1,172 @@ +"""组织四级标签:多公司作用域级联 dept/office/squad(公司之间互不干扰)。""" + +from __future__ import annotations + +from sqlalchemy import update +from sqlmodel import select + +from bisheng.common.errcode.department import DepartmentNotFoundError +from bisheng.common.errcode.points import PointsCompanyRootConflictError, PointsNotCompanyRootError +from bisheng.core.database import get_async_db_session +from bisheng.database.models.department import Department, DepartmentDao +from bisheng.points.domain.constants.org_levels import ( + ORG_LEVEL_COMPANY, + ORG_LEVELS, + org_level_for_relative_depth, + relative_depth, +) +from bisheng.points.domain.schemas.points_schema import ( + ClearCompanyRootResponse, + DepartmentOrgLevelItem, + SetCompanyRootResponse, +) +from bisheng.points.domain.services.points_auth import require_platform_admin + + +class DepartmentOrgLevelService: + """维护 department.org_level;不改拓扑与用户挂载。""" + + @staticmethod + async def _resolve_department(dept_key: str) -> Department: + """支持内部数字 id 或业务 dept_id。""" + key = (dept_key or "").strip() + if not key: + raise DepartmentNotFoundError() + if key.isdigit(): + row = await DepartmentDao.aget_by_id(int(key)) + if row is not None: + return row + row = await DepartmentDao.aget_by_dept_id(key) + if row is None: + raise DepartmentNotFoundError() + return row + + @staticmethod + def _assert_no_company_nesting(company: Department, existing_companies: list) -> None: + """禁止嵌套:目标在其他公司子树内,或子树内已有其他公司根。""" + company_path = str(company.path or "") + company_id = int(company.id) + for row in existing_companies: + other_id = int(row.id) + if other_id == company_id: + continue + other_path = str(row.path or "") + if not other_path or not company_path: + continue + # 目标落在已有公司子树内 + if company_path.startswith(other_path): + raise PointsCompanyRootConflictError() + # 已有公司落在目标子树内 + if other_path.startswith(company_path): + raise PointsCompanyRootConflictError() + + async def list_org_levels(self, user) -> list[DepartmentOrgLevelItem]: + """列出当前租户活跃部门的 org_level(只读)。""" + _ = user # 登录即可读;租户过滤由 ORM 事件注入。 + rows = await DepartmentDao.aget_all_active() + return [ + DepartmentOrgLevelItem( + id=int(row.id), + dept_id=row.dept_id, + name=row.name, + parent_id=row.parent_id, + path=row.path, + org_level=row.org_level, + ) + for row in rows + ] + + async def set_company_root(self, user, dept_key: str) -> SetCompanyRootResponse: + """指定公司根并仅在该子树内级联打标;允许多公司并列,禁止嵌套。 + + 同一公司根可重复调用以重算子树。只清空目标 path 子树标签后再写入。 + """ + require_platform_admin(user) + company = await self._resolve_department(dept_key) + if not company.path: + raise DepartmentNotFoundError(msg="部门缺少 path,无法级联打标") + + async with get_async_db_session() as session: + existing = ( + await session.exec( + select(Department).where( + Department.org_level == ORG_LEVEL_COMPANY, + Department.status == "active", + ) + ) + ).all() + self._assert_no_company_nesting(company, list(existing)) + + # 仅清空本公司子树标签,不影响其他公司。 + await session.exec( + update(Department) + .where( + Department.path.like(f"{company.path}%"), + Department.status == "active", + ) + .values(org_level=None) + ) + + subtree = ( + await session.exec( + select(Department).where( + Department.path.like(f"{company.path}%"), + Department.status == "active", + ) + ) + ).all() + levels = {level: 0 for level in ORG_LEVELS} + labeled = 0 + for node in subtree: + rel = relative_depth(company.path, node.path) + if rel is None: + continue + label = org_level_for_relative_depth(rel) + node.org_level = label + session.add(node) + levels[label] = levels.get(label, 0) + 1 + labeled += 1 + await session.commit() + + return SetCompanyRootResponse( + company_id=int(company.id), + labeled_count=labeled, + levels={ + "company": levels.get(ORG_LEVEL_COMPANY, 0), + "dept": levels.get("dept", 0), + "office": levels.get("office", 0), + "squad": levels.get("squad", 0), + }, + ) + + async def clear_company_root(self, user, dept_key: str) -> ClearCompanyRootResponse: + """取消公司根:仅清空该公司 path 子树的 org_level。""" + require_platform_admin(user) + company = await self._resolve_department(dept_key) + if company.org_level != ORG_LEVEL_COMPANY: + raise PointsNotCompanyRootError() + if not company.path: + raise DepartmentNotFoundError(msg="部门缺少 path,无法取消公司标签") + + async with get_async_db_session() as session: + labeled = ( + await session.exec( + select(Department).where( + Department.path.like(f"{company.path}%"), + Department.org_level.is_not(None), + Department.status == "active", + ) + ) + ).all() + cleared_count = len(labeled) + await session.exec( + update(Department) + .where( + Department.path.like(f"{company.path}%"), + Department.status == "active", + ) + .values(org_level=None) + ) + await session.commit() + + return ClearCompanyRootResponse(cleared_count=cleared_count) diff --git a/src/backend/bisheng/points/domain/services/points_auth.py b/src/backend/bisheng/points/domain/services/points_auth.py new file mode 100644 index 000000000..481e4f785 --- /dev/null +++ b/src/backend/bisheng/points/domain/services/points_auth.py @@ -0,0 +1,19 @@ +"""积分写操作的统一平台超级管理员校验。""" + +from bisheng.common.errcode.points import PointsPermissionDeniedError + + +def is_platform_super_admin(user) -> bool: + """平台超管:RBAC AdminRole 或 JWT 已解析的 is_global_super。""" + if not user: + return False + if getattr(user, "is_global_super", False): + return True + check = getattr(user, "is_admin", None) + return bool(check() if callable(check) else check) + + +def require_platform_admin(user) -> None: + """不具备平台管理员身份时抛出积分模块错误码。""" + if not is_platform_super_admin(user): + raise PointsPermissionDeniedError() diff --git a/src/backend/bisheng/points/domain/services/points_award_facade.py b/src/backend/bisheng/points/domain/services/points_award_facade.py new file mode 100644 index 000000000..9f7f594d4 --- /dev/null +++ b/src/backend/bisheng/points/domain/services/points_award_facade.py @@ -0,0 +1,332 @@ +"""业务侧自动发分门面:解析规则、豁免与受益人后调用账本。""" + +from __future__ import annotations + +import logging +from collections.abc import Awaitable, Callable +from dataclasses import dataclass, field +from typing import Any + +from bisheng.points.domain.constants.space_level_rules import earn_rule_for_space_level +from bisheng.points.domain.services.points_ledger_service import LedgerResult, PointsLedgerService + +logger = logging.getLogger(__name__) + +IsSuperAdminFn = Callable[[int], Awaitable[bool]] + + +@dataclass(frozen=True) +class SpaceFileReadyEvent: + """文件成功进入目标空间(直传或发布审批通过)后的发分上下文。""" + + tenant_id: int + space_id: int + space_level: str + file_id: int + uploader_id: int + publisher_id: int | None = None + is_favorite_space: bool = False + # 目标库 creator + admin 用户 ID;P7=B 只看受益人是否在此集合。 + space_manager_ids: frozenset[int] = field(default_factory=frozenset) + + +@dataclass(frozen=True) +class DocumentSharedEvent: + """库间 SHARE 审批通过后的发分上下文(G7)。""" + + tenant_id: int + share_entry_id: int + uploader_id: int + sharer_id: int + # 源库与目标库的 creator/admin 并集。 + related_manager_ids: frozenset[int] = field(default_factory=frozenset) + + +@dataclass(frozen=True) +class FavoriteChangedEvent: + """文档收藏人数变化后的 G3 阶梯发分上下文。""" + + tenant_id: int + file_id: int + uploader_id: int + unique_favoriter_count: int + space_manager_ids: frozenset[int] = field(default_factory=frozenset) + + +@dataclass(frozen=True) +class AnswerAdoptedEvent: + """问答答案被采纳后的 G4 发分上下文。""" + + tenant_id: int + answer_id: int + answerer_id: int + + +@dataclass +class AwardOutcome: + """门面调用结果;主业务只关心是否抛异常(不应抛)。""" + + skipped: bool = True + reason: str | None = None + result: LedgerResult | None = None + + +class PointsAwardFacade: + """供 knowledge / approval / qa_expert 调用的薄门面。 + + 任何内部失败只记日志,不向外抛出,避免拖垮主业务(AC-11)。 + """ + + def __init__( + self, + repository, + ledger: PointsLedgerService, + *, + enabled: bool | None = None, + is_platform_super_admin: IsSuperAdminFn | None = None, + ): + self.repository = repository + self.ledger = ledger + self._enabled_override = enabled + self._is_platform_super_admin = is_platform_super_admin or _always_false + + def _is_enabled(self) -> bool: + """读取 points.enabled;构造时可注入覆盖值便于单测。""" + if self._enabled_override is not None: + return bool(self._enabled_override) + try: + # 运行时配置入口在 ConfigService,而非 settings.Settings 模块级单例。 + from bisheng.common.services.config_service import settings + + return bool(getattr(getattr(settings, "points", None), "enabled", False)) + except Exception: + return False + + async def on_space_file_ready(self, event: SpaceFileReadyEvent) -> AwardOutcome: + """入库类自动发分(G1/G2/G5/G6)。""" + return await self._safe("on_space_file_ready", self._award_space_file, event) + + async def on_document_shared(self, event: DocumentSharedEvent) -> AwardOutcome: + """库间分享发分(G7);外链分享不得调用本方法。""" + return await self._safe("on_document_shared", self._award_document_shared, event) + + async def on_favorite_changed(self, event: FavoriteChangedEvent) -> AwardOutcome: + """收藏阶梯补差价(G3)。""" + return await self._safe("on_favorite_changed", self._award_favorite_tier, event) + + async def on_answer_adopted(self, event: AnswerAdoptedEvent) -> AwardOutcome: + """问答采纳发分(G4)。""" + return await self._safe("on_answer_adopted", self._award_answer_adopted, event) + + async def _safe(self, op: str, fn, event) -> AwardOutcome: + """统一吞异常,保证主路径成功。""" + try: + if not self._is_enabled(): + return AwardOutcome(skipped=True, reason="points_disabled") + return await fn(event) + except Exception: + logger.exception("points.award.rejected op=%s event=%s", op, event) + return AwardOutcome(skipped=True, reason="error") + + async def _award_space_file(self, event: SpaceFileReadyEvent) -> AwardOutcome: + if event.is_favorite_space: + return AwardOutcome(skipped=True, reason="favorite_space") + rule_code = earn_rule_for_space_level(event.space_level) + if rule_code is None: + return AwardOutcome(skipped=True, reason="personal_or_unmapped_level") + rule = await self.repository.get_rule(event.tenant_id, rule_code) + if not rule or rule.status != "enabled" or rule.rule_type != "earn": + logger.info("points.award.rejected reason=rule_disabled code=%s", rule_code) + return AwardOutcome(skipped=True, reason="rule_disabled") + payee, role = self._resolve_beneficiary( + rule.beneficiary, + uploader_id=event.uploader_id, + publisher_id=event.publisher_id, + ) + if payee is None: + return AwardOutcome(skipped=True, reason="beneficiary_unresolved") + skip = await self._should_skip_payee(payee, event.space_manager_ids) + if skip: + return AwardOutcome(skipped=True, reason=skip) + score = _fixed_score(rule.score_expr) + if score <= 0: + return AwardOutcome(skipped=True, reason="invalid_score") + key = f"earn:{rule_code}:{event.file_id}:{event.space_id}" + result = await self.ledger.award( + tenant_id=event.tenant_id, + user_id=payee, + delta=score, + title=rule.name or rule_code, + rule_code=rule_code, + idempotency_key=key, + daily_cap=rule.daily_cap, + biz_type="space_file", + biz_id=str(event.file_id), + beneficiary_role=role, + ) + if result.skipped_cap: + logger.info("points.award.rejected reason=daily_cap code=%s key=%s", rule_code, key) + return AwardOutcome(skipped=True, reason="daily_cap", result=result) + return AwardOutcome(skipped=False, result=result) + + async def _award_document_shared(self, event: DocumentSharedEvent) -> AwardOutcome: + rule = await self.repository.get_rule(event.tenant_id, "G7") + if not rule or rule.status != "enabled" or rule.rule_type != "earn": + return AwardOutcome(skipped=True, reason="rule_disabled") + payee, role = self._resolve_beneficiary( + rule.beneficiary, + uploader_id=event.uploader_id, + sharer_id=event.sharer_id, + ) + if payee is None: + return AwardOutcome(skipped=True, reason="beneficiary_unresolved") + skip = await self._should_skip_payee(payee, event.related_manager_ids) + if skip: + return AwardOutcome(skipped=True, reason=skip) + score = _fixed_score(rule.score_expr) + if score <= 0: + return AwardOutcome(skipped=True, reason="invalid_score") + key = f"earn:G7:{event.share_entry_id}" + result = await self.ledger.award( + tenant_id=event.tenant_id, + user_id=payee, + delta=score, + title=rule.name or "G7", + rule_code="G7", + idempotency_key=key, + daily_cap=rule.daily_cap, + biz_type="share_entry", + biz_id=str(event.share_entry_id), + beneficiary_role=role, + ) + if result.skipped_cap: + return AwardOutcome(skipped=True, reason="daily_cap", result=result) + return AwardOutcome(skipped=False, result=result) + + async def _award_favorite_tier(self, event: FavoriteChangedEvent) -> AwardOutcome: + rule = await self.repository.get_rule(event.tenant_id, "G3") + if not rule or rule.status != "enabled" or rule.rule_type != "earn": + return AwardOutcome(skipped=True, reason="rule_disabled") + payee = event.uploader_id + skip = await self._should_skip_payee(payee, event.space_manager_ids) + if skip: + return AwardOutcome(skipped=True, reason=skip) + s_target, highest_tier = _tier_target(rule.score_expr, event.unique_favoriter_count) + prior = await self.repository.get_favorite_tier_award(event.tenant_id, event.file_id) + s_done = int(prior.points_granted_total) if prior else 0 + if s_target <= s_done: + return AwardOutcome(skipped=True, reason="tier_already_granted") + delta = s_target - s_done + key = f"earn:G3:{event.file_id}:{s_target}" + result = await self.ledger.award( + tenant_id=event.tenant_id, + user_id=payee, + delta=delta, + title=rule.name or "G3", + rule_code="G3", + idempotency_key=key, + daily_cap=rule.daily_cap, + biz_type="favorite_tier", + biz_id=str(event.file_id), + beneficiary_role="uploader", + ) + if result.skipped_cap: + return AwardOutcome(skipped=True, reason="daily_cap", result=result) + if result.applied_delta > 0 or result.replayed: + await self.repository.upsert_favorite_tier_award( + event.tenant_id, + event.file_id, + highest_tier=highest_tier, + points_granted_total=s_target, + ) + return AwardOutcome(skipped=False, result=result) + + async def _award_answer_adopted(self, event: AnswerAdoptedEvent) -> AwardOutcome: + rule = await self.repository.get_rule(event.tenant_id, "G4") + if not rule or rule.status != "enabled" or rule.rule_type != "earn": + return AwardOutcome(skipped=True, reason="rule_disabled") + payee = event.answerer_id + if await self._is_platform_super_admin(payee): + return AwardOutcome(skipped=True, reason="super_admin") + score = _fixed_score(rule.score_expr) + if score <= 0: + return AwardOutcome(skipped=True, reason="invalid_score") + key = f"earn:G4:{event.answer_id}" + result = await self.ledger.award( + tenant_id=event.tenant_id, + user_id=payee, + delta=score, + title=rule.name or "G4", + rule_code="G4", + idempotency_key=key, + daily_cap=rule.daily_cap, + biz_type="answer", + biz_id=str(event.answer_id), + beneficiary_role="answerer", + ) + if result.skipped_cap: + return AwardOutcome(skipped=True, reason="daily_cap", result=result) + return AwardOutcome(skipped=False, result=result) + + async def _should_skip_payee(self, payee: int, manager_ids: frozenset[int]) -> str | None: + """P7=B:受益人是相关库 creator/admin,或平台超管 → skip。""" + if payee in manager_ids: + return "space_manager_payee" + if await self._is_platform_super_admin(payee): + return "super_admin" + return None + + @staticmethod + def _resolve_beneficiary( + beneficiary: str | None, + *, + uploader_id: int | None = None, + publisher_id: int | None = None, + sharer_id: int | None = None, + answerer_id: int | None = None, + ) -> tuple[int | None, str | None]: + """按规则 beneficiary 解析唯一入账用户。""" + role = (beneficiary or "").strip() + mapping = { + "uploader": uploader_id, + "publisher": publisher_id if publisher_id is not None else uploader_id, + "sharer": sharer_id, + "answerer": answerer_id, + } + user_id = mapping.get(role) + if user_id is None: + return None, None + return int(user_id), role + + +def _fixed_score(score_expr: dict[str, Any] | None) -> int: + """读取 fixed 模式分值。""" + expr = score_expr or {} + if expr.get("mode") != "fixed": + return 0 + try: + return int(expr.get("score") or 0) + except (TypeError, ValueError): + return 0 + + +def _tier_target(score_expr: dict[str, Any] | None, unique_count: int) -> tuple[int, int]: + """按去重收藏人数计算应得累计分与最高阈值档。""" + expr = score_expr or {} + tiers = list(expr.get("tiers") or []) + tiers.sort(key=lambda item: int(item.get("threshold") or 0)) + s_target = 0 + highest_tier = 0 + for item in tiers: + threshold = int(item.get("threshold") or 0) + if unique_count >= threshold: + s_target = int(item.get("score") or 0) + highest_tier = threshold + lifetime = expr.get("lifetime_cap") + if lifetime is not None: + s_target = min(s_target, int(lifetime)) + return s_target, highest_tier + + +async def _always_false(_: int) -> bool: + return False diff --git a/src/backend/bisheng/points/domain/services/points_award_hooks.py b/src/backend/bisheng/points/domain/services/points_award_hooks.py new file mode 100644 index 000000000..599374454 --- /dev/null +++ b/src/backend/bisheng/points/domain/services/points_award_hooks.py @@ -0,0 +1,362 @@ +"""业务成功后的积分旁路挂钩:可异步投递 Celery,失败不影响主业务。""" + +from __future__ import annotations + +import logging +from collections.abc import Iterable +from typing import Any + +from bisheng.common.models.space_channel_member import SpaceChannelMemberDao, UserRoleEnum +from bisheng.core.database import get_async_db_session +from bisheng.knowledge.domain.models.knowledge import KnowledgeDao +from bisheng.knowledge.domain.models.knowledge_file import KnowledgeFileDao +from bisheng.knowledge.domain.models.knowledge_space_scope import ( + KnowledgeSpaceLevelEnum, + KnowledgeSpaceScopeDao, +) +from bisheng.points.domain.repositories.points_repository import PointsRepository +from bisheng.points.domain.services.points_award_facade import ( + AnswerAdoptedEvent, + DocumentSharedEvent, + FavoriteChangedEvent, + PointsAwardFacade, + SpaceFileReadyEvent, +) +from bisheng.points.domain.services.points_ledger_service import PointsLedgerService + +logger = logging.getLogger(__name__) + + +async def is_platform_super_admin_user(user_id: int) -> bool: + """按用户 ID 判断平台超管(与中间件同源)。""" + try: + from bisheng.utils.http_middleware import _check_is_global_super + + return bool(await _check_is_global_super(int(user_id))) + except Exception: + logger.exception("points.award.hooks super_admin_check_failed user_id=%s", user_id) + return False + + +async def resolve_space_manager_ids(*space_ids: int) -> frozenset[int]: + """汇总空间 creator/admin,并兜底纳入知识库 owner。""" + managers: set[int] = set() + for raw in space_ids: + try: + space_id = int(raw) + except (TypeError, ValueError): + continue + if space_id <= 0: + continue + try: + members = await SpaceChannelMemberDao.async_get_members_by_space( + space_id, + user_roles=[UserRoleEnum.CREATOR, UserRoleEnum.ADMIN], + ) + for member in members or []: + if getattr(member, "user_id", None) is not None: + managers.add(int(member.user_id)) + space = await KnowledgeDao.aquery_by_id(space_id) + if space is not None and getattr(space, "user_id", None) is not None: + managers.add(int(space.user_id)) + except Exception: + logger.exception("points.award.hooks resolve_managers_failed space_id=%s", space_id) + return frozenset(managers) + + +async def resolve_space_level(space_id: int) -> str: + """读取空间等级;缺失时按 personal 处理(Facade 会 skip)。""" + try: + scope = await KnowledgeSpaceScopeDao.aget_by_space_id(int(space_id)) + if scope is None or scope.level is None: + return KnowledgeSpaceLevelEnum.PERSONAL.value + return str(getattr(scope.level, "value", scope.level)) + except Exception: + logger.exception("points.award.hooks resolve_level_failed space_id=%s", space_id) + return KnowledgeSpaceLevelEnum.PERSONAL.value + + +def _award_async_enabled() -> bool: + """读取 points.award_async_enabled;默认 True。""" + try: + from bisheng.common.services.config_service import settings + + return bool(getattr(getattr(settings, "points", None), "award_async_enabled", True)) + except Exception: + return True + + +async def _run_with_facade(action) -> None: + """打开独立积分会话执行门面并提交。""" + async with get_async_db_session() as session: + repository = PointsRepository(session) + ledger = PointsLedgerService(repository) + facade = PointsAwardFacade( + repository, + ledger, + is_platform_super_admin=is_platform_super_admin_user, + ) + await action(facade) + await session.commit() + + +def _resolve_award_queue() -> str: + """解析发分投递队列名。 + + 默认 points_award_celery;POINTS_AWARD_CELERY_QUEUE 非空时覆盖(压测隔离)。 + """ + import os + + from bisheng.core.config.celery_queues import POINTS_AWARD_QUEUE + + return (os.environ.get("POINTS_AWARD_CELERY_QUEUE") or "").strip() or POINTS_AWARD_QUEUE + + +def _enqueue_award_event(body: dict[str, Any]) -> None: + """投递 Celery 发分任务;抽出以便单测 patch,避开 conftest 对 worker 包的 mock。 + + 默认投递到 points_award_celery(见 celery_queues.POINTS_AWARD_QUEUE)。 + """ + from bisheng.worker.points.tasks import process_points_award_event + + process_points_award_event.apply_async(args=[body], queue=_resolve_award_queue()) + + +async def _dispatch(event_type: str, payload: dict[str, Any]) -> None: + """按开关异步投递或同步入账;enqueue 失败则 fallback 同步一次。""" + body = {"event_type": event_type, **payload} + if not _award_async_enabled(): + await _run_payload_sync(body) + return + try: + _enqueue_award_event(body) + logger.info( + "points.award.enqueued event_type=%s tenant_id=%s", + event_type, + payload.get("tenant_id"), + ) + except Exception: + # Broker 不可用时降级同步,避免丢事件。 + logger.exception( + "points.award.enqueue_fallback_sync event_type=%s tenant_id=%s", + event_type, + payload.get("tenant_id"), + ) + await _run_payload_sync(body) + + +async def _run_payload_sync(payload: dict[str, Any]) -> None: + """与 Celery worker 相同的事件分发,供同步路径与 enqueue fallback 复用。""" + + async def _award(facade: PointsAwardFacade) -> None: + event_type = str(payload.get("event_type") or "") + if event_type == "space_file_ready": + await facade.on_space_file_ready( + SpaceFileReadyEvent( + tenant_id=int(payload["tenant_id"]), + space_id=int(payload["space_id"]), + space_level=str(payload["space_level"]), + file_id=int(payload["file_id"]), + uploader_id=int(payload["uploader_id"]), + publisher_id=( + int(payload["publisher_id"]) + if payload.get("publisher_id") is not None + else None + ), + is_favorite_space=bool(payload.get("is_favorite_space")), + space_manager_ids=frozenset( + int(x) for x in (payload.get("space_manager_ids") or []) + ), + ) + ) + elif event_type == "document_shared": + await facade.on_document_shared( + DocumentSharedEvent( + tenant_id=int(payload["tenant_id"]), + share_entry_id=int(payload["share_entry_id"]), + uploader_id=int(payload["uploader_id"]), + sharer_id=int(payload["sharer_id"]), + related_manager_ids=frozenset( + int(x) for x in (payload.get("related_manager_ids") or []) + ), + ) + ) + elif event_type == "favorite_changed": + await facade.on_favorite_changed( + FavoriteChangedEvent( + tenant_id=int(payload["tenant_id"]), + file_id=int(payload["file_id"]), + uploader_id=int(payload["uploader_id"]), + unique_favoriter_count=int(payload["unique_favoriter_count"]), + space_manager_ids=frozenset( + int(x) for x in (payload.get("space_manager_ids") or []) + ), + ) + ) + elif event_type == "answer_adopted": + await facade.on_answer_adopted( + AnswerAdoptedEvent( + tenant_id=int(payload["tenant_id"]), + answer_id=int(payload["answer_id"]), + answerer_id=int(payload["answerer_id"]), + ) + ) + else: + raise ValueError(f"unknown points award event_type={event_type}") + + await _run_with_facade(_award) + + +async def notify_space_files_ready( + *, + tenant_id: int, + space_id: int, + files: Iterable[Any], + uploader_id: int, + publisher_id: int | None = None, + is_favorite_space: bool | None = None, + space_level: str | None = None, +) -> None: + """上传/发布入库成功后发分;一文件一任务(或同步路径一批处理)。""" + try: + file_ids = [int(f.id) for f in files if getattr(f, "id", None)] + if not file_ids: + return + favorite = is_favorite_space + if favorite is None: + space = await KnowledgeDao.aquery_by_id(int(space_id)) + favorite = bool(space and getattr(space, "is_favorite", False)) + level = space_level or await resolve_space_level(space_id) + managers = await resolve_space_manager_ids(space_id) + manager_list = sorted(int(x) for x in managers) + + if not _award_async_enabled(): + # 同步:同一会话批量处理,减少连接开销。 + async def _award(facade: PointsAwardFacade) -> None: + for file_id in file_ids: + await facade.on_space_file_ready( + SpaceFileReadyEvent( + tenant_id=int(tenant_id), + space_id=int(space_id), + space_level=level, + file_id=file_id, + uploader_id=int(uploader_id), + publisher_id=int(publisher_id) if publisher_id is not None else None, + is_favorite_space=bool(favorite), + space_manager_ids=managers, + ) + ) + + await _run_with_facade(_award) + return + + for file_id in file_ids: + await _dispatch( + "space_file_ready", + { + "tenant_id": int(tenant_id), + "space_id": int(space_id), + "space_level": level, + "file_id": int(file_id), + "uploader_id": int(uploader_id), + "publisher_id": int(publisher_id) if publisher_id is not None else None, + "is_favorite_space": bool(favorite), + "space_manager_ids": manager_list, + }, + ) + except Exception: + logger.exception( + "points.award.hooks space_file_ready_failed space_id=%s uploader_id=%s", + space_id, + uploader_id, + ) + + +async def notify_favorite_changed( + *, + tenant_id: int, + source_file_id: int, + source_space_id: int, + uploader_id: int | None = None, +) -> None: + """新收藏成功后触发 G3 阶梯;重复收藏入口不应调用。""" + try: + file_id = int(source_file_id) + payee = uploader_id + if payee is None: + source_file = await KnowledgeFileDao.query_by_id(file_id) + if source_file is None or getattr(source_file, "user_id", None) is None: + return + payee = int(source_file.user_id) + referrers = await KnowledgeFileDao.aget_favorite_referrers(file_id) + unique_count = len({int(row.user_id) for row in referrers if getattr(row, "user_id", None)}) + managers = await resolve_space_manager_ids(int(source_space_id)) + await _dispatch( + "favorite_changed", + { + "tenant_id": int(tenant_id), + "file_id": file_id, + "uploader_id": int(payee), + "unique_favoriter_count": int(unique_count), + "space_manager_ids": sorted(int(x) for x in managers), + }, + ) + except Exception: + logger.exception( + "points.award.hooks favorite_changed_failed file_id=%s", + source_file_id, + ) + + +async def notify_answer_adopted( + *, + tenant_id: int, + answer_id: int, + answerer_id: int, +) -> None: + """问答采纳成功后触发 G4。""" + try: + if not answer_id or not answerer_id: + return + await _dispatch( + "answer_adopted", + { + "tenant_id": int(tenant_id), + "answer_id": int(answer_id), + "answerer_id": int(answerer_id), + }, + ) + except Exception: + logger.exception( + "points.award.hooks answer_adopted_failed answer_id=%s", + answer_id, + ) + + +async def notify_document_shared( + *, + tenant_id: int, + share_entry_id: int, + source_space_id: int, + target_space_id: int, + uploader_id: int, + sharer_id: int, +) -> None: + """库间 SHARE 审批通过后触发 G7;外链分享不得调用。""" + try: + managers = await resolve_space_manager_ids(int(source_space_id), int(target_space_id)) + await _dispatch( + "document_shared", + { + "tenant_id": int(tenant_id), + "share_entry_id": int(share_entry_id), + "uploader_id": int(uploader_id), + "sharer_id": int(sharer_id), + "related_manager_ids": sorted(int(x) for x in managers), + }, + ) + except Exception: + logger.exception( + "points.award.hooks document_shared_failed share_entry_id=%s", + share_entry_id, + ) diff --git a/src/backend/bisheng/points/domain/services/points_ledger_service.py b/src/backend/bisheng/points/domain/services/points_ledger_service.py new file mode 100644 index 000000000..53afd9eec --- /dev/null +++ b/src/backend/bisheng/points/domain/services/points_ledger_service.py @@ -0,0 +1,91 @@ +"""积分账本服务:同事务维护余额缓存和不可变流水。""" + +from dataclasses import dataclass +from datetime import datetime +from zoneinfo import ZoneInfo + +from bisheng.common.errcode.points import PointsInvalidAdjustError +from bisheng.points.domain.models import UserPointLog + +SHANGHAI = ZoneInfo("Asia/Shanghai") + + +@dataclass +class LedgerResult: + """记账结果;跳过日上限时 log_id 为空。""" + applied_delta: int + balance: int + log_id: int | None = None + replayed: bool = False + skipped_cap: bool = False + + +class PointsLedgerService: + """执行账户锁定、幂等、日上限和流水追加。""" + + def __init__(self, repository, notify_service=None): + self.repository = repository + self.notify_service = notify_service + + async def award(self, *, tenant_id: int, user_id: int, delta: int, title: str, rule_code: str, idempotency_key: str, daily_cap: int | None = None, source: str = "auto", **kwargs) -> LedgerResult: + """自动发分;剩余额度不足整笔时跳过,不做部分截断。""" + if delta <= 0: + raise PointsInvalidAdjustError(msg="发放积分必须为正数") + return await self._write(tenant_id=tenant_id, user_id=user_id, delta=delta, title=title, rule_code=rule_code, idempotency_key=idempotency_key, daily_cap=daily_cap, source=source, **kwargs) + + async def adjust(self, *, tenant_id: int, user_id: int, delta: int, title: str = "管理员调整积分", idempotency_key: str, operator_id: int, remark: str, **kwargs) -> LedgerResult: + """管理员自由调分;余额允许变为负数。""" + if not isinstance(delta, int) or delta == 0 or abs(delta) > 10000 or not 5 <= len(remark.strip()) <= 100: + raise PointsInvalidAdjustError() + return await self._write(tenant_id=tenant_id, user_id=user_id, delta=delta, title=title, rule_code="MANUAL", idempotency_key=idempotency_key, source="manual_adjust", operator_id=operator_id, remark=remark, **kwargs) + + async def deduct(self, *, tenant_id: int, user_id: int, delta: int, title: str, rule_code: str, idempotency_key: str, operator_id: int, remark: str | None = None, **kwargs) -> LedgerResult: + """按 R 规则扣分;负余额是合法业务状态。""" + if delta >= 0: + raise PointsInvalidAdjustError(msg="扣减积分必须为负数") + return await self._write(tenant_id=tenant_id, user_id=user_id, delta=delta, title=title, rule_code=rule_code, idempotency_key=idempotency_key, source="manual_deduct", operator_id=operator_id, remark=remark, **kwargs) + + async def _write(self, *, tenant_id: int, user_id: int, delta: int, title: str, rule_code: str, idempotency_key: str, source: str, daily_cap: int | None = None, operator_id: int | None = None, remark: str | None = None, biz_type: str | None = None, biz_id: str | None = None, beneficiary_role: str | None = None) -> LedgerResult: + """在调用方事务内写入一笔账本记录并建立 outbox。""" + account = await self.repository.lock_or_create_account(tenant_id, user_id) + existing = await self.repository.get_log_by_idempotency(tenant_id, idempotency_key) + if existing: + return LedgerResult(existing.delta, existing.balance_after, existing.id, replayed=True) + if delta > 0 and daily_cap is not None: + now = datetime.now(SHANGHAI) + day_start = now.replace(hour=0, minute=0, second=0, microsecond=0).replace(tzinfo=None) + earned = await self.repository.sum_earn_today(tenant_id, user_id, rule_code, day_start) + # 产品约定:不足本次 delta 时整笔跳过,避免用户因并发得到部分奖励。 + if daily_cap - earned < delta: + return LedgerResult(0, account.balance, skipped_cap=True) + balance = account.balance + delta + account.balance = balance + account.version += 1 + if delta > 0: + account.lifetime_earned += delta + else: + account.lifetime_deducted += -delta + # MySQL 严格模式下 ORM 传 None 不会回落到 server_default,需显式写入业务时间。 + occurred_at = datetime.now(SHANGHAI).replace(tzinfo=None) + log = await self.repository.append_log( + UserPointLog( + tenant_id=tenant_id, + user_id=user_id, + delta=delta, + balance_after=balance, + direction="earn" if delta > 0 else "deduct", + rule_code=rule_code, + title=title, + source=source, + biz_type=biz_type, + biz_id=biz_id, + idempotency_key=idempotency_key, + operator_id=operator_id, + remark=remark, + score_snapshot=abs(delta), + beneficiary_role=beneficiary_role, + occurred_at=occurred_at, + ) + ) + await self.repository.add_outbox(tenant_id, int(log.id), {"user_id": user_id, "delta": delta, "log_id": log.id}) + return LedgerResult(delta, balance, log.id) diff --git a/src/backend/bisheng/points/domain/services/points_monthly_reward_service.py b/src/backend/bisheng/points/domain/services/points_monthly_reward_service.py new file mode 100644 index 000000000..fd47448cb --- /dev/null +++ b/src/backend/bisheng/points/domain/services/points_monthly_reward_service.py @@ -0,0 +1,337 @@ +"""管理员月奖:次月 1 日结算上月,登录≥1,多角色取最高 M*。""" + +from __future__ import annotations + +import calendar +import logging +from collections.abc import Awaitable, Callable +from datetime import datetime, timedelta +from zoneinfo import ZoneInfo + +from bisheng.common.models.space_channel_member import ( + BusinessTypeEnum, + MembershipStatusEnum, + SpaceChannelMember, + UserRoleEnum, +) +from bisheng.core.database import get_async_db_session +from bisheng.database.constants import AdminRole +from bisheng.knowledge.domain.models.knowledge_space_scope import KnowledgeSpaceScopeDao +from bisheng.points.domain.constants.monthly_reward_rules import ( + MONTHLY_RULE_MATCHERS, + MonthlyRuleMatcher, + fixed_score, + pick_highest_reward, +) +from bisheng.points.domain.repositories.points_repository import PointsRepository +from bisheng.points.domain.services.points_ledger_service import PointsLedgerService +from bisheng.points.domain.services.points_notify_service import PointsNotifyService +from bisheng.user.domain.models.user_role import UserRole +from sqlmodel import select + +logger = logging.getLogger(__name__) +SHANGHAI = ZoneInfo("Asia/Shanghai") + +LoginUsersFn = Callable[[int, str, str], Awaitable[set[int]]] + + +def previous_month_key(now: datetime | None = None) -> str: + """返回应结算的上月 period key(Asia/Shanghai)。""" + current = now or datetime.now(SHANGHAI) + if current.tzinfo is not None: + current = current.astimezone(SHANGHAI) + else: + current = current.replace(tzinfo=SHANGHAI) + first = current.replace(day=1, hour=0, minute=0, second=0, microsecond=0) + prev = first - timedelta(days=1) + return prev.strftime("%Y-%m") + + +def month_local_date_bounds(period_key: str) -> tuple[str, str]: + """`YYYY-MM` → 当月首末日 `YYYY-MM-DD`(含)。""" + year_s, month_s = period_key.split("-", 1) + year, month = int(year_s), int(month_s) + last = calendar.monthrange(year, month)[1] + return f"{year:04d}-{month:02d}-01", f"{year:04d}-{month:02d}-{last:02d}" + + +async def default_login_users(tenant_id: int, start_date: str, end_date: str) -> set[int]: + """从日活事实索引取上月至少登录过一次的用户。""" + from bisheng.core.search.elasticsearch.manager import get_statistics_es_connection + from bisheng.telemetry.domain.mid_table.daily_participation import DailyParticipationFact + + client = await get_statistics_es_connection() + # BaseMidTable 是 Pydantic BaseModel:类属性 _index_name 是 PrivateAttr, + # 类访问会得到 ModelPrivateAttr,ES 会去查字面量 default='…'。必须取实例值。 + index = DailyParticipationFact(ensure_sync_index=False)._index_name + body = { + "size": 0, + "query": { + "bool": { + "filter": [ + {"term": {"tenant_id": str(tenant_id)}}, + {"range": {"local_date": {"gte": start_date, "lte": end_date}}}, + { + "bool": { + "should": [ + {"term": {"logged_in": True}}, + {"range": {"login_count": {"gt": 0}}}, + ], + "minimum_should_match": 1, + } + }, + ] + } + }, + "aggs": { + "users": { + "terms": { + "field": "user_id", + "size": 10000, + } + } + }, + } + try: + resp = await client.search(index=index, body=body) + except Exception: + logger.exception( + "points.monthly.login_query_failed tenant_id=%s %s..%s", + tenant_id, + start_date, + end_date, + ) + raise + buckets = (((resp or {}).get("aggregations") or {}).get("users") or {}).get("buckets") or [] + result: set[int] = set() + for bucket in buckets: + key = bucket.get("key") + try: + result.add(int(key)) + except (TypeError, ValueError): + continue + return result + + +class PointsMonthlyRewardService: + """扫描空间角色并发放上月管理员月奖。""" + + def __init__( + self, + *, + login_users_fn: LoginUsersFn | None = None, + notify: PointsNotifyService | None = None, + ): + self._login_users_fn = login_users_fn or default_login_users + self.notify = notify or PointsNotifyService() + + async def run_all_tenants(self, now: datetime | None = None) -> dict: + """Beat 入口:按有账户或默认租户发放。""" + from bisheng.core.context.tenant import bypass_tenant_filter, set_current_tenant_id + + period_key = previous_month_key(now) + with bypass_tenant_filter(): + async with get_async_db_session() as session: + repo = PointsRepository(session) + tenant_ids = await repo.list_tenant_ids_with_accounts() + totals = {"period_key": period_key, "tenants": 0, "awarded": 0, "skipped": 0} + for tid in tenant_ids or [1]: + set_current_tenant_id(int(tid)) + try: + out = await self.run_for_tenant(int(tid), period_key=period_key) + totals["tenants"] += 1 + totals["awarded"] += int(out.get("awarded") or 0) + totals["skipped"] += int(out.get("skipped") or 0) + except Exception: + logger.exception("points.monthly.tenant_failed tenant_id=%s", tid) + return totals + + async def run_for_tenant(self, tenant_id: int, *, period_key: str | None = None) -> dict: + """对单个租户结算指定月(默认上月)。""" + month_key = period_key or previous_month_key() + start_date, end_date = month_local_date_bounds(month_key) + async with get_async_db_session() as session: + repo = PointsRepository(session) + rules = await repo.list_rules(tenant_id, rule_type="admin_reward", status="enabled") + rule_by_code = {r.rule_code: r for r in rules} + active_matchers = { + code: matcher + for code, matcher in MONTHLY_RULE_MATCHERS.items() + if code in rule_by_code and fixed_score(rule_by_code[code].score_expr) > 0 + } + if not active_matchers: + return {"tenant_id": tenant_id, "period_key": month_key, "awarded": 0, "skipped": 0} + + user_candidates = await self._collect_user_candidates(active_matchers, rule_by_code) + if not user_candidates: + return {"tenant_id": tenant_id, "period_key": month_key, "awarded": 0, "skipped": 0} + + exclude = await self._load_super_admin_ids() + try: + logged_in = await self._login_users_fn(tenant_id, start_date, end_date) + except Exception: + # 日活不可用时整租户跳过,避免误发无登录校验的奖励。 + return { + "tenant_id": tenant_id, + "period_key": month_key, + "awarded": 0, + "skipped": len(user_candidates), + "error": "login_query_failed", + } + + awarded = 0 + skipped = 0 + for user_id, (rule_code, score) in user_candidates.items(): + if user_id in exclude: + skipped += 1 + continue + if user_id not in logged_in: + skipped += 1 + continue + rule = rule_by_code[rule_code] + ok = await self._award_one( + tenant_id=tenant_id, + user_id=user_id, + rule_code=rule_code, + rule_name=rule.name or rule_code, + score=score, + period_key=month_key, + ) + if ok: + awarded += 1 + else: + skipped += 1 + + logger.info( + "points.monthly.done tenant_id=%s period=%s awarded=%s skipped=%s", + tenant_id, + month_key, + awarded, + skipped, + ) + return { + "tenant_id": tenant_id, + "period_key": month_key, + "awarded": awarded, + "skipped": skipped, + } + + async def _award_one( + self, + *, + tenant_id: int, + user_id: int, + rule_code: str, + rule_name: str, + score: int, + period_key: str, + ) -> bool: + """单用户单月幂等入账;失败只记日志。""" + key = f"reward:{rule_code}:{user_id}:{period_key}" + try: + async with get_async_db_session() as session: + repo = PointsRepository(session) + ledger = PointsLedgerService(repo) + result = await ledger.award( + tenant_id=tenant_id, + user_id=user_id, + delta=score, + title=rule_name, + rule_code=rule_code, + idempotency_key=key, + source="monthly_reward", + biz_type="monthly_reward", + biz_id=period_key, + beneficiary_role="subject", + ) + await session.commit() + if result.replayed or result.skipped_cap: + return bool(result.replayed) + try: + await self.notify.notify( + user_id=user_id, + template_code="earn_publish", + rule_name=rule_name, + delta=score, + ) + except Exception: + logger.exception("points.monthly.notify_failed user_id=%s", user_id) + return True + except Exception: + logger.exception( + "points.monthly.award_failed user_id=%s rule=%s key=%s", + user_id, + rule_code, + key, + ) + return False + + async def _collect_user_candidates( + self, + matchers: dict[str, MonthlyRuleMatcher], + rule_by_code: dict, + ) -> dict[int, tuple[str, int]]: + """聚合用户 → 最高分 M*。""" + # level → [(rule_code, roles)] + by_level: dict[str, list[tuple[str, set[str]]]] = {} + for code, matcher in matchers.items(): + for level in matcher.levels: + by_level.setdefault(level, []).append((code, set(matcher.roles))) + + # user_id → [(rule_code, score)] + raw: dict[int, list[tuple[str, int]]] = {} + for level, entries in by_level.items(): + space_ids = await KnowledgeSpaceScopeDao.aget_space_ids_by_levels([level]) + if not space_ids: + continue + needed_roles: set[str] = set() + for _, roles in entries: + needed_roles |= roles + role_enums = [UserRoleEnum(role) for role in needed_roles] + members = await self._list_managers_for_spaces(space_ids, role_enums) + for member in members: + role_value = getattr(member.user_role, "value", member.user_role) + user_id = int(member.user_id) + for rule_code, roles in entries: + if role_value not in roles: + continue + score = fixed_score(rule_by_code[rule_code].score_expr) + raw.setdefault(user_id, []).append((rule_code, score)) + + result: dict[int, tuple[str, int]] = {} + for user_id, candidates in raw.items(): + best = pick_highest_reward(candidates) + if best is not None: + result[user_id] = best + return result + + @staticmethod + async def _list_managers_for_spaces( + space_ids: list[int], + roles: list[UserRoleEnum], + ) -> list[SpaceChannelMember]: + """批量读取空间 creator/admin 成员。""" + if not space_ids or not roles: + return [] + business_ids = [str(sid) for sid in space_ids] + async with get_async_db_session() as session: + rows = ( + await session.exec( + select(SpaceChannelMember).where( + SpaceChannelMember.business_type == BusinessTypeEnum.SPACE, + SpaceChannelMember.business_id.in_(business_ids), + SpaceChannelMember.status == MembershipStatusEnum.ACTIVE, + SpaceChannelMember.user_role.in_(roles), + ) + ) + ).all() + return list(rows) + + @staticmethod + async def _load_super_admin_ids() -> set[int]: + """平台超管不获月奖(Q16)。""" + async with get_async_db_session() as session: + rows = ( + await session.exec(select(UserRole.user_id).where(UserRole.role_id == AdminRole)) + ).all() + return {int(r[0] if isinstance(r, tuple) else r) for r in rows} diff --git a/src/backend/bisheng/points/domain/services/points_notify_service.py b/src/backend/bisheng/points/domain/services/points_notify_service.py new file mode 100644 index 000000000..7607a8582 --- /dev/null +++ b/src/backend/bisheng/points/domain/services/points_notify_service.py @@ -0,0 +1,31 @@ +"""积分变动的尽力通知服务。""" + +import logging + +from bisheng.points.domain.constants.notify_templates import NOTIFY_TEMPLATES, POINTS_CHANGED_ACTION_CODE + +logger = logging.getLogger(__name__) + + +class PointsNotifyService: + """渲染代码模板并委托消息模块发送,不影响已提交的积分账本。""" + + def __init__(self, message_service=None): + self.message_service = message_service + + async def notify(self, *, user_id: int, template_code: str, **values) -> None: + """发送积分变动站内信;消息依赖故障只记录,不回滚积分。""" + if not self.message_service: + return + try: + content = NOTIFY_TEMPLATES[template_code].format(**values) + await self.message_service.send_generic_notify( + sender=0, receiver_user_ids=[user_id], + content_item_list=[{"type": "system_text", "content": content}], + action_code=POINTS_CHANGED_ACTION_CODE, + ) + except (KeyError, ValueError, TypeError) as exc: + logger.warning("积分通知模板渲染失败:%s", exc) + except Exception: + # 通知是账本提交后的旁路,不允许消息系统故障影响积分变动。 + logger.exception("积分通知发送失败") diff --git a/src/backend/bisheng/points/domain/services/points_pending_deduct_service.py b/src/backend/bisheng/points/domain/services/points_pending_deduct_service.py new file mode 100644 index 000000000..6bb32294f --- /dev/null +++ b/src/backend/bisheng/points/domain/services/points_pending_deduct_service.py @@ -0,0 +1,261 @@ +"""违规删除后的稳定幂等扣分与补扣队列。 + +主路径:先删内容,再尝试扣分;扣分失败写入 ``point_pending_deduct``, +由 Beat 重试。幂等键 ``deduct:{rule}:{biz_type}:{biz_id}`` 保证不双扣。 +""" + +from __future__ import annotations + +import logging +from dataclasses import dataclass +from datetime import datetime, timedelta + +from bisheng.common.errcode.points import PointsInvalidAdjustError, PointsRuleNotFoundError +from bisheng.core.database import get_async_db_session +from bisheng.points.domain.models import PointPendingDeduct +from bisheng.points.domain.repositories.points_repository import PointsRepository +from bisheng.points.domain.services.points_ledger_service import PointsLedgerService +from bisheng.points.domain.services.points_notify_service import PointsNotifyService + +logger = logging.getLogger(__name__) + +MAX_RETRIES = 10 + + +@dataclass(frozen=True) +class DeductAttemptResult: + """单次扣分尝试结果。""" + + applied: bool + pending: bool + replayed: bool = False + reason: str | None = None + + +def stable_deduct_idempotency_key(rule_code: str, biz_type: str, biz_id: str) -> str: + """内容级稳定幂等键:同一违规内容只扣一次。""" + return f"deduct:{rule_code.strip().upper()}:{biz_type}:{biz_id}" + + +class PointsPendingDeductService: + """封装「立即扣分或入补扣队列」与定时 drain。""" + + def __init__(self, notify: PointsNotifyService | None = None): + self.notify = notify or PointsNotifyService() + + async def deduct_or_enqueue( + self, + *, + tenant_id: int, + user_id: int, + rule_code: str, + biz_type: str, + biz_id: str, + operator_id: int, + remark: str | None = None, + ) -> DeductAttemptResult: + """尝试按 R* 扣分;失败则写入补扣队列(删除已成功时的兜底)。""" + code = rule_code.strip().upper() + key = stable_deduct_idempotency_key(code, biz_type, biz_id) + try: + async with get_async_db_session() as session: + repo = PointsRepository(session) + ledger = PointsLedgerService(repo) + rule = await repo.get_rule(tenant_id, code) + if rule is None or rule.rule_type != "deduct" or rule.status != "enabled": + raise PointsRuleNotFoundError() + score = abs(int((rule.score_expr or {}).get("score", 0))) + if score == 0: + raise PointsInvalidAdjustError(msg="扣减规则分值为 0") + result = await ledger.deduct( + tenant_id=tenant_id, + user_id=user_id, + delta=-score, + rule_code=rule.rule_code, + title=rule.name or rule.rule_code, + idempotency_key=key, + operator_id=operator_id, + remark=remark, + biz_type=biz_type, + biz_id=biz_id, + ) + await session.commit() + if result.replayed: + return DeductAttemptResult(applied=True, pending=False, replayed=True) + if result.log_id is None: + await self._enqueue( + tenant_id=tenant_id, + user_id=user_id, + rule_code=code, + biz_type=biz_type, + biz_id=biz_id, + idempotency_key=key, + operator_id=operator_id, + remark=remark, + last_error="ledger_returned_no_log", + ) + return DeductAttemptResult(applied=False, pending=True, reason="ledger_empty") + try: + await self.notify.notify( + user_id=user_id, + template_code="deduct_admin", + delta=score, + rule_name=rule.name or code, + reason=remark or "", + ) + except Exception: + logger.exception("points.pending_deduct.notify_failed user_id=%s key=%s", user_id, key) + return DeductAttemptResult(applied=True, pending=False) + except Exception as exc: + logger.exception( + "points.pending_deduct.immediate_failed key=%s user_id=%s", + key, + user_id, + ) + await self._enqueue( + tenant_id=tenant_id, + user_id=user_id, + rule_code=code, + biz_type=biz_type, + biz_id=biz_id, + idempotency_key=key, + operator_id=operator_id, + remark=remark, + last_error=str(exc)[:1000], + ) + return DeductAttemptResult(applied=False, pending=True, reason=type(exc).__name__) + + async def drain(self, *, limit: int = 100) -> dict: + """重试到期补扣;成功标 done,超限标 dead。""" + from bisheng.core.context.tenant import bypass_tenant_filter + + processed = done = failed = dead = 0 + with bypass_tenant_filter(): + async with get_async_db_session() as session: + repo = PointsRepository(session) + rows = await repo.list_due_pending_deducts(limit=limit) + for row in rows: + processed += 1 + outcome = await self._process_one(repo, row) + if outcome == "done": + done += 1 + elif outcome == "dead": + dead += 1 + else: + failed += 1 + await session.commit() + result = {"processed": processed, "done": done, "failed": failed, "dead": dead} + logger.info("points.pending_deduct.drain_done %s", result) + return result + + async def _enqueue( + self, + *, + tenant_id: int, + user_id: int, + rule_code: str, + biz_type: str, + biz_id: str, + idempotency_key: str, + operator_id: int, + remark: str | None, + last_error: str | None, + ) -> None: + """写入补扣行;已存在则仅更新 last_error。""" + try: + async with get_async_db_session() as session: + repo = PointsRepository(session) + existing = await repo.get_pending_deduct_by_key(tenant_id, idempotency_key) + if existing is not None: + if existing.status == "done": + await session.commit() + return + existing.last_error = last_error + existing.status = "pending" + existing.next_retry_at = datetime.utcnow() + timedelta(seconds=30) + await repo.save_pending_deduct(existing) + else: + await repo.upsert_pending_deduct( + PointPendingDeduct( + tenant_id=tenant_id, + user_id=user_id, + rule_code=rule_code, + biz_type=biz_type, + biz_id=biz_id, + idempotency_key=idempotency_key, + operator_id=operator_id, + remark=remark, + status="pending", + last_error=last_error, + next_retry_at=datetime.utcnow() + timedelta(seconds=30), + ) + ) + await session.commit() + except Exception: + logger.exception("points.pending_deduct.enqueue_failed key=%s", idempotency_key) + + async def _process_one(self, repo: PointsRepository, row: PointPendingDeduct) -> str: + """处理单条补扣。""" + ledger = PointsLedgerService(repo) + try: + rule = await repo.get_rule(int(row.tenant_id), row.rule_code) + if rule is None or rule.rule_type != "deduct" or rule.status != "enabled": + raise PointsRuleNotFoundError() + score = abs(int((rule.score_expr or {}).get("score", 0))) + if score == 0: + raise PointsInvalidAdjustError(msg="扣减规则分值为 0") + result = await ledger.deduct( + tenant_id=int(row.tenant_id), + user_id=int(row.user_id), + delta=-score, + rule_code=rule.rule_code, + title=rule.name or rule.rule_code, + idempotency_key=row.idempotency_key, + operator_id=int(row.operator_id or 0), + remark=row.remark, + biz_type=row.biz_type, + biz_id=row.biz_id, + ) + row.status = "done" + row.last_error = None + row.next_retry_at = None + await repo.save_pending_deduct(row) + if not result.replayed and result.log_id is not None: + try: + await self.notify.notify( + user_id=int(row.user_id), + template_code="deduct_admin", + delta=score, + rule_name=rule.name or row.rule_code, + reason=row.remark or "", + ) + except Exception: + logger.exception( + "points.pending_deduct.drain_notify_failed id=%s", + row.id, + ) + return "done" + except Exception as exc: + row.retry_count = int(row.retry_count or 0) + 1 + row.last_error = str(exc)[:1000] + if row.retry_count >= MAX_RETRIES: + row.status = "dead" + row.next_retry_at = None + await repo.save_pending_deduct(row) + logger.error( + "points.pending_deduct.dead id=%s key=%s err=%s", + row.id, + row.idempotency_key, + exc, + ) + return "dead" + backoff = min(3600, 30 * (2 ** max(row.retry_count - 1, 0))) + row.next_retry_at = datetime.utcnow() + timedelta(seconds=backoff) + await repo.save_pending_deduct(row) + logger.warning( + "points.pending_deduct.retry id=%s retry=%s err=%s", + row.id, + row.retry_count, + exc, + ) + return "failed" diff --git a/src/backend/bisheng/points/domain/services/points_query_service.py b/src/backend/bisheng/points/domain/services/points_query_service.py new file mode 100644 index 000000000..2e5666b1e --- /dev/null +++ b/src/backend/bisheng/points/domain/services/points_query_service.py @@ -0,0 +1,407 @@ +"""积分查询与管理端写操作(调分/扣减)。""" + +from __future__ import annotations + +import logging +import uuid +from datetime import datetime +from zoneinfo import ZoneInfo + +from bisheng.common.dependencies.user_deps import UserPayload +from bisheng.common.errcode.points import PointsInvalidAdjustError, PointsRuleNotFoundError +from bisheng.common.schemas.api import PageData +from bisheng.points.domain.schemas.points_schema import ( + PointAdjustRequest, + PointAdminUserItem, + PointAuditLogItem, + PointDeductRequest, + PointLeaderboardItem, + PointLeaderboardResponse, + PointLogResponse, + PointOverviewResponse, + PointSummaryResponse, +) +from bisheng.points.domain.services.points_auth import require_platform_admin +from bisheng.points.domain.services.points_ledger_service import PointsLedgerService +from bisheng.points.domain.services.points_notify_service import PointsNotifyService + +logger = logging.getLogger(__name__) +SHANGHAI = ZoneInfo("Asia/Shanghai") +# 运营概览缓存:三个指标均为全历史聚合,AC-19 允许 5min 陈旧。 +OVERVIEW_CACHE_PREFIX = "points:overview:" +OVERVIEW_CACHE_TTL = 300 + + +class PointsQueryService: + """聚合摘要、明细、榜单与运营概览;管理端调分/扣减封装账本与通知。""" + + def __init__(self, session, repository, ledger: PointsLedgerService, notify: PointsNotifyService | None = None): + self.session = session + self.repository = repository + self.ledger = ledger + self.notify = notify or PointsNotifyService() + + @staticmethod + def _month_bounds(now: datetime | None = None) -> tuple[datetime, datetime]: + """返回上海时区当月起止(naive,对齐 occurred_at 落库方式)。""" + current = now or datetime.now(SHANGHAI) + if current.tzinfo is not None: + current = current.astimezone(SHANGHAI) + start = current.replace(day=1, hour=0, minute=0, second=0, microsecond=0, tzinfo=None) + if current.month == 12: + end = current.replace( + year=current.year + 1, month=1, day=1, hour=0, minute=0, second=0, microsecond=0, tzinfo=None + ) + else: + end = current.replace( + month=current.month + 1, day=1, hour=0, minute=0, second=0, microsecond=0, tzinfo=None + ) + return start, end + + @staticmethod + def _log_response(log) -> PointLogResponse: + """将流水 ORM 转为响应 DTO。""" + return PointLogResponse( + id=int(log.id), + title=log.title, + delta=log.delta, + balance_after=log.balance_after, + direction=log.direction, + rule_code=log.rule_code, + source=log.source, + remark=log.remark, + occurred_at=log.occurred_at, + ) + + @staticmethod + async def _resolve_user_company_id(user_id: int) -> int | None: + """解析用户主部门所属公司根 id;无公司标签返回 None。""" + from bisheng.database.models.department import DepartmentDao, UserDepartmentDao + from bisheng.points.domain.services.points_rank_service import resolve_company_id + + primary_map = UserDepartmentDao.get_primary_department_map_by_user_ids([int(user_id)]) + primary = primary_map.get(int(user_id)) + all_depts = await DepartmentDao.aget_all_active() + dept_by_id = {int(d.id): d for d in all_depts} + return resolve_company_id(primary, dept_by_id) + + async def my_summary(self, tenant_id: int, user_id: int) -> PointSummaryResponse: + """余额、当月收支与本公司排名(无公司则排名为 —)。""" + account = await self.repository.find_account(tenant_id, user_id) + balance = int(account.balance) if account else 0 + month_start, month_end = self._month_bounds() + month_earned = await self.repository.sum_user_delta( + tenant_id, user_id, direction="earn", start=month_start, end=month_end + ) + month_deducted = abs( + await self.repository.sum_user_delta( + tenant_id, user_id, direction="deduct", start=month_start, end=month_end + ) + ) + period_key = datetime.now(SHANGHAI).strftime("%Y-%m") + company_id = await self._resolve_user_company_id(user_id) + global_snap = None + if company_id is not None: + global_snap = await self.repository.find_user_rank( + tenant_id, "month", "global", company_id, period_key, user_id + ) + global_rank = int(global_snap.rank_no) if global_snap else None + if global_rank is None: + display = "-" + elif global_rank > 999: + display = "999+" + else: + display = str(global_rank) + # 部门榜:快照上的 dept_id 即 org_level=dept 桶;无桶则展示为 —(AC-22)。 + dept_rank = None + if global_snap is not None and global_snap.dept_id is not None: + dept_snap = await self.repository.find_user_rank( + tenant_id, "month", "dept", int(global_snap.dept_id), period_key, user_id + ) + if dept_snap is not None: + dept_rank = int(dept_snap.rank_no) + refreshed = await self.repository.latest_rank_refreshed_at(tenant_id, "month", period_key) + return PointSummaryResponse( + balance=balance, + month_earned=month_earned, + month_deducted=month_deducted, + dept_rank=dept_rank, + global_rank=global_rank, + global_rank_display=display, + rank_refreshed_at=refreshed, + ) + + async def my_logs( + self, + tenant_id: int, + user_id: int, + *, + direction: str | None = None, + page: int = 1, + page_size: int = 20, + from_time: datetime | None = None, + to_time: datetime | None = None, + ) -> tuple[list[PointLogResponse], int]: + """分页返回当前用户流水。""" + dir_filter = None if direction in (None, "all") else direction + rows, total = await self.repository.list_logs( + tenant_id, + user_id, + dir_filter, + page, + page_size, + from_time=from_time, + to_time=to_time, + ) + return [self._log_response(r) for r in rows], total + + async def leaderboard( + self, tenant_id: int, period: str, user_id: int + ) -> PointLeaderboardResponse: + """读取当前用户所属公司的小时快照 TOP10;无公司则空榜(AC-15)。""" + now = datetime.now(SHANGHAI) + if period == "year": + period_key = now.strftime("%Y") + elif period == "all": + period_key = "all" + else: + period = "month" + period_key = now.strftime("%Y-%m") + company_id = await self._resolve_user_company_id(user_id) + refreshed = await self.repository.latest_rank_refreshed_at(tenant_id, period, period_key) + if company_id is None: + return PointLeaderboardResponse(period=period, refreshed_at=refreshed, items=[]) + rows = await self.repository.list_top_ranks( + tenant_id, period, "global", company_id, period_key, limit=10 + ) + user_ids = [int(r.user_id) for r in rows] + name_by_user, dept_by_user = await self._leaderboard_display_maps(user_ids) + items = [ + PointLeaderboardItem( + rank=int(r.rank_no), + user_id=int(r.user_id), + user_name=name_by_user.get(int(r.user_id), str(r.user_id)), + dept_name=dept_by_user.get(int(r.user_id), "—"), + balance=int(r.balance), + period_score=int(r.period_score), + ) + for r in rows + ] + return PointLeaderboardResponse(period=period, refreshed_at=refreshed, items=items) + + @staticmethod + async def _leaderboard_display_maps( + user_ids: list[int], + ) -> tuple[dict[int, str], dict[int, str]]: + """批量解析榜单用户名与主部门名称。""" + if not user_ids: + return {}, {} + from bisheng.database.models.department import UserDepartmentDao + from bisheng.user.domain.models.user import UserDao + + users = await UserDao.aget_user_by_ids(user_ids) or [] + name_by_user = { + int(u.user_id): str(getattr(u, "user_name", None) or u.user_id) for u in users + } + primary_map = UserDepartmentDao.get_primary_department_map_by_user_ids(user_ids) + dept_by_user = { + uid: str(dept.name) for uid, dept in primary_map.items() if getattr(dept, "name", None) + } + return name_by_user, dept_by_user + + async def overview(self, tenant_id: int, user: UserPayload) -> PointOverviewResponse: + """运营概览:总发放 / 余额合计 / 违规扣减。 + + 三个指标都是全历史聚合,耗时随流水量线性增长;按 AC-19 允许 5min 陈旧, + 因此走 Redis 缓存。缓存不可用时退化为直查库,不影响可用性。 + """ + require_platform_admin(user) + cache_key = f"{OVERVIEW_CACHE_PREFIX}{tenant_id}" + cached = await self._overview_cache_get(cache_key) + if cached is not None: + return PointOverviewResponse(**cached) + payload = { + "total_issued": await self.repository.sum_total_issued(tenant_id), + "total_balance": await self.repository.sum_total_balance(tenant_id), + "total_violation_deducted": await self.repository.sum_violation_deducted(tenant_id), + } + await self._overview_cache_set(cache_key, payload) + return PointOverviewResponse(**payload) + + @staticmethod + async def _overview_cache_get(key: str) -> dict | None: + """读概览缓存;Redis 不可用时按未命中处理。""" + try: + from bisheng.core.cache.redis_manager import get_redis_client + + cached = await (await get_redis_client()).aget(key) + except Exception: + # 概览是只读统计,缓存故障时直接查库即可,无需中断请求。 + logger.warning("points.overview cache read failed key=%s", key, exc_info=True) + return None + return cached if isinstance(cached, dict) else None + + @staticmethod + async def _overview_cache_set(key: str, payload: dict) -> None: + """写概览缓存;失败仅告警,不影响本次返回。""" + try: + from bisheng.core.cache.redis_manager import get_redis_client + + await (await get_redis_client()).aset(key, payload, expiration=OVERVIEW_CACHE_TTL) + except Exception: + logger.warning("points.overview cache write failed key=%s", key, exc_info=True) + + async def admin_list_users( + self, + tenant_id: int, + user: UserPayload, + *, + keyword: str | None = None, + page: int = 1, + page_size: int = 20, + ) -> PageData[PointAdminUserItem]: + """管理端用户积分列表(账户 + 姓名/主部门 + 本月净变动)。""" + require_platform_admin(user) + page = max(int(page), 1) + page_size = min(max(int(page_size), 1), 100) + user_ids_filter: list[int] | None = None + kw = (keyword or "").strip() + if kw: + from bisheng.user.domain.models.user import UserDao + + matched = await UserDao.aget_users_by_username(kw) + # 精确同名可能只有少量;再做 like 搜索兜底 + like_rows = UserDao.search_user_by_name(kw) or [] + ids = {int(u.user_id) for u in (matched or [])} | {int(u.user_id) for u in like_rows} + # 纯数字关键词按 user_id 命中 + if kw.isdigit(): + ids.add(int(kw)) + user_ids_filter = sorted(ids) + accounts, total = await self.repository.list_accounts_page( + tenant_id, page=page, page_size=page_size, user_ids=user_ids_filter + ) + ids = [int(a.user_id) for a in accounts] + name_by_user, dept_by_user = await self._leaderboard_display_maps(ids) + start, end = self._month_bounds() + # 只聚合当页用户;此前是对全租户整月流水做 GROUP BY 后再取子集。 + month_scores = await self.repository.sum_deltas_by_users( + tenant_id, ids, start=start, end=end + ) + data = [ + PointAdminUserItem( + user_id=int(a.user_id), + user_name=name_by_user.get(int(a.user_id), str(a.user_id)), + dept_name=dept_by_user.get(int(a.user_id), "—"), + balance=int(a.balance), + month_score=int(month_scores.get(int(a.user_id), 0)), + ) + for a in accounts + ] + return PageData(data=data, total=total) + + async def admin_list_audit_logs( + self, + tenant_id: int, + user: UserPayload, + *, + page: int = 1, + page_size: int = 20, + user_id: int | None = None, + ) -> PageData[PointAuditLogItem]: + """管理端操作记录:手动调分 / R* 扣减。""" + require_platform_admin(user) + page = max(int(page), 1) + page_size = min(max(int(page_size), 1), 100) + rows, total = await self.repository.list_audit_logs( + tenant_id, + page=page, + page_size=page_size, + sources=["manual_adjust", "manual_deduct"], + user_id=user_id, + ) + ids = sorted({int(r.user_id) for r in rows}) + name_by_user, _ = await self._leaderboard_display_maps(ids) + data = [ + PointAuditLogItem( + id=int(r.id), + user_id=int(r.user_id), + user_name=name_by_user.get(int(r.user_id), str(r.user_id)), + title=r.title, + delta=int(r.delta), + balance_after=int(r.balance_after), + direction=r.direction, + rule_code=r.rule_code, + source=r.source, + operator_id=r.operator_id, + remark=r.remark, + occurred_at=r.occurred_at, + ) + for r in rows + ] + return PageData(data=data, total=total) + + async def admin_adjust( + self, tenant_id: int, user: UserPayload, body: PointAdjustRequest + ) -> PointLogResponse: + """平台超管手动调分;提交后再发站内信。""" + require_platform_admin(user) + result = await self.ledger.adjust( + tenant_id=tenant_id, + user_id=body.user_id, + delta=body.delta, + title="管理员调分", + idempotency_key=f"manual:{user.user_id}:{body.user_id}:{uuid.uuid4().hex}", + operator_id=int(user.user_id), + remark=body.remark, + ) + await self.session.commit() + if result.log_id is None: + raise PointsInvalidAdjustError() + log = await self.repository.get_log_by_id(int(result.log_id)) + assert log is not None + await self.notify.notify( + user_id=body.user_id, + template_code="adjust_admin", + delta=int(log.delta), + reason=body.remark or "", + ) + return self._log_response(log) + + async def admin_deduct( + self, tenant_id: int, user: UserPayload, body: PointDeductRequest + ) -> PointLogResponse: + """平台超管按 R* 规则扣减。""" + require_platform_admin(user) + rule = await self.repository.get_rule(tenant_id, body.rule_code.strip().upper()) + if rule is None or rule.rule_type != "deduct" or rule.status != "enabled": + raise PointsRuleNotFoundError() + score = abs(int((rule.score_expr or {}).get("score", 0))) + if score == 0: + raise PointsInvalidAdjustError(msg="扣减规则分值为 0") + result = await self.ledger.deduct( + tenant_id=tenant_id, + user_id=body.user_id, + delta=-score, + rule_code=rule.rule_code, + title=rule.name, + idempotency_key=( + f"deduct:{rule.rule_code}:{user.user_id}:{body.user_id}:{uuid.uuid4().hex}" + ), + operator_id=int(user.user_id), + remark=body.remark, + biz_type=body.biz_type, + biz_id=body.biz_id, + ) + await self.session.commit() + if result.log_id is None: + raise PointsInvalidAdjustError() + log = await self.repository.get_log_by_id(int(result.log_id)) + assert log is not None + await self.notify.notify( + user_id=body.user_id, + template_code="deduct_admin", + delta=abs(int(log.delta)), + rule_name=rule.name, + reason=body.remark or "", + ) + return self._log_response(log) diff --git a/src/backend/bisheng/points/domain/services/points_rank_service.py b/src/backend/bisheng/points/domain/services/points_rank_service.py new file mode 100644 index 000000000..c85336281 --- /dev/null +++ b/src/backend/bisheng/points/domain/services/points_rank_service.py @@ -0,0 +1,285 @@ +"""积分排行快照:按公司隔离的月/年/总榜与部门桶;稠密并列名次。""" + +from __future__ import annotations + +import logging +from datetime import datetime +from zoneinfo import ZoneInfo + +from bisheng.core.database import get_async_db_session +from bisheng.database.constants import AdminRole +from bisheng.database.models.department import Department, DepartmentDao, UserDepartmentDao +from bisheng.points.domain.constants.org_levels import ORG_LEVEL_COMPANY, ORG_LEVEL_DEPT +from bisheng.points.domain.models import PointRankSnapshot +from bisheng.points.domain.repositories.points_repository import PointsRepository +from bisheng.user.domain.models.user_role import UserRole + +logger = logging.getLogger(__name__) +SHANGHAI = ZoneInfo("Asia/Shanghai") + + +def period_keys(now: datetime | None = None) -> dict[str, str]: + """返回当前上海时区的 period → period_key。""" + current = now or datetime.now(SHANGHAI) + if current.tzinfo is not None: + current = current.astimezone(SHANGHAI) + return { + "month": current.strftime("%Y-%m"), + "year": current.strftime("%Y"), + "all": "all", + } + + +def year_bounds(now: datetime | None = None) -> tuple[datetime, datetime]: + """上海时区自然年起止(naive)。""" + current = now or datetime.now(SHANGHAI) + if current.tzinfo is not None: + current = current.astimezone(SHANGHAI) + start = current.replace(month=1, day=1, hour=0, minute=0, second=0, microsecond=0, tzinfo=None) + end = current.replace( + year=current.year + 1, month=1, day=1, hour=0, minute=0, second=0, microsecond=0, tzinfo=None + ) + return start, end + + +def resolve_company_id( + primary: Department | None, + departments: dict[int, Department], +) -> int | None: + """主部门沿 path 向上找最近 org_level=company;找不到返回 None。""" + if primary is None or not primary.path: + return None + parts: list[int] = [] + for part in str(primary.path).strip("/").split("/"): + if part.isdigit(): + parts.append(int(part)) + for dept_id in reversed(parts): + node = departments.get(dept_id) + if node is not None and getattr(node, "org_level", None) == ORG_LEVEL_COMPANY: + return int(node.id) + return None + + +def resolve_dept_bucket_id( + primary: Department | None, + departments: dict[int, Department], +) -> int | None: + """主部门沿 path 向上找最近 org_level=dept;找不到返回 None(AC-22)。""" + if primary is None or not primary.path: + return None + parts: list[int] = [] + for part in str(primary.path).strip("/").split("/"): + if part.isdigit(): + parts.append(int(part)) + for dept_id in reversed(parts): + node = departments.get(dept_id) + if node is not None and getattr(node, "org_level", None) == ORG_LEVEL_DEPT: + return int(node.id) + return None + + +def build_ranked_rows( + *, + tenant_id: int, + period: str, + scope: str, + scope_id: int | None, + period_key: str, + scores: dict[int, int], + balances: dict[int, int], + dept_ids: dict[int, int | None], + exclude_user_ids: set[int], + refreshed_at: datetime, +) -> list[PointRankSnapshot]: + """按 period_score 降序生成快照行;同分稠密同名次,列表序按 user_id 稳定。""" + candidates = [ + (user_id, score) + for user_id, score in scores.items() + if user_id not in exclude_user_ids + ] + candidates.sort(key=lambda item: (-item[1], item[0])) + rows: list[PointRankSnapshot] = [] + prev_score: int | None = None + rank_no = 0 + for user_id, score in candidates: + score_i = int(score) + # 稠密名次:分不同才 +1;同分共用上一名次(100,100,90 → 1,1,2)。 + if prev_score is None or score_i != prev_score: + rank_no += 1 + prev_score = score_i + rows.append( + PointRankSnapshot( + tenant_id=tenant_id, + period=period, + scope=scope, + scope_id=scope_id, + period_key=period_key, + user_id=user_id, + rank_no=rank_no, + period_score=score_i, + balance=int(balances.get(user_id, 0)), + dept_id=dept_ids.get(user_id), + refreshed_at=refreshed_at, + ) + ) + return rows + + +class PointsRankService: + """重建并写入 point_rank_snapshot。""" + + def __init__(self, repository: PointsRepository | None = None): + self.repository = repository + + async def refresh_rank_snapshots(self, tenant_id: int) -> dict: + """刷新指定租户的 month/year/all × 公司 global / dept 快照。""" + async with get_async_db_session() as session: + repo = self.repository or PointsRepository(session) + result = await self._refresh_with_repo(repo, int(tenant_id)) + await session.commit() + return result + + async def refresh_all_tenants(self) -> dict: + """Beat 入口:扫有账户的租户并刷新。""" + from bisheng.core.context.tenant import bypass_tenant_filter, set_current_tenant_id + + with bypass_tenant_filter(): + async with get_async_db_session() as session: + repo = PointsRepository(session) + tenant_ids = await repo.list_tenant_ids_with_accounts() + totals = {"tenants": 0, "rows": 0} + for tid in tenant_ids or [1]: + set_current_tenant_id(int(tid)) + try: + out = await self.refresh_rank_snapshots(int(tid)) + totals["tenants"] += 1 + totals["rows"] += int(out.get("rows") or 0) + except Exception: + logger.exception("points.rank.refresh_failed tenant_id=%s", tid) + return totals + + async def _refresh_with_repo(self, repo: PointsRepository, tenant_id: int) -> dict: + now = datetime.now(SHANGHAI) + keys = period_keys(now) + refreshed_at = now.replace(tzinfo=None) + accounts = await repo.list_accounts(tenant_id) + balances = {int(a.user_id): int(a.balance) for a in accounts} + lifetime_earned = {int(a.user_id): int(a.lifetime_earned) for a in accounts} + if not balances: + return {"tenant_id": tenant_id, "rows": 0} + + from bisheng.points.domain.services.points_query_service import PointsQueryService + + month_start, month_end = PointsQueryService._month_bounds(now) + year_start, year_end = year_bounds(now) + month_scores = await repo.sum_deltas_by_user(tenant_id, start=month_start, end=month_end) + year_scores = await repo.sum_deltas_by_user(tenant_id, start=year_start, end=year_end) + all_scores = { + user_id: earned for user_id, earned in lifetime_earned.items() if earned > 0 + } + + exclude = await self._load_super_admin_ids() + company_by_user, bucket_by_user = await self._load_company_and_dept_buckets( + list(balances.keys()) + ) + company_ids = sorted({cid for cid in company_by_user.values() if cid is not None}) + + written = 0 + period_score_map = { + "month": month_scores, + "year": year_scores, + "all": all_scores, + } + for period, scores in period_score_map.items(): + period_key = keys[period] + # 整 period 清桶,去掉旧全租户 global(scope_id=NULL) 与失效公司桶。 + await repo.clear_period_rank_snapshots(tenant_id, period, period_key) + period_rows: list[PointRankSnapshot] = [] + + for company_id in company_ids: + company_scores = { + uid: sc + for uid, sc in scores.items() + if company_by_user.get(uid) == company_id and uid not in exclude + } + period_rows.extend( + build_ranked_rows( + tenant_id=tenant_id, + period=period, + scope="global", + scope_id=company_id, + period_key=period_key, + scores=company_scores, + balances=balances, + dept_ids=bucket_by_user, + exclude_user_ids=set(), + refreshed_at=refreshed_at, + ) + ) + + buckets: dict[int, dict[int, int]] = {} + for user_id, score in company_scores.items(): + bucket = bucket_by_user.get(user_id) + if bucket is None: + continue + buckets.setdefault(bucket, {})[user_id] = score + for scope_id, bucket_scores in buckets.items(): + period_rows.extend( + build_ranked_rows( + tenant_id=tenant_id, + period=period, + scope="dept", + scope_id=scope_id, + period_key=period_key, + scores=bucket_scores, + balances=balances, + dept_ids=bucket_by_user, + exclude_user_ids=set(), + refreshed_at=refreshed_at, + ) + ) + + written += await repo.bulk_insert_rank_snapshots(period_rows) + + logger.info( + "points.rank.refreshed tenant_id=%s rows=%s companies=%s periods=%s", + tenant_id, + written, + len(company_ids), + list(keys.values()), + ) + return { + "tenant_id": tenant_id, + "rows": written, + "period_keys": keys, + "companies": len(company_ids), + } + + @staticmethod + async def _load_super_admin_ids() -> set[int]: + """平台超管(AdminRole)不进激励榜。""" + from sqlmodel import select + + async with get_async_db_session() as session: + rows = ( + await session.exec(select(UserRole.user_id).where(UserRole.role_id == AdminRole)) + ).all() + return {int(r[0] if isinstance(r, tuple) else r) for r in rows} + + @staticmethod + async def _load_company_and_dept_buckets( + user_ids: list[int], + ) -> tuple[dict[int, int | None], dict[int, int | None]]: + """批量解析用户主部门 → 公司根与 dept 桶。""" + if not user_ids: + return {}, {} + primary_map = UserDepartmentDao.get_primary_department_map_by_user_ids(user_ids) + all_depts = await DepartmentDao.aget_all_active() + dept_by_id = {int(d.id): d for d in all_depts} + companies: dict[int, int | None] = {} + buckets: dict[int, int | None] = {} + for user_id in user_ids: + primary = primary_map.get(int(user_id)) + companies[int(user_id)] = resolve_company_id(primary, dept_by_id) + buckets[int(user_id)] = resolve_dept_bucket_id(primary, dept_by_id) + return companies, buckets diff --git a/src/backend/bisheng/points/domain/services/points_reconcile_service.py b/src/backend/bisheng/points/domain/services/points_reconcile_service.py new file mode 100644 index 000000000..ed2134f99 --- /dev/null +++ b/src/backend/bisheng/points/domain/services/points_reconcile_service.py @@ -0,0 +1,85 @@ +"""积分余额对账:核对 sum(log.delta) 与 account.balance,只告警不改流水。""" + +from __future__ import annotations + +import logging + +from bisheng.core.database import get_async_db_session +from bisheng.points.domain.repositories.points_repository import PointsRepository + +logger = logging.getLogger(__name__) + + +class PointsReconcileService: + """日对账任务;发现不一致仅记录,禁止静默改流水(AC-04)。""" + + async def reconcile_all_tenants(self) -> dict: + """扫有账户的租户并逐一核对。""" + from bisheng.core.context.tenant import bypass_tenant_filter, set_current_tenant_id + + with bypass_tenant_filter(): + async with get_async_db_session() as session: + repo = PointsRepository(session) + tenant_ids = await repo.list_tenant_ids_with_accounts() + totals = {"tenants": 0, "checked": 0, "mismatches": 0} + for tid in tenant_ids or [1]: + set_current_tenant_id(int(tid)) + try: + out = await self.reconcile_tenant(int(tid)) + totals["tenants"] += 1 + totals["checked"] += int(out.get("checked") or 0) + totals["mismatches"] += int(out.get("mismatches") or 0) + except Exception: + logger.exception("points.reconcile.tenant_failed tenant_id=%s", tid) + if totals["mismatches"]: + logger.error("points.reconcile.done_with_mismatches %s", totals) + else: + logger.info("points.reconcile.done %s", totals) + return totals + + async def reconcile_tenant(self, tenant_id: int) -> dict: + """核对单租户:期望余额 = 流水 delta 之和。""" + async with get_async_db_session() as session: + repo = PointsRepository(session) + accounts = await repo.list_accounts(tenant_id) + ledger_sums = await repo.sum_lifetime_deltas_by_user(tenant_id) + + mismatches: list[dict] = [] + checked = 0 + seen_users: set[int] = set() + for account in accounts: + user_id = int(account.user_id) + seen_users.add(user_id) + expected = int(ledger_sums.get(user_id, 0)) + actual = int(account.balance) + checked += 1 + if expected != actual: + item = { + "tenant_id": tenant_id, + "user_id": user_id, + "balance": actual, + "ledger_sum": expected, + } + mismatches.append(item) + logger.error("points.reconcile.mismatch %s", item) + + # 有流水但无账户的异常行也告警(通常不应出现)。 + for user_id, expected in ledger_sums.items(): + if int(user_id) in seen_users: + continue + checked += 1 + item = { + "tenant_id": tenant_id, + "user_id": int(user_id), + "balance": None, + "ledger_sum": int(expected), + } + mismatches.append(item) + logger.error("points.reconcile.orphan_ledger %s", item) + + return { + "tenant_id": tenant_id, + "checked": checked, + "mismatches": len(mismatches), + "details": mismatches[:50], + } diff --git a/src/backend/bisheng/points/domain/services/points_rule_service.py b/src/backend/bisheng/points/domain/services/points_rule_service.py new file mode 100644 index 000000000..c1baeccd4 --- /dev/null +++ b/src/backend/bisheng/points/domain/services/points_rule_service.py @@ -0,0 +1,165 @@ +"""积分规则与说明文案服务。""" + +from __future__ import annotations + +from bisheng.common.dependencies.user_deps import UserPayload +from bisheng.common.errcode.points import PointsRuleConflictError, PointsRuleNotFoundError +from bisheng.points.domain.constants.beneficiary import allowed_beneficiaries +from bisheng.points.domain.models import PointRule +from bisheng.points.domain.schemas.points_schema import ( + PointCopiesUpdateRequest, + PointCopyItem, + PointRuleRequest, + PointRuleResponse, +) +from bisheng.points.domain.services.points_auth import require_platform_admin + + +class PointsRuleService: + """校验规则受益人,保证历史流水不因规则变更被重写。""" + + def __init__(self, session, repository): + self.session = session + self.repository = repository + + @staticmethod + def validate_beneficiary(rule_code: str, rule_type: str, beneficiary: str | None) -> None: + """校验 earn 规则的受益人是否与编码白名单相符。""" + allowed = allowed_beneficiaries(rule_code) + if rule_type == "earn" and (not beneficiary or beneficiary not in allowed): + raise PointsRuleConflictError(msg="该规则不允许指定的积分受益人") + + @staticmethod + def require_enabled_deduct(rule) -> None: + """扣减前确保规则存在、已启用且类型为 deduct。""" + if not rule or rule.rule_type != "deduct" or rule.status != "enabled": + raise PointsRuleNotFoundError() + + def _to_dto(self, rule: PointRule) -> PointRuleResponse: + """规则 ORM → 响应 DTO。""" + return PointRuleResponse( + id=int(rule.id), + rule_code=rule.rule_code, + rule_type=rule.rule_type, + name=rule.name, + score_expr=rule.score_expr or {}, + daily_cap=rule.daily_cap, + beneficiary=rule.beneficiary, + beneficiary_options=list(allowed_beneficiaries(rule.rule_code)), + status=rule.status, + remark=rule.remark, + sort_order=int(rule.sort_order or 0), + ) + + async def list_rules( + self, + tenant_id: int, + user: UserPayload, + *, + rule_type: str | None = None, + status: str | None = None, + ) -> list[PointRuleResponse]: + """管理端规则列表。""" + require_platform_admin(user) + rows = await self.repository.list_rules(tenant_id, rule_type=rule_type, status=status) + return [self._to_dto(r) for r in rows] + + async def create_rule( + self, tenant_id: int, user: UserPayload, body: PointRuleRequest + ) -> PointRuleResponse: + """创建规则;rule_code 租户内唯一。""" + require_platform_admin(user) + if not body.rule_code or not body.rule_type or not body.name: + raise PointsRuleConflictError(msg="创建规则缺少必填字段") + code = body.rule_code.strip().upper() + if await self.repository.get_rule(tenant_id, code): + raise PointsRuleConflictError(msg=f"规则编码 {code} 已存在") + self.validate_beneficiary(code, body.rule_type, body.beneficiary) + rule = PointRule( + tenant_id=tenant_id, + rule_code=code, + rule_type=body.rule_type, + name=body.name, + score_expr=body.score_expr or {}, + daily_cap=body.daily_cap, + beneficiary=body.beneficiary, + status=body.status or "enabled", + remark=body.remark, + sort_order=body.sort_order or 0, + ) + saved = await self.repository.save_rule(rule) + await self.session.commit() + return self._to_dto(saved) + + async def update_rule( + self, + tenant_id: int, + user: UserPayload, + rule_id: int, + body: PointRuleRequest, + ) -> PointRuleResponse: + """更新可变字段;不提供物理删除,仅可改状态与配置。""" + require_platform_admin(user) + rule = await self.repository.get_rule_by_id(rule_id) + if rule is None or int(rule.tenant_id) != tenant_id: + raise PointsRuleNotFoundError() + fields = body.model_fields_set + if "name" in fields and body.name is not None: + rule.name = body.name + if "score_expr" in fields and body.score_expr is not None: + rule.score_expr = body.score_expr + if "daily_cap" in fields: + rule.daily_cap = body.daily_cap + if "status" in fields and body.status is not None: + if body.status not in ("enabled", "disabled"): + raise PointsRuleConflictError(msg="规则状态仅允许 enabled/disabled") + rule.status = body.status + if "remark" in fields: + rule.remark = body.remark + if "sort_order" in fields and body.sort_order is not None: + rule.sort_order = body.sort_order + if "beneficiary" in fields: + self.validate_beneficiary(rule.rule_code, rule.rule_type, body.beneficiary) + rule.beneficiary = body.beneficiary + saved = await self.repository.save_rule(rule) + await self.session.commit() + return self._to_dto(saved) + + async def list_copies(self, tenant_id: int, user: UserPayload) -> list[PointCopyItem]: + """管理端说明文案列表。""" + require_platform_admin(user) + rows = await self.repository.list_copies(tenant_id) + return [ + PointCopyItem(copy_key=r.copy_key, content=r.content, sort_order=int(r.sort_order or 0)) + for r in rows + ] + + async def update_copies( + self, tenant_id: int, user: UserPayload, body: PointCopiesUpdateRequest + ) -> list[PointCopyItem]: + """Replace-set upsert for copy rows; keys omitted from the payload are deleted.""" + require_platform_admin(user) + rows = await self.repository.upsert_copies( + tenant_id, + [item.model_dump() for item in body.items], + ) + await self.session.commit() + return [ + PointCopyItem(copy_key=r.copy_key, content=r.content, sort_order=int(r.sort_order or 0)) + for r in rows + ] + + async def public_rules(self, tenant_id: int) -> dict: + """前台规则弹窗:启用的 earn/deduct + 文案;不暴露月奖 M*。""" + rules = await self.repository.list_rules(tenant_id, status="enabled") + earn = [self._to_dto(r) for r in rules if r.rule_type == "earn"] + deduct = [self._to_dto(r) for r in rules if r.rule_type == "deduct"] + copies = [ + PointCopyItem(copy_key=r.copy_key, content=r.content, sort_order=int(r.sort_order or 0)) + for r in await self.repository.list_copies(tenant_id) + ] + return { + "earn_rules": [r.model_dump() for r in earn], + "deduct_rules": [r.model_dump() for r in deduct], + "copies": [c.model_dump() for c in copies], + } diff --git a/src/backend/bisheng/points/domain/services/points_sync_outbox_service.py b/src/backend/bisheng/points/domain/services/points_sync_outbox_service.py new file mode 100644 index 000000000..2a5049cf5 --- /dev/null +++ b/src/backend/bisheng/points/domain/services/points_sync_outbox_service.py @@ -0,0 +1,104 @@ +"""积分外部同步 outbox:投递旁路,失败不回滚账本。""" + +from __future__ import annotations + +import logging +from collections.abc import Awaitable, Callable +from datetime import datetime, timedelta + +from bisheng.core.database import get_async_db_session +from bisheng.points.domain.models import PointSyncOutbox +from bisheng.points.domain.repositories.points_repository import PointsRepository + +logger = logging.getLogger(__name__) + +# 外部协同办公适配器未接入前的占位投递;成功时调用方可返回 True。 +DeliverFn = Callable[[PointSyncOutbox], Awaitable[bool]] + +MAX_RETRIES = 8 + + +async def _default_deliver(_row: PointSyncOutbox) -> bool: + """默认无外部适配器:显式失败,由 drain 标为 skipped(非无限重试)。""" + raise RuntimeError("points_sync_adapter_not_configured") + + +class PointsSyncOutboxService: + """消费 point_sync_outbox;开关关闭时保持 pending(AC-23)。""" + + def __init__(self, *, deliver: DeliverFn | None = None): + self._deliver = deliver or _default_deliver + + async def drain(self, *, limit: int = 100) -> dict: + """批量投递到期 outbox。""" + from bisheng.common.services.config_service import settings + from bisheng.core.context.tenant import bypass_tenant_filter + + conf = getattr(settings, "points", None) + if conf is None or not bool(getattr(conf, "sync_outbox_enabled", False)): + logger.info("points.outbox.disabled keep pending") + return {"skipped": True, "reason": "sync_outbox_disabled"} + + processed = sent = failed = skipped = 0 + with bypass_tenant_filter(): + async with get_async_db_session() as session: + repo = PointsRepository(session) + rows = await repo.list_due_sync_outbox(limit=limit) + for row in rows: + processed += 1 + outcome = await self._process_one(repo, row) + if outcome == "sent": + sent += 1 + elif outcome == "skipped": + skipped += 1 + else: + failed += 1 + await session.commit() + + result = { + "processed": processed, + "sent": sent, + "failed": failed, + "skipped": skipped, + } + logger.info("points.outbox.drain_done %s", result) + return result + + async def _process_one(self, repo: PointsRepository, row: PointSyncOutbox) -> str: + """投递单条;适配器未配置 → skipped;瞬时失败 → failed+backoff。""" + try: + ok = await self._deliver(row) + if not ok: + raise RuntimeError("deliver_returned_false") + row.status = "sent" + row.sent_at = datetime.utcnow() + row.last_error = None + await repo.save_outbox(row) + return "sent" + except Exception as exc: + message = str(exc)[:500] + # 未配置适配器:标 skipped,避免永久占用 pending 队列。 + if "points_sync_adapter_not_configured" in message: + row.status = "skipped" + row.last_error = message + row.retry_count = int(row.retry_count or 0) + 1 + await repo.save_outbox(row) + return "skipped" + row.retry_count = int(row.retry_count or 0) + 1 + row.last_error = message + if row.retry_count >= MAX_RETRIES: + row.status = "failed" + row.next_retry_at = None + else: + row.status = "failed" + # 指数退避,供下次 drain 捞起(status 仍为 failed 但 next_retry_at 到期)。 + backoff = min(3600, 30 * (2 ** max(row.retry_count - 1, 0))) + row.next_retry_at = datetime.utcnow() + timedelta(seconds=backoff) + await repo.save_outbox(row) + logger.warning( + "points.outbox.deliver_failed id=%s retry=%s err=%s", + row.id, + row.retry_count, + message, + ) + return "failed" diff --git a/src/backend/bisheng/qa_expert/api/endpoints.py b/src/backend/bisheng/qa_expert/api/endpoints.py index 8ec2a354f..08187353a 100644 --- a/src/backend/bisheng/qa_expert/api/endpoints.py +++ b/src/backend/bisheng/qa_expert/api/endpoints.py @@ -9,10 +9,12 @@ from loguru import logger from bisheng.api.v1.schemas import UploadFileResponse from bisheng.common.dependencies.user_deps import UserPayload +from bisheng.common.errcode.base import BaseErrorCode from bisheng.common.errcode.http_error import ServerError from bisheng.common.schemas.api import resp_200, resp_500 from bisheng.core.cache.utils import save_uploaded_file from bisheng.knowledge.domain.services.knowledge_service import KnowledgeService +from bisheng.qa_expert.domain.moderate_delete_service import ModerateDeleteService from bisheng.qa_expert.domain.rich_text import question_description_to_plain_text from bisheng.qa_expert.domain.schemas import ( AdoptAnswerRequest, @@ -25,6 +27,7 @@ from bisheng.qa_expert.domain.schemas import ( ExpertResponse, ExpertUpdateRequest, GetCommentsRequest, + ModerateDeleteRequest, QAExpertStatsResponse, QANotificationResponse, QuestionCheckRequest, @@ -455,6 +458,38 @@ async def create_comment( return resp_200(data=comment) +@router.post("/admin/moderate-delete") +async def moderate_delete( + request: ModerateDeleteRequest, + user: UserPayload = Depends(UserPayload.get_login_user), +): + """平台超管违规删除问题/回答/评论/追问:先删内容,再按 R* 扣分(失败入补扣队列)。""" + try: + result = await ModerateDeleteService().moderate_delete( + operator=user, + target_type=request.target_type, # type: ignore[arg-type] + target_id=request.target_id, + rule_code=request.rule_code, + remark=request.remark, + ) + return resp_200( + data={ + "deleted": result.deleted, + "target_type": result.target_type, + "target_id": result.target_id, + "target_user_id": result.target_user_id, + "deducted": result.deducted, + "pending_deduct": result.pending_deduct, + "reason": result.reason, + } + ) + except BaseErrorCode as exc: + return exc.return_resp_instance() + except Exception as e: + logger.exception("qa.moderate_delete.failed") + return resp_500(code=500, msg=str(e)) + + @router.post( "/allcomments", ) diff --git a/src/backend/bisheng/qa_expert/api/router.py b/src/backend/bisheng/qa_expert/api/router.py index 822a1a310..5dd03216e 100644 --- a/src/backend/bisheng/qa_expert/api/router.py +++ b/src/backend/bisheng/qa_expert/api/router.py @@ -53,6 +53,13 @@ router.add_api_route("/questions/{question_id}/answers", endpoints.get_answersby router.add_api_route("/comments", endpoints.create_comment, methods=["POST"]) router.add_api_route("/allcomments", endpoints.get_allcomments, methods=["POST"]) +# 平台超管违规删除(含积分扣减 / 补扣队列) +router.add_api_route( + "/admin/moderate-delete", + endpoints.moderate_delete, + methods=["POST"], +) + # 投票 router.add_api_route("/votes/question", endpoints.vote_question, methods=["POST"]) router.add_api_route("/votes/answer", endpoints.vote_answer, methods=["POST"]) diff --git a/src/backend/bisheng/qa_expert/domain/models.py b/src/backend/bisheng/qa_expert/domain/models.py index a53cda45d..20d4352bc 100644 --- a/src/backend/bisheng/qa_expert/domain/models.py +++ b/src/backend/bisheng/qa_expert/domain/models.py @@ -112,10 +112,10 @@ class Question: if expert_id not in self.invited_experts: self.invited_experts.append(expert_id) - def adopt_answer(self, answer_id: int): - """采纳最佳回答""" - if self.status == QuestionStatus.SOLVED: - raise ValueError("问题已有最佳回答,不能重复采纳") + def adopt_answer(self, answer_id: int, *, adopted_count: int = 0): + """采纳最佳回答(同题最多 3 条;adopted_count 为当前未删除已采纳数)。""" + if adopted_count >= 3: + raise ValueError("每个问题最多采纳 3 个最佳答案") self.adopted_answer_id = answer_id self.status = QuestionStatus.SOLVED self.updated_at = datetime.utcnow() diff --git a/src/backend/bisheng/qa_expert/domain/moderate_delete_service.py b/src/backend/bisheng/qa_expert/domain/moderate_delete_service.py new file mode 100644 index 000000000..56a554e3d --- /dev/null +++ b/src/backend/bisheng/qa_expert/domain/moderate_delete_service.py @@ -0,0 +1,178 @@ +"""平台超管在专家问答违规删除:先删内容,再扣分(失败入补扣队列)。""" + +from __future__ import annotations + +import logging +from dataclasses import dataclass +from typing import Literal + +from bisheng.core.context.tenant import DEFAULT_TENANT_ID, get_current_tenant_id +from bisheng.points.domain.services.points_auth import require_platform_admin +from bisheng.points.domain.services.points_pending_deduct_service import PointsPendingDeductService +from bisheng.qa_expert.domain.repositories import ( + AnswerRepository, + CommentRepository, + ExpertRepository, + QuestionRepository, +) +from bisheng.qa_expert.domain.services import ( + AnswerNotFoundError, + ExpertNotFoundError, + PermissionDeniedError, + QuestionNotFoundError, +) +from bisheng.telemetry.domain.mid_table.realtime_qa_question import RealtimeQaQuestionFact + +logger = logging.getLogger(__name__) + +TargetType = Literal["question", "answer", "comment"] + +# qa_answer.status: 1=normal, 2=adopted, 3=deleted +_ANSWER_STATUS_DELETED = 3 + + +@dataclass(frozen=True) +class ModerateDeleteResult: + """违规删除结果。""" + + deleted: bool + target_type: str + target_id: int + target_user_id: int + deducted: bool + pending_deduct: bool + reason: str | None = None + + +class ModerateDeleteService: + """专家问答违规删除编排:鉴权 → 删内容 → 扣分/补扣。""" + + def __init__(self): + self.question_repo = QuestionRepository() + self.answer_repo = AnswerRepository() + self.comment_repo = CommentRepository() + self.expert_repo = ExpertRepository() + self.pending_deduct = PointsPendingDeductService() + + async def moderate_delete( + self, + *, + operator, + target_type: TargetType, + target_id: int, + rule_code: str | None = None, + remark: str | None = None, + ) -> ModerateDeleteResult: + """删除问题、回答或评论/追问;若传入启用中的 R* 则对内容作者扣分。 + + 未选规则 = 只删不扣。扣分失败不回滚删除, 写入 point_pending_deduct 供 Beat 补扣. + 删回答时级联硬删其下评论,扣分仅针对回答作者。 + """ + require_platform_admin(operator) + if target_type not in ("question", "answer", "comment"): + raise PermissionDeniedError(message="unsupported target_type") + if int(target_id) <= 0: + raise QuestionNotFoundError() + + author_id, biz_type, biz_id = await self._resolve_and_delete(target_type, int(target_id)) + code = (rule_code or "").strip().upper() + if not code: + return ModerateDeleteResult( + deleted=True, + target_type=target_type, + target_id=int(target_id), + target_user_id=author_id, + deducted=False, + pending_deduct=False, + reason="no_rule", + ) + + tenant_id = int(get_current_tenant_id() or DEFAULT_TENANT_ID) + attempt = await self.pending_deduct.deduct_or_enqueue( + tenant_id=tenant_id, + user_id=author_id, + rule_code=code, + biz_type=biz_type, + biz_id=biz_id, + operator_id=int(operator.user_id), + remark=remark, + ) + return ModerateDeleteResult( + deleted=True, + target_type=target_type, + target_id=int(target_id), + target_user_id=author_id, + deducted=attempt.applied, + pending_deduct=attempt.pending, + reason=attempt.reason, + ) + + async def _resolve_and_delete( + self, target_type: TargetType, target_id: int + ) -> tuple[int, str, str]: + """解析作者并删除, 返回 (author_user_id, biz_type, biz_id).""" + if target_type == "question": + return await self._delete_question(target_id) + if target_type == "answer": + return await self._delete_answer(target_id) + + comment = await self.comment_repo.get_by_id(target_id) + if not comment: + raise AnswerNotFoundError(message="Comment not found") + author_id = int(comment.user_id) + deleted = await self.comment_repo.delete(target_id) + if not deleted: + raise AnswerNotFoundError(message="Comment not found") + return author_id, "qa_comment", str(target_id) + + async def _delete_question(self, target_id: int) -> tuple[int, str, str]: + """硬删问题并清理 telemetry 事实表。""" + question = await self.question_repo.get_by_id(target_id) + if not question: + raise QuestionNotFoundError() + author_id = int(question.user_id) + deleted = await self.question_repo.delete(target_id) + if not deleted: + raise QuestionNotFoundError() + try: + await RealtimeQaQuestionFact.delete_question( + tenant_id=int(get_current_tenant_id() or DEFAULT_TENANT_ID), + question_id=target_id, + qa_type="expert", + ) + except Exception: + logger.exception("qa.moderate_delete.telemetry_failed question_id=%s", target_id) + return author_id, "qa_question", str(target_id) + + async def _delete_answer(self, target_id: int) -> tuple[int, str, str]: + """软删回答:解析专家作者、级联评论、维护问题计数与最佳答案指针。""" + answer = await self.answer_repo.get_by_id(target_id) + if not answer or int(getattr(answer, "status", 0) or 0) == _ANSWER_STATUS_DELETED: + raise AnswerNotFoundError() + + if not getattr(answer, "expert_id", None): + raise ExpertNotFoundError(message="Answer has no expert author") + expert = await self.expert_repo.get_by_id(int(answer.expert_id)) + if expert is None or getattr(expert, "user_id", None) is None: + raise ExpertNotFoundError(message="Answer expert not found") + author_id = int(expert.user_id) + + question_id = int(answer.question_id) + deleted = await self.answer_repo.delete(target_id) + if not deleted: + raise AnswerNotFoundError() + + # 级联硬删该回答下评论;不对评论作者逐条扣分 + await self.comment_repo.delete_by_answer_id(target_id) + + question = await self.question_repo.get_by_id(question_id) + if question is not None: + next_count = max(0, int(question.answer_count or 0) - 1) + update_kwargs: dict = {"answer_count": next_count} + # 删掉最佳答案时回退为未解决,避免详情脏指针 + if int(question.adopted_answer_id or 0) == target_id: + update_kwargs["adopted_answer_id"] = None + update_kwargs["status"] = 0 + await self.question_repo.update(question_id, **update_kwargs) + + return author_id, "qa_answer", str(target_id) diff --git a/src/backend/bisheng/qa_expert/domain/repositories.py b/src/backend/bisheng/qa_expert/domain/repositories.py index 74c94c521..7d8770dad 100644 --- a/src/backend/bisheng/qa_expert/domain/repositories.py +++ b/src/backend/bisheng/qa_expert/domain/repositories.py @@ -430,6 +430,19 @@ class AnswerRepository: return result.first() + async def count_adopted_by_question_id(self, question_id: int) -> int: + """统计同题未软删且已采纳的回答数(用于最多 3 个最佳答案上限)。""" + async with get_async_db_session() as session: + stmt = select(func.count()).select_from(Answer).where( + and_( + Answer.question_id == question_id, + Answer.adopted.is_(True), + Answer.status != 3, + ) + ) + result = await session.exec(stmt) + return int(result.one() or 0) + async def get_by_question_id( self, question_id: int, skip: int = 0, limit: int = 100, sort_by: str | None = None ) -> tuple[list[Answer], int]: @@ -472,14 +485,15 @@ class AnswerRepository: return answer async def delete(self, answer_id: int) -> bool: - """删除回答""" + """软删除回答(status=3)。""" async with get_async_db_session() as session: - answer = await self.get_by_id(answer_id) + answer = (await session.exec(select(Answer).where(Answer.id == answer_id))).first() if not answer: return False - answer.status = "deleted" + # ORM: 1=normal, 2=adopted, 3=deleted — 勿写字符串 + answer.status = 3 session.add(answer) - await session.flush() + await session.commit() return True async def get_answer_vote_count(self, question_id: int) -> int: @@ -503,6 +517,33 @@ class CommentRepository: await session.flush(comment) return comment + async def get_by_id(self, comment_id: int) -> Comment | None: + """按主键读取评论/追问。""" + async with get_async_db_session() as session: + return (await session.exec(select(Comment).where(Comment.id == comment_id))).first() + + async def delete(self, comment_id: int) -> bool: + """硬删除评论/追问。""" + async with get_async_db_session() as session: + comment = (await session.exec(select(Comment).where(Comment.id == comment_id))).first() + if not comment: + return False + await session.delete(comment) + await session.commit() + return True + + async def delete_by_answer_id(self, answer_id: int) -> int: + """硬删除某回答下全部评论/追问。返回删除条数。""" + async with get_async_db_session() as session: + rows = ( + await session.exec(select(Comment).where(Comment.answer_id == answer_id)) + ).all() + for comment in rows: + await session.delete(comment) + if rows: + await session.commit() + return len(rows) + async def get_by_answer_id( self, answer_id: int, question_id: int | None = None, skip: int = 0, limit: int = 100 ) -> tuple[list[Comment], int]: diff --git a/src/backend/bisheng/qa_expert/domain/schemas.py b/src/backend/bisheng/qa_expert/domain/schemas.py index b8c1cb476..d88b06bcc 100644 --- a/src/backend/bisheng/qa_expert/domain/schemas.py +++ b/src/backend/bisheng/qa_expert/domain/schemas.py @@ -3,7 +3,7 @@ Expert QA Pydantic Schemas - 请求/响应数据模型 """ from datetime import datetime -from typing import Any, List, Optional +from typing import Any from pydantic import BaseModel, Field, field_validator, model_validator @@ -18,7 +18,7 @@ BOOLEAN_TRUE_BYTES_VALUES = {b"\x01", b"1"} BOOLEAN_FALSE_BYTES_VALUES = {b"\x00", b"", b"0"} -def _decode_db_text(value: Any) -> Optional[str]: +def _decode_db_text(value: Any) -> str | None: if value is None: return None if isinstance(value, str): @@ -69,31 +69,57 @@ def _coerce_db_bool(value: Any) -> bool: # ==================== 专家 Schemas ==================== +class ModerateDeleteRequest(BaseModel): + """平台超管违规删除问题/回答/评论;可选按 R* 扣分。""" + + target_type: str = Field(..., description="question | answer | comment") + target_id: int = Field(..., ge=1) + # 空/省略 = 只删除不扣分 + rule_code: str | None = Field(default=None, max_length=32) + remark: str | None = Field(default=None, max_length=200) + + @field_validator("target_type") + @classmethod + def _validate_target_type(cls, value: str) -> str: + normalized = (value or "").strip().lower() + if normalized not in {"question", "answer", "comment"}: + raise ValueError("target_type must be question, answer or comment") + return normalized + + @field_validator("rule_code") + @classmethod + def _normalize_rule_code(cls, value: str | None) -> str | None: + if value is None: + return None + normalized = value.strip().upper() + return normalized or None + + class ExpertCreateRequest(BaseModel): """创建专家 - 请求""" expert_name: str = Field(..., description="专家名称") - introduction: Optional[str] = Field(None, description="专家介绍") - depart_ment: Optional[str] = Field(default=[], description="所属部门") - user_id: Optional[int] = Field(None, description="关联用户ID(可选)") - major: Optional[str] = Field(None, description="所属专业") - position: Optional[str] = Field(None, description="所属岗位") - job_family: Optional[str] = Field(None, description="所属岗位族") - job_category: Optional[str] = Field(None, description="所属岗位分类") - wechat_user_id: Optional[str] = Field(None, description="绑定企业微信用户id") + introduction: str | None = Field(None, description="专家介绍") + depart_ment: str | None = Field(default=[], description="所属部门") + user_id: int | None = Field(None, description="关联用户ID(可选)") + major: str | None = Field(None, description="所属专业") + position: str | None = Field(None, description="所属岗位") + job_family: str | None = Field(None, description="所属岗位族") + job_category: str | None = Field(None, description="所属岗位分类") + wechat_user_id: str | None = Field(None, description="绑定企业微信用户id") class ExpertUpdateRequest(BaseModel): """更新专家 - 请求""" - expert_name: Optional[str] = None - introduction: Optional[str] = None - depart_ment: Optional[str] = None - major: Optional[str] = Field(None, description="所属专业") - position: Optional[str] = Field(None, description="所属岗位") - job_family: Optional[str] = Field(None, description="所属岗位族") - job_category: Optional[str] = Field(None, description="所属岗位分类") - wechat_user_id: Optional[str] = Field(None, description="绑定企业微信用户id") + expert_name: str | None = None + introduction: str | None = None + depart_ment: str | None = None + major: str | None = Field(None, description="所属专业") + position: str | None = Field(None, description="所属岗位") + job_family: str | None = Field(None, description="所属岗位族") + job_category: str | None = Field(None, description="所属岗位分类") + wechat_user_id: str | None = Field(None, description="绑定企业微信用户id") class ExpertResponse(BaseModel): @@ -102,15 +128,15 @@ class ExpertResponse(BaseModel): id: int user_id: int expert_name: str - introduction: Optional[str] - depart_ment: Optional[str] = None - department_id: Optional[int | str] = None - position: Optional[str] = None - job_family: Optional[str] = None - job_category: Optional[str] = None - major: Optional[str] = None + introduction: str | None + depart_ment: str | None = None + department_id: int | str | None = None + position: str | None = None + job_family: str | None = None + job_category: str | None = None + major: str | None = None level: str - business_domains: List[str] + business_domains: list[str] verified: bool answer_count: int adoption_count: int @@ -137,15 +163,15 @@ class QuestionCreateRequest(BaseModel): description: str = Field(..., min_length=0, description="问题描述") business_domain: str = Field(..., description="所属业务域") - attachments: Optional[str] = Field(default=None, description="附件列表") - related_docs: Optional[str] = Field(default=None, description="关联文档ID") + attachments: str | None = Field(default=None, description="附件列表") + related_docs: str | None = Field(default=None, description="关联文档ID") - invited_experts: Optional[str] = Field(default=None, description="邀请专家ID,多个用分号;分割") - experts_names: Optional[str] = Field(default=None, description="邀请专家名称,多个用分号;分割") + invited_experts: str | None = Field(default=None, description="邀请专家ID,多个用分号;分割") + experts_names: str | None = Field(default=None, description="邀请专家名称,多个用分号;分割") - image_url: Optional[str] = Field(default=None, max_length=1024, schema_extra={"comment": "图片URL"}) - file_url: Optional[str] = Field(default=None, max_length=1024, schema_extra={"comment": "文件URL"}) - file_name: Optional[str] = Field(default=None, max_length=512, schema_extra={"comment": "文件名"}) + image_url: str | None = Field(default=None, max_length=1024, schema_extra={"comment": "图片URL"}) + file_url: str | None = Field(default=None, max_length=1024, schema_extra={"comment": "文件URL"}) + file_name: str | None = Field(default=None, max_length=512, schema_extra={"comment": "文件名"}) @field_validator("description") @classmethod @@ -171,22 +197,22 @@ class QuestionCreateRequest(BaseModel): class QuestionUpdateRequest(BaseModel): """更新问题 - 请求""" - title: Optional[str] = None - description: Optional[str] = None - business_domain: Optional[str] = None - attachments: Optional[str] = Field(default=None, description="附件列表") - related_docs: Optional[str] = Field(default=None, description="关联文档ID") - invited_experts: Optional[str] = Field(default=None, description="邀请专家ID,多个用分号;分割") - experts_names: Optional[str] = Field(default=None, description="邀请专家名称,多个用分号;分割") - image_url: Optional[str] = Field(default=None, max_length=1024, description="图片URL") - file_url: Optional[str] = Field(default=None, max_length=1024, description="文件URL") - file_name: Optional[str] = Field(default=None, max_length=512, description="文件名") + title: str | None = None + description: str | None = None + business_domain: str | None = None + attachments: str | None = Field(default=None, description="附件列表") + related_docs: str | None = Field(default=None, description="关联文档ID") + invited_experts: str | None = Field(default=None, description="邀请专家ID,多个用分号;分割") + experts_names: str | None = Field(default=None, description="邀请专家名称,多个用分号;分割") + image_url: str | None = Field(default=None, max_length=1024, description="图片URL") + file_url: str | None = Field(default=None, max_length=1024, description="文件URL") + file_name: str | None = Field(default=None, max_length=512, description="文件名") status: int | str | None = Field(default=None, description="状态: unsolved/solved/closed/pending") - created_by: Optional[str] = Field(default=None, description="创建人") + created_by: str | None = Field(default=None, description="创建人") @field_validator("description") @classmethod - def sanitize_description(cls, value: Optional[str]) -> Optional[str]: + def sanitize_description(cls, value: str | None) -> str | None: return sanitize_question_description(value) if value is not None else None @field_validator("invited_experts", mode="before") @@ -252,14 +278,14 @@ class QuestionDetailResponse(BaseModel): status: str user_id: int anonymous: bool - attachments: List[str] - related_docs: List[int] - invited_experts: List[int] - experts_names: Optional[str] = Field(default=None, description="邀请专家名称,多个用分号;分割") - adopted_answer_id: Optional[int] - image_url: Optional[str] = Field(default=None, description="图片URL") - file_url: Optional[str] = Field(default=None, description="文件URL") - file_name: Optional[str] = Field(default=None, description="文件名") + attachments: list[str] + related_docs: list[int] + invited_experts: list[int] + experts_names: str | None = Field(default=None, description="邀请专家名称,多个用分号;分割") + adopted_answer_id: int | None + image_url: str | None = Field(default=None, description="图片URL") + file_url: str | None = Field(default=None, description="文件URL") + file_name: str | None = Field(default=None, description="文件名") vote_count: int answer_count: int view_count: int @@ -267,8 +293,8 @@ class QuestionDetailResponse(BaseModel): updated_at: datetime # 展开的关系数据(可选) - answers: Optional[List["AnswerDetailResponse"]] = None - expert_status: Optional[dict] = None # 专家回复状态 + answers: list["AnswerDetailResponse"] | None = None + expert_status: dict | None = None # 专家回复状态 class Config: from_attributes = True @@ -282,17 +308,17 @@ class AnswerCreateRequest(BaseModel): question_id: int = Field(..., description="问题ID") content: str = Field(..., min_length=1, description="回答内容") - attachments: Optional[str] = Field(default=None, description="附件列表") - related_docs: Optional[str] = Field(default=None, description="关联文档ID") - images_url: Optional[str] = Field(default=None, description="图片URL") + attachments: str | None = Field(default=None, description="附件列表") + related_docs: str | None = Field(default=None, description="关联文档ID") + images_url: str | None = Field(default=None, description="图片URL") class AnswerUpdateRequest(BaseModel): """更新回答 - 请求""" - content: Optional[str] = None - attachments: Optional[List[str]] = None - related_docs: Optional[List[int]] = None + content: str | None = None + attachments: list[str] | None = None + related_docs: list[int] | None = None class AnswerDetailResponse(BaseModel): @@ -301,21 +327,21 @@ class AnswerDetailResponse(BaseModel): id: int question_id: int user_id: int - expert_id: Optional[int] + expert_id: int | None content: str status: str - attachments: List[str] - related_docs: List[int] + attachments: list[str] + related_docs: list[int] vote_count: int comment_count: int created_at: datetime updated_at: datetime # 专家信息(如果是专家回答) - expert_info: Optional[ExpertResponse] = None + expert_info: ExpertResponse | None = None # 评论列表(可选) - comments: Optional[List["CommentDetailResponse"]] = None + comments: list["CommentDetailResponse"] | None = None class Config: from_attributes = True @@ -330,14 +356,14 @@ class CommentCreateRequest(BaseModel): answer_id: int = Field(..., description="回答ID") content: str = Field(..., description="评论内容") is_follow_up: bool = Field(default=False, description="是否为追问") - question_id: Optional[int] = Field(None, description="问题ID(仅追问时需要)") + question_id: int | None = Field(None, description="问题ID(仅追问时需要)") class GetCommentsRequest(BaseModel): """获取评论/追问 - 请求""" answer_id: int = Field(..., ge=0, description="Answer ID. Use 0 to query question follow-ups.") - question_id: Optional[int] = Field(None, ge=0, description="Required when answer_id is 0.") + question_id: int | None = Field(None, ge=0, description="Required when answer_id is 0.") page: int = Field(default=1, ge=1) page_size: int = Field(default=100, ge=1, le=1000) @@ -356,7 +382,7 @@ class CommentDetailResponse(BaseModel): answer_id: int question_id: int user_id: int - user_name: Optional[str] = None + user_name: str | None = None content: str is_follow_up: bool vote_count: int @@ -384,7 +410,7 @@ class CommentDetailResponse(BaseModel): class CommentPageData(BaseModel): """Comment list page response.""" - comments: List[CommentDetailResponse] + comments: list[CommentDetailResponse] total: int @@ -431,8 +457,8 @@ class QANotificationResponse(BaseModel): class QuestionListQuery(BaseModel): """问题列表查询条件""" - domain: Optional[str] = Field(None, description="业务域") - status: Optional[int] = Field(0, description="状态: unsolved/solved/closed") + domain: str | None = Field(None, description="业务域") + status: int | None = Field(0, description="状态: unsolved/solved/closed") sort_by: str = Field(default="latest", description="排序: latest/hottest/unanswered") page: int = Field(default=1, ge=1) page_size: int = Field(default=20, ge=1, le=100) @@ -446,9 +472,9 @@ class QuestionListQuery(BaseModel): class ExpertListQuery(BaseModel): """专家列表查询条件""" - business_domain: Optional[str] = None - level: Optional[str] = None - keyword: Optional[str] = None + business_domain: str | None = None + level: str | None = None + keyword: str | None = None page: int = Field(default=1, ge=1) page_size: int = Field(default=20, ge=1, le=100) @@ -495,7 +521,7 @@ class QAExpertStatsResponse(BaseModel): class QuestionPageData(BaseModel): """问题列表页面数据""" - questions: List[QuestionSimpleResponse] + questions: list[QuestionSimpleResponse] total: int - business_domains: List[str] # 所有业务域 + business_domains: list[str] # 所有业务域 stats: QuestionStatsResponse diff --git a/src/backend/bisheng/qa_expert/domain/services.py b/src/backend/bisheng/qa_expert/domain/services.py index 7f7b301fb..ee17c4b8f 100644 --- a/src/backend/bisheng/qa_expert/domain/services.py +++ b/src/backend/bisheng/qa_expert/domain/services.py @@ -81,6 +81,17 @@ class PermissionDeniedError(BaseErrorCode): Msg = "Permission denied" +class AdoptLimitExceededError(BaseErrorCode): + """每个问题最多采纳 3 个最佳答案""" + + Code = 10906 + Msg = "每个问题最多采纳 3 个最佳答案" + + +# 同题未删除回答中,adopted=true 的上限 +MAX_ADOPTED_ANSWERS_PER_QUESTION = 3 + + class QAExpertStatsService: """Expert QA statistics service.""" @@ -484,36 +495,70 @@ class QuestionService: return question async def adopt_answer(self, question_id: int, answer_id: int, operator_id: int) -> Question: - """采纳最佳回答""" + """采纳最佳回答:同题最多 3 条;已采纳幂等;G4 仍按 answer_id。""" question = await self.repository.get_by_id(question_id) if not question: raise QuestionNotFoundError() # 只有提问者可以采纳 if question.user_id != operator_id: - raise PermissionDeniedError(message="Only question author can adopt answer") + raise PermissionDeniedError(msg="Only question author can adopt answer") answer = await self.answer_repo.get_by_id(answer_id) - if not answer: + if not answer or int(getattr(answer, "status", 0) or 0) == 3: raise AnswerNotFoundError() if answer.question_id != question_id: - raise InvalidInvitationError(message="Answer does not belong to this question") + raise InvalidInvitationError(msg="Answer does not belong to this question") - # 更新问题状态 + # 已采纳:幂等返回,不重复加采纳数 / 通知 / G4 旁路 + if bool(getattr(answer, "adopted", False)): + logger.info( + "Answer %s already adopted for question %s; idempotent return", + answer_id, + question_id, + ) + return question + + adopted_count = await self.answer_repo.count_adopted_by_question_id(question_id) + if adopted_count >= MAX_ADOPTED_ANSWERS_PER_QUESTION: + raise AdoptLimitExceededError() + + # 更新问题状态(adopted_answer_id = 最近一次采纳) question.adopted_answer_id = answer_id question.status = 1 # 已解决 await self.repository.update(question_id, adopted_answer_id=answer_id, status=1) - # 更新回答状态 - answer.status = 1 # 已采纳 + # 采纳标记以 adopted 为准;status 保持与现网写路径一致(列表过滤看 status!=3) await self.answer_repo.update(answer_id, status=1, adopted=True) - # 增加采纳采纳数 - await self.expert_repo.increment_adoption_count(answer.expert_id, count=1) + if getattr(answer, "expert_id", None): + await self.expert_repo.increment_adoption_count(answer.expert_id, count=1) # 发送采纳通知 await self._send_adoption_notification(question, answer) + # 积分旁路:给回答者发 G4;expert.user_id 才是平台用户 ID。 + try: + from bisheng.core.context.tenant import DEFAULT_TENANT_ID, get_current_tenant_id + from bisheng.points.domain.services.points_award_hooks import notify_answer_adopted + + answerer_id = None + if getattr(answer, "expert_id", None): + expert = await self.expert_repo.get_by_id(answer.expert_id) + if expert is not None and getattr(expert, "user_id", None) is not None: + answerer_id = int(expert.user_id) + if answerer_id: + await notify_answer_adopted( + tenant_id=int(get_current_tenant_id() or DEFAULT_TENANT_ID), + answer_id=int(answer_id), + answerer_id=answerer_id, + ) + except Exception: + logger.exception( + "points.award.hooks adopt notify failed answer_id=%s", + answer_id, + ) + logger.info(f"Answer {answer_id} adopted for question {question_id}") return question diff --git a/src/backend/bisheng/worker/__init__.py b/src/backend/bisheng/worker/__init__.py index f02c57ff5..a3a2f1fa6 100644 --- a/src/backend/bisheng/worker/__init__.py +++ b/src/backend/bisheng/worker/__init__.py @@ -72,6 +72,13 @@ from bisheng.worker.permission.department_transfer_cleanup import ( scan_due_events, ) from bisheng.worker.permission.retry_failed_tuples import retry_failed_tuples +from bisheng.worker.points.tasks import ( + drain_points_sync_outbox, + process_points_award_event, + reconcile_point_balances, + refresh_points_rank_snapshots, + run_monthly_admin_rewards, +) from bisheng.worker.portal_course.tasks import ( process_portal_course_media_cleanup, scan_portal_course_media_cleanup, diff --git a/src/backend/bisheng/worker/points/__init__.py b/src/backend/bisheng/worker/points/__init__.py new file mode 100644 index 000000000..a62864aee --- /dev/null +++ b/src/backend/bisheng/worker/points/__init__.py @@ -0,0 +1 @@ +"""积分模块 Celery 任务包。""" diff --git a/src/backend/bisheng/worker/points/tasks.py b/src/backend/bisheng/worker/points/tasks.py new file mode 100644 index 000000000..4576e7e3b --- /dev/null +++ b/src/backend/bisheng/worker/points/tasks.py @@ -0,0 +1,158 @@ +"""积分模块 Celery 任务:排行、月奖、对账、outbox 与异步发分。""" + +from __future__ import annotations + +import logging +from typing import Any + +from bisheng.worker._asyncio_utils import run_async_task +from bisheng.worker.main import bisheng_celery + +logger = logging.getLogger(__name__) + + +@bisheng_celery.task( + acks_late=True, + time_limit=120, + soft_time_limit=60, + name="bisheng.worker.points.tasks.process_points_award_event", +) +def process_points_award_event(payload: dict[str, Any]): + """消费自动发分事件;由 hooks 投递,幂等键防双发。""" + return run_async_task(lambda: _process_award_async(payload)) + + +async def _process_award_async(payload: dict[str, Any]) -> dict: + """在独立会话中执行 Facade 并提交(复用 hooks 同步分发,避免双份 event 映射)。""" + from bisheng.core.context.tenant import set_current_tenant_id + from bisheng.points.domain.services.points_award_hooks import _run_payload_sync + + event_type = str(payload.get("event_type") or "") + if event_type not in { + "space_file_ready", + "document_shared", + "favorite_changed", + "answer_adopted", + }: + logger.error("points.award.unknown_event_type type=%s", event_type) + return {"ok": False, "reason": "unknown_event_type", "event_type": event_type} + + tenant_id = int(payload["tenant_id"]) + set_current_tenant_id(tenant_id) + await _run_payload_sync(payload) + logger.info("points.award.processed event_type=%s", event_type) + return {"ok": True, "event_type": event_type} + + +@bisheng_celery.task( + acks_late=True, + time_limit=1800, + soft_time_limit=1500, + name="bisheng.worker.points.tasks.refresh_points_rank_snapshots", +) +def refresh_points_rank_snapshots(): + """每小时刷新积分榜快照;受 points.rank_cron_enabled 控制。""" + return run_async_task(_refresh_rank_async) + + +async def _refresh_rank_async() -> dict: + """异步刷新排行入口;cron 关闭时直接跳过。""" + from bisheng.common.services.config_service import settings + from bisheng.points.domain.services.points_rank_service import PointsRankService + + conf = getattr(settings, "points", None) + if conf is not None and not bool(getattr(conf, "rank_cron_enabled", True)): + logger.info("points.rank.cron_disabled skip refresh") + return {"skipped": True} + result = await PointsRankService().refresh_all_tenants() + logger.info("points.rank.cron_done %s", result) + return result + + +@bisheng_celery.task( + acks_late=True, + time_limit=3600, + soft_time_limit=3300, + name="bisheng.worker.points.tasks.run_monthly_admin_rewards", +) +def run_monthly_admin_rewards(): + """每月 1 日发放上月管理员月奖;受 points.monthly_reward_enabled 控制。""" + return run_async_task(_monthly_reward_async) + + +async def _monthly_reward_async() -> dict: + """异步月奖入口。""" + from bisheng.common.services.config_service import settings + from bisheng.points.domain.services.points_monthly_reward_service import ( + PointsMonthlyRewardService, + ) + + conf = getattr(settings, "points", None) + if conf is not None and not bool(getattr(conf, "monthly_reward_enabled", True)): + logger.info("points.monthly.cron_disabled skip") + return {"skipped": True} + result = await PointsMonthlyRewardService().run_all_tenants() + logger.info("points.monthly.cron_done %s", result) + return result + + +@bisheng_celery.task( + acks_late=True, + time_limit=1800, + soft_time_limit=1500, + name="bisheng.worker.points.tasks.reconcile_point_balances", +) +def reconcile_point_balances(): + """每日对账:sum(log.delta) 与 account.balance;只告警不改流水。""" + return run_async_task(_reconcile_async) + + +async def _reconcile_async() -> dict: + """异步对账入口。""" + from bisheng.points.domain.services.points_reconcile_service import PointsReconcileService + + result = await PointsReconcileService().reconcile_all_tenants() + logger.info("points.reconcile.cron_done %s", result) + return result + + +@bisheng_celery.task( + acks_late=True, + time_limit=1800, + soft_time_limit=1500, + name="bisheng.worker.points.tasks.drain_points_sync_outbox", +) +def drain_points_sync_outbox(): + """消费积分同步 outbox;sync_outbox_enabled=false 时保持 pending。""" + return run_async_task(_drain_outbox_async) + + +async def _drain_outbox_async() -> dict: + """异步 outbox drain 入口。""" + from bisheng.points.domain.services.points_sync_outbox_service import PointsSyncOutboxService + + result = await PointsSyncOutboxService().drain() + logger.info("points.outbox.cron_done %s", result) + return result + + +@bisheng_celery.task( + acks_late=True, + time_limit=1800, + soft_time_limit=1500, + name="bisheng.worker.points.tasks.drain_points_pending_deduct", +) +def drain_points_pending_deduct(): + """重试违规删除后失败的补扣队列。""" + return run_async_task(_drain_pending_deduct_async) + + +async def _drain_pending_deduct_async() -> dict: + """异步补扣 drain 入口。""" + from bisheng.points.domain.services.points_pending_deduct_service import ( + PointsPendingDeductService, + ) + + result = await PointsPendingDeductService().drain() + logger.info("points.pending_deduct.cron_done %s", result) + return result diff --git a/src/backend/entrypoint.sh b/src/backend/entrypoint.sh index e2b765d6d..44e58583c 100755 --- a/src/backend/entrypoint.sh +++ b/src/backend/entrypoint.sh @@ -29,6 +29,9 @@ DEFAULT_CONCURRENCY="${DEFAULT_CONCURRENCY:-100}" KNOWLEDGE_PDF_CONCURRENCY="${KNOWLEDGE_PDF_CONCURRENCY:-2}" +POINTS_AWARD_POOL="${POINTS_AWARD_POOL:-threads}" +POINTS_AWARD_CONCURRENCY="${POINTS_AWARD_CONCURRENCY:-20}" + LINSIGHT_WORKER_NUM="${LINSIGHT_WORKER_NUM:-4}" LINSIGHT_MAX_CONCURRENCY="${LINSIGHT_MAX_CONCURRENCY:-5}" @@ -87,6 +90,16 @@ start_pdf() { -n knowledge_pdf@%h } +start_points_award() { + echo "Starting Points Award Celery worker..." + exec celery -A bisheng.worker.main worker \ + -l info \ + -c "$POINTS_AWARD_CONCURRENCY" \ + -P "$POINTS_AWARD_POOL" \ + -Q points_award_celery \ + -n points_award@%h +} + start_beat() { echo "Starting Celery beat..." exec celery -A bisheng.worker.main beat -l info @@ -127,6 +140,7 @@ start_all_workers() { run_background start_pdf run_background start_workflow run_background start_default + run_background start_points_award run_background start_beat set +e @@ -155,6 +169,9 @@ case "$START_MODE" in pdf) start_pdf ;; + points_award) + start_points_award + ;; beat) start_beat ;; @@ -166,7 +183,7 @@ case "$START_MODE" in ;; *) echo "Invalid start mode: $START_MODE" - echo "Use one of: api, worker, knowledge, workflow, default, pdf, beat, linsight" + echo "Use one of: api, worker, knowledge, workflow, default, pdf, points_award, beat, linsight" exit 1 ;; esac diff --git a/src/backend/test/celery/test_knowledge_parse_queue_routing.py b/src/backend/test/celery/test_knowledge_parse_queue_routing.py index c4149c64f..810570c1c 100644 --- a/src/backend/test/celery/test_knowledge_parse_queue_routing.py +++ b/src/backend/test/celery/test_knowledge_parse_queue_routing.py @@ -13,6 +13,8 @@ from bisheng.core.config.celery_queues import ( KNOWLEDGE_PARSE_TASKS, KNOWLEDGE_PDF_QUEUE, PDF_ARTIFACT_TASK, + POINTS_AWARD_QUEUE, + POINTS_AWARD_TASK, WORKFLOW_CELERY_QUEUE, build_celery_task_routes, ) @@ -86,6 +88,7 @@ def test_non_parse_tasks_route_to_default_queue(task_name: str): ("task_name", "expected_queue"), [ (PDF_ARTIFACT_TASK, KNOWLEDGE_PDF_QUEUE), + (POINTS_AWARD_TASK, POINTS_AWARD_QUEUE), ("bisheng.worker.workflow.tasks.run_workflow", WORKFLOW_CELERY_QUEUE), ("bisheng.worker.approval.tasks.execute_approval_outbox", WORKFLOW_CELERY_QUEUE), ], @@ -94,6 +97,19 @@ def test_protected_non_default_routes_are_unchanged(task_name: str, expected_que assert _resolve_queue(task_name, build_celery_task_routes({})) == expected_queue +def test_other_points_tasks_do_not_use_award_queue(): + """刷榜/月奖等非发分任务不进 points_award_celery(无显式路由时 Celery 落 default)。""" + routes = build_celery_task_routes({}) + for task_name in ( + "bisheng.worker.points.tasks.refresh_points_rank_snapshots", + "bisheng.worker.points.tasks.run_monthly_admin_rewards", + "bisheng.worker.points.tasks.reconcile_point_balances", + ): + queue = _resolve_queue(task_name, routes) + assert queue != POINTS_AWARD_QUEUE + assert queue in (None, DEFAULT_CELERY_QUEUE) + + def test_legacy_broad_knowledge_route_cannot_capture_non_parse_tasks(): routes = build_celery_task_routes( { @@ -157,6 +173,23 @@ def test_worker_entrypoints_keep_default_queue_consumers_enabled(): assert "--prefetch-multiplier=1" in source +def test_worker_entrypoints_include_points_award_in_worker_bundle(): + """发分队列必须进 All-in-one worker;本地也可单独 entrypoint.sh points_award。""" + backend = (BACKEND_DIR / "entrypoint.sh").read_text(encoding="utf-8") + deploy = (PROJECT_DIR / "docker/bisheng/entrypoint.sh").read_text(encoding="utf-8") + + for source in (backend, deploy): + assert "start_points_award" in source + assert "points_award_celery" in source + assert "points_award" in source + + backend_bundle = backend.split("start_all_workers()", 1)[1].split('case "$START_MODE"', 1)[0] + assert "start_points_award" in backend_bundle + + deploy_bundle = deploy.split('elif [ "$start_mode" = "worker" ]', 1)[1].split("else", 1)[0] + assert "start_points_award" in deploy_bundle + + def test_non_parse_production_dispatches_do_not_target_knowledge_queue(): allowed_files = { BACKEND_DIR / "bisheng/knowledge/domain/services/knowledge_parse_dispatch_service.py", diff --git a/src/backend/test/e2e_ui/points/.gitignore b/src/backend/test/e2e_ui/points/.gitignore new file mode 100644 index 000000000..e16c8f020 --- /dev/null +++ b/src/backend/test/e2e_ui/points/.gitignore @@ -0,0 +1,4 @@ +node_modules/ +test-results/ +playwright-report/ +playwright/.cache/ diff --git a/src/backend/test/e2e_ui/points/README.md b/src/backend/test/e2e_ui/points/README.md new file mode 100644 index 000000000..b829b4fa9 --- /dev/null +++ b/src/backend/test/e2e_ui/points/README.md @@ -0,0 +1,53 @@ +# F070 Points — Playwright Gates + +Headless Chromium acceptance for milestones **G-M1 … G-M5**. + +## Environment + +| Item | Value | +|------|--------| +| Middleware | `192.168.106.171` (MySQL/Redis/Milvus/ES/MinIO) — do **not** start local docker middleware for points 联调 | +| Apps (local) | Backend `:7860`, Portal BFF `:8010`, Portal `:5173`, Platform `:3001`, Client `:4001` | +| Admin user | `E2E_POINTS_ADMIN` (default `admin`) | +| Normal user | `E2E_POINTS_USER` (default `gzx01`) | +| Password | `E2E_POINTS_PASSWORD` (required; local shared password via env only) | + +## Setup + +```bash +cd src/backend/test/e2e_ui/points +npm install +npm run install:browsers +npm run test:list +``` + +## Run a Gate + +```bash +export E2E_POINTS_RUN_GATES=1 +export E2E_PORTAL_BASE_URL=http://127.0.0.1:5173 +export E2E_POINTS_PASSWORD='…' # local shared password +npm run test:gm1 +npm run test:gm2 # needs points.enabled=true + G7 rule +npm run test:gm3 # Platform :3001 + Portal 首页榜 / 我的积分排名 +npm run test:gm4 # beneficiary + R* deduct + rules modal +npm run test:gm5 # 串行 gm1–gm4 + 对账/开关负例/入口冒烟(发布前) +npm run test:gm5:only # 仅 G-M5 本文件 +``` + +统一数据工厂:`helpers/factory_trigger.py`(`runFactory`)覆盖 G2/G3/G4/G7 造数、对账、`enabled=false`、schema 检查。 + +G-M2 uses `helpers/gm2_trigger.py`(经 AwardFacade hooks 造数)+ Portal「我的积分」UI 断言。 + +G-M3 uses `helpers/gm3_trigger.py`(排行快照刷新 + org_level 只读级联校验)+ Platform「设为公司根」入口可见性 + Portal 三 Tab 榜 / 排名。 + +**共享库安全**:`set-company-root` 会清空整租户 `org_level` 再级联。默认 Gate **不调用**;仅当同时设置: + +```bash +export E2E_POINTS_ALLOW_ORG_MUTATE=1 +export E2E_POINTS_COMPANY_DEPT_ID='+ {t("bs:department.orgLevelHint")} +
+