diff --git a/.gitignore b/.gitignore index 364cad49b..527d9724e 100644 --- a/.gitignore +++ b/.gitignore @@ -290,6 +290,12 @@ sg-sync-api-内部.md mdf_file_read_count.sh .dev-stack/ +# Points local perf harness output (JSON/MD matrices) +src/backend/test/points/_perf_reports/ + +# Points Playwright e2e_ui (local-only; do not commit) +src/backend/test/e2e_ui/ + # Local-only 门户联调 (do not commit) .cursor/rules/local-portal-dev-stack.mdc scripts/dev-stack.sh diff --git a/docker/bisheng/config/config.yaml b/docker/bisheng/config/config.yaml index d48df82bc..bac9b39c4 100644 --- a/docker/bisheng/config/config.yaml +++ b/docker/bisheng/config/config.yaml @@ -62,6 +62,14 @@ celery_redis_url: "redis://redis:6379/2" # password: encrypt(gAAAAABlp5vQN7g85IfoeK8nLCb9cfVqpy9ZK9kN7b0qbAXZ4NOZT_Ef7stKJBY6PjL0dngQnCvQMdsavuGu-EE2o6Zlvv6l-Frye08DBXRR4WmL7y7EfK4=) # db: 10 +# 积分系统开关(挂载为 /app/bisheng/config.yaml;enabled 默认 false,测试/生产需显式打开) +points: + enabled: true + award_async_enabled: true + notify_enabled: true + sync_outbox_enabled: false + rank_cron_enabled: true + monthly_reward_enabled: true celery_task: # 对celery熟悉的用户可以自定义配置任务的路由,启动不同类型的worker处理不同类型的异步任务。注意工作流的执行只能在一个进程内!!! @@ -74,6 +82,8 @@ celery_task: queue: knowledge_celery bisheng.worker.knowledge.pdf_artifact_worker.generate_knowledge_file_pdf_celery: queue: knowledge_pdf_celery + bisheng.worker.points.tasks.process_points_award_event: + queue: points_award_celery bisheng.worker.knowledge.*: # 其他知识库后台任务 queue: celery bisheng.worker.workflow.*: # 工作流相关任务 diff --git a/docker/bisheng/config/config_dev.yaml b/docker/bisheng/config/config_dev.yaml index aa7a4a287..de529c452 100644 --- a/docker/bisheng/config/config_dev.yaml +++ b/docker/bisheng/config/config_dev.yaml @@ -35,6 +35,16 @@ redis_url: "redis://192.168.106.171:6379/11" # celery的broken地址 celery_redis_url: "redis://192.168.106.171:6379/13" + +# 积分系统开关(compose-dev 挂载为 /app/bisheng/config.yaml) +points: + enabled: true + award_async_enabled: true + notify_enabled: true + sync_outbox_enabled: false + rank_cron_enabled: true + monthly_reward_enabled: true + celery_task: # 文件解析处理的速率 多少文件并发每秒。不能高于celery的worker并发数 knowledge_file_time_limit: null # 每秒处理10个文件 null: 表示无限制 @@ -48,6 +58,8 @@ celery_task: queue: knowledge_celery bisheng.worker.knowledge.pdf_artifact_worker.generate_knowledge_file_pdf_celery: queue: knowledge_pdf_celery + bisheng.worker.points.tasks.process_points_award_event: + queue: points_award_celery bisheng.worker.knowledge.*: # 其他知识库后台任务 queue: celery bisheng.worker.workflow.*: # 工作流相关任务 diff --git a/docker/bisheng/entrypoint.sh b/docker/bisheng/entrypoint.sh index 2cb913525..f1fe66931 100644 --- a/docker/bisheng/entrypoint.sh +++ b/docker/bisheng/entrypoint.sh @@ -34,6 +34,11 @@ start_pdf(){ celery -A bisheng.worker.main worker -l info -c "${KNOWLEDGE_PDF_CONCURRENCY:-2}" -P threads -Q knowledge_pdf_celery -n knowledge_pdf@%h } +start_points_award(){ + # 积分异步发分专用队列,避免被共享 Broker 上远端 default Worker 抢走 + celery -A bisheng.worker.main worker -l info -c "${POINTS_AWARD_CONCURRENCY:-20}" -P threads -Q points_award_celery -n points_award@%h +} + if [ "$start_mode" = "api" ]; then echo "Running database migrations..." alembic upgrade head || echo "WARNING: alembic migration failed, continuing startup..." @@ -54,6 +59,9 @@ elif [ "$start_mode" = "default" ]; then elif [ "$start_mode" = "pdf" ]; then echo "Starting Knowledge PDF Celery worker..." start_pdf +elif [ "$start_mode" = "points_award" ]; then + echo "Starting Points Award Celery worker..." + start_points_award elif [ "$start_mode" = "linsight" ]; then echo "Starting LinSight worker..." start_linsight @@ -67,10 +75,12 @@ elif [ "$start_mode" = "worker" ]; then start_linsight & # 默认其他任务的执行worker,目前是定时统计埋点数据 start_default & + # 积分异步发分专用 worker + start_points_award & start_beat echo "All workers started successfully." else - echo "Invalid start mode. Use api、worker、knowledge、workflow、beat、default、pdf、linsight." + echo "Invalid start mode. Use api、worker、knowledge、workflow、beat、default、pdf、points_award、linsight." exit 1 fi diff --git a/docs/PRD/积分系统/PRD-V1.1-extracted.md b/docs/PRD/积分系统/PRD-V1.1-extracted.md new file mode 100644 index 000000000..014c89c38 --- /dev/null +++ b/docs/PRD/积分系统/PRD-V1.1-extracted.md @@ -0,0 +1,367 @@ +首钢股份知库 +积分模块产品需求文档 +PRD V1.1 + +| 产品名称 | 首钢股份知库—积分模块 | +| --- | --- | +| 文档版本 | V1.1 | +| 编写日期 | 2026-07-29 | +| 编写人 | 任彩云 | +| 审核人 | 项目组成员 | +| 文档状态 | 已评审 | + +修订记录 + +| 版本 | 日期 | 修订人 | 修订内容 | 审核人 | +| --- | --- | --- | --- | --- | +| V1.0 | 2026-07-29 | 任彩云 | 初始版本,覆盖积分获取/扣减/管理全流程 | — | +| V1.1 | 2026-08-04 | 任彩云 | 增加文案说明配置、积分规则可配选项扩增、去掉管理员加分规则前台显示等 | — | + +一、需求背景与目标 +1.1业务背景 +首钢股份知库平台已上线知识文档管理、专家问答等核心功能,但存在以下问题: +内容贡献不足:知识上传依赖行政推动,员工自发贡献意愿低。 +缺乏量化激励:优秀贡献者无法被识别和奖励,知识共享文化难以形成。 +1.2产品目标 + +| 目标维度 | 量化指标 | 衡量周期 | +| --- | --- | --- | +| 内容增长 | 知识上传/发布量月环比增长≥20% | 月度 | +| 用户留存 | 月活用户数环比增长≥15% | 月度 | +| 内容质量 | 违规内容占比下降至<2% | 季度 | + +1.3产品定位 +积分模块作为知库平台的激励引擎,通过自动化积分发放与可视化管理,将用户的知识贡献行为转化为可量化、可排名、可运营的数字资产。 +二、受众与使用场景 +2.1用户角色与权限矩阵 + +| 角色 | 描述 | 积分获取 | 规则配置 | 积分调整 | 操作审计 | +| --- | --- | --- | --- | --- | --- | +| 普通用户 | 一线员工 | ✅ | ❌ | ❌ | ❌ | +| 科室库管理员 | 管理科室级知识库 | ✅ +50分/月 | ❌ | ❌ | ❌ | +| 部门库管理员 | 管理部门级知识库 | ✅ +100分/月 | ❌ | ❌ | ❌ | +| 公共库管理员 | 管理公共级知识库 | ✅ +200分/月 | ✅ | ✅ | ✅ | +| 平台管理员 | 平台运营 | ❌ | ✅ | ✅ | ✅ | + +注:同一用户可兼任多个管理角色,月度奖励取最高档,不累加。管理员管辖库内上传/发布不叠加G1/G2奖励。 +2.2使用场景 +场景一:普通员工获取积分 +张三(技术研发部)从团队库发布一篇《连铸工艺优化方案》到部门库→团队库管理员+部门库管理员审核通过→积分+2→消息中心提醒→张三在“我的积分”页面看到明细更新。 +场景二:管理员配置规则 +平台管理员发现G1奖励偏低→进入积分管理后台→编辑G1规则,将分值从+2改为+5→保存后立即生效→新上传行为按新规则执行,张三在“我的积分”页面看到明细更新。历史记录不变。 +场景三:违规扣减 +李四在问答区发表不当言论→管理员发现并核实后手动扣减-100分→积分-100→消息中心提醒→李四在“我的积分”页面看到明细更新。 +场景四:月度奖励自动发放 +每月1日凌晨00:05→定时任务扫描所有管理员→校验当月登录记录≥1→按管理角色(若多个角色按最高)发放相应奖励→消息中心提醒→写入积分明细。 +三、需求范围 + +| 模块 | 功能点 | +| --- | --- | +| 用户端-我的积分 | 统计卡片、积分明细表、筛选器、规则弹窗、消息提醒 | +| 用户端-首页榜单 | TOP10展示、月/年/总榜切换、当前用户排名高亮 | +| 管理端-规则配置 | 新增/编辑/删除/启用禁用积分规则 | +| 管理端-用户管理 | 搜索用户、手动调整积分、查看详情、操作记录审计 | +| 管理端-数据概览 | 总积分、有效积分、违规扣减积分 | + +功能需求 +4.1 我的积分(用户端) +4.1.2 我的积分统计 +展示当前登录用户的总积分、本月获得积分、本月扣减积分、部门与总榜排名,界面原型如下: + +![原型图/配图 1: image1.png](media/image1.png) + +统计指标说明: + +| 统计指标 | 指标说明 | 更新时机 | +| --- | --- | --- | +| 我的积分 | 该用户当前的积分值 | 实时 | +| 本月获得 | 该用户本月累计获得的积分数(起:本月1号00:00;止:本月最后一天24:00(若未到,按当前时间)) | 实时 | +| 本月扣减 | 该用户本月累计扣减的积分数(起:本月1号00:00;止:本月最后一天24:00(若未到,按当前时间)) | 实时 | +| 部门排名 | 同部门级内,按积分降序名次 | 每小时(需要显示最后更新时间) | +| 总榜排名 | 全平台按积分降序名次(999后统一显示999+) | 每小时(需要显示最后更新时间) | + +4.1.1 页面结构 +功能入口:在每个用户的账号下拉菜单中,如下图所示: + +![原型图/配图 2: image2.png](media/image2.png) + +点击【我的积分】后打开页面如下图: + +![原型图/配图 3: image3.png](media/image3.png) + +4.1.3 积分规则 +用户可点击【积分规则】,查看平台的积分规则说明,界面原型如下: + +![原型图/配图 4: image4.png](media/image4.png) + +其中积分获取规则、积分扣减规则的内容取自后台积分规则配置的内容,最下方的5条说明文案也取自后台的配置。 +4.1.4 积分明细 +以列表形式展示当前登录用户所有的积分获取、扣减等明细,可按类型、时间范围组合筛选,可按时间排序,界面原型如下: + +![原型图/配图 5: image3.png](media/image3.png) + +表格字段定义: + +| 列名 | 数据类型 | 说明 | 示例 | +| --- | --- | --- | --- | +| 积分项 | 文本 | 对应规则名称 | 上传文档到公共库 | +| 积分变动 | int | 正数前缀+,负数前缀- | +3/-100 | +| 余额 | int | 变动后当前积分 | 2,580 | +| 类型 | 枚举 | 获得/扣减 | 获得(绿徽标) | +| 时间 | datetime | 积分变动发生时间,精确到秒 | 2026-07-29 14:32:05 | + +筛选条件: + +| 筛选条件 | 类型 | 选项 | 默认 | +| --- | --- | --- | --- | +| 类型 | 单选 | 全部/获得/扣减 | 全部 | +| 时间范围 | 单选+日期 | 近7天/近30天/自定义 | 近30天 | + +空状态: +无积分记录时:展示文案“暂无积分记录,上传/发布知识可赚取积分!” +4.1.5消息提醒 +当用户的积分有变动时发送消息给该用户。 +消息标题:积分变动提醒 +消息内容: +您上传/发布文档至部门库/公共库,获得xx积分,您可前往【我的积分】查看完整记录; +您的文档被收藏xx次,获得xx积分,您可前往【我的积分】查看完整记录; +您的回答被采纳,获得xx积分,您可前往【我的积分】查看完整记录; +管理员为您扣减xx积分,原因:{扣减项名称或填写的调整原因},您可前往【我的积分】查看完整记录; +管理员为您增加xx积分,原因:{填写的调整原因},您可前往【我的积分】查看完整记录。 +其中【我的积分】可点击进入我的积分页面。 +4.2 首页积分榜单(用户端) +4.2.1页面结构 +首页右侧最下方展示用户积分排行榜,包括本月、本年度、总榜三个时间维度,可切换展示不同时间维度下的积分排行信息,界面原型如下: + +![原型图/配图 6: image5.png](media/image5.png) + +4.2.2榜单类型与规则 +榜单类型: + +| Tab | 排序依据 | 更新频率 | 数据范围 | 表格积分列名
(最后一列) | +| --- | --- | --- | --- | --- | +| 本月 | 当月累计积分降序 | 每小时 | 当月有积分变动的用户 | 本月积分 | +| 本年度 | 当年累计积分降序 | 每小时 | 当年有积分变动的用户 | 年度积分 | +| 总榜 | 历史总积分降序 | 每小时 | 全平台所有用户 | 当前积分 | + +榜单列表定义: + +| 列名 | 说明 | 数据类型 | 示例 | +| --- | --- | --- | --- | +| 排名 | 序号1~N,根据当前Tab排序 | int | 1 / 2 / 3 / ... / 15 | +| 用户 | 用户姓名 | 文本 | 王五 | +| 部门 | 用户所属部门级的组织 | 文本 | 质量管理部 | +| 当前积分 | 该用户当前总积分(所有Tab都展示) | int | 4,120 | +| 本月/年度 | 根据Tab动态切换列名和数值 | int | 本月:+450/年度:+3600 | + +展示规则: +榜单排名展示前10名,按从高到低排序; +当前登录用户若在排行榜内,在姓名后加(我)进行标识,如“张三(我)”; +数值千分位格式化展示(如18,600); +无数据时展示“暂无排名数据”+引导文案“快去上传/发布知识赚取积分吧!”。 +视觉设计注意:总榜内,因为没有总积分这一列,所以当前积分这一列改到最右侧展示。 +4.3 积分管理后台(管理端) +4.3.1页面结构 +平台管理员可在后台查看积分统计信息、配置积分规则、管理用户积分,界面原型如下: + +![原型图/配图 7: image6.png](media/image6.png) + +4.3.2平台积分统计 +展示平台的总积分发放、当前有效可用积分、违规扣减积分,界面原型如下: + +![原型图/配图 8: image7.png](media/image7.png) + +统计指标说明: + +| 统计指标 | 指标说明 | 更新频率 | +| --- | --- | --- | +| 平台总积分发放 | 平台自上线以来,所有渠道发放出去积分的总和(只统计发放动作) | 5min实时 | +| 当前有效可用总积分 | 截止统计时刻,所有用户账户内尚未使用、未过期、未冻结,可以正常支配的积分总和。(目前我们平台就是所有用户的总积分) | 5min实时 | +| 违规扣减积分 | 平台自上线以来,所有渠道违规扣减积分的总和(只统计扣减动作) | 5min实时 | + +4.3.3积分规则配置 +积分规则配置包含四个Tab:「积分获取规则」「积分扣减规则」「管理员奖励」「说明文案」。每个Tab下包含系统预置规则,同时支持平台管理员新增规则,界面原型如下: + +![原型图/配图 9: image8.png](media/image8.png) + +4.3.3.1 Tab1—积分获取规则(默认配置G1~G4) + +| 编码 | 规则名称 | 触发条件 | 默认分值 | 每日上限 | 积分获益人 | +| --- | --- | --- | --- | --- | --- | +| G1 | 发布/上传到公共库 | 上传/发布成功 | +3 | 15分(5篇) | 可配文档上传人/发布人 | +| G2 | 发布/上传到部门库 | 上传/发布成功 | +2 | 10分(5篇) | 可配文档上传人/发布人 | +| G5 | 发布/上传到科室库 | 上传/发布成功 | - | - | 可配文档上传人/发布人 | +| G6 | 发布/上传到团队库 | 上传/发布成功 | - | - | 可配文档上传人/发布人 | +| G7 | 知识分享 | 分享成功 | - | - | 可配文档上传人/分享人 | +| G3 | 文档被收藏 | 单文档被75/150/300个用户收藏 | +5/+10/+15 | 15分(单文档上限) | 文档上传人 | +| G4 | 问答被采纳 | 提问者点击采纳 | +3 | 15分(5次) | 被采纳答案的回答专家 | + +以下G1~G4为系统预置的获取规则,可删除,可编辑分值、每日上限和状态等,G5~G7为可增加规则,也就是新增时可以选择(某规则已存在时,新增时无法再选择该规则): +注:所有者、管理员管辖库内上传/发布不适用G1/G2/G5/G6/G7奖励; +同一用户收藏多次只记一次; +同一加分场景触发只记首次,例:某文档被收藏已达75次,加5积分,后续有人取消收藏,再次收藏后又达到75次不再加积分。 +规则列表界面原型: + +![原型图/配图 10: image9.png](media/image9.png) + +新增/编辑原型界面: + +![原型图/配图 11: image10.png](media/image10.png) + +新增/编辑表单定义: + +| 字段 | 类型 | 必填 | 校验规则 | +| --- | --- | --- | --- | +| 规则类型 | 单选 | ✅ | 获取/扣减/管理员奖励(新增时可选择,编辑时锁定) | +| 规则编码 | 单选 | ✅ | 下拉项见上面的规则表,已生效的置灰不可选 | +| 积分项名称 | 文本 | ✅ | 自动获取规则编码里内容,可修改,2~20个中文字符 | +| 积分分值 | 文本 | ✅ | 正整数或“+5/+10/+15”(阶梯) | +| 每日上限 | 数字 | ❌ | 正整数或不填(不填=不限制) | +| 积分获益人 | 单选 | ✅ | 见上面的规则表 | +| 备注说明 | 文本域 | ❌ | ≤200字符 | +| 状态 | 单选 | ✅ | 启用/禁用,默认启用 | + +4.3.3.2 Tab2—积分扣减规则(默认配置R1~R3) +以下为系统预置的扣减规则,可删除,可编辑分值、状态等: + +| 编码 | 规则名称 | 触发方式 | 默认分值 | +| --- | --- | --- | --- | +| R1 | 色情低俗/暴力违法 | 管理员手动 | -100 | +| R2 | 不当言论 | 管理员手动 | -100 | +| R3 | 其他违反规范 | 管理员手动 | -100 | + +扣减的功能按钮放置在前台以下地方:知识库文档列表和阅读页,专家问答管理员删除功能的弹窗里。 +新增/编辑原型界面: + +![原型图/配图 12: image11.jpeg](media/image11.jpeg) + +新增/编辑表单定义: + +| 字段 | 类型 | 必填 | 校验规则 | +| --- | --- | --- | --- | +| 规则类型 | 单选 | ✅ | 获取/扣减/管理员奖励(新增时可选择,编辑时锁定) | +| 规则编码 | 单选 | ✅ | 见上面的规则表,已生效的置灰不可选 | +| 积分项名称 | 文本 | ✅ | 自动获取规则编码里内容,可修改,2~20个中文字符 | +| 积分分值 | 文本 | ✅ | 负整数 | +| 备注说明 | 文本域 | ❌ | ≤200字符 | +| 状态 | 单选 | ✅ | 启用/禁用,默认启用 | + +4.3.3.3 Tab3—管理员月度奖励(默认配置M1、M4、M6) +以下M1、M4、M6为系统预置的管理员月度规则,可删除,可编辑分值、状态等,M2、M3、M5、M7、M8为可增加规则: + +| 编码 | 管理角色 | 月奖励 | 发放条件 | 发放时间 | 特殊规则 | +| --- | --- | --- | --- | --- | --- | +| M1 | 公共库所有者 | 200分 | 当月登录≥1次
当月登录≥1次
当月登录≥1次 | 次月1日凌晨 | 同一用户可兼任多个管理角色,月度奖励取最高档,不累加。管理员管辖库内上传/发布不叠加G1/G2奖励。 | +| M2 | 公共库管理员 | - | | | | +| M3 | 部门库所有者 | - | | | | +| M4 | 部门库管理员 | 100分 | | | | +| M5 | 科室库所有者 | - | | | | +| M6 | 科室库管理员 | 50分 | | | | +| M7 | 团队库所有者 | - | | | | +| M8 | 团队库管理员 | - | | | | + +新增/编辑原型界面: + +![原型图/配图 13: image12.jpeg](media/image12.jpeg) + +新增/编辑表单定义: + +| 字段 | 类型 | 必填 | 校验规则 | +| --- | --- | --- | --- | +| 规则类型 | 单选 | ✅ | 获取/扣减/管理员奖励(新增时可选择,编辑时锁定) | +| 编码 | 单选 | ✅ | 见上面的规则表,已生效的置灰不可选 | +| 积分项名称 | 文本 | ✅ | 自动获取规则编码里内容,可修改,2~20个中文字符 | +| 积分分值 | 文本 | ✅ | 正整数 | +| 备注说明 | 文本域 | ❌ | ≤200字符 | +| 状态 | 单选 | ✅ | 启用/禁用,默认启用 | + +编辑规则:编辑保存后立即生效,历史记录不受影响。 +4.3.3.4 Tab4—说明文案 +积分规则的说明文案可在后台配置,以下5条说明文案为系统预置的,可修改,修改保存后立即生效。 +预置文案: +1. 积分获取:用户通过发布优质内容、参与互动等方式获取积分,每日设有上限,防止刷分行为。 +2. 积分扣减:对于违反平台规范的行为,将扣除相应积分作为惩戒,严重违规将额外处理。 +3. 管理员奖励:不同层级的管理员根据其管理职责,每月可获得固定积分奖励。 +4. 积分用途:本平台积分会定期发送到协同办公平台,用于党群礼物兑换。 +5. 申诉机制:如对积分变动有异议,可在7个工作日内向管理员提出申诉。 +4.3.4用户积分管理 +用户积分管理包含两个Tab:「用户积分列表」和「操作记录」。平台管理员可在此搜索用户、查看积分概况、手动调整积分、查看明细等,界面原型如下: + +![原型图/配图 14: image13.png](media/image13.png) + +4.3.4.1 Tab1—用户积分列表 +默认选中此Tab,支持搜索姓名和部门,支持按部门、用户类型删选,列表按当前积分降序排列。 +列表字段定义: + +| 字段名 | 说明 | 示例 | +| --- | --- | --- | +| 用户姓名 | 组织架构中姓名 | 王五 | +| 部门 | 用户所属部门级组织 | 设备管理部 | +| 用户类型 | 用户当前角色类型(普通用户+管理员月奖励配置中生效的角色) | 普通用户 | +| 当前积分 | 该用户积分余额(千分位格式) | 4,120 | +| 本月积分 | 当月净增减,正数绿色+前缀“+”,负数红色+前缀“-” | +450(绿)/ -50(红) | +| 操作 | 两个操作入口 | [调整积分]按钮+[查看详情]按钮 | + +筛选与搜索: + +| 筛选控件 | 类型 | 选项/行为 | +| --- | --- | --- | +| 搜索框 | 文本输入 | 支持搜索用户姓名和部门,输入后实时筛选 | +| 部门下拉 | 单选下拉 | 默认“全部部门”,选项来自部门树,选择后列表刷新 | +| 角色下拉 | 单选下拉 | 默认“全部角色”,选项:全部角色/普通用户/科室库管理员/部门库管理员/公共库管理员 | + +4.3.4.2 Tab2—操作记录(审计日志) +点击“操作记录”Tab切换到操作记录列表,记录平台管理员所有积分调整操作,不可编辑,仅可查看,按时间倒序排列。 +界面原型: + +![原型图/配图 15: image14.png](media/image14.png) + +列表字段定义: + +| 字段名 | 说明 | 示例 | +| --- | --- | --- | +| 操作人 | 执行调整的管理员姓名 | 张三 | +| 操作类型 | 中文展示操作类别 | 积分调整/积分扣减 | +| 用户 | 被操作用户姓名 | 王五 | +| 积分变动 | 正数绿色+前缀"+",负数红色+前缀"-" | +100(绿)/-50(红) | +| 备注 | 操作备注文字 | 月度优秀贡献奖励 | +| 时间 | 操作发生时间,精确到分 | 2026-07-28 10:00 | + +4.3.4.3调整用户积分 +点击「调整积分」按钮弹出调整用户积分表单,界面原型如下: + +![原型图/配图 16: image15.png](media/image15.png) + +表单字段定义: + +| 字段名 | 必填 | 校验规则 | 错误提示 | +| --- | --- | --- | --- | +| 用户名 | ✅ | 锁死不可编辑 | | +| 当前积分 | ✅ | 锁死不可编辑 | | +| 调整分值 | ✅ | 整数,范围 -10000 ~ +10000,不可为0 | 请输入非零整数(-10000~10000) | +| 调整原因 | ✅ | 5~100字符,不能为纯空格 | 调整原因需填写5~100个字符 | + +4.3.4.4用户积分详情 +点击「查看详情」按钮弹出用户积分详情表单,可查看该用户最近的积分变动明细,可下拉选择时间范围:近七天,近一个月,自定义时间,默认近一个月,界面原型如下: + +![原型图/配图 17: image16.png](media/image16.png) + +积分详情字段定义: + +| 列名 | 说明 | 示例 | +| --- | --- | --- | +| 积分项 | 规则名称(中文) | 发布知识文档/文档被收藏/管理员月度奖励 | +| 积分变动 | 正数绿色+前缀"+",负数红色+前缀"-" | +50(绿)/-100(红) | +| 余额 | 变动后的积分余额(千分位) | 2,580 | +| 时间 | 积分变动时间,精确到秒 | 2026-07-28 10:00 | + +五、外部系统接口需求 +本平台需每日向协同办公平台推送用户积分增量数据,实现两个平台积分数据互通。 +同步方式:定时任务,每日执行一次增量推送; +同步数据范围:上一同步周期内,所有用户产生的积分变动增量(区分加分、扣减); +数据来源:user_point_log 用户积分明细流水表; +推送方向:知识管理平台主动调用协同办公平台接收接口; +数据字段、鉴权、地址、报文格式等待双方对接后确认。 +六、性能与安全需求 +性能:积分列表、明细支持千人级用户无卡顿加载(≤1.5秒); +数据安全:管理员操作日志禁止删除、修改;积分相关表每日全量备份,保留30天。 \ No newline at end of file diff --git a/docs/PRD/积分系统/images/01-my-points-stats-cards.png b/docs/PRD/积分系统/images/01-my-points-stats-cards.png new file mode 100644 index 000000000..200ab49ee Binary files /dev/null and b/docs/PRD/积分系统/images/01-my-points-stats-cards.png differ diff --git a/docs/PRD/积分系统/images/02-header-menu-entry.png b/docs/PRD/积分系统/images/02-header-menu-entry.png new file mode 100644 index 000000000..bf6902505 Binary files /dev/null and b/docs/PRD/积分系统/images/02-header-menu-entry.png differ diff --git a/docs/PRD/积分系统/images/03-my-points-page.png b/docs/PRD/积分系统/images/03-my-points-page.png new file mode 100644 index 000000000..c509df79d Binary files /dev/null and b/docs/PRD/积分系统/images/03-my-points-page.png differ diff --git a/docs/PRD/积分系统/images/04-points-rules-modal.png b/docs/PRD/积分系统/images/04-points-rules-modal.png new file mode 100644 index 000000000..b72bb009c Binary files /dev/null and b/docs/PRD/积分系统/images/04-points-rules-modal.png differ diff --git a/docs/PRD/积分系统/images/05-home-leaderboard.png b/docs/PRD/积分系统/images/05-home-leaderboard.png new file mode 100644 index 000000000..fa3657697 Binary files /dev/null and b/docs/PRD/积分系统/images/05-home-leaderboard.png differ diff --git a/docs/PRD/积分系统/images/06-admin-console-overview.png b/docs/PRD/积分系统/images/06-admin-console-overview.png new file mode 100644 index 000000000..439d4349d Binary files /dev/null and b/docs/PRD/积分系统/images/06-admin-console-overview.png differ diff --git a/docs/PRD/积分系统/images/07-admin-overview-metrics.png b/docs/PRD/积分系统/images/07-admin-overview-metrics.png new file mode 100644 index 000000000..a47b20fe8 Binary files /dev/null and b/docs/PRD/积分系统/images/07-admin-overview-metrics.png differ diff --git a/docs/PRD/积分系统/images/08-admin-rules-tabs.png b/docs/PRD/积分系统/images/08-admin-rules-tabs.png new file mode 100644 index 000000000..cc3d9739e Binary files /dev/null and b/docs/PRD/积分系统/images/08-admin-rules-tabs.png differ diff --git a/docs/PRD/积分系统/images/09-admin-earn-rules-list.png b/docs/PRD/积分系统/images/09-admin-earn-rules-list.png new file mode 100644 index 000000000..2de244181 Binary files /dev/null and b/docs/PRD/积分系统/images/09-admin-earn-rules-list.png differ diff --git a/docs/PRD/积分系统/images/10-admin-earn-rule-edit.png b/docs/PRD/积分系统/images/10-admin-earn-rule-edit.png new file mode 100644 index 000000000..3f0c219ae Binary files /dev/null and b/docs/PRD/积分系统/images/10-admin-earn-rule-edit.png differ diff --git a/docs/PRD/积分系统/images/11-admin-deduct-rule-edit.jpeg b/docs/PRD/积分系统/images/11-admin-deduct-rule-edit.jpeg new file mode 100644 index 000000000..629e94829 Binary files /dev/null and b/docs/PRD/积分系统/images/11-admin-deduct-rule-edit.jpeg differ diff --git a/docs/PRD/积分系统/images/12-admin-reward-rule-edit.jpeg b/docs/PRD/积分系统/images/12-admin-reward-rule-edit.jpeg new file mode 100644 index 000000000..a7d605b50 Binary files /dev/null and b/docs/PRD/积分系统/images/12-admin-reward-rule-edit.jpeg differ diff --git a/docs/PRD/积分系统/images/13-admin-audit-logs.png b/docs/PRD/积分系统/images/13-admin-audit-logs.png new file mode 100644 index 000000000..e347c3014 Binary files /dev/null and b/docs/PRD/积分系统/images/13-admin-audit-logs.png differ diff --git a/docs/PRD/积分系统/images/14-admin-adjust-modal.png b/docs/PRD/积分系统/images/14-admin-adjust-modal.png new file mode 100644 index 000000000..9522b155c Binary files /dev/null and b/docs/PRD/积分系统/images/14-admin-adjust-modal.png differ diff --git a/docs/PRD/积分系统/images/15-admin-user-detail-modal.png b/docs/PRD/积分系统/images/15-admin-user-detail-modal.png new file mode 100644 index 000000000..54969328f Binary files /dev/null and b/docs/PRD/积分系统/images/15-admin-user-detail-modal.png differ diff --git a/docs/PRD/积分系统/images/README.md b/docs/PRD/积分系统/images/README.md new file mode 100644 index 000000000..015917688 --- /dev/null +++ b/docs/PRD/积分系统/images/README.md @@ -0,0 +1,7 @@ +# 积分 PRD V1.1 · 原型图目录 + +从 `积分PRD V1.1.docx` 抽出的全部界面原型。语义命名见 `0N-*.png|jpeg`;`raw/` 为 docx 原始 `imageN` 文件名。 + +前端实现索引(推荐):[`features/v2.6.0/070-points-system/ui-prototypes/README.md`](../../../features/v2.6.0/070-points-system/ui-prototypes/README.md) + +兼容旧链:`my-points-stats.png` / `home-leaderboard.png` / `admin-rules.png`(分别对应 01 / 05 / 08)。 diff --git a/docs/PRD/积分系统/images/admin-rules.png b/docs/PRD/积分系统/images/admin-rules.png new file mode 100644 index 000000000..cc3d9739e Binary files /dev/null and b/docs/PRD/积分系统/images/admin-rules.png differ diff --git a/docs/PRD/积分系统/images/home-leaderboard.png b/docs/PRD/积分系统/images/home-leaderboard.png new file mode 100644 index 000000000..fa3657697 Binary files /dev/null and b/docs/PRD/积分系统/images/home-leaderboard.png differ diff --git a/docs/PRD/积分系统/images/my-points-stats.png b/docs/PRD/积分系统/images/my-points-stats.png new file mode 100644 index 000000000..200ab49ee Binary files /dev/null and b/docs/PRD/积分系统/images/my-points-stats.png differ diff --git a/docs/PRD/积分系统/images/raw/image1.png b/docs/PRD/积分系统/images/raw/image1.png new file mode 100644 index 000000000..200ab49ee Binary files /dev/null and b/docs/PRD/积分系统/images/raw/image1.png differ diff --git a/docs/PRD/积分系统/images/raw/image10.png b/docs/PRD/积分系统/images/raw/image10.png new file mode 100644 index 000000000..3f0c219ae Binary files /dev/null and b/docs/PRD/积分系统/images/raw/image10.png differ diff --git a/docs/PRD/积分系统/images/raw/image11.jpeg b/docs/PRD/积分系统/images/raw/image11.jpeg new file mode 100644 index 000000000..629e94829 Binary files /dev/null and b/docs/PRD/积分系统/images/raw/image11.jpeg differ diff --git a/docs/PRD/积分系统/images/raw/image12.jpeg b/docs/PRD/积分系统/images/raw/image12.jpeg new file mode 100644 index 000000000..a7d605b50 Binary files /dev/null and b/docs/PRD/积分系统/images/raw/image12.jpeg differ diff --git a/docs/PRD/积分系统/images/raw/image13.png b/docs/PRD/积分系统/images/raw/image13.png new file mode 100644 index 000000000..e347c3014 Binary files /dev/null and b/docs/PRD/积分系统/images/raw/image13.png differ diff --git a/docs/PRD/积分系统/images/raw/image14.png b/docs/PRD/积分系统/images/raw/image14.png new file mode 100644 index 000000000..9522b155c Binary files /dev/null and b/docs/PRD/积分系统/images/raw/image14.png differ diff --git a/docs/PRD/积分系统/images/raw/image15.png b/docs/PRD/积分系统/images/raw/image15.png new file mode 100644 index 000000000..54969328f Binary files /dev/null and b/docs/PRD/积分系统/images/raw/image15.png differ diff --git a/docs/PRD/积分系统/images/raw/image2.png b/docs/PRD/积分系统/images/raw/image2.png new file mode 100644 index 000000000..bf6902505 Binary files /dev/null and b/docs/PRD/积分系统/images/raw/image2.png differ diff --git a/docs/PRD/积分系统/images/raw/image3.png b/docs/PRD/积分系统/images/raw/image3.png new file mode 100644 index 000000000..c509df79d Binary files /dev/null and b/docs/PRD/积分系统/images/raw/image3.png differ diff --git a/docs/PRD/积分系统/images/raw/image4.png b/docs/PRD/积分系统/images/raw/image4.png new file mode 100644 index 000000000..b72bb009c Binary files /dev/null and b/docs/PRD/积分系统/images/raw/image4.png differ diff --git a/docs/PRD/积分系统/images/raw/image5.png b/docs/PRD/积分系统/images/raw/image5.png new file mode 100644 index 000000000..fa3657697 Binary files /dev/null and b/docs/PRD/积分系统/images/raw/image5.png differ diff --git a/docs/PRD/积分系统/images/raw/image6.png b/docs/PRD/积分系统/images/raw/image6.png new file mode 100644 index 000000000..439d4349d Binary files /dev/null and b/docs/PRD/积分系统/images/raw/image6.png differ diff --git a/docs/PRD/积分系统/images/raw/image7.png b/docs/PRD/积分系统/images/raw/image7.png new file mode 100644 index 000000000..a47b20fe8 Binary files /dev/null and b/docs/PRD/积分系统/images/raw/image7.png differ diff --git a/docs/PRD/积分系统/images/raw/image8.png b/docs/PRD/积分系统/images/raw/image8.png new file mode 100644 index 000000000..cc3d9739e Binary files /dev/null and b/docs/PRD/积分系统/images/raw/image8.png differ diff --git a/docs/PRD/积分系统/images/raw/image9.png b/docs/PRD/积分系统/images/raw/image9.png new file mode 100644 index 000000000..2de244181 Binary files /dev/null and b/docs/PRD/积分系统/images/raw/image9.png differ diff --git a/docs/PRD/积分系统/积分系统需求分析-V1.1.md b/docs/PRD/积分系统/积分系统需求分析-V1.1.md new file mode 100644 index 000000000..fa405cc21 --- /dev/null +++ b/docs/PRD/积分系统/积分系统需求分析-V1.1.md @@ -0,0 +1,538 @@ +# 首钢股份知库 · 积分系统需求分析(对照 PRD V1.1) + +| 项 | 内容 | +|---|---| +| 来源 PRD | 《积分模块产品需求文档》V1.1(任彩云,2026-07-29 / 修订 2026-08-04,已评审) | +| 分析日期 | 2026-08-05 | +| 分析视角 | 资深全栈 + 对照 BiSheng / Portal 现网能力 | +| 原文摘录 | [PRD-V1.1-extracted.md](./PRD-V1.1-extracted.md)(docx 抽取,含表格;原型图见 `images/`) | +| 结论摘要 | **业务闭环可做、技术可行**;2026-08-06 产品拍板已闭环(见 §11,含组织四级标签)。可进入 SDD;组织打标属中高风险,SDD 须含 blast radius 与仅超管可操作约束。 | + +--- + +## 0. 一句话结论 + +积分模块本质是「**事件驱动的激励账本 + 可配置规则 + 排行榜缓存 + 运营后台 + 外部增量同步**」。 +BiSheng 侧已有空间层级/角色、上传/发布审批、收藏、专家采纳、站内信、日活事实、Celery Beat;Portal 首页已有**硬编码积分榜 UI**。 +仓库内**无**积分 ledger / 规则引擎 / 余额账户。可落库新建独立 DDD 模块,但必须先把下文「不清晰点」拍板,否则联调阶段会反复改语义。 + +--- + +## 1. 需求拆分归纳(按交付切片) + +### 1.1 用户端(Portal 为主) + +| 切片 | PRD 要点 | 现状对照 | +|---|---|---| +| **U1 我的积分** | 入口在账号下拉;总积分 / 本月获得 / 本月扣减 / 部门与总榜排名;规则弹窗;明细筛选;空状态文案 | Portal `Header` 有账号下拉;**无**积分页。Client `UserPopMenu` 另有一套账号菜单,需产品确认入口是否仅 Portal | +| **U2 首页榜单** | 本月 / 本年度 / 总榜 TOP10;当前用户高亮或置底「我」;每小时刷新 | Portal `HomePage.tsx` 已有领奖台 + 列表 **mock 数据**(`POINTS_PODIUM` / `POINTS_ROWS`),可替换 API | +| **U3 消息提醒** | 积分变动站内信,多模板,链到「我的积分」 | BiSheng `MessageService.send_message` + `action_code` 可扩展;无需先扩 `MessageTypeEnum` | + +### 1.2 管理端 + +| 切片 | PRD 要点 | 现状对照 | +|---|---|---| +| **A1 数据概览** | 平台总发放 / 有效可用总积分 / 违规扣减;5min 刷新;原型还含环比 | Platform 或 Portal 管理页?PRD 写「后台」,**落点未定义** | +| **A2 规则配置** | 获取 G* / 扣减 R* / 管理员 M* / 说明文案 4 Tab;预置可删可改;编码互斥 | 全新;规则表 + 种子数据 | +| **A3 用户积分管理** | 搜索/筛部门与角色、调分、详情、操作审计(不可改删) | 全新;依赖用户主部门与空间角色聚合 | +| **A4 前台扣减入口** | 知识库文档列表/阅读页、专家问答删除弹窗挂「扣减」 | 跨 Portal 知识 iframe(Client)+ 专家问答页,**权限与入口分散** | + +### 1.3 规则与自动化(领域核心) + +| 规则族 | 编码 | 触发 | 关键约束(PRD) | +|---|---|---|---| +| 获取 | G1/G2(预置)、G5/G6/G7(可选增) | 上传/发布成功;分享成功;收藏阶梯;问答采纳 | 每日上限;所有者/管理员管辖库内上传发布**不叠加** G1/G2/G5/G6/G7;收藏同用户只计一次;阶梯阈值只记首次 | +| 扣减 | R1–R3 | **管理员手动** | 负分;前台挂载入口 | +| 月奖 | M1/M4/M6 预置,其余可增 | 次月 1 日 00:05;当月登录 ≥1;多角色取最高不累加 | 依赖「管理员角色」识别 + 日活 | +| 文案 | 5 条预置可改 | 前台规则弹窗展示 | 「用途 / 申诉」仅文案,无功能 | + +### 1.4 外部系统 + +| 切片 | PRD 要点 | 状态 | +|---|---|---| +| **E1 协同办公同步** | 每日增量推送 `user_point_log`;字段/鉴权/地址「对接后确认」 | **接口未闭环**,只能预留 Outbox | + +### 1.5 非功能 + +- 列表/明细千人级 ≤1.5s +- 审计日志禁止删改 +- 积分表每日全量备份保留 30 天(偏运维,非应用层) + +### 1.6 业务目标(成功指标,非功能) + +| 目标 | 指标 | 说明 | +|---|---|---| +| 内容增长 | 上传/发布月环比 ≥20% | 需埋点基线对比,积分模块本身不保证达成 | +| 用户留存 | MAU 环比 ≥15% | 同上 | +| 内容质量 | 违规占比 <2% | 「违规」定义与统计口径 PRD 未给 | + +--- + +## 2. 可行性与技术选型 + +### 2.1 可行性总评 + +| 维度 | 评级 | 说明 | +|---|---|---| +| 业务价值落地 | 中高 | 激励闭环清晰;兑换/申诉仅文案,短期可不做兑换引擎 | +| 后端落地 | **可行** | 新建 `points`(建议 errcode **182**)DDD 模块;账本 + 规则 + 排行快照 | +| 触发挂钩 | **可行但侵入** | 无领域事件总线;需在 upload / 发布审批通过 / favorite / adopt / 手动调分 等成功路径显式调用 `PointsService` | +| 前端落点 | **跨仓** | 用户端与首页榜 → **Portal**;管理端可能 Portal 运营页或 Platform;知识库扣减入口在 Client iframe | +| 多租户 / DM8 | 需遵守现有规范 | `tenant_id` 自动注入;`JsonType` / Alembic;月任务按租户 fanout(Beat × N) | +| 外部同步 | **阻塞风险** | 报文未定;必须做本地 Outbox,避免业务被外部接口拖死 | +| 总体工期量级(粗估) | **中大** | 账本+规则+挂钩+Portal 三页+管理端+Beat+同步骨架 ≈ 多迭代;不含协同办公联调 | + +**风险等级(跨边界):高** —— 触及知识空间语义(管理员是否计分)、审批发布结果、收藏计数、专家采纳、站内信、Portal/Client 多端 UI、未来外部积分互通。按仓库规则:**澄清清单确认前不宜直接写生产代码**。 + +### 2.2 推荐架构(业界 ledger 实践 × 本仓约束) + +对齐业界常见做法(append-only ledger、idempotency key、余额缓存、日 cap、异步副作用): + +``` +业务成功路径(upload / approve-publish / favorite / adopt / admin adjust / monthly job) + │ + ▼ +PointsAwardService.award / deduct / adjust + · 读启用规则 + 解析分值(含 G3 阶梯) + · 日上限 / 幂等键 / 管理员豁免校验 + · 同一事务:INSERT ledger + UPDATE balance(行锁) + │ + ├─► 站内信(可异步 / outbox) + ├─► 失效排行缓存标记 or 写 Redis ZSET + └─► 外部同步 outbox(日批 drain) +``` + +#### 核心数据表(建议) + +| 表 | 职责 | +|---|---| +| `user_point_account` | 用户余额缓存:`balance`、`lifetime_earned`、`lifetime_deducted`、版本号/更新时间 | +| `user_point_log` | **append-only** 流水:delta、balance_after、rule_code、biz_type、biz_id、idempotency_key、operator_id、remark、occurred_at | +| `point_rule` | 规则配置:code、type(earn/deduct/admin_reward)、name、score_expr、daily_cap、beneficiary、status、remark | +| `point_rule_copy` | Tab4 说明文案(5 条或 key-value) | +| `point_admin_audit` | 仅管理员调分审计(也可与 log 合并并用 `source=manual` 区分) | +| `point_rank_snapshot` | 小时榜快照:scope(dept/global)、period(month/year/all)、rank、score、refreshed_at | +| `point_sync_outbox` | 外部推送:pending/sent/failed、payload、retry | + +**硬约束(最佳实践):** + +1. **流水不可 UPDATE/DELETE**(应用层禁止;审计同理)。纠错用**冲正流水**,不改历史。 +2. **幂等键**唯一:`(tenant_id, idempotency_key)`,例:`g1:file:{file_id}:publish`、`g3:file:{id}:tier:75`、`m1:user:{id}:2026-07`。 +3. **余额与流水同事务**;并发用 `SELECT … FOR UPDATE` 锁账户行。 +4. 排行榜:**小时批算快照**(对齐 PRD)+ Redis ZSET 可选加速;「我的排名」读快照并展示 `refreshed_at`。 +5. 日上限:Redis `INCR` + TTL 到日切,或 SQL 日聚合;失败要可回滚一致性。 + +#### 技术栈选型(贴合本仓) + +| 层 | 选型 | 理由 | +|---|---|---| +| 后端模块 | FastAPI DDD:`points/{api,domain,repositories}` | 对齐 knowledge / message | +| 异步 | Celery:月奖、小时排行、外部 sync、可选延迟站内信 | 已有 Beat 注册模式(`CeleryTaskSettings`) | +| 缓存 | Redis Sorted Set(榜)+ 日 cap key | 本仓已有 Redis | +| 消息 | `MessageService` + 新 `action_code`(如 `points_changed`) | 已有企微 outbox 旁路 | +| 登录条件 | `DailyParticipationFact` / mid-table 按月聚合 `logged_in` | 已有日活,无需新登录表 | +| 空间角色 | `SpaceChannelMember`(creator/admin)+ `KnowledgeSpaceLevelEnum` + ReBAC `can_manage` | 映射 M1–M8;**「科室」≈ `TEAM_KS`,需产品确认** | +| 前端用户端 | Portal(首页榜 + 我的积分 + 规则弹窗) | 已有 mock 榜单 | +| 前端管理端 | **待定**:Portal 运营后台 or Platform | 见开放问题 | +| 错误码 | 模块号建议 **182** | 181=approval、189=migration 已占 | +| 规则引擎 | **首期不做通用规则引擎** | 编码白名单 + score_expr(整数或阶梯 JSON)足够;避免过度设计 | + +#### 明确不推荐 + +| 方案 | 原因 | +|---|---| +| 只改用户表一个 `points` 字段 | 无法审计、无法对账、并发不安全 | +| 全量实时算全站排名 | 千人尚可,增长后拖垮;与 PRD「每小时」不符 | +| 先接协同办公再上线积分 | 外部字段未定,应 Outbox 解耦 | +| 在 Client/Platform 混放用户「我的积分」主入口 | 与首钢知库主入口(Portal)不一致,除非产品明确双端 | + +### 2.3 与现有挂钩点映射 + +| 业务事件 | 现有落点(核实) | 积分动作 | +|---|---|---| +| 上传到某级库 | `POST .../knowledge/space/{id}/files` + hook | G1/G2/G5/G6(按 `space.level`) | +| 跨库发布 | 审批 scenario `knowledge_space_file_publish_request`,通过后 handler | G1/G2…(按**目标库** level);注意「上传」与「发布」是否重复计分 | +| 知识库间分享(非外链) | 文档分发 `SHARE`:`KnowledgeDocumentDistributionService.share_approved`(分享到其他知识空间,如部门库→部门库) | G7 | +| 收藏 | `create_shougang_portal_favorite` + `aget_favorite_referrers` | G3 阶梯;需自建阈值状态 | +| 问答采纳 | `QuestionService.adopt_answer` | G4 | +| 月登录 | `DailyParticipationFact` | M* 发放条件 | +| 站内信 | `MessageService.send_message` | 变动通知 | +| 扣减 UI | 文档列表/阅读、问答删除弹窗 | R* 或自由原因?PRD 混用规则名与手填原因 | + +--- + +## 3. 异常与边界(必须设计,PRD 大量未写) + +### 3.1 账本与余额 + +| 场景 | 建议默认(待产品确认) | 风险 | +|---|---|---| +| 扣减后余额 < 0 | 允许负数 **或** 扣到 0 | 榜单、外部兑换、展示样式全受影响 | +| 并发双击调分 / 重复采纳回调 | 幂等键拒绝第二次 | 否则双倍积分 | +| 规则中途改分值 | 仅影响新流水(PRD 已明确) | 历史对账按流水当时分值 | +| 规则禁用后已触发事件 | 不计分 | 用户感知「做了没分」需否提示 | +| 规则删除 | 流水保留 rule_code 快照名;列表「已删规则」展示 | 外键勿级联删流水 | +| 冲正 / 误发 | PRD 无;建议仅管理员调分或显式 reversal | 禁止改历史行 | + +### 3.2 业务事件语义 + +| 场景 | 问题 | 建议澄清 | +|---|---|---| +| 同一文档先上传部门库(+G2)再发布公共库(+G1) | 是否两次都给? | 场景一暗示发布审核通过后 +2,需定义「上传成功」vs「发布成功」 | +| 上传即进公共库(无审批) | 触发点? | 与审批发布是否共用幂等维度 | +| 文档删除 / 下架 / 回收站 | 已发积分是否回收? | 业界多为**不回滚激励**(防刷另靠日 cap);若回收需冲正 | +| 取消收藏导致人数跌破 75 | PRD:再次达 75 **不再**加 | 需持久化 `tier_awarded` 集合 | +| 同一用户对一文多次收藏 | 只计一次 | favorite 唯一约束已有?需核实 | +| 管理员/所有者在本库上传 | 不叠加 G* | 「管辖库」定义:任一 admin/creator 的 space?含子空间? | +| 兼任多空间管理员 | 月奖取最高 M* | 跨 level 取 max(score);一人多 public admin 不叠 | +| 平台管理员 | 矩阵写不可获积分 | 手动调分给自己?月奖?需禁或审计例外 | +| G3「单文档上限 15」与阶梯 +5/+10/+15 | 是累计封顶 15 还是三档互斥? | **高歧义** | +| G1 每日 15 分(5 篇) vs 分值可改成 +5 | 上限按「分」还是「次」? | 改分后 15 分可能不足 5 篇或超过 | +| 获益人「上传人/发布人」 | 两人不同时给谁? | 单选配置:只给一个角色 | +| 分享 G7 | ~~外链~~ → **知识库间 SHARE 分发成功**;与发布(publish)是否重叠需防双计 | 挂钩 `share_approved` | +| 科室库 / 团队库 | 代码 `TEAM` vs `TEAM_KS` | 与 M5–M8、G5/G6 映射表 | +| 时区 | 「每月 1 日 00:05」「本月 1 号 00:00」 | 建议 Asia/Shanghai 固定,写入配置 | +| 用户调部门 | 部门榜用当前主部门还是历史? | 快照按刷新时主部门 | +| 多租户 | 榜单租户内还是集团? | 默认租户隔离 | +| 外部同步失败 | 重试?补推?对账? | Outbox + 告警;PRD 未写 | +| 申诉(文案有、功能无) | 7 个工作日申诉 | 仅展示 or 工单? | + +### 3.3 权限与安全 + +| 项 | 说明 | +|---|---| +| 谁可配规则 / 调分 | 矩阵:公共库管理员 + 平台管理员;需映射现有 RBAC/ReBAC,避免只靠前端藏按钮 | +| 前台扣减按钮 | 文档页在 Client iframe:权限校验必须后端强制 | +| 审计不可删 | 无管理 API;DB 账号权限与备份策略 | +| 刷分 | 日 cap + 管理员豁免 + 分享/收藏幂等;缺设备/IP 风控(首期可不做,需产品接受) | +| 备份 30 天 | 属 DBA/运维;应用层做逻辑删除备份不满足「全量备份」表述 | + +### 3.4 性能边界 + +| 项 | 策略 | +|---|---| +| 明细分页 | `(user_id, occurred_at, id)` 索引;禁深翻页大 offset 可考虑 cursor | +| 排行小时任务 | 增量聚合当月流水比全表 sort 用户更稳 | +| 统计卡片「实时」 | 账户表 + 当月流水聚合;注意与榜「每小时」不一致,UI 需展示榜更新时间 | +| 管理端全用户列表 | 千人可接受;需部门/角色筛的预聚合或 JOIN 优化 | +| Beat × 租户 | 月奖、排行、同步均按活跃租户循环,评估耗时 | + +--- + +## 4. 数据埋点与监控 + +PRD 几乎只给业务 KPI,**缺少工程可观测性**。建议分层补齐(可与现有 telemetry / audit 对齐)。 + +### 4.1 产品 / 运营指标(验证 PRD 目标) + +| 指标 | 口径建议 | 周期 | +|---|---|---| +| 知识上传/发布量 | 已有 audit/telemetry;对比积分上线前后 | 月 | +| MAU | 日活 mid-table | 月 | +| 违规占比 | **需产品定义**(扣减次数?删文?举报?) | 季 | +| 人均积分获得、规则命中分布 | 按 `rule_code` 聚合流水 | 周/月 | +| 日 cap 触达率 | cap 拒绝次数 / 触发次数 | 日 | +| 榜单曝光与「我的积分」PV | 前端埋点 | 周 | +| 外部同步延迟与失败率 | outbox | 日 | + +### 4.2 工程埋点(事件) + +建议统一前缀 `points.*`(后端结构化日志 + 可选 telemetry): + +| 事件 | 关键字段 | +|---|---| +| `points.award.success` / `.rejected` | rule_code, user_id, delta, reason(cap/idempotent/exempt/disabled) | +| `points.deduct.manual` | operator_id, target_user, delta, rule_or_reason | +| `points.monthly_reward` | role_code, users_paid, skipped_no_login | +| `points.rank.refresh` | scope, duration_ms, user_count | +| `points.sync.push` | batch_size, success/fail | +| `points.balance.mismatch` | 对账任务发现余额≠流水和 | **P0 告警** | + +前端(Portal): + +- `points_page_view` / `points_rules_open` / `leaderboard_tab_switch` / `points_message_click` + +### 4.3 监控与告警 + +| 监控项 | 阈值建议(初值) | +|---|---| +| 记分失败率 | >1% / 5min 告警 | +| 月奖任务未跑完 / 未触发 | 每月 1 日 01:00 仍未 success | +| 排行刷新延迟 | >2h 未更新 | +| 同步 outbox 积压 | >1000 或 oldest >24h | +| 余额对账不一致 | 立即 P0 | +| 接口 P95 | 我的积分/明细/榜 >1.5s | + +### 4.4 对账任务(强烈建议,PRD 未写) + +日批:抽样或全量校验 `sum(log.delta) == account.balance`;不一致写修复工单,**禁止静默改流水**。 + +--- + +## 5. 需求完整性与闭环检查 + +### 5.1 已相对闭环的部分 + +- 用户查看积分与明细、规则展示(配置驱动) +- 管理员配置预置规则、手动调分、审计列表 +- 消息通知文案骨架 +- 月奖时间点与「多角色取最高」 +- 规则变更不影响历史 +- 外部同步方向(知库 → 协同办公)与数据源表名意向 + +### 5.2 未闭环 / 仅文案承诺 + +| 项 | 说明 | +|---|---| +| 积分用途(党群礼物兑换) | 仅说明文案 + 外部同步;**无兑换、无核销、无过期** | +| 申诉机制 | 文案有,无入口、无流程、无时效处理 | +| 「冻结 / 过期」 | 有效积分定义提到「未过期、未冻结」,同时又说「目前就是所有用户总积分」——**自相矛盾** | +| 违规内容占比 KPI | 无统计定义 | +| 协同办公接口 | 字段/鉴权/地址待定 | +| 备份 30 天 | 无 RPO/RTO、无执行方 | + +### 5.3 文档内部不一致(已发现) + +1. **章节编号**:4.1.2 在 4.1.1 之前。 +2. **角色矩阵 vs M 表**:矩阵写「科室/部门/公共库**管理员**」;M 表区分**所有者 / 管理员**(M1 所有者 200、M2 管理员可配)。前台角色筛选项又只列三级「管理员」。 +3. **平台管理员**:矩阵「不可获取积分」,但未说明是否出现在榜单、是否可被调分。 +4. **原型 vs 规则表**:明细原型出现「文档被订阅」,规则表无对应 G 编码;管理概览原型有环比/%,正文指标表无。 +5. **榜单列**:正文写总榜无「总积分列、当前积分改右侧」;与「所有 Tab 都展示当前积分」并存,实现需以原型为准再确认。 +6. **G3 每日上限 15 与阶梯 +5/+10/+15**:算术关系不清。 +7. **扣减**:规则预置 R1–R3,调分表单又是自由「调整分值 + 原因」——前台扣减走规则选择还是自由调分? +8. **公共库管理员**:矩阵可配规则/调分;与「仅平台管理员运营」的常见模型可能冲突,需权限模型确认。 +9. **V1.1 修订**「去掉管理员加分规则前台显示」:前台规则弹窗是否隐藏全部 M*?仅隐藏分值? + +--- + +## 6. 不清晰点清单(建议产品逐条拍板) + +> 下列全部建议在进入 SDD / 开发前关闭。标注 **P0** 为阻塞设计。 + +### 6.1 范围与端侧 — P0 + +1. 「我的积分」入口是否**仅 Portal**?Client / Platform 是否要入口? +2. 管理端(规则/用户/概览)落在 **Portal 还是 Platform**?URL 与菜单归属? +3. 首期是否包含 **G5/G6/G7、M2/M3/M5/M7/M8**,还是只上预置 G1–G4 + R1–R3 + M1/M4/M6? +4. 外部同步是否 **MVP 阻塞项**,还是可先 Outbox 不推送? + +### 6.2 计分语义 — P0 + +5. 「上传成功」与「发布成功」是否分别计分?同一文档多级流转如何去重? +6. 个人库 / 收藏库上传是否永不计分? +7. 「所有者、管理员管辖库」精确判定算法(成员角色?ReBAC can_manage?部门管理员?) +8. G3 阶梯:三档叠加还是替换?「单文档上限 15」如何与阶梯共存? +9. 每日上限单位:**分**还是**次数**?改规则分值后上限是否联动? +10. 余额是否允许为负? +11. 内容删除/审核驳回是否冲正积分? +12. 分享 G7 的成功定义与防刷策略? + +### 6.3 组织与角色 — P0 + +13. 「部门排名」的部门 = 用户**主部门**?部门树哪一层? +14. 科室 vs 团队在积分规则中的枚举映射(对齐 `TEAM` / `TEAM_KS`)。 +15. 用户类型筛选项:所有者是否单独展示?兼任时展示「最高奖励角色」还是多标签? +16. 平台管理员是否上榜、是否可持有积分。 + +### 6.4 体验与文案 — P1 + +17. 前台规则弹窗是否完全不展示管理员月奖(V1.1)? +18. 消息模板是否可配置,还是写死五种? +19. 榜单当前用户不在 TOP10 时:仅置底一行(原型)还是另开「我的排名」?PRD 与原型不完全一致。 +20. 「999+」仅用于「我的积分」总榜名次,首页榜是否也截断? + +### 6.5 运营与合规 — P1/P2 + +21. 申诉是否做功能?不做则文案是否改掉以免合规预期? +22. 「冻结/过期」是否在范围内?若否,后台文案去掉相关表述。 +23. 调分与 R* 扣减是否同一 API?前台是否必须选 R 规则? +24. 公共库管理员与平台管理员的权限差(删规则、看全平台用户等)。 +25. 多租户 / 多法人部署下榜单与同步范围。 + +### 6.6 数据与运维 — P1 + +26. 时区与自然月边界。 +27. 历史数据:上线前行为是否补分(通常**不补**)? +28. 备份与保留策略的执行责任方。 +29. 协同办公:幂等、对账、失败重试、用户匹配键(工号?)。 + +--- + +## 7. 业界 / 开源实践对照(对本案的启示) + +| 实践 | 来源共识 | 对本案建议 | +|---|---|---| +| Append-only ledger + 余额缓存 | 忠诚度/积分 LLD 通识;开源实现常见 trigger 禁 UPDATE | 直接采用;流水为 SSOT | +| Idempotency key | 防 MQ/HTTP 重试双计 | 所有自动发放必带业务幂等键 | +| 规则版本化 | 部分系统 DRAFT→PUBLISHED | 首期「改规则立即生效 + 流水快照名称/分值」即可;不必上完整版本状态机 | +| 日 cap / cooldown | 防刷标配 | 按规则 daily_cap;分享/收藏尤其需要 | +| 冲正而非改史 | 会计惯例 | 误操作走调分或 reversal | +| 排行异步物化 | 游戏/社区榜单 | 严格按 PRD 小时批,避免实时全表 | +| 积分与兑换拆域 | 航空里程/电商积分 | **兑换放协同办公**;知库只做发放与增量同步,边界清晰是优点 | +| 事件总线 | 大厂常见 | 本仓无总线,首期显式调用;若挂钩点 >10,再评估轻量 Domain Event 表 | + +参考方向(非选型依赖):Bricqs points/ledger 论述、常见 Loyalty LLD(ledger + idempotency + expiry)、以及开源「append-only ledger + daily cap + adjustments」类实现。 +**过期/FIFO 核销**本 PRD 基本不做——不要过早引入 FIFO 复杂度。 + +--- + +## 8. 建议实施分期(按 §11 决议修订) + +| 阶段 | 范围 | 目标 | +|---|---|---| +| **P0 澄清** | §11 已全部拍板(含组织四级) | 可写 SDD | +| **M1 账本 MVP** | 账户+流水+手动调分+审计;Portal「我的积分」;站内信文案代码常量 | 可运营人工激励 | +| **M2 自动发放** | G1/G2/G4/G5/G6/**G7** 挂钩;日 cap(按分);管理员豁免;G3 阶梯补差 | 主路径自动化(G7=库间 SHARE) | +| **M3 榜单与月奖** | 小时榜(首页仅 TOP10);M1/M4/M6 + 可增 M*;组织维度排名 | 激励可见性 | +| **M4 运营完善** | 前台 R* 扣减入口;规则全量可配;说明文案;管理概览三绝对数 | 对齐运营台 | +| **M5 外部同步** | Outbox + 协同办公联调 + 对账(不阻塞 M1–M4 上线) | 打通兑换生态 | + +每阶段配套:单元测试(幂等/cap/豁免)、API 测试、Portal 手测清单;余额对账任务从 M1 即上。 + +--- + +## 9. 与仓库规范的衔接提醒 + +- 新模块走 `Router → Endpoint → Service → Repository`;禁止 endpoint 直引 `database.models`。 +- 权限写操作走 `PermissionService`;资源级授权勿查 `role_access`。 +- 双库:Alembic + `dialect_helpers`;DM8 在 CI/Linux 验证。 +- 非平凡功能走 SDD:`features/v{X.Y.Z}/{NNN}-points/`(版本号待定)。 +- 跨仓:BiSheng API + **Portal**(用户端 + 管理端);Client 仅前台违规扣减入口(若做)。 +- **组织四级标签**:复用 `department` 增 `org_level`(或等价)+「指定唯一公司根并级联打标」;仅平台超管可操作(Platform);SDD 须写 blast radius。 + +--- + +## 10. 附录 + +### 10.1 原型图(实现对照) + +docx 内 **15** 张原型已抽出并语义命名,完整索引: + +- 实现首选:[features/v2.6.0/070-points-system/ui-prototypes/](../../../features/v2.6.0/070-points-system/ui-prototypes/README.md) +- PRD 侧副本:[images/](./images/README.md)(含 `raw/imageN.*`) +- Portal 仓副本:`shougang-group-knowledge-portal/docs/points-ui-prototypes/` + +常用三张(兼容旧文件名): + +- 统计卡片:[images/01-my-points-stats-cards.png](./images/01-my-points-stats-cards.png)(=`my-points-stats.png`) +- 首页榜单:[images/05-home-leaderboard.png](./images/05-home-leaderboard.png)(=`home-leaderboard.png`) +- 管理端规则 Tab:[images/08-admin-rules-tabs.png](./images/08-admin-rules-tabs.png)(=`admin-rules.png`) + +### 10.2 相关代码锚点(调研时点) + +- Portal 首页 mock 榜:`shougang-group-knowledge-portal/frontend/src/pages/HomePage.tsx`(`POINTS_PODIUM` / `POINTS_ROWS`) +- Portal 管理端已有:`AdminPage.tsx`(含科室知识库绑定等)→ 积分管理宜挂此处附近 +- 空间层级:`bisheng/knowledge/domain/models/knowledge_space_scope.py` +- 空间成员角色:`bisheng/common/models/space_channel_member.py` +- 现有组织树:`department` / `user_department`(主部门 `is_primary=1`,**无**公司/部门/科室/班组四级枚举) +- 收藏:`KnowledgeSpaceService.create_shougang_portal_favorite` +- 发布审批:`approval/.../shougang_approval_handler.py` +- 采纳:`qa_expert` → `QuestionService.adopt_answer` +- 站内信:`message/domain/services/message_service.py` +- 日活:`telemetry/domain/mid_table/daily_participation.py` +- Beat 注册:`bisheng/core/config/settings.py` → `CeleryTaskSettings` + +### 10.3 文档维护 + +- 本文是**需求分析 / 可行性 + 产品决议**,不是已确认的技术设计说明书。 +- 组织层级拍板后,另开 `spec.md`(SDD),并以 §11 为需求基线。 + +--- + +## 11. 产品决议表(2026-08-06) + +> 回复原文中 `G6`/`G7` 按笔误对应 **Q6**/**Q7** 记录。 + +### 11.1 已拍板 + +| ID | 议题 | 决议 | 工程含义 | +|---|---|---|---| +| Q1 | 用户端入口 | **仅 Portal** | Client/Platform 不做「我的积分」主入口 | +| Q2 | 管理端落点 | **Portal 运营后台** | 挂 Portal Admin;BiSheng 提供 API | +| Q3 | 首期规则范围 | **按 PRD 全开可增项**(G5–G7、M2/M3/M5/M7/M8 可配置新增) | 规则目录与后台要支持;G7 触发见 Q12 | +| Q4 | 外部同步 | **不阻塞 MVP**;预留 Outbox | M5 再联调协同办公 | +| Q5 | 上传 vs 发布 | **进入目标库成功计一次**;按目标库 level;同 file+目标库幂等 | 直传/审批发布共用「入库成功」语义 | +| Q6 | 个人库/收藏库 | **永不计 G\*** | 挂钩前过滤 space 类型 | +| Q7 | 管理员豁免 | **space 成员 `creator`/`admin` 豁免 G\*** | 不用扩大到全部 `can_manage` | +| Q8 | G3 阶梯 | **互斥取最高档 + 补差价**;单文档终身上限 15 | 持久化已授档位;取消收藏再达阈值不重发 | +| Q9 | 每日上限 | **按分** | 改分值后自然影响可触发次数 | +| Q10 | 负余额 | **允许为负** | 展示与榜单兼容负数 | +| Q11 | 删文/驳回 | **本期:已入账不回收**;二期拟「延迟 T 小时入账,窗口内删除则不计」(见 F070 design §7) | 本期无冲正;二期改 pending→settle | +| Q12 | G7 分享 | **本期接线**:知识库间文档 SHARE(`share_approved`);**不是**外链。幂等键 `earn:G7:{share_entry_id}`;获益人可配上传人/分享人;管理员豁免同 G*;日 cap 生效 | 挂钩审批成功路径;禁止挂 `share-links`;与 PUBLISH/G1–G6 分键防双计 | +| Q13/Q14 | 组织与库映射 | **不改用户挂载**;给现有 `department` 树贴四级标签并自指定「公司」节点起按深度自动推导(见 §11.2,已闭环) | 部门榜按标签聚合;用户仍用 `user_department` | +| Q15 | 月奖角色 | **预置 M1/M4/M6**;所有者/管理员分档;可增其余 M* | 改 PRD 矩阵「只写管理员」的表述 | +| Q16 | 平台管理员 | **自动分跳过;不进激励榜;必要时可调分+强审计** | 记分与榜单过滤运营账号 | +| Q17 | 配规则/全站调分 | **仅平台管理员**;公共库管理员最多前台 R\* 扣减 | **收权,不按 PRD 矩阵给公共库管理员配规则权** | +| Q18 | 扣减 vs 调分 | **前台必选 R\***;后台自由 ±分+原因 | 两套入口、一类流水 | +| Q19 | 申诉 | **不做功能;线下申诉** | 说明文案改为「线下联系管理员」,禁止写「站内 7 日申诉入口」 | +| Q20 | 文档被订阅 | **不做**;示例/原型删除 | 无新规则编码 | +| Q21 | 概览环比 | **不做** | 仅三绝对数 | +| Q22 | 前台规则弹窗 | **不展示 M\*** | 只展示获取/扣减 + 说明文案 | +| Q23 | 首页榜「我」 | **仅 TOP10**;我的排名只在「我的积分」页 | 去掉首页置底「我」行 | +| Q24 | 消息模板 | **写死代码常量**;不建 `point_message_template` 表 | `PointsNotifyService` 内置文案;改文案需发版 | +| Q25 | 历史补分 | **不补** | 启用日零点起算 | +| Q26 | 时区 | **Asia/Shanghai** | 日切/月切/月奖 cron 固定该时区 | +| F1–F6 | 风险收口 | **全部同意** | 无冻结过期指标表述;公共库管理员不改全站规则;同步不阻塞;删除不扣回;日上限单一口径;申诉文案不承诺站内功能 | + +### 11.2 组织四级层级 — 已闭环(2026-08-06) + +#### 模型 + +| 项 | 决议 | +|---|---| +| 用户挂载 | **不改**:仍 `user_department` + 主部门 `is_primary` | +| 主数据 | **复用**现有 `department` 树,不新建并行组织表 | +| 四级标签 | `公司 → 部门 → 科室 → 班组`(建议字段如 `org_level`) | +| 推导规则 | 指定某节点为「公司」后,相对深度 +1→部门、+2→科室、+3→班组,递归打标 | +| 深度溢出 | 班组之下更深层 **仍标班组** | +| 深度不足 | 允许缺省中间层(如公司下仅一层则只出现「部门」) | +| 公司根数量 | **全树仅允许一个「公司」**(O5' B) | +| 未覆盖节点 | **不允许长期无标签**:组织须挂到该公司子树下并完成打标(O6' B) | +| 部门排名桶 | 从用户主部门节点 **向上找最近的「部门」标签**,在该桶内排名(O3' A) | +| 与知识库计分 | **两套独立**(O7' A):标签服务排名/筛人;G* 仍按 knowledge space level | +| 维护入口 | **Platform**,仅 **超级管理员 / 平台管理员**(O8' B);Portal 积分管理不开放打标 | +| O2 | **不适用**(已作废) | + +示意: + +```text +[唯一指定] 首钢股份 = 公司 + └─ 技术部 → 部门 + └─ 连铸车间 → 科室 + └─ 甲班 → 班组 + └─ 更深层 → 仍为班组 +``` + +张三主部门 = 甲班 → 部门榜归入「技术部」桶(向上最近部门标签)。 + +#### 风险与约束(SDD 必写) + +- **风险等级:中高**(改共享 `department` 语义/字段,波及部门树展示、筛选、可能影响依赖部门名的报表;**不改**用户挂载与 ReBAC 主路径)。 +- 可行性:可改干净;前置条件为超管完成「指定公司根 + 级联打标」;工作量中等(字段 + 级联 API + Platform UI + 积分排名读标签)。 +- 上线前校验:存在且仅有一个 `org_level=company`;其余活跃部门均在其子树并已打标,否则部门榜/筛人降级策略须在 SDD 写明(建议:阻断发布或运维告警)。 +- **建议不做**:借打标重做组织同步、改用户挂载、把知识库 level 与 org_level 强绑。 + +### 11.3 决议衍生的 PRD 修订建议(给产品改文档用) + +1. 权限矩阵:配规则/全站调分改为**仅平台管理员**;公共库管理员仅前台违规扣减。 +2. 删除「冻结/过期」措辞;有效积分 = 全站余额合计。 +3. 申诉改为「线下联系管理员」。 +4. 去掉原型「文档被订阅」、概览环比。 +5. 首页榜不展示当前用户置底行。 +6. 明确 G7=知识库间 SHARE(非外链),**本期接线**;G3 为补差价至最高档、单文档终身 ≤15。 +7. 组织:现有部门树贴四级标签;唯一公司根;部门排名 = 主部门向上最近「部门」标签;打标仅 Platform 超管。 +8. 积分管理端在 Portal;组织打标在 Platform——两端分工写清。 + +### 11.4 端侧分工(决议汇总) + +| 能力 | 端 | 操作者 | +|---|---|---| +| 我的积分 / 首页 TOP10 榜 / 规则弹窗 | Portal 用户端 | 登录用户 | +| 积分规则 / 用户调分 / 概览 / 说明文案 / 审计 | Portal 运营后台 | 平台管理员 | +| 前台 R* 违规扣减 | Portal/Client 内容页 | 平台管理员(及产品若再放开的角色) | +| 组织四级打标(指定公司根 + 级联) | **Platform** | **仅超级/平台管理员** | +| 协同办公同步 | 后端 Job | 不阻塞 MVP | + +--- + +**下一步:** spec/design 已确认;任务已拆 → [`features/v2.6.0/070-points-system/tasks.md`](../../../features/v2.6.0/070-points-system/tasks.md)(T001–T028)。可从 T001/T002 开工实现。 diff --git a/features/v2.6.0/070-points-system/design.md b/features/v2.6.0/070-points-system/design.md new file mode 100644 index 000000000..7d2e5de0e --- /dev/null +++ b/features/v2.6.0/070-points-system/design.md @@ -0,0 +1,1035 @@ +# F070 积分系统 · 后端设计方案 + +| 项 | 内容 | +|---|---| +| 关联 spec | [spec.md](./spec.md) | +| 产品决议 | [积分系统需求分析-V1.1.md §11](../../../docs/PRD/积分系统/积分系统需求分析-V1.1.md) | +| 模块 | `bisheng/points`,错误码 **182** | +| Alembic | revision `f078_points_system`(**勿用 f070/f077**:已被占用) | +| 设计原则 | Append-only 流水 + 余额缓存 + 幂等键(对齐 Open edX Ledger / Sylius Loyalty / 通用 loyalty LLD);**本期不做**规则版本状态机、FIFO 过期、兑换核销、延迟入账反作弊(§7 二期预留) | + +--- + +## 0.1 产品拍板与 PRD 偏差(2026-08-06) + +相对 PRD 原文的收权/纠错,以本表为准;实现不得回退到未拍板语义。 + +| # | 议题 | 拍板 | 说明 | +|---|------|------|------| +| P1 | 管理写权限 + 前台 R* | **仅平台超管** | 对应 spec **AD-10 = B**。不给公共库管理员 / space admin 规则配置、调分、审计写权限;文档列表/阅读页/问答弹窗的 R* 按钮亦仅超管可见。相对 PRD §2.1 为有意收权。 | +| P2 | 月奖登录窗口 | **次月 1 日发上月奖;校验上月登录 ≥1** | PRD 场景四「当月」为笔误;Beat 不得查发放当月登录。 | +| P3 | G3 配置 | **阈值 / 档位分 / `lifetime_cap` 表单可配**;终身上限走 `score_expr.lifetime_cap`,**禁止**把终身 15 填进 `daily_cap` | 运营表单对 `mode=tier` 须暴露 tiers + lifetime_cap;`daily_cap` 仅表示日累计分上限(G3 种子默认 `daily_cap=NULL`)。 | +| P4 | 日 cap | **超限整笔不发**(不截断部分发放);skip 不写流水 | 与「部分入账」伪代码决裂;幂等键仅在实际入账时占用(skip 后同业务事件次日可能再试——接受)。 | +| P5 | 违规扣减概览 | **`manual_deduct` + 负向 `manual_adjust`** 的 \|delta\| 之和 | 不含正向调分、不含自动 earn 冲正以外的渠道。 | +| P6 | 规则「删除」 | **无 DELETE API**;固定目录 + `status=disabled` | PRD「可删除」本期语义 = 停用。有流水规则禁止物理删。 | +| P7 | 豁免判断主体 | **B:按受益人** | 解析出的**入账用户**若为目标库(及 G7 时目标/源库)的 `creator`/`admin`,则 skip,无论操作人是谁。例:管理员上传、他人发布、受益人为 uploader(管理员)→ 不发分。平台超管入账仍一律 skip。 | + +--- + +## 0. 设计取舍(防过度设计) + +| 采用(够用) | 不采用(过度) | +|---|---| +| 流水 append-only + 账户余额同行事务更新 | 纯 SUM 流水无缓存(千人明细可接受,榜单/摘要会慢) | +| `idempotency_key` 唯一约束防双计 | Kafka/事件总线 | +| 规则表 + `score_expr` JSON(固定/阶梯) | 通用规则引擎 / DRAFT→PUBLISHED 版本链 | +| 小时排行快照表 | 实时全表排序 / Redis 为唯一真相 | +| 同步 Outbox 骨架 | 首期对接协同办公完整兑换 | +| 应用层禁止 UPDATE/DELETE 流水 | DB TRIGGER 禁改(DM8/运维成本高,首期不做) | +| 本期立即入账 + 日 cap/幂等/豁免 | 延迟入账反作弊(§7,二期;默认 T=24h) | + +--- + +## 1. DB 设计 + +### 1.1 ER 关系(逻辑) + +```mermaid +erDiagram + user_point_account ||--o{ user_point_log : "1:N" + point_rule ||--o{ user_point_log : "rule_code" + user_point_log ||--o| point_sync_outbox : "optional" + point_favorite_tier_award ||--|| knowledge_file : "file_id逻辑关联" + department ||--o| department : "org_level标签" + point_rank_snapshot }o--|| user_point_account : "user_id" + point_copy ||--|| tenant : "tenant文案" +``` + +### 1.2 新增表 + +#### 1.2.1 `user_point_account` — 用户积分账户(余额缓存) + +| 字段 | 类型 | 约束 | 说明 | +|---|---|---|---| +| id | BIGINT | PK AI | | +| tenant_id | INT | NOT NULL, DEFAULT 1, IDX | 多租户 | +| user_id | INT | NOT NULL | 用户 | +| balance | INT | NOT NULL, DEFAULT 0 | 当前余额,**允许负** | +| lifetime_earned | INT | NOT NULL, DEFAULT 0 | 历史累计获得(只增不因扣减减少) | +| lifetime_deducted | INT | NOT NULL, DEFAULT 0 | 历史累计扣减绝对值之和 | +| version | INT | NOT NULL, DEFAULT 0 | 乐观锁备用(主路径用行锁) | +| create_time / update_time | DATETIME | NOT NULL | `UPDATE_TIME_SERVER_DEFAULT` | + +**唯一/索引** + +| 名 | 列 | 类型 | +|---|---|---| +| uk_upa_tenant_user | (tenant_id, user_id) | UNIQUE | +| ix_upa_tenant_balance | (tenant_id, balance) | 总榜辅助 | + +**作用**:读路径 O(1) 取余额;写路径与流水同事务更新。真相源仍是流水,可用对账任务重算。 + +--- + +#### 1.2.2 `user_point_log` — 积分流水(append-only) + +| 字段 | 类型 | 约束 | 说明 | +|---|---|---|---| +| id | BIGINT | PK AI | | +| tenant_id | INT | NOT NULL, IDX | | +| user_id | INT | NOT NULL | 获益/被扣用户 | +| delta | INT | NOT NULL | 正=获得,负=扣减,**禁止 0** | +| balance_after | INT | NOT NULL | 变动后余额 | +| direction | VARCHAR(16) | NOT NULL | `earn` / `deduct`(冗余便于筛) | +| rule_code | VARCHAR(32) | NULL | G1/R1/M1/`MANUAL` 等 | +| title | VARCHAR(64) | NOT NULL | 展示用积分项名称快照 | +| source | VARCHAR(32) | NOT NULL | `auto` / `manual_adjust` / `manual_deduct` / `monthly_reward` | +| biz_type | VARCHAR(32) | NULL | `space_file` / `favorite` / `qa_adopt` / `admin` … | +| biz_id | VARCHAR(64) | NULL | 业务主键字符串 | +| idempotency_key | VARCHAR(128) | NOT NULL | 幂等 | +| operator_id | INT | NULL | 手动操作人;自动为 NULL | +| remark | VARCHAR(200) | NULL | 调分原因等 | +| score_snapshot | INT | NULL | 当时规则分值快照(阶梯可为本次实际发放分) | +| beneficiary_role | VARCHAR(32) | NULL | 本笔受益主体枚举快照(uploader/publisher/sharer/…) | +| occurred_at | DATETIME | NOT NULL | 业务发生时间(一般=写入时) | +| create_time | DATETIME | NOT NULL | | + +**唯一/索引** + +| 名 | 列 | 类型 | +|---|---|---| +| uk_upl_tenant_idem | (tenant_id, idempotency_key) | UNIQUE | +| ix_upl_user_time | (tenant_id, user_id, occurred_at, id) | 我的明细 | +| ix_upl_tenant_time | (tenant_id, occurred_at) | 同步/统计 | +| ix_upl_tenant_dir_time | (tenant_id, direction, occurred_at) | 本月获得/扣减聚合 | +| ix_upl_tenant_source | (tenant_id, source, occurred_at) | 违规扣减统计、审计 | + +**作用**:不可变审计账本。应用层**禁止** UPDATE/DELETE;纠错只追加新流水。 + +--- + +#### 1.2.3 `point_rule` — 积分规则 + +| 字段 | 类型 | 约束 | 说明 | +|---|---|---|---| +| id | INT | PK AI | | +| tenant_id | INT | NOT NULL | | +| rule_code | VARCHAR(16) | NOT NULL | G1…G7 / R1… / M1… | +| rule_type | VARCHAR(32) | NOT NULL | `earn` / `deduct` / `admin_reward` | +| name | VARCHAR(40) | NOT NULL | 2–20 中文展示名(存原文字符串) | +| score_expr | JsonType | NOT NULL | 见下 | +| daily_cap | INT | NULL | **按分**;NULL=不限制 | +| beneficiary | VARCHAR(32) | NULL | **积分受益主体**(见 §1.2.3.1);earn 类必填;deduct/admin_reward 可空 | +| status | VARCHAR(16) | NOT NULL | `enabled` / `disabled` | +| remark | VARCHAR(200) | NULL | | +| sort_order | INT | NOT NULL DEFAULT 0 | | +| create_time / update_time | DATETIME | | | + +`score_expr` 示例: + +```json +{"mode": "fixed", "score": 3} +{"mode": "tier", "tiers": [{"threshold": 75, "score": 5}, {"threshold": 150, "score": 10}, {"threshold": 300, "score": 15}], "lifetime_cap": 15} +``` + +> **G3 表单(P3)**:`mode=tier` 时后台必须可编辑 `tiers[].threshold` / `tiers[].score` / `lifetime_cap`;`daily_cap` 与终身上限分离,不得用「每日上限」字段承载终身 15。 + +**唯一/索引** + +| 名 | 列 | | +|---|---|---| +| uk_pr_tenant_code | (tenant_id, rule_code) | UNIQUE(同编码一条,启停靠 status) | + +> 产品「已占用编码不可再新增」:同 `rule_code` 已存在则拒绝 create。**无 DELETE 接口(P6)**:规则只能 `enabled`/`disabled`;已产生流水的规则禁止物理删除。 + +#### 1.2.3.1 积分受益主体 `beneficiary`(可配置) + +后台规则表单「积分获益人」单选,写入本字段。发分时 **只给解析出的一个 user_id** 入账(不做多人拆分)。 + +| 枚举值 | 含义 | 解析方式(实现锚点) | +|---|---|---| +| `uploader` | 文档上传人 | 源/目标文件的 `user_id`(创建者);无则跳过并打日志 | +| `publisher` | 发布人 | 本次跨库发布审批的发起人 / 发布操作者 | +| `sharer` | 分享人 | 本次库间 SHARE 审批的发起人 / 分享操作者 | +| `owner` | 文档所有者 | 与 uploader 同义时可映射为 uploader;若业务有独立 owner 字段则取其 | +| `answerer` | 被采纳回答者 | G4:`adopted_answer` 的回答专家 user_id | +| `subject` | 事件主体本人 | 月奖 M*:被发放的管理员本人(固定,表单可只读) | + +**按规则编码允许的选项(校验写死,防配错):** + +| rule_code | 可选 beneficiary | 种子默认 | +|---|---|---| +| G1 / G2 / G5 / G6 | `uploader` \| `publisher` | `uploader` | +| G7 | `uploader` \| `sharer` | `sharer` | +| G3 | 仅 `uploader`(表单锁定) | `uploader` | +| G4 | 仅 `answerer`(表单锁定) | `answerer` | +| R* / MANUAL | 无(扣减/调分目标由操作指定) | NULL | +| M* | 仅 `subject`(锁定) | `subject` | + +**API / 管理端** + +- 创建/编辑规则:`beneficiary` 必填(earn 且非锁定规则时);提交值必须 ∈ 该 `rule_code` 允许集合,否则 `18203`。 +- 前台规则弹窗:可展示「积分归属:上传人/发布人/分享人」文案(由枚举映射中文)。 +- 流水:`user_id` = 实际受益人;可选在 `remark` 或扩展字段不强制存 beneficiary 快照(需要审计时可加 `beneficiary_snapshot VARCHAR(32)`——**建议加上**便于对账)。 + +**建议列补充(与上表同步进迁移):** + +| 字段 | 类型 | 说明 | +|---|---|---| +| (已有)beneficiary | VARCHAR(32) | 规则配置的受益角色 | +| beneficiary_snapshot | — | **放在 `user_point_log`**:VARCHAR(32) NULL,记本笔使用的角色枚举 | + +`user_point_log` 增加: + +| 字段 | 类型 | 说明 | +|---|---|---| +| beneficiary_role | VARCHAR(32) NULL | 本笔发分时的受益主体枚举快照(uploader/publisher/sharer/…) | + +--- + +#### 1.2.4 `point_copy` — 说明文案 + +| 字段 | 类型 | 说明 | +|---|---|---| +| id | INT PK | | +| tenant_id | INT | | +| copy_key | VARCHAR(64) | 如 `earn_intro` / `deduct_intro` / `admin_reward_intro` / `usage` / `appeal` | +| content | LargeText | 文案;申诉条写「线下联系管理员」 | +| sort_order | INT | | +| create_time / update_time | | | + +UK: `(tenant_id, copy_key)` + +--- + +#### 1.2.5 消息文案 — **不建表,代码常量** + +产品决议更正:站内信文案**写死在代码**(如 `points/domain/constants/notify_templates.py`),不建 `point_message_template`,无管理端配置 API。 + +建议常量键与正文占位符: + +| template_code | 场景 | 占位符 | +|---|---|---| +| `earn_publish` | 上传/发布入库得分 | `{delta}` `{rule_name}` `{points_url}` | +| `earn_share` | 库间 SHARE 得分 | 同上 | +| `earn_favorite` | 收藏阶梯得分 | 同上 | +| `earn_adopt` | 问答采纳得分 | 同上 | +| `deduct_admin` | 前台 R* 扣减 | `{delta}` `{rule_name}` `{reason}` `{points_url}` | +| `adjust_admin` | 后台调分 | `{delta}` `{reason}` `{points_url}` | + +标题统一默认:「积分变动提醒」。改文案需发版。 + +--- + +#### 1.2.6 `point_rank_snapshot` — 排行快照 + +| 字段 | 类型 | 说明 | +|---|---|---| +| id | BIGINT PK | | +| tenant_id | INT | | +| period | VARCHAR(16) | `month` / `year` / `all` | +| scope | VARCHAR(16) | `global` / `dept` | +| scope_id | INT NULL | dept 时为部门节点 id(org_level=dept 的桶) | +| period_key | VARCHAR(16) | `2026-08` / `2026` / `all` | +| user_id | INT | | +| rank_no | INT | | +| period_score | INT | 周期净/累计分(月/年=周期 delta 和;总榜=balance) | +| balance | INT | 当前总积分(展示列) | +| dept_id | INT NULL | 展示用主部门或桶 id | +| refreshed_at | DATETIME | | +| create_time | DATETIME | | + +**索引** + +| 名 | 列 | +|---|---| +| uk_prs_dims_user | (tenant_id, period, scope, scope_id, period_key, user_id) UNIQUE | +| ix_prs_board | (tenant_id, period, scope, scope_id, period_key, rank_no) | + +**刷新策略**:每小时按租户重建当月/当年/总榜所需行(或先删后插同一 `period_key`)。仅保留当前 `period_key` 即可,不必长期历史快照。 + +--- + +#### 1.2.7 `point_favorite_tier_award` — G3 已授档位 + +| 字段 | 类型 | 说明 | +|---|---|---| +| id | BIGINT PK | | +| tenant_id | INT | | +| file_id | INT | 被收藏源文件 | +| highest_tier | INT | 已达到的最高阈值 75/150/300 | +| points_granted_total | INT | 已对该文件发放总分(≤15) | +| create_time / update_time | | | + +UK: `(tenant_id, file_id)` + +--- + +#### 1.2.8 `point_sync_outbox` — 外部同步 + +| 字段 | 类型 | 说明 | +|---|---|---| +| id | BIGINT PK | | +| tenant_id | INT | | +| log_id | BIGINT | 关联流水 | +| payload | JsonType | 增量报文草稿 | +| status | VARCHAR(16) | `pending` / `sent` / `failed` / `skipped` | +| retry_count | INT | | +| next_retry_at | DATETIME NULL | | +| last_error | LargeText NULL | | +| sent_at | DATETIME NULL | | +| create_time / update_time | | | + +索引: `(tenant_id, status, next_retry_at)`;UK 可选 `(tenant_id, log_id)`。 + +--- + +#### 1.2.9 `point_admin_audit` — 可选(可与 log 合并) + +若希望管理端「操作记录」不扫全量流水:可冗余一张仅 `source in (manual_adjust, manual_deduct)` 的审计表;**首期推荐直接查 `user_point_log`**,减少表数量。本设计**不强制**新建 audit 表。 + +--- + +### 1.3 修改表 + +#### `department` 增加组织层级标签 + +| 字段 | 类型 | 说明 | +|---|---|---| +| org_level | VARCHAR(16) NULL | `company` / `dept` / `office` / `squad`;NULL=未打标 | + +**索引** + +| 名 | 列 | 说明 | +|---|---|---| +| ix_department_tenant_org_level | (tenant_id, org_level) | 查唯一 company、按标签筛 | +| 部分唯一(应用保证) | 每 tenant 至多一行 `org_level='company'` | MySQL 函数索引/应用校验;DM8 用查询校验 | + +**不修改**:`parent_id`、`path`、`user_department`。 + +--- + +### 1.4 种子数据(迁移或启动 seed) + +按租户(至少 default tenant)插入: + +- 规则:G1–G4、R1–R3、M1/M4/M6(enabled);G5/G6/G7、其余 M* **不预插入**,后台「新增」时可选编码列表由代码常量提供。 +- G3 种子:`score_expr.lifetime_cap=15`,`daily_cap=NULL`(P3)。 +- 文案 5 条(申诉改为线下)。 +- 站内信文案:代码常量,无需种子。 + +--- + +### 1.5 数据迁移方案(Alembic) + +**单文件**:`v2_6_0_f078_points_system.py` + +``` +upgrade: + 1. department ADD COLUMN org_level VARCHAR(16) NULL + index(幂等:列已存在则跳过) + 2. CREATE TABLE user_point_account / user_point_log / point_rule / point_copy / + point_rank_snapshot / point_favorite_tier_award / point_sync_outbox(table_exists 守卫) + 3. 种子:仅当 point_rule 在该 tenant 为空时插入预置规则/说明文案 +downgrade: + DROP 新表(逆序);DROP COLUMN org_level +``` + +**兼容** + +- 使用 `dialect_helpers.JsonType` / `LargeText` / `UPDATE_TIME_SERVER_DEFAULT` / `table_exists` / `index_exists`。 +- 无历史积分补数(产品决议)。 +- 在线 DDL:新表无锁业务;`department.org_level` 可空列增加,MySQL 8 / DM8 评估为短暂 metadata 锁,低峰执行。 + +**数据回填** + +- 积分:无需回填。 +- 组织:上线后由超管执行「设为公司根」一次级联;迁移**不**自动猜公司根。 + +--- + +## 2. 接口定义(前后端契约) + +> 统一包装:`{ status_code, status_message, data }`。 +> 分页:`PageData` → `data: { data: T[], total: number }`。 +> 认证:Cookie/JWT 登录用户。 +> **权限(P1 / AD-10)**:规则 CRUD/启停、文案、调分、扣减、审计、概览写读中的管理写接口 → **仅平台超管**;前台 R* 扣减入口同此。 +> 时间:ISO8601,业务日界 **Asia/Shanghai**。 +> **规则无 DELETE(P6)**:仅 `PUT` 改 `status` / 可变配置。 + +### 2.1 公共类型 + +```ts +/** 流水方向 */ +type PointDirection = 'earn' | 'deduct'; + +type PointRuleType = 'earn' | 'deduct' | 'admin_reward'; +type PointRuleStatus = 'enabled' | 'disabled'; + +type ScoreExpr = + | { mode: 'fixed'; score: number } + | { mode: 'tier'; tiers: { threshold: number; score: number }[]; lifetime_cap: number }; + +interface PointLogItem { + id: number; + title: string; + delta: number; // 带符号 + balance_after: number; + direction: PointDirection; + rule_code: string | null; + source: string; + remark: string | null; + occurred_at: string; +} + +/** 积分受益主体(point_rule.beneficiary) */ +type PointBeneficiary = + | 'uploader' // 文档上传人 + | 'publisher' // 发布人 + | 'sharer' // 库间分享人 + | 'owner' // 文档所有者(可与 uploader 同义) + | 'answerer' // 被采纳回答者 + | 'subject'; // 事件主体(月奖本人) + +interface PointRuleDTO { + id: number; + rule_code: string; + rule_type: PointRuleType; + name: string; + score_expr: ScoreExpr; + daily_cap: number | null; + /** 积分受益主体;后台单选,按 rule_code 限制可选集 */ + beneficiary: PointBeneficiary | null; + /** 前端展示:该规则允许选择的受益主体列表 */ + beneficiary_options?: PointBeneficiary[]; + status: PointRuleStatus; + remark: string | null; +} +``` + +### 2.2 用户端 + +#### `GET /api/v1/points/me/summary` + +**Response `data`** + +```ts +{ + balance: number; + month_earned: number; // 本月 delta>0 之和 + month_deducted: number; // 本月 |delta<0| 之和(正数展示,前端加 '-') + dept_rank: number | null; // null → 前端显示 — + global_rank: number | null; + global_rank_display: string; // "38" | "999+" + rank_refreshed_at: string | null; +} +``` + +#### `GET /api/v1/points/me/logs` + +**Query** + +```ts +{ + direction?: 'earn' | 'deduct' | 'all'; // default all + from?: string; // ISO date + to?: string; + page?: number; // default 1 + page_size?: number; // default 20, max 100 +} +``` + +**Response `data`**: `PageData` + +#### `GET /api/v1/points/rules/public` + +**Response `data`** + +```ts +{ + earn_rules: PointRuleDTO[]; // 不含 admin_reward + deduct_rules: PointRuleDTO[]; + copies: { copy_key: string; content: string; sort_order: number }[]; +} +``` + +#### `GET /api/v1/points/leaderboard` + +**Query**: `{ period: 'month' | 'year' | 'all' }` + +**Response `data`** + +```ts +{ + period: 'month' | 'year' | 'all'; + refreshed_at: string; + items: { + rank: number; + user_id: number; + user_name: string; + dept_name: string; + balance: number; + period_score: number; // 月/年积分;总榜可与 balance 相同 + }[]; // length ≤ 10 +} +``` + +### 2.3 管理端 + +#### `GET /api/v1/points/admin/overview` + +```ts +{ + total_issued: number; // 历史 earn 之和 + total_balance: number; // sum(account.balance) + // P5:违规扣减 = |manual_deduct 全部 delta| + |manual_adjust 且 delta<0| + total_violation_deducted: number; +} +``` + +#### `GET /api/v1/points/admin/rules` Query: `rule_type` + +#### `POST /api/v1/points/admin/rules` + +**Request** + +```ts +{ + rule_code: string; + rule_type: PointRuleType; + name: string; + score_expr: ScoreExpr; + daily_cap?: number | null; + beneficiary?: string | null; + status?: PointRuleStatus; + remark?: string; +} +``` + +#### `PUT /api/v1/points/admin/rules/{id}` + +同 POST 可改字段;`rule_code` / `rule_type` **不可改**。 + +#### `GET|PUT /api/v1/points/admin/copies` + +PUT body: `{ items: { copy_key: string; content: string }[] }` + +#### `GET /api/v1/points/admin/users` + +**Query**: `keyword?`, `department_id?`, `role_code?`(普通用户 / 对应 M* 角色), `page`, `page_size` + +**Response item** + +```ts +{ + user_id: number; + user_name: string; + dept_name: string; + user_type_label: string; + balance: number; + month_net: number; // 本月净增减 +} +``` + +#### `POST /api/v1/points/admin/users/{user_id}/adjust` + +**Request**: `{ delta: number; reason: string }` +**Response**: `{ user_id: number; balance: number; log_id: number }` + +#### `GET /api/v1/points/admin/users/{user_id}/logs` — 同我的明细 Query + +#### `GET /api/v1/points/admin/audit-logs` + +筛 `source in (manual_adjust, manual_deduct)` 的流水视图: + +```ts +{ + operator_name: string; + op_type: '积分调整' | '积分扣减'; + target_user_name: string; + delta: number; + remark: string | null; + occurred_at: string; +} +``` + +#### `POST /api/v1/points/admin/deduct` + +**Request** + +```ts +{ + user_id: number; + rule_code: string; // 必须是启用中的 deduct 规则 + biz_type?: string; + biz_id?: string; + remark?: string; +} +``` + +分值取规则 `score_expr`(负),不可自由改分(自由改分走私调分接口)。 + +### 2.4 组织打标(Platform) + +#### `GET /api/v1/departments/org-levels` + +树节点附加 `org_level: string | null`。 + +#### `POST /api/v1/departments/{id}/set-company-root` + +**Request**: `{}` 或 `{ confirm: true }` +**Response** + +```ts +{ + company_id: number; + labeled_count: number; + levels: { company: 1; dept: number; office: number; squad: number }; +} +``` + +**错误**:已存在其他 company → `18205`。 + +### 2.5 错误码与前端分支 + +| code | 前端建议 | +|---|---| +| 18201 | Toast 无权限 | +| 18202 | 表单校验提示 | +| 18203 | 规则编码冲突 | +| 18204 | 请选择有效扣减规则 | +| 18205 | 公司根冲突,先清理或换节点 | + +--- + +## 3. 系统流程 + +### 3.1 模块调用总览 + +```mermaid +flowchart LR + subgraph triggers [业务触发] + UP[上传成功] + PUB[发布审批通过] + SH[库间分享SHARE通过] + FAV[收藏] + ADOPT[问答采纳] + ADM[管理调分/扣减] + BEAT[Beat 月奖/排行] + end + + subgraph points [bisheng/points] + FACADE[PointsAwardFacade] + LEDGER[PointsLedgerService] + RULE[PointsRuleService] + RANK[PointsRankService] + NOTIFY[PointsNotifyService] + OUTBOX[PointsSyncOutboxService] + end + + UP --> FACADE + PUB --> FACADE + SH --> FACADE + FAV --> FACADE + ADOPT --> FACADE + ADM --> LEDGER + BEAT --> LEDGER + BEAT --> RANK + FACADE --> RULE + FACADE --> LEDGER + LEDGER --> NOTIFY + LEDGER --> OUTBOX + NOTIFY --> MSG[MessageService] +``` + +### 3.2 自动发放时序(入库 G*) + +```mermaid +sequenceDiagram + participant Biz as Knowledge/Approval + participant Facade as PointsAwardFacade + participant Rule as PointsRuleService + participant Ledger as PointsLedgerService + participant DB as MySQL/DM8 + participant Msg as MessageService + + Biz->>Facade: on_space_file_ready(user, file, space) + Facade->>Facade: 过滤 personal/favorite + Facade->>Facade: 解析 beneficiary → payee + Facade->>Facade: 若 payee 为目标库 creator/admin → skip(P7=B) + Facade->>Facade: 若 payee 为平台超管 → skip + Facade->>Rule: 按 space.level 取启用规则 G* + alt 无规则或 disabled + Facade-->>Biz: ok(skipped) + else 有规则 + Facade->>Ledger: award(payee, idempotency_key, delta, ...) + Ledger->>DB: BEGIN; SELECT account FOR UPDATE + Ledger->>DB: 查当日同 rule 已得分; 剩余不足本次 delta → 整笔 skip(P4) + alt 幂等键已存在 + Ledger-->>Facade: existing + else + Ledger->>DB: INSERT log; UPDATE account + Ledger->>DB: INSERT sync_outbox pending + Ledger->>DB: COMMIT + Ledger->>Msg: send_message(points_changed) + end + Facade-->>Biz: ok + end +``` + +> 业务主路径:Facade **不抛**导致上传失败的异常;内部记日志即可(AC-11)。 +> **P7=B**:豁免只看**受益人(payee)**是否管辖相关库,不看操作人。 + +### 3.2.1 G7 库间分享时序(本期) + +```mermaid +sequenceDiagram + participant Appr as ApprovalHandler + participant Dist as DistributionService + participant Facade as PointsAwardFacade + participant Ledger as PointsLedgerService + + Appr->>Dist: share_approved(...) + Dist-->>Appr: share_entry ACTIVE + Appr->>Facade: on_document_shared(share_entry, source, target, actor) + Facade->>Facade: 读启用中的 G7;解析获益人 + Facade->>Facade: 豁免/超管/日cap 校验 + Facade->>Ledger: award(idem=earn:G7:{share_entry_id}) + Note over Appr,Ledger: Facade 异常只打日志,不影响分享结果 +``` + +### 3.3 G3 收藏阶梯(补差价) + +```mermaid +sequenceDiagram + participant Fav as Favorite API + participant Facade as PointsAwardFacade + participant DB as DB + + Fav->>Facade: on_favorite_changed(source_file_id) + Facade->>DB: 统计去重收藏人数 N + Facade->>DB: 读 point_favorite_tier_award + Facade->>Facade: 计算应得最高档 S_target 与已授 S_done + alt S_target <= S_done 或 达 lifetime_cap + Facade-->>Fav: skip + else + Note over Facade: delta = min(S_target - S_done, cap_left) + Facade->>Facade: award(idem=G3:file:tier) + Facade->>DB: upsert tier_award + end +``` + +### 3.4 月奖状态机(单用户单月) + +```mermaid +stateDiagram-v2 + [*] --> ScanEligible: Beat 每月1日 00:05 Asia/Shanghai + ScanEligible --> CheckLogin: 聚合上月角色 → 最高 M* + CheckLogin --> SkipNoLogin: 上月无登录 + CheckLogin --> Award: 上月登录≥1 + Award --> Done: 幂等写入成功 + Award --> Done: 幂等已存在 + SkipNoLogin --> Done + Done --> [*] +``` + +> **P2**:例如 8 月 1 日 00:05 发放的是 **7 月**月奖,登录条件查 **7 月**(≥1 次),不得查 8 月。幂等键含结算月 `yyyy-mm`(上月)。 + +### 3.5 排行刷新 + +```mermaid +sequenceDiagram + participant Beat as Celery Beat + participant Rank as PointsRankService + participant DB as DB + + Beat->>Rank: refresh_rank_snapshots(tenant) + Rank->>DB: 聚合 all / month / year 分数 + Rank->>DB: 用户主部门 → 向上找 org_level=dept 桶 + Rank->>DB: 过滤平台超管 + Rank->>DB: 替换写入 snapshot(按 period_key) +``` + +### 3.6 组织打标 + +```mermaid +sequenceDiagram + participant Admin as Platform 超管 + participant API as DepartmentOrgLevelService + participant DB as department + + Admin->>API: set_company_root(dept_id) + API->>DB: 查是否已有其他 company + alt 冲突 + API-->>Admin: 18205 + else + API->>DB: 清空本租户全部 org_level(或仅原子树重算) + API->>DB: 根=company; BFS/DFS 按相对深度写 dept/office/squad + API-->>Admin: labeled_count + end +``` + +**深度映射**:相对深度 0→company,1→dept,2→office,≥3→squad。 + +### 3.7 账本写路径状态(单笔) + +```mermaid +stateDiagram-v2 + [*] --> Validate + Validate --> Reject: delta=0/超管读拦截/规则无效 + Validate --> LockAccount + LockAccount --> CheckIdempotent + CheckIdempotent --> ReturnExisting: key 已存在 + CheckIdempotent --> CheckDailyCap: auto earn + CheckDailyCap --> SkipCap: 剩余额度0 + CheckDailyCap --> Write + CheckIdempotent --> Write: manual + Write --> NotifyAndOutbox + NotifyAndOutbox --> [*] + ReturnExisting --> [*] + SkipCap --> [*] + Reject --> [*] +``` + +--- + +## 4. 生产系统升级方案 + +### 4.1 发布策略 + +| 阶段 | 动作 | 说明 | +|---|---|---| +| T0 准备 | 合并代码;准备 Alembic;Portal/Platform 前端包 | 功能开关见下 | +| T1 DB | 低峰执行 `alembic upgrade` → f077 | 可先于流量;新表空、org_level NULL | +| T2 后端 | 滚动发布 API + Worker/Beat | **先不启挂钩**或 `points.enabled=false` | +| T3 打标 | 超管 Platform 指定公司根 | 部门榜可用前置条件 | +| T4 前端 | 发布 Portal/Platform | 我的积分/榜/管理台 | +| T5 打开挂钩 | `points.enabled=true` + 重启/热更配置 | 开始自动记分;**不补历史** | +| T6 观察 | 对账任务、发放日志、P95 | 异常可关挂钩保留只读 | + +### 4.2 功能开关(建议) + +配置项(DB config 或 env,与现网配置体系对齐): + +```yaml +points: + enabled: false # 总开关:false 时 Facade 直接 skip + notify_enabled: true + sync_outbox_enabled: false # 外部未就绪保持 false + rank_cron_enabled: true + monthly_reward_enabled: true + # 二期(本期禁止打开,见 §7): + # deferred_settle_enabled: false + # deferred_settle_hours: 24 +``` + +### 4.3 回滚 + +| 层级 | 做法 | +|---|---| +| 挂钩回滚 | `points.enabled=false`,业务恢复无记分 | +| 前端回滚 | 回退 Portal 包;隐藏菜单 | +| DB 回滚 | **不建议**生产 `downgrade` 删流水;若必须回滚应用,保留表数据 | +| 误打标 | 超管重新 `set_company_root` 全量重算;或运维把 org_level 置 NULL 后重打 | + +### 4.4 兼容与风险 + +| 风险 | 缓解 | +|---|---| +| 上传路径增加 Facade 调用失败 | try/except + 日志,不影响上传成功 | +| Beat × 租户耗时 | 单租户任务超时告警;排行可分片 | +| department 加列锁 | 低峰迁移;列可空无回填 | +| 与现有 f070 migration 编号冲突 | 使用 **f077+** revision id | +| 双库 DM8 | CI 跑迁移;禁 MySQL JSON 函数 | + +### 4.5 上线检查清单 + +- [ ] Alembic upgrade 成功(MySQL + DM8 CI) +- [ ] 种子规则/文案/模板存在 +- [ ] 唯一公司根已打标;抽查四级 +- [ ] `points.enabled` 灰度打开后:试上传计分、幂等重试不双计、日 cap、管理员豁免 +- [ ] 首页 TOP10 / 我的积分 / 调分 / R* 扣减 +- [ ] 对账任务首次成功 +- [ ] 同步 outbox 为 skipped/pending 且不报错 + +### 4.6 跨仓发布顺序 + +1. BiSheng API + Worker(含迁移) +2. Platform(打标,可先于 Portal) +3. Portal(用户端+运营台) +4. 打开 `points.enabled` + +BFF:若 Portal 经 `:8010` 代理,需增加 `/api/v1/points/**` 转发(与现网知识 API 相同模式)。 + +--- + +## 5. 核心伪代码(实现锚点) + +```python +async def award(...): + async with session.begin(): + account = await repo.lock_account(tenant_id, user_id) # FOR UPDATE + if await repo.exists_idem(tenant_id, key): + return existing + granted = await repo.sum_earn_today(tenant_id, user_id, rule_code, day_start_shanghai) + # P4:剩余额度不足本次 delta → 整笔不发,不截断;不写流水/不占幂等键 + if daily_cap is not None and daily_cap - granted < delta: + return skipped_cap + new_balance = account.balance + delta + await repo.insert_log(...) # occurred_at 显式写入业务时区 + account.balance = new_balance + if delta > 0: + account.lifetime_earned += delta + else: + account.lifetime_deducted += -delta + await repo.insert_outbox_if_needed(...) + await notify_safe(...) +``` + +--- + +## 6. 与 spec 的关系 + +| 文档 | 职责 | +|---|---| +| [spec.md](./spec.md) | 用户故事、AC、架构决策边界、文件清单 | +| **本 design.md** | DB/索引/迁移、Request/Response、时序与状态机、生产升级、反作弊二期预留(§7) | + +AC 变更时同步改两处相关章节;实现以本设计字段名为准。 + +--- + +## 7. 反作弊与公平性(二期预留 · **本期不做**) + +> **产品拍板(2026-08-06)**:采用方案 **B — 延迟 T 小时入账**;写入本设计作为演进方向。 +> **本期范围**:仍按「业务成功后立即入账 + 已入账不因删文冲正」实现(Q11 本期语义)。 +> **二期开启前**:不得实现 pending 表写路径,不得打开 `deferred_settle_enabled`。 + +### 7.1 目标 + +| 目标 | 手段 | +|---|---| +| 公平性 | 降低「灌水上传 → 拿分 → 立刻删除」;限制单日暴利(已有日 cap) | +| 稳定性 | 延迟结算仍走同一账本事务模型,不引入可改历史 | +| 数据安全 | pending 只追加状态迁移;入账后仍 append-only | + +### 7.2 本期已具备的基线(F070 上线即有) + +实现期必须落地,**不属于二期**: + +1. 自动发放 `idempotency_key` + UK,防技术双计 +2. 规则日 cap(按分);预置规则禁止空 cap +3. 个人库/收藏库不计;space `creator`/`admin` 豁免 G* +4. 入库同一 `file+space` 只计一次;G3 档位终身补差、取消收藏不重发 +5. 平台超管不参与自动分与激励榜 +6. AwardFacade **不暴露**公网领分 API;业务失败不得因记分失败而失败(内部吞 + 日志) +7. 对账任务 + `points.award.rejected` 可观测 +8. 门户外链 `share-links` **不计分**;G7 仅挂库间 `SHARE`(见下) + +### 7.2.1 G7 知识库间分享(本期接线 · 2026-08-06 拍板 B) + +| 项 | 说明 | +|---|---| +| **含义** | 文档**分享到其他知识空间**(如部门库→部门库),`entry_type=SHARE`;审批 `knowledge_space_file_share_request` → `share_approved` | +| **不是** | 门户外链 / `create_shougang_portal_share_link` | +| **与发布** | `PUBLISH` ≠ `SHARE`;G1/G2/G5/G6 走入库/发布;G7 仅分享成功;幂等键 `earn:G7:{share_entry_id}` | +| **获益人** | 规则可配:文档上传人 / 分享操作人 | +| **豁免 / cap** | **P7=B**:解析后的受益人(uploader/sharer 等)若为**目标库或源库**的 `creator`/`admin` → skip;不看分享操作人本人是否管理员(除非其恰为受益人)。日 cap 按分且整笔 skip(P4) | +| **挂钩** | `share_approved` 成功且 entry ACTIVE 后 → `PointsAwardFacade.on_document_shared(...)`;Facade 失败不影响分享成功 | +| **消息** | 可用模板如 `earn_share`(可配) | + +### 7.3 二期方案 B:延迟入账(Deferred Settle) + +#### 语义 + +| 阶段 | 行为 | +|---|---| +| T0 业务成功(上传/发布入库等) | **不**改 `balance`;写入 `point_pending_award`(或 log.`settle_status=pending`) | +| T0 → T0+T | 用户明细可展示「待生效」;**不计入**余额、月获得、排行 | +| 窗口内文件删除/彻底不可见 | pending → `cancelled`,**永不入账** | +| 到达 T0+T 且内容仍有效 | Beat/Worker settle → 走现有 `award()` 写流水+余额(幂等键与 pending 绑定) | +| 已 settle 之后再删文 | **仍不冲正**(与「入账后不回收」一致) | + +默认 **T = 24 小时**(`deferred_settle_hours`,Asia/Shanghai)。 + +适用规则(建议):**G1/G2/G5/G6**(入库类)。 +不适用(建议仍立即入账):G3 收藏阶梯、G4 采纳、M* 月奖、手动调分/R* 扣减。 + +#### 状态机 + +```mermaid +stateDiagram-v2 + [*] --> Pending: 入库事件(二期) + Pending --> Cancelled: 窗口内删除/失效 + Pending --> Settled: 到达T且仍有效 → award() + Cancelled --> [*] + Settled --> [*] +``` + +#### 表扩展(二期再迁,本期不建) + +`point_pending_award`(示意): + +| 字段 | 说明 | +|---|---| +| id / tenant_id / user_id | | +| rule_code / delta / title | 预估发放(settle 时仍受当日 cap 约束) | +| biz_type / biz_id / space_id / file_id | 校验「仍有效」 | +| idempotency_key | 与最终 log 相同或可推导 | +| status | `pending` / `settled` / `cancelled` | +| eligible_at | T0 + T | +| create_time / update_time | | + +索引:`(tenant_id, status, eligible_at)` 供 settle Job;UK `(tenant_id, idempotency_key)`。 + +#### Settle Job(二期) + +- Cron:如每 10–30 分钟扫 `status=pending AND eligible_at <= now` +- 校验文件仍存在且对计分语义有效(如解析 SUCCESS、未进回收站等,细则实现期定) +- 成功:`award()`;失败可重试;文件没了:`cancelled` +- 日 cap:以 **settle 当日** 已得分计算(避免跨日套利歧义,实现期写死并单测) + +#### 对前端的影响(二期) + +- 明细增加状态:待生效 / 已到账 / 已取消(窗口内删除) +- 摘要「我的积分」仅含已 settle +- 文案说明:入库类积分约 T 小时后到账 + +#### 与本期行为对照 + +| 项 | 本期(F070) | 二期(§7.3) | +|---|---|---| +| 入库得分时机 | 立即入账 | 延迟 T 小时 | +| 删文 | 已入账不回收 | 未入账可取消;已入账仍不回收 | +| 表 | 无 pending | 新增 pending 或 log 状态列 | +| 开关 | — | `deferred_settle_enabled` 默认 false,灰度打开 | + +### 7.4 二期可选增强(更后,按需) + +| 项 | 说明 | +|---|---| +| 仅 SUCCESS 计分 | 解析失败/空文件不进 pending | +| 运营异常面板 | 日触达 cap 用户、1h 大量入库、高取消率 pending | +| G3 收藏人资格 | 登录满 N 天等(防纯小号) | +| 危险规则配置校验 | 禁止清空日 cap、单规则分值上限告警 | +| 大额调分加强 | 超阈值二次确认或双人审计 | + +**明确永不首期做**:设备指纹集群、实时 ML 反作弊、积分转让市场。 + +### 7.5 本期实现禁令(避免范围蔓延) + +- [ ] 不创建 `point_pending_award` 表 +- [ ] 不实现 settle/cancel Job +- [ ] 不在明细 API 返回 pending 状态 +- [ ] 配置中 `deferred_settle_enabled` 即使存在也必须默认 false,且代码路径不读取执行 + +二期单独立项(可仍挂 F070 增量或 F07x),更新 AC 与迁移后再做。 diff --git a/features/v2.6.0/070-points-system/spec.md b/features/v2.6.0/070-points-system/spec.md new file mode 100644 index 000000000..e091720ab --- /dev/null +++ b/features/v2.6.0/070-points-system/spec.md @@ -0,0 +1,431 @@ +# Feature: F070-points-system(首钢知库积分系统) + +**关联 PRD**: [../../../docs/PRD/积分系统/积分系统需求分析-V1.1.md](../../../docs/PRD/%E7%A7%AF%E5%88%86%E7%B3%BB%E7%BB%9F/%E7%A7%AF%E5%88%86%E7%B3%BB%E7%BB%9F%E9%9C%80%E6%B1%82%E5%88%86%E6%9E%90-V1.1.md) §1–§11(含产品决议);原文 PRD V1.1 见同目录抽取稿 +**后端设计方案(DB / 接口契约 / 时序图 / 升级)**: **[design.md](./design.md)** ← 实现以该文档字段与流程为准 +**优先级**: P0(激励闭环主能力) +**所属版本**: v2.6.0 +**模块编码**: **182**(`points`,新建 `common/errcode/points.py`) +**依赖**: F002 部门树 + F004 ReBAC + F009 组织同步(只读)+ F012 多租户 + 现有 knowledge / approval 发布结果 / qa_expert / message / telemetry 日活;Portal 兄弟仓前端 + +> **范围边界(产品决议基线)** +> - **纳入**:积分账户与 append-only 流水;规则 G*/R*/M* 配置(含可增编码);自动发放(上传/发布入库、**知识库间 SHARE 分享 G7**、收藏阶梯、问答采纳、月奖);手动调分与前台 R* 扣减;我的积分;首页 TOP10 榜;排行快照;说明文案(`point_copy`);站内信文案**代码常量**;组织 `org_level` 打标;外部同步 Outbox 骨架。 +> - **明确排除 / 延后**:门户**外链** `share-links` 不计分;站内申诉;冻结/过期;兑换核销;管理概览环比;首页「我」置底行;历史补分;「文档被订阅」规则;协同办公真实联调(不阻塞上线);**延迟入账反作弊(见 design.md §7,本期不做)**。 +> - **端侧**:用户端与积分运营后台 → **Portal**;组织打标 → **Platform(仅超管)**;Client 仅可选挂前台 R* 扣减入口。 + +--- + +## 1. 概述与用户故事 + +**故事 A(贡献者)**: +作为 **普通登录用户**, +我希望 **因上传/发布知识、被收藏、回答被采纳等获得积分,并在「我的积分」看到余额、明细与排名**, +以便 **贡献行为被量化激励**。 + +**故事 B(浏览者)**: +作为 **Portal 首页用户**, +我希望 **看到本月/本年/总榜 TOP10**, +以便 **感知榜样并参与贡献**。 + +**故事 C(平台运营)**: +作为 **平台超级管理员**, +我希望 **在 Portal 配置规则/说明文案、调分与审计,在 Platform 指定唯一公司根并级联组织标签**, +以便 **运营激励且组织排名口径正确**。 + +**故事 D(内容治理)**: +作为 **平台管理员**, +我希望 **在文档/问答场景按扣减规则扣分**, +以便 **违规可惩戒并可追溯**。 + +--- + +## 2. 验收标准 + +### 2.1 账本与手动调分 + +| ID | 角色 | 操作 | 预期结果 | +|----|------|------|---------| +| AC-01 | 系统 | 任意成功记分(自动/手动) | 同事务写入 `user_point_log` 并更新 `user_point_account.balance`;流水含 `balance_after`、`delta`、`idempotency_key`(自动必填);禁止改/删历史流水 | +| AC-02 | 平台管理员 | Portal 调整积分:非 0 整数 ∈[-10000,10000],原因 5–100 字 | 余额可变负;写流水 `source=manual_adjust`;写审计;目标用户收站内信(代码常量模板) | +| AC-03 | 非平台管理员 | 调用调分/规则写接口 | 拒绝,错误码 `PointsPermissionDeniedError`(18201) | +| AC-04 | 运维任务 | 日对账 | `sum(log.delta)==account.balance`;不一致告警,禁止静默改流水 | + +### 2.2 规则配置 + +| ID | 角色 | 操作 | 预期结果 | +|----|------|------|---------| +| AC-05 | 平台管理员 | 查看规则四 Tab:获取/扣减/管理员奖励/说明文案 | 预置 G1–G4、R1–R3、M1/M4/M6 与 5 条说明文案;G5–G7、其余 M* 可作为编码新增;G7 启用后走库间 SHARE 触发 | +| AC-06 | 平台管理员 | 新增/编辑规则分值、每日上限(按**分**)、**积分受益主体**、启停 | 保存立即生效;历史流水不变;同租户同 `rule_code` 启用互斥;G1/G2/G5/G6 受益主体可选上传人/发布人;G7 可选上传人/分享人;G3/G4/M* 按编码锁定;非法选项返回 18203 | +| AC-07 | 普通用户 | 打开「积分规则」弹窗 | 仅展示启用中的获取/扣减规则 + 说明文案;**不展示 M\***;申诉相关文案为线下联系,无站内入口 | + +### 2.3 自动发放 + +| ID | 角色 | 操作 | 预期结果 | +|----|------|------|---------| +| AC-08 | 用户 | 文件成功进入目标库(直传或发布审批通过) | 按目标库 `space.level` 匹配 G1/G2/G5/G6;幂等键含 `file_id+target_space_id`;个人库/收藏库不计;入账用户 = 规则 `beneficiary` 解析结果(只发给一方);**受益人**为目标库 `creator`/`admin` 则豁免(P7=B,不看操作人);平台超管入账 skip | +| AC-09 | 用户 | 文档收藏人数达 75/150/300 | G3 补差价至当前最高档,单文档终身累计 ≤15;取消收藏再达阈值不重发;同用户对同文档收藏只计一次人数 | +| AC-10 | 提问者 | 采纳答案 | 被采纳回答者按 G4 得分(规则启用且未超日 cap);幂等键含 `answer_id` | +| AC-30 | 用户 | 知识库间文档分享审批通过(`share_approved`,目标为其他空间) | 若 G7 启用:按获益人配置给上传人或分享人发分;幂等键 `earn:G7:{share_entry_id}`;日 cap/管理员豁免同其他 G*;**不**对外链 share-links 计分;与 PUBLISH/G1–G6 分键,同一次业务不双计 | +| AC-11 | 系统 | 触发已禁用规则或触达日 cap(按分) | 不计分;可记 `points.award.rejected` 日志;不报用户硬错误(业务主路径成功) | +| AC-12 | 平台超管用户 | 触发任意自动发放 | **跳过**;不进激励榜 | +| AC-13 | Beat | 每月 1 日 00:05(Asia/Shanghai) | 对拥有对应空间角色的用户发 M*(多角色取最高档不累加);当月登录≥1(日活);幂等键含 `user_id+yyyy-mm+rule` | + +### 2.4 用户端与榜单 + +| ID | 角色 | 操作 | 预期结果 | +|----|------|------|---------| +| AC-14 | 登录用户 | Portal 账号菜单进入「我的积分」 | 见总积分、本月获得、本月扣减、部门排名、总榜排名(>999 显示 999+)及排名刷新时间;明细可筛类型/时间;无记录时空态文案;本月窗口为上海时区当月 1 日 00:00 至当前 | +| AC-15 | 登录用户 | 查看首页积分榜 | 本月/本年/总榜 TOP10;**无**当前用户置底行;无数据空态文案;数据来自小时快照 | +| AC-16 | 系统 | 每小时刷新排行 | 写入 `point_rank_snapshot`;部门桶 = 主部门向上最近 `org_level=dept`;总榜全租户(或约定作用域)非跨租户 | + +### 2.5 扣减、消息、概览 + +| ID | 角色 | 操作 | 预期结果 | +|----|------|------|---------| +| AC-17 | 平台管理员 | 前台对文档/问答执行违规扣减 | 必须选择启用中的 R* 规则;写流水;通知用户 | +| AC-18 | 系统 | 积分变动 | 按**代码内常量模板**发站内信 `action_code=points_changed`,可跳转「我的积分」;无消息模板配置表/API | +| AC-19 | 平台管理员 | 查看数据概览 | 三绝对数:总发放、当前余额合计、违规扣减合计;**无环比**;约 5min 可缓存 | + +### 2.6 组织四级标签 + +| ID | 角色 | 操作 | 预期结果 | +|----|------|------|---------| +| AC-20 | 平台超管 | 在 Platform 指定某部门为唯一「公司」并执行级联打标 | 该公司=`company`,相对深度+1/`dept`、+2/`office`、+3 及更深=`squad`;不改 `parent_id`/`path`/用户挂载;若已存在其他 company 则拒绝 | +| AC-21 | 非超管 | 调用打标接口 | 拒绝 18201 | +| AC-22 | 系统 | 部门榜计算时主部门无向上 `dept` | 该用户部门排名展示为「—」或未参与部门榜(总榜仍可参与);运维侧应对未打标活跃部门告警 | + +### 2.7 外部同步与非功能 + +| ID | 角色 | 操作 | 预期结果 | +|----|------|------|---------| +| AC-23 | 系统 | 产生积分流水 | 写入 `point_sync_outbox`(pending);无外部配置时 Job 跳过或保持 pending,**不阻塞**记分 | +| AC-24 | 任意 | 我的积分/明细/榜接口 | P95 ≤1.5s(千人级租户) | +| AC-25 | MySQL/DM8 | 迁移与读写 | 双库可用;使用 `dialect_helpers`;禁止 MySQL 专属 JSON/ON UPDATE 假设 | + +### 2.8 明确不做(反向 AC) + +| ID | 说明 | +|----|------| +| AC-26 | 门户外链 `share-links` **永不**触发积分;仅库间 `SHARE`/`share_approved` 可走 G7 | +| AC-27 | 无站内申诉工单/入口 | +| AC-28 | 文档删除/审批驳回不自动冲正已发积分 | +| AC-29 | 上线前历史行为不补分 | + +--- + +## 3. 边界情况 + +- 并发重复事件:依赖 `idempotency_key` 唯一约束,第二次返回已存在结果或静默成功。 +- 规则分值修改:仅影响新流水;日 cap 按「当日已得分」与新上限比较。 +- 余额为负:展示带「-」;榜单按数值排序(更负更靠后)。 +- 用户无主部门:部门排名「—」;总榜仍按余额/周期分。 +- 组织未完成唯一公司打标:部门榜降级(AC-22),积分发放不阻断。 +- 多租户:账户/流水/规则/快照均带 `tenant_id`;Beat 按活跃租户 fanout。 +- **不支持**:FIFO 过期、兑换商城、积分转让、外链分享计分、公共库管理员改全站规则。 + +--- + +## 4. 架构决策 + +| ID | 决策 | 选项 | 结论 | 理由 | +|----|------|------|------|------| +| AD-01 | 账本模型 | A: 仅用户字段 / B: append-only ledger + 余额缓存 | **B** | 可审计、可对账、防并发丢分(INV-11) | +| AD-02 | 事件挂钩 | A: 通用事件总线 / B: 业务成功路径显式调用 PointsService | **B** | 本仓无总线;挂钩点有限;后续可再抽 | +| AD-03 | 日上限 | A: 按次 / B: 按分 / C: 可配 | **B** | 产品拍板;实现简单 | +| AD-04 | G3 | A: 三档叠加 / B: 最高档补差价终身≤15 | **B** | 产品拍板 | +| AD-05 | 上传/发布 | A: 各计一次 / B: 进入目标库成功计一次 | **B** | 防重复激励 | +| AD-06 | 排行 | A: 实时全表 / B: 小时快照 | **B** | 对齐 PRD;可控 | +| AD-07 | 组织层级 | A: 新组织表 / B: department.org_level + 唯一公司级联 | **B** | 不改用户挂载 | +| AD-08 | 管理端 | A: Platform / B: Portal 运营 + Platform 打标 | **B** | 产品拍板 | +| AD-09 | 外部同步 | A: 阻塞上线 / B: Outbox 不阻塞 | **B** | 接口未定 | +| AD-10 | 权限 | A: 按 PRD 给公共库管理员配规则 / B: 仅超管 | **B** | 收权降风险 | +| AD-11 | 模块位置 | A: 塞进 knowledge / B: 独立 `bisheng/points` | **B** | 跨域激励,独立 DDD | + +### 4.1 组织打标 Blast Radius(MUST) + +| 项 | 内容 | +|----|------| +| 不变量 | 部门树拓扑与用户挂载不变;仅增加/维护 `org_level`;至多一个 company | +| 波及面 | 部门树 API/Platform UI;积分部门榜与管理端部门筛(若按标签);依赖部门展示的报表(只读多字段,通常无害) | +| 例外路径 | 未打标节点不参与部门榜;不影响知识空间 ReBAC | +| 可行性 | 中等工作量;需超管一次性打标;回滚=清空 org_level 或迁移 down | +| 风险 | **中高**——误标导致排名桶错误;多公司误操作(接口拒绝) | +| 验证 | 指定公司后抽查四级;部门榜抽样;回归部门树 CRUD/同步 | +| 不做 | 强绑 knowledge space level;改 SSO 同步结构 | + +--- + +## 5. 数据库 & Domain 模型 + +### 5.1 表 + +| 表 | 说明 | +|----|------| +| `user_point_account` | `user_id` UK、`balance`、`lifetime_earned`、`lifetime_deducted`、`tenant_id`、时间戳 | +| `user_point_log` | append-only:`delta`、`balance_after`、`rule_code`、`title`、`biz_type`、`biz_id`、`idempotency_key`、`source`、`operator_id`、`remark`、`occurred_at`;UK `(tenant_id, idempotency_key)` | +| `point_rule` | `rule_code`、`rule_type`(earn/deduct/admin_reward)、`name`、`score_expr`(JSON:固定或阶梯)、`daily_cap`、`beneficiary`、`status`、`remark` | +| `point_copy` | 说明文案 key + content(预置 5 条) | +| (无表)站内信文案 | 代码常量 `notify_templates.py`,不建表 | +| `point_rank_snapshot` | `period`(month/year/all)、`scope`(global/dept)、`scope_id`、`user_id`、`rank`、`score`、`balance`、`refreshed_at` | +| `point_sync_outbox` | 同步载荷、状态、重试 | +| `point_favorite_tier_award` | `file_id`、`tier`、`points_granted_total` — G3 已授档位 | +| `department.org_level` | 扩展列:`company`/`dept`/`office`/`squad`/NULL | + +> 时间字段用 `UPDATE_TIME_SERVER_DEFAULT`;JSON 用 `JsonType`;大文本用 `LargeText`。 + +### 5.2 规则编码与 space.level 映射(计分,独立于 org_level) + +| rule_code | space.level(示意) | 默认分/备注 | +|-----------|-------------------|------------| +| G1 | `public` | +3,日 cap 15 | +| G2 | `department` | +2,日 cap 10 | +| G5 | `team_ks` | 可配,默认可空/未启用 | +| G6 | `team` | 可配 | +| G3 | n/a 收藏 | 阶梯 5/10/15 | +| G4 | n/a 采纳 | +3 | +| G7 | n/a **知识库间文档分享**(`SHARE` / `knowledge_space_file_share_request` → `share_approved`) | **本期接线**;获益人可配上传人/分享人;幂等 `earn:G7:{share_entry_id}`;**不是**门户外链 | +| R1–R3 | 手动 | -100 | +| M1/M4/M6 | 公共所有者/部门管理员/科室管理员 | 200/100/50;其余 M* 可增 | + +月奖角色判定:用户在对应 level 的 space 上为 `creator`(所有者档)或 `admin`(管理员档),按启用中 M* 取最高分。 + +### 5.3 幂等键约定(示例) + +| 场景 | idempotency_key | +|------|-----------------| +| 入库计分 | `earn:{rule}:{file_id}:{space_id}` | +| G3 补差 | `earn:G3:{file_id}:tier:{tier}` | +| G4 | `earn:G4:{answer_id}` | +| G7 库间分享 | `earn:G7:{share_entry_id}` | +| 月奖 | `reward:{rule}:{user_id}:{yyyy-mm}` | +| 手动调分 | `manual:{operator}:{target}:{uuid}` | +| 前台扣减 | `deduct:{rule}:{operator}:{target}:{biz}:{uuid}` | + +--- + +## 6. API 契约 + +> 前缀建议:`/api/v1/points`;组织打标可挂 `/api/v1/department/org-level` 或 points 管理子路径。 +> 认证:`UserPayload.get_login_user`;写操作校验平台超管(除用户读自己的数据)。 + +### 6.1 用户端 + +| Method | Path | 描述 | +|--------|------|------| +| GET | `/api/v1/points/me/summary` | 余额、本月获得/扣减、部门/总榜名次、`rank_refreshed_at` | +| GET | `/api/v1/points/me/logs` | 明细分页;query: type, from, to | +| GET | `/api/v1/points/rules/public` | 前台规则弹窗(无 M*) | +| GET | `/api/v1/points/leaderboard` | query: period=month\|year\|all;TOP10 | + +### 6.2 管理端(Portal) + +| Method | Path | 描述 | +|--------|------|------| +| GET/PUT | `/api/v1/points/admin/rules` | 规则 CRUD/启停 | +| GET/PUT | `/api/v1/points/admin/copies` | 说明文案 | +| GET | `/api/v1/points/admin/users` | 用户积分列表(搜姓名/部门、角色筛) | +| POST | `/api/v1/points/admin/users/{user_id}/adjust` | 手动调分 | +| GET | `/api/v1/points/admin/users/{user_id}/logs` | 用户明细 | +| GET | `/api/v1/points/admin/audit-logs` | 调分审计 | +| GET | `/api/v1/points/admin/overview` | 三绝对数 | +| POST | `/api/v1/points/admin/deduct` | 前台 R* 扣减 | + +### 6.3 组织打标(Platform) + +| Method | Path | 描述 | +|--------|------|------| +| GET | `/api/v1/departments/org-levels` | 树+标签 | +| POST | `/api/v1/departments/{id}/set-company-root` | 指定唯一公司并级联打标 | + +### 6.4 请求/响应示例 + +**我的积分摘要**: +```http +GET /api/v1/points/me/summary +``` +```json +{ + "status_code": 200, + "status_message": "SUCCESS", + "data": { + "balance": 2580, + "month_earned": 320, + "month_deducted": 50, + "dept_rank": 12, + "global_rank": 38, + "global_rank_display": "38", + "rank_refreshed_at": "2026-08-06T14:05:00+08:00" + } +} +``` + +**手动调分**: +```http +POST /api/v1/points/admin/users/1001/adjust +{ + "delta": 100, + "reason": "月度优秀贡献奖励" +} +``` +```json +{ + "status_code": 200, + "status_message": "SUCCESS", + "data": { + "user_id": 1001, + "balance": 2680, + "log_id": 90001 + } +} +``` + +**明细分页**(`PageData`): +```json +{ + "status_code": 200, + "status_message": "SUCCESS", + "data": { + "data": [ + { + "title": "发布/上传文档到公共库", + "delta": 3, + "balance_after": 2580, + "type": "earn", + "occurred_at": "2026-07-29T14:32:05+08:00" + } + ], + "total": 7 + } +} +``` + +### 6.5 错误码(182xx) + +| HTTP/Body | Code | Class | 场景 | 关联 AC | +|-----------|------|-------|------|---------| +| 200 body | 18201 | PointsPermissionDeniedError | 非超管写操作 | AC-03, AC-21 | +| 200 body | 18202 | PointsInvalidAdjustError | 调分校验失败 | AC-02 | +| 200 body | 18203 | PointsRuleConflictError | 规则编码冲突/非法 | AC-06 | +| 200 body | 18204 | PointsRuleNotFoundError | 扣减规则不存在/未启用 | AC-17 | +| 200 body | 18205 | PointsCompanyRootConflictError | 公司根冲突或节点非法 | AC-20 | +| 200 body | 18206 | PointsIdempotentReplayError | 显式幂等冲突(通常内部吞掉) | AC-01, AC-08 | + +--- + +## 7. Service 层逻辑 + +| 服务 | 职责 | +|------|------| +| `PointsLedgerService` | award/deduct/adjust;锁账户;写流水;日 cap;幂等 | +| `PointsRuleService` | 规则/文案/模板 CRUD;种子数据 | +| `PointsAwardFacade` | 供 knowledge/approval/qa_expert 调用的薄门面(解析规则+豁免+获益人) | +| `PointsRankService` | 小时聚合写快照;读榜/读个人名次 | +| `PointsMonthlyRewardService` | Beat 月奖 | +| `PointsNotifyService` | 代码常量模板渲染 + `MessageService.send_message` | +| `DepartmentOrgLevelService` | 唯一公司根 + 级联打标(Platform) | +| `PointsSyncOutboxService` | 写 outbox;drain Job(外部未就绪则 no-op) | + +### 挂钩点(修改方,只增加调用,不夺所有权) + +| 业务成功点 | 调用 | +|------------|------| +| 空间文件上传成功(非 personal/favorite) | `AwardFacade.on_space_file_ready` | +| 发布审批 outbox 执行成功 | 同上(目标库) | +| 收藏创建 | `on_favorite_changed` → 可能 G3 | +| 问答采纳 | `on_answer_adopted` | +| 审批 `share_approved`(G7) | `PointsAwardFacade.on_document_shared(...)`;勿挂外链 | +| 不挂钩 | 外链 share-links、删除冲正 | + +### 权限 + +- 读自己的积分:登录即可。 +- 管理写:`UserPayload` 超级管理员 / 平台管理员判定(与现网 admin 一致,具体对齐 `is_admin`/`super_admin` 实现)。 +- **不**给公共库管理员规则写权限。 +- 资源创建若产生新积分实体无需 OpenFGA 资源元组(积分非 ReBAC 资源);账户按 user 隔离。 + +--- + +## 8. 前端设计 + +### 8.1 Portal(兄弟仓 `shougang-group-knowledge-portal`) + +| 页面 | 说明 | +|------|------| +| Header 账号菜单 | 「我的积分」入口 | +| `/points` 或抽屉页 | 统计卡片 + 明细 + 规则弹窗 | +| `HomePage` | 替换 `POINTS_PODIUM`/`POINTS_ROWS` mock 为 API;去掉「我」行 | +| `AdminPage` | 新菜单:积分概览、规则、用户、审计 | + +状态:沿用 Portal 现有方案;HTTP 经 BFF 或直连 BiSheng(与现网门户一致,BFF 若需代理则加路由)。 + +### 8.2 Platform + +| 页面 | 说明 | +|------|------| +| 部门树管理 | 「设为公司根并级联打标」;展示 org_level 徽章;仅超管可见操作 | + +### 8.3 Client(可选) + +- 文档列表/阅读、专家问答删除弹窗:平台管理员可见「积分扣减」→ 调 `admin/deduct`。 +- **不做**「我的积分」入口。 + +--- + +## 9. 文件清单 + +### 新建(BiSheng) + +| 文件 | 说明 | +|------|------| +| `bisheng/points/**` | api / domain models / repositories / services | +| `bisheng/common/errcode/points.py` | 182xx | +| `bisheng/core/database/alembic/versions/*_points.py` | 表 + `department.org_level` | +| `bisheng/worker/points/*.py` | 排行、月奖、对账、sync drain | +| `test/points/**` | 账本幂等/cap/豁免/G3/月奖/打标 | + +### 修改(BiSheng) + +| 文件 | 变更 | +|------|------| +| `api/router.py` | 注册 points 路由 | +| `core/config/settings.py` | Beat 任务 | +| `database/models/department.py` | `org_level` 字段 | +| knowledge / approval handler / qa_expert / favorite | 成功路径调用 AwardFacade | +| message 侧 | 仅被调用,不改所有权 | + +### 新建/修改(Portal / Platform) + +| 位置 | 变更 | +|------|------| +| Portal Header / HomePage / AdminPage / points 页 | 用户端+运营台 | +| Platform 部门树 | 打标 UI | + +--- + +## 10. 非功能要求 + +- **性能**:读接口 ≤1.5s;排行小时批;概览可 5min 缓存。 +- **安全**:超管写;审计不可删;无密钥入仓;调分强审计。 +- **可观测**:`points.award.*`、`rank.refresh`、`balance.mismatch`、`sync.*`。 +- **时区**:Asia/Shanghai。 +- **兼容**:不影响未启用积分挂钩时的主业务(AwardFacade 内部吞拒绝原因,主流程成功)。 +- **备份**:流水表纳入现有 DB 备份策略(运维),应用层不实现「删审计」。 + +--- + +## 11. Celery Beat + +| 任务 | Cron(上海) | 说明 | +|------|-------------|------| +| `points.refresh_rank_snapshots` | `5 * * * *` | 小时榜 | +| `points.monthly_admin_rewards` | `5 0 1 * *` | 月奖 | +| `points.reconcile_balances` | `30 2 * * *` | 对账 | +| `points.drain_sync_outbox` | `0 3 * * *` | 外部同步(可空转) | + +--- + +## 相关文档 + +- **后端设计**: [design.md](./design.md)(表/索引/迁移、Request/Response、Mermaid 时序与状态机、生产升级) +- 版本契约: [../release-contract.md](../release-contract.md)(INV-11~14,模块 182) +- 需求分析决议: [../../../docs/PRD/积分系统/积分系统需求分析-V1.1.md](../../../docs/PRD/%E7%A7%AF%E5%88%86%E7%B3%BB%E7%BB%9F/%E7%A7%AF%E5%88%86%E7%B3%BB%E7%BB%9F%E9%9C%80%E6%B1%82%E5%88%86%E6%9E%90-V1.1.md) §11 +- PRD 抽取: [../../../docs/PRD/积分系统/PRD-V1.1-extracted.md](../../../docs/PRD/%E7%A7%AF%E5%88%86%E7%B3%BB%E7%BB%9F/PRD-V1.1-extracted.md) diff --git a/features/v2.6.0/070-points-system/tasks.md b/features/v2.6.0/070-points-system/tasks.md new file mode 100644 index 000000000..d0d005c07 --- /dev/null +++ b/features/v2.6.0/070-points-system/tasks.md @@ -0,0 +1,387 @@ +# Tasks: F070-points-system(首钢知库积分系统) + +**关联规格**: [spec.md](./spec.md) · [design.md](./design.md) +**版本**: v2.6.0 +**模块编码**: 182 +**基线依赖**: F002 部门树 / F004 ReBAC / knowledge·approval·qa_expert·message·telemetry;Portal 兄弟仓 + +--- + +## 状态 + +| 步骤 | 状态 | 备注 | +|------|------|------| +| spec.md | ✅ 已评审 | 2026-08-06 用户确认(含 design.md) | +| design.md | ✅ 已评审 | 与 spec 同步确认 | +| tasks.md | ✅ 已拆解 | 含阶段性无头浏览器验收门禁(2026-08-06 增补) | +| 实现 | ✅ M5 完成 / G-M5 通过 | T000–T028、G-M1~G-M5 完成;**T025 取消不做**(用户确认无 Client 内容页扣减场景);AC-17 以 Portal「违规扣减」为准 | + +--- + +## 开发模式 + +- **后端 Test-First(务实)**:账本幂等/日 cap/豁免/G3/受益人解析必须先测后实现;挂钩点可用 mock Facade。 +- **阶段性验收(强制)**:每个里程碑结束后必须跑通对应 **Gate(G-Mx)**,以 **Playwright 无头浏览器** + API 断言做高标准验收;**Gate 未绿不得进入下一阶段**。 +- **UI 验收默认自动化**:Portal / Platform 关键路径用 headless Chromium;仅无障碍场景(如企微扫码)可降级并记入「实际偏差记录」。 +- **双库**:Alembic 用 `dialect_helpers`;revision 建议 **`f078_points_system`**(`f077` 已被 folder sort weight 占用)。 +- **开关**:`points.enabled` 默认 false,挂钩打开后再计自动分。 +- **自包含任务**:文件路径 + 逻辑 + AC;实现时以 design 字段名为准。 +- **不做**:`point_message_template` 表;延迟入账(design §7 二期);外链 share-links 计分。 + +--- + +## 阶段性验收门禁(Gate) + +### 原则 + +| 项 | 要求 | +|---|---| +| 工具 | Playwright(headless Chromium);实现期 Agent 亦可用 Cursor `user-playwright` MCP 复跑同一脚本 | +| 环境 | **基础中间件走线上测试机 `192.168.106.171`**(与 `config.yaml` 一致);应用本机:Backend `:7860`、Portal BFF `:8010`、Portal `:5173`、Platform `:3001`(按需)、Client `:4001` | +| 账号 | 默认 `E2E_POINTS_ADMIN=admin` / `E2E_POINTS_USER=gzx01`;密码仅经 `E2E_POINTS_PASSWORD` 注入,禁止写死进 spec | +| 产物 | 每次 Gate:控制台日志 + **失败必留截图/trace**(`test-results/points-gate-mX/`);通过则记录 commit SHA | +| 标准 | 用例映射 AC;关键文案/数值/权限断言明确;禁止「页面能开就算过」 | +| 阻塞 | **G-Mx FAIL → 禁止合并该阶段、禁止开下一阶段任务** | + +### 门禁一览 + +| Gate | 触发时机 | 覆盖重点 | 依赖阶段任务 | +|------|---------|---------|-------------| +| **G-M1** | M1 API 可用后 | 超管登录 Portal 管理端:规则列表/调分/概览;普通用户「我的积分」摘要与明细(可先 mock 数据或种子流水);非超管调分被拒 | T001–T008, T018–T019 最小可用;T021 可先做只读页 | +| **G-M2** | 自动发放挂钩后 | 无头走:上传/发布入库加分、G7 分享加分、G3 收藏阶梯、G4 采纳;明细出现对应流水;外链分享**不加分**;超管账号不加自动分 | T009–T013 + `points.enabled=true` | +| **G-M3** | 排行/打标/月奖后 | Platform 设公司根四级标签;首页榜 TOP10 三 Tab;我的积分部门/总榜名次;手动触发月奖后明细有 M*(或管理接口触发) | T014–T017, T022, T024 | +| **G-M4** | 运营台与前台齐后 | 规则受益人配置生效;前台 R* 扣减(Portal 或 Client);规则弹窗无 M*、申诉为线下文案;审计列表 | T021–T025 | +| **G-M5** | 发布前 | 全链路冒烟 + 对账无 mismatch;开关关闭后面包屑/入口可降级;无严重 console error | T026–T028 | + +### Gate 用例最低集(高标准) + +**G-M1** + +1. 超管打开 Portal 积分管理 → 见预置规则 Tab,G1 beneficiary 可选上传人/发布人。 +2. 对测试用户调分 +10 → 余额变化 → 站内信/明细可见。 +3. 普通用户打开「我的积分」→ 见余额、本月获得/扣减、明细筛选。 +4. 普通用户调调分 API/入口 → 18201 或入口不可见。 + +**G-M2** + +1. 普通用户上传文件到部门库(非本人管理库)→ 明细出现 G2(或当前启用规则),余额增加 ≤ 日 cap。 +2. 同文件同库重复触发不双计。 +3. 库间 SHARE 审批通过 → G7 流水(规则启用时)。 +4. 创建外链 share-link → **无**新积分流水。 +5. 超管上传 → **无**自动分流水。 + +**G-M3** + +1. 超管 Platform 指定公司根 → 子节点 org_level 四级正确。 +2. Portal 首页榜切换 本月/本年/总榜,长度 ≤10,无「我」置底行。 +3. 我的积分展示部门排名(或 —)与 `rank_refreshed_at`。 + +**G-M4** + +1. 改 G1 beneficiary 为 publisher 后,发布路径得分落在发布人。 +2. 超管前台 R* 扣减 → 余额下降、明细 direction=deduct。 +3. 规则弹窗无管理员月奖、申诉文案含「线下」。 + +**G-M5** + +1. 串联 G-M1~M4 冒烟全绿。 +2. `points.enabled=false` 后新上传不再加分。 +3. 对账任务无 balance mismatch。 + +--- + +## 执行阶段计划 + +1. **M0 脚手架**:Playwright + 联调账号约定 + Gate 脚本骨架。 +2. **M1 账本底座** → **G-M1**。 +3. **M2 自动发放** → **G-M2**。 +4. **M3 排行与月奖** → **G-M3**。 +5. **M4 运营台与前台** → **G-M4**。 +6. **M5 运维与发布** → **G-M5**。 + +--- + +## Tasks + +### 阶段 0:基础设施 + 验收脚手架 + +- [x] **T000**: Playwright 无头验收脚手架 + **文件**(建议): + - `src/backend/test/e2e_ui/points/playwright.config.ts`(或 Portal 仓 `e2e/points/`,二选一,优先与 Portal 同仓若页面测为主) + - `…/points/fixtures/auth.ts`、`…/gates/gm1.spec.ts`(先建空骨架) + - `…/README.md`(如何起栈、环境变量、跑 Gate) + **逻辑**: headless Chromium(可用本机 Chrome channel);baseURL Portal;截图/trace on failure;默认账号 admin/gzx01;密码仅 env + **验收标准**: `npx playwright test --list` 能列出 Gate 套件;文档写明中间件 `192.168.106.171` 与 `E2E_POINTS_*` + **覆盖 AC**: —(基建) + **依赖**: 无;中间件指向测试机 + +- [x] **T001**: 错误码 182xx + **文件**: `src/backend/bisheng/common/errcode/points.py` + **逻辑**: `PointsPermissionDeniedError`(18201)、`PointsInvalidAdjustError`(18202)、`PointsRuleConflictError`(18203)、`PointsRuleNotFoundError`(18204)、`PointsCompanyRootConflictError`(18205)、`PointsIdempotentReplayError`(18206);继承 `BaseErrorCode` + **覆盖 AC**: AC-03, AC-06, AC-20, AC-21 + **依赖**: 无 + +- [x] **T002**: Alembic 迁移 + ORM 模型 + **文件**: + - `src/backend/bisheng/core/database/alembic/versions/v2_6_0_f078_points_system.py` + - `src/backend/bisheng/points/domain/models/*.py` + - `src/backend/bisheng/database/models/department.py`(`org_level`) + **逻辑**: 按 design §1 建表:`user_point_account`、`user_point_log`(含 `beneficiary_role`)、`point_rule`、`point_copy`、`point_rank_snapshot`、`point_favorite_tier_award`、`point_sync_outbox`;`department.org_level`;种子预置 G1–G4/R1–R3/M1/M4/M6 + 5 条说明文案;**不建** message_template 表 + **覆盖 AC**: AC-25, AC-29 + **依赖**: 无 + +- [x] **T003**: Repository 实现 + **文件**: `src/backend/bisheng/points/domain/repositories/**` + **逻辑**: 账户锁行 `FOR UPDATE`、流水 append-only、规则 CRUD、文案、快照替换写、pending outbox、G3 tier upsert;禁止 Service 内散落 ORM + **覆盖 AC**: AC-01 + **依赖**: T002 + +### 阶段 1:账本与规则核心 → 门禁 G-M1 + +- [x] **T004**: `PointsLedgerService` 单元测试 + **文件**: `src/backend/test/points/test_points_ledger_service.py` + **逻辑**: 覆盖 award/deduct/adjust:幂等、日 cap clamp、负余额、禁止 delta=0、并发同 key + **覆盖 AC**: AC-01, AC-02, AC-11 + **依赖**: T003 + +- [x] **T005**: `PointsLedgerService` 实现 + **文件**: `src/backend/bisheng/points/domain/services/points_ledger_service.py` + **逻辑**: 同事务写 log + 更新 account;自动发放必填 idempotency_key;写 sync_outbox pending;调用 Notify(best-effort) + **测试**: T004 全绿 + **覆盖 AC**: AC-01, AC-02, AC-11, AC-23 + **依赖**: T004 + +- [x] **T006**: 站内信常量 + `PointsNotifyService` + **文件**: `src/backend/bisheng/points/domain/constants/notify_templates.py`, `.../points_notify_service.py` + **逻辑**: 写死 earn_publish/earn_share/earn_favorite/earn_adopt/deduct_admin/adjust_admin;渲染后 `MessageService.send_message`,`action_code=points_changed` + **覆盖 AC**: AC-18 + **依赖**: T005 + +- [x] **T007**: `PointsRuleService` 测试 + 实现 + **文件**: `test/points/test_points_rule_service.py`, `points/domain/services/points_rule_service.py` + **逻辑**: 规则 CRUD/启停;beneficiary 按 rule_code 白名单校验;说明文案 PUT;非超管 18201 + **覆盖 AC**: AC-03, AC-05, AC-06, AC-07 + **依赖**: T003, T001 + +- [x] **T008**: 超管判定工具 + **文件**: `points/domain/services/points_auth.py`(或复用现有 UserPayload 能力) + **逻辑**: 统一 `require_platform_admin` / `is_platform_super_admin`;自动分与榜单过滤超管 + **覆盖 AC**: AC-03, AC-12, AC-21 + **依赖**: 无 + +- [x] **T018**: 用户端 API(M1 可先交付) + **文件**: `points/api/endpoints/me.py`, router 注册 + **逻辑**: `GET /me/summary`、`/me/logs`、`/rules/public`、`/leaderboard`;PageData + **覆盖 AC**: AC-07, AC-14, AC-15, AC-24 + **依赖**: T005, T007 + +- [x] **T019**: 管理端 API(M1 可先交付) + **文件**: `points/api/endpoints/admin.py` + **逻辑**: overview、rules、copies、users、adjust、user logs、audit-logs、deduct;**无** message-templates + **覆盖 AC**: AC-02, AC-03, AC-05, AC-06, AC-17, AC-19 + **依赖**: T005, T007, T008 + +- [x] **T021a**: Portal — 我的积分 + 管理端最小页(支撑 G-M1) + **仓**: Portal + **逻辑**: 账号菜单入口、摘要/明细、管理端规则列表与调分表单(可后续美化) + **覆盖 AC**: AC-02, AC-07, AC-14 + **依赖**: T018, T019, T000 + +- [x] **G-M1**: 无头浏览器验收 — 账本与管理可读可调 + **文件**: `e2e/.../gates/gm1.spec.ts` + **逻辑**: 执行「Gate 用例最低集 · G-M1」;失败留 screenshot/trace + **通过标准**: 全部用例 green;无未捕获 pageerror + **覆盖 AC**: AC-02, AC-03, AC-05, AC-07, AC-14 + **依赖**: T000, T018, T019, T021a + **阻塞**: 未通过不得开始 T009 + +### 阶段 2:自动发放 Facade 与挂钩 → 门禁 G-M2 + +- [x] **T009**: `PointsAwardFacade` 测试 ✅ 2026-08-06 + **文件**: `test/points/test_points_award_facade.py` + **逻辑**: 个人库/收藏库 skip;creator/admin 豁免;超管 skip;beneficiary 解析;规则 disabled/cap;异常不向外抛 + **覆盖 AC**: AC-08, AC-11, AC-12, AC-28 + **依赖**: T005, T007, **G-M1**;豁免按 **P7=B 受益人**(见 design §0.1) + +- [x] **T010**: `PointsAwardFacade` 实现 ✅ 2026-08-06 + **文件**: `points/domain/services/points_award_facade.py` + **逻辑**: `on_space_file_ready` / `on_document_shared` / `on_favorite_changed` / `on_answer_adopted`;受 `points.enabled` 控制 + **测试**: T009 全绿(19 passed) + **覆盖 AC**: AC-08, AC-09, AC-10, AC-30, AC-26 + **依赖**: T009 + +- [x] **T011**: 挂钩 — 上传 / 发布入库(G1/G2/G5/G6) ✅ 2026-08-06 + **文件**: `KnowledgeSpaceService.add_file`;`shougang_approval_handler` 发布 F059/旧版复制 + **逻辑**: `notify_space_files_ready` → `on_space_file_ready`;独立会话;不影响主业务 + **覆盖 AC**: AC-08, AC-26, AC-28 + **依赖**: T010 + +- [x] **T012**: 挂钩 — G3 收藏阶梯 ✅ 2026-08-06 + **文件**: `create_shougang_portal_favorite`(新收藏路径)+ `point_favorite_tier_award` + **逻辑**: 去重人数;补差价;终身 ≤15;已存在收藏 early-return 不触发 + **覆盖 AC**: AC-09 + **依赖**: T010 + +- [x] **T013**: 挂钩 — G4 采纳 + G7 库间 SHARE ✅ 2026-08-06 + **文件**: `QuestionService.adopt_answer`;Share handler `share_approved` 之后 + **逻辑**: G4/G7 幂等键;禁止 share-links(未挂外链) + **覆盖 AC**: AC-10, AC-30, AC-26 + **依赖**: T010 + +- [x] **T020**: 功能开关配置 ✅ 2026-08-06 + **文件**: `core/config/settings.py` → `PointsConf`;Facade 读取 `settings.points.enabled` + **逻辑**: `points.enabled` 等;默认 false;单测可注入覆盖 + **依赖**: T010 + +- [x] **G-M2**: 无头浏览器验收 — 自动发放主路径 ✅ 2026-08-06 + **文件**: `e2e_ui/points/gates/gm2.spec.ts` + `helpers/gm2_trigger.py` + **逻辑**: API/Facade 造数 + Portal「我的积分」明细断言;外链负例;超管负例 + **通过标准**: **4/4 green**(`E2E_POINTS_RUN_GATES=1 npm run test:gm2`) + **覆盖 AC**: AC-08, AC-12, AC-26, AC-30(G3/G4 UI 路径留 M4/后续) + **依赖**: T011–T013, T020, T021a + **阻塞**: 未通过不得开始 T014 + +### 阶段 3:排行、月奖、组织打标 → 门禁 G-M3 + +- [x] **T014**: `DepartmentOrgLevelService` 测试 + 实现 + API ✅ 2026-08-07 + **文件**: `points/.../department_org_level_service.py`;`department/api/.../department_org_level.py` + **逻辑**: 唯一 company;级联 dept/office/squad;`GET /org-levels` + `POST /{id}/set-company-root` + **覆盖 AC**: AC-20, AC-21(AC-22 属排行展示,随 T015) + **依赖**: T002, T008, **G-M2** + +- [x] **T015**: `PointsRankService` + 小时 Beat ✅ 2026-08-07 + **文件**: `points/.../points_rank_service.py`;`worker/points/tasks.py`;Beat `points_refresh_rank_snapshots` + **逻辑**: month/year/all 快照;部门桶;过滤超管;`my_summary.dept_rank` 读快照 + **覆盖 AC**: AC-15, AC-16, AC-22 + **依赖**: T003, T014 + +- [x] **T016**: 月奖 Beat ✅ 2026-08-07 + **文件**: `points/.../points_monthly_reward_service.py`;`worker/points/tasks.py`;Beat `points_monthly_admin_rewards` + **逻辑**: 每月 1 日 00:05 结算上月;上月登录≥1(日活 ES);多角色取最高 M*;幂等 `reward:{rule}:{user}:{yyyy-mm}` + **覆盖 AC**: AC-13 + **依赖**: T005, T007, T008 + +- [x] **T017**: 对账 + sync outbox drain ✅ 2026-08-07 + **文件**: `points_reconcile_service.py` / `points_sync_outbox_service.py`;Beat `points_reconcile_balances` / `points_drain_sync_outbox` + **逻辑**: 日对账只告警不改流水;outbox 关闭时保持 pending,开启且无适配器则 skipped + **覆盖 AC**: AC-04, AC-23 + **依赖**: T005 + +- [x] **T022**: Portal — 首页积分榜 ✅ 2026-08-07 + **文件**: Portal `PointsLeaderboardPanel.tsx` + `fetchPointsLeaderboard`;BiSheng leaderboard 补 user_name/dept_name + **逻辑**: 本月/本年/总榜 TOP10;无「我」置底;空态文案;数据来自小时快照 + **覆盖 AC**: AC-15 + **依赖**: T018, T015 + +- [x] **T024**: Platform — 组织四级打标 UI ✅ 2026-08-07 + **文件**: `DepartmentPage` 树徽章 + `DepartmentSettings`「设为公司根」;API `org-levels` / `set-company-root` + **逻辑**: 仅平台超管可见打标按钮;树/详情展示 org_level;非超管后端仍 18201 + **覆盖 AC**: AC-20, AC-21 + **依赖**: T014 + +- [x] **G-M3**: 无头浏览器验收 — 榜单与组织标签 ✅ 2026-08-07 + **文件**: `e2e_ui/points/gates/gm3.spec.ts` + `helpers/gm3_trigger.py` + **逻辑**: Platform「设为公司根」入口可见;Portal 三 Tab 榜无「我」置底;我的积分部门/总榜;org_level 只读级联校验;破坏性打标需 `E2E_POINTS_ALLOW_ORG_MUTATE=1` + **通过标准**: **4/4 green**(1 skipped mutate);`E2E_POINTS_RUN_GATES=1 npm run test:gm3` + **覆盖 AC**: AC-15, AC-16, AC-20, AC-22 + **依赖**: T015, T022, T024 + **阻塞**: 未通过不得开始 T023 完整运营台验收 + +### 阶段 4:运营台与前台齐套 → 门禁 G-M4 + +- [x] **T021b**: Portal — 我的积分完善(规则弹窗/空态/999+) ✅ 2026-08-07 + **文件**: Portal `PointsPage.tsx`(`rank_refreshed_at`、999+、空态、规则弹窗过滤 M*) + **逻辑**: 展示排名刷新时间;总榜 `999+` 不误拼 `#`;明细空态引导文案;规则弹窗仅 earn/deduct + 线下申诉 copies + **覆盖 AC**: AC-07, AC-14, AC-27 + **依赖**: T021a, **G-M3** + +- [x] **T023**: Portal — 积分管理后台完整 ✅ 2026-08-07 + **文件**: Portal `PointsManagementPanel` + `PointsRuleEditModal`;BiSheng `admin/users` / `admin/audit-logs` + **逻辑**: 概览三绝对数;规则四 Tab(获取/扣减/月奖/文案)可编辑分值·日 cap·受益人·启停;用户列表+操作记录;列表内「调整积分」直接调分(正加负减,不强制 R*) + **覆盖 AC**: AC-02, AC-05, AC-06, AC-19;AC-17 由本面板「违规扣减」覆盖(见 T025 取消说明) + **依赖**: T019 + +- [x] **T025**: Client(可选)— 前台 R* 扣减 — **取消 / WONTFIX** ✅ 2026-08-07 + **覆盖 AC**: AC-17(正式路径 = Portal 管理端「违规扣减」+ `POST /admin/deduct`) + **依赖**: T019 + **备注**: 2026-08-07 用户确认:业务不用 Client 文档/问答页就地扣分入口,T025 不再排期;不在 Client 实现该按钮 + +- [x] **G-M4**: 无头浏览器验收 — 运营配置与扣减 ✅ 2026-08-07 + **文件**: `e2e_ui/points/gates/gm4.spec.ts` + `helpers/gm4_trigger.py` + **逻辑**: G1 beneficiary→publisher 后发分落发布人;Portal「违规扣减」R1;规则弹窗无月奖文案且含「线下」;审计可见 + **通过标准**: **3/3 green**(`E2E_POINTS_RUN_GATES=1 npm run test:gm4`) + **覆盖 AC**: AC-06, AC-07, AC-17, AC-19, AC-27 + **依赖**: T021b, T023 + **阻塞**: 未通过不得宣称功能可交付 + +### 阶段 5:发布前 → 门禁 G-M5 + +- [x] **T026**: 本地联调(dev-stack)+ 补齐 Gate 数据工厂 ✅ 2026-08-07 + **文件**: `e2e_ui/points/helpers/factory_trigger.py` + `factory.ts` + **逻辑**: 统一造数 G2/G3/G4/G7、超管负例、外链负例、对账、`enabled=false`、schema 检查 + **覆盖 AC**: AC-08~AC-16, AC-26, AC-30 + **依赖**: T011–T025 + +- [x] **T027**: 双库迁移冒烟 ✅ 2026-08-07 + **文件**: `test/points/test_points_dual_db_smoke.py` + **逻辑**: 静态校验 dialect_helpers;MySQL 表可读(或 skip);DM8 macOS skip / CI Linux + **覆盖 AC**: AC-25 + **依赖**: T002 + +- [x] **T028**: 上线检查清单记录 ✅ 2026-08-07 + **文件**: `features/v2.6.0/070-points-system/release-checklist.md` + **逻辑**: design §4.5;灰度开关;对账;发布顺序;Gate 记录 + **覆盖 AC**: AC-04, AC-23, AC-29 + **依赖**: T026, T027 + +- [x] **G-M5**: 无头浏览器验收 — 发布前全量冒烟 ✅ 2026-08-07 + **文件**: `e2e_ui/points/gates/gm5.spec.ts`;`npm run test:gm5` 串行 gm1–gm4 + gm5 + **逻辑**: 对账无 mismatch;`enabled=false` 不加分;outbox 安全;入口冒烟;回归 gm1–gm4 + **通过标准**: **22 passed / 1 skipped**(org mutate) + **覆盖 AC**: 回归 AC-01~AC-30 主路径 + **依赖**: G-M1…G-M4, T027, T028 + **阻塞**: 未通过不得合入主干/发版 + +--- + +## Agent 执行约定(实现期) + +1. 完成某阶段最后开发任务后,**必须**启动联调栈并跑对应 `G-Mx`(headless)。 +2. 可用 `npx playwright test …/gmX.spec.ts` 或 Cursor Playwright MCP 按同一用例操作;结果以脚本可重复执行为准。 +3. Gate 失败:修缺陷 → 重跑 Gate → 通过后再勾选任务并进入下一阶段。 +4. 在 `tasks.md` 状态表或下方偏差记录写明:Gate 通过时间、commit、报告路径。 + +--- + +## AC 覆盖矩阵(摘要) + +| AC | 主要任务 / Gate | +|----|----------------| +| AC-01~04 | T004–T005, T017, G-M5 | +| AC-05~07 | T007, T018, T021*, T023, G-M1, G-M4 | +| AC-08~13, AC-30, AC-26 | T009–T013, T016, G-M2 | +| AC-14~16, AC-22 | T015, T018, T021–T022, G-M3 | +| AC-17~19 | T006, T019, T023, T025, G-M4 | +| AC-20~21 | T014, T024, G-M3 | +| AC-23~25, AC-27~29 | T002, T005, T017, T021b, T027–T028, G-M5 | + +--- + +## 实际偏差记录 + +> 实现阶段填写。 + +- 2026-08-06:源码曾被清空,按简化 DDD(单 `points_repository`)恢复;API 已接真实查询/调分/规则启停。 +- 2026-08-06:**G-M1 PASS**(`E2E_POINTS_RUN_GATES=1 npm run test:gm1`,**4/4**:规则列表、调分、我的积分、非超管 18201);Playwright 使用本机 Chrome `channel: 'chrome'`;登录改走 `/login`。 +- 调分曾因 MySQL 严格模式未写入 `occurred_at` 失败,已在 `PointsLedgerService` 显式落业务时间。 +- Portal 调分默认备注改为 ≥5 字以匹配后端校验。 +- 2026-08-06:`design.md` §0.1 写入 P1–P7;**P7 用户确认 = B(按受益人豁免)**,T009 可开。 +- 2026-08-06:从 `积分PRD V1.1.docx` 抽出 15 张模块原型 → `ui-prototypes/`(Portal 仓 `docs/points-ui-prototypes/` 有副本);前端以该目录为准。 +- 2026-08-06:**T009/T010/T020 完成**:`PointsAwardFacade` + 单测;空间等级映射 `public→G1 / department→G2 / team→G5 / team_ks→G6`;P7=B;`points.enabled` 默认 false。 +- 2026-08-06:**T011–T013 完成**:`points_award_hooks.py` 独立会话旁路;挂 `add_file` / 发布审批 / 收藏 / 采纳 / `share_approved`。种子补 G5/G6/G7(仅空租户首次 seed;已有库需管理端补规则或手工 INSERT)。`test/points/` 22 passed。 +- 2026-08-06:**G-M2 PASS 4/4**。修复 Facade 读开关路径(`config_service.settings`,原先误 import 导致始终 disabled)。本地 `config.yaml` `points.enabled=true`;171 库补 G7(id=11)。Gate 用 hooks 造数 + UI 断言(完整上传/审批 UI 流未在门禁内重放)。 +- 2026-08-07:**T014 完成**:`DepartmentOrgLevelService` + API;单测 5 条;联调冒烟 `gzx01` 调 `set-company-root` → **18201**,`GET org-levels` 200(46 节点)。未在共享库执行真实打标(避免误改 org_level)。`points_auth` 同时认 `is_global_super`。 +- 2026-08-07:**T023/T021b 完成**;用户列表直接调分弹窗对齐设计(+/- 按钮 + 纯数字);调分校验错误改为弹窗内展示。 +- 2026-08-07:**T025 曾延后**(Client 文档页 R* 入口);Portal 管理端补「违规扣减」弹窗作 AC-17 浏览器路径。**G-M4 PASS 3/3**。 +- 2026-08-07:**T026–T028 + G-M5 PASS**(`npm run test:gm5` → 22 passed / 1 skipped)。统一 `factory_trigger`;双库静态冒烟;`release-checklist.md`;修 gm3「排名」strict 选择器。 +- 2026-08-07:**T025 取消不做**(用户确认:用不到 Client 内容页扣减;违规扣减只走 Portal 运营后台)。 diff --git a/features/v2.6.0/release-contract.md b/features/v2.6.0/release-contract.md index eddb9df28..0e38a0ed3 100644 --- a/features/v2.6.0/release-contract.md +++ b/features/v2.6.0/release-contract.md @@ -25,6 +25,8 @@ | KnowledgeFilePdfArtifact | F063-unified-pdf-artifact | 知识文件统一 PDF 派生产物的当前 generation、独立处理状态、对象引用、重试信息和删除清理;不拥有 KnowledgeFile 解析状态、预览或下载行为 | | Tag.reviewer_id / Tag.review_time / ReviewTag.reviewer_id(扩展) | F079-tag-management-console | 标签审核留痕字段:谁审的、什么时候审的。审核通过搬运 `review_tag` → `tag` 时一并携带。不取得 `Tag` / `ReviewTag` 本身的创建、命名、打标或 Link A/Link B 解析行为所有权 | | DeveloperToken.file_sync_rule(扩展) | F066-token-configured-filelib-sync | 每个开发者 Token 最多一份完整的文件同步业务配置;只保存稳定分类/业务域编码、知识空间 ID、可空目录 ID、固定/动态模式及动态来源,不保存路径快照,不取得门户配置、部门、知识空间、目录或文件的所有权 | +| UserPointAccount / UserPointLog / PointRule / PointCopy / PointRankSnapshot / PointSyncOutbox / PointFavoriteTierAward | F070-points-system | 积分账户、append-only 流水、规则/说明文案、排行快照、外部同步 outbox、收藏阶梯已授档位;站内信文案为代码常量(无模板表);不拥有知识文件/审批/站内信本体写所有权 | +| Department.org_level(扩展) | F070-points-system | 仅拥有部门节点组织层级标签(company/dept/office/squad)的写入与「指定唯一公司根 → 级联打标」行为;不拥有部门树结构、用户挂载、组织同步 | | Department.short_name(扩展) | F082-department-short-name | 部门简称字段的创建、修改、清空与本地维护语义;不取得 F002 对 Department 其他 CRUD、物化路径和名称唯一性的所有权,不改变 F009/F014/F015 的同步事实源 | **规则**: @@ -49,7 +51,11 @@ | INV-8 | 动态业务域只读取当前租户首钢门户聚合配置中的 `domains[].department_ids`;Token 仅保存业务域编码引用,不复制部门与业务域映射,不建立第二配置事实源 | DeveloperToken.file_sync_rule, ShougangPortalAdminConfig | F066 | | INV-9 | 文件同步配置缺失、不完整、空间/目录引用失效、跨租户、目录不属于所选空间、绑定用户无目标节点上传权限、动态来源参数缺失、动态解析无唯一结果或业务域与目标空间未绑定时必须失败关闭;不得回退到根目录、请求中的其他 ID、调用人默认值、任意知识空间或旧接口固定规则 | DeveloperToken.file_sync_rule, Department, Knowledge, KnowledgeFile | F066 | | INV-10 | F069 的四个 Filelib 查询接口必须先通过 Developer Token 调用资格校验;可选 `external_id` 只在 Token 校验成功后决定业务用户、权限和全局数据作用域,缺失时回退 Token 绑定用户。显式值必须全局唯一匹配有效用户,不存在、禁用或重复均统一失败关闭;目标用户完整继承角色、ReBAC/RBAC 和全局超级管理员权限。该不变量仅适用于未启用租户功能的部署,启用多租户前必须重新评审,禁止直接沿用全局作用域。 | DeveloperToken, User, Knowledge | F069 | -| INV-11 | 首钢门户范围内的动态部门展示统一为 `trim(short_name) or name`,部门路径逐级应用同一规则,搜索同时匹配正式名称和简称,展示排序按展示名称稳定排序;既有 `name` / `department_name` 继续表达正式名称,部门 ID、权限、同步匹配及历史审批快照不得被简称替代或批量改写。该不变量不适用于 Platform 组织架构、首页无部门 ID 的硬编码积分榜、Filelib/同步/遥测事实字段。 | Department, User, Knowledge, Permission, Approval, QAExpert | F083 | +| INV-11 | 积分流水 `user_point_log` 为 append-only:禁止 UPDATE/DELETE;纠错仅追加冲正或调分流水;账户余额与流水在同一事务内更新,可用流水重算对账 | UserPointAccount, UserPointLog | F070 | +| INV-12 | 自动记分必须带租户内唯一 `idempotency_key`;重试/重复事件不得双计;平台超级管理员账号不参与自动发放与激励榜 | UserPointLog, User | F070 | +| INV-13 | `department.org_level` 全租户(或约定作用域)至多一个 `company` 节点;级联打标不得改写 `parent_id`/`path`/用户挂载;组织标签与知识空间 level 不得强绑 | Department.org_level, KnowledgeSpace | F070 | +| INV-14 | 积分规则配置、全站调分与说明文案仅平台超级管理员可写;公共库管理员不得改规则;站内不做申诉流程;积分站内信文案为代码常量不可运营配置 | PointRule, PointCopy, UserPointLog | F070 | +| INV-15 | 首钢门户范围内的动态部门展示统一为 `trim(short_name) or name`,部门路径逐级应用同一规则,搜索同时匹配正式名称和简称,展示排序按展示名称稳定排序;既有 `name` / `department_name` 继续表达正式名称,部门 ID、权限、同步匹配及历史审批快照不得被简称替代或批量改写。该不变量不适用于 Platform 组织架构、首页无部门 ID 的硬编码积分榜、Filelib/同步/遥测事实字段。 | Department, User, Knowledge, Permission, Approval, QAExpert | F083 | **规则**: - 新增不变量:先在此表追加,再写 AC @@ -72,6 +78,7 @@ | F066-token-configured-filelib-sync | F044, F047, F060 | 扩展开发者 Token 配置,收口 F047 的 11 个固定规则接口,复用 F060 动态空间解析器、Knowledge 目录只读契约与 PermissionService;不复制门户业务域配置或授权事实 | | F079-tag-management-console | F013 | 依赖多租户权限隔离基线;复用 workstation 现有审核标签可见空间解析与 knowledge 标签库服务;只为 `tag` / `review_tag` 追加审核留痕字段,不新增领域对象、不改 Link A/Link B 打标解析行为 | | F069-filelib-external-user-context | F004, F044 | 复用统一 PermissionService 与 Developer Token 认证;不新增身份或授权事实,只为四个 Filelib 查询接口组合调用资格与可选业务用户上下文;仅允许在未启用租户功能的部署发布 | +| F070-points-system | F002, F004, F009, F012, F025(发布审批结果只读), 现有 knowledge/qa_expert/message/telemetry | 新建积分域;扩展 Department.org_level;挂钩只读/调用知识上传发布、收藏、采纳、日活与站内信,不取得其写所有权;外部协同办公同步不阻塞 MVP | | F082-department-short-name | F002, F009, F014/F015 | 扩展 F002 的 Department 字段与既有创建/详情/更新链路;简称由本地维护,F009/F014/F015 组织同步不得覆盖 | | F083-portal-department-display-name | F082, F025, F060, F064, F065 | 只读 F082 的 `Department.short_name`,统一首钢门户、嵌入式知识门户、成员管理、审批、专家和水印展示;保留正式名称、历史快照、权限与同步契约,不新增领域对象或数据迁移 | @@ -88,6 +95,7 @@ | 198 | developer_token | F044 开发者 Token 管理与认证错误码;F066 在该模块追加文件同步规则 19813 与目标树游标 19814 | | 199 | filelib_sync | F047 文件同步既有错误码;F066 只在该模块内追加 Token 文件同步配置缺失等运行时错误码 | | 250 | portal_course | F062 门户课程管理、媒体校验与播放进度错误码 | +| 182 | points | F070 积分账户、流水、规则、排行、组织打标与同步 outbox 错误码 | | 120 | workstation(工作台) | 沿用现有 `common/errcode/workstation.py`(12000–12099);F079 追加标签管理控制台错误码 12046–12049,不得与既有 12040–12045 冲突 | --- @@ -108,5 +116,6 @@ | 2026-07-22 | 扩展 F066 固定目标到知识空间根目录或目录;明确选项、保存和运行时按 Token 绑定用户过滤/复核 `upload_file`,目录失效或无权不得回退根目录 | F066, Knowledge, Permission | | 2026-08-07 | 登记 F079 标签管理控制台:`Tag` / `ReviewTag` 审核留痕字段写所有权、120 模块错误码段 12046–12049、F013/F063 依赖;**批准 INV-6 定向豁免**——`/api/v1/workstation/tags/console/search` 与 `/review/search` 使用 page/total 分页,理由为可见空间集合一次性解析后下推 `IN`,不做逐行 ReBAC 判定,且属低频管理后台;豁免仅限这两个端点,接口若演化为逐行判权则自动失效 | F079, INV-6 | | 2026-08-02 | 登记 F069 Filelib 外部用户上下文:新增 INV-10 与 F004/F044 依赖,明确 Token 资格优先、可选目标用户完整权限、全局唯一匹配失败关闭及无租户部署边界 | F069, F004, F044, User, Knowledge | +| 2026-08-06 | 登记 F070 积分系统:领域对象、Department.org_level 扩展、INV-11~14、模块编码 182;外部同步不阻塞 MVP | F070, F002, Department, Knowledge, Message | | 2026-08-10 | 登记 F082 部门简称:为 `Department.short_name` 建立字段扩展所有权,明确可空 64 字符、本地维护、同步不覆盖及不改变组织树/搜索/权限边界 | F082, F002, F009, F014, F015 | | 2026-08-10 | 登记 F083 门户部门简称统一展示:新增 INV-11 与 F082/F025/F060/F064/F065 依赖;门户动态展示、搜索、排序和路径使用简称回退,正式名称字段、历史快照、部门 ID、权限及同步事实保持不变 | F083, F082, User, Knowledge, Permission, Approval, QAExpert | diff --git a/src/backend/AGENTS.md b/src/backend/AGENTS.md index 390f7c83d..83b4b8025 100644 --- a/src/backend/AGENTS.md +++ b/src/backend/AGENTS.md @@ -63,6 +63,9 @@ uv run uvicorn bisheng.main:app --host 0.0.0.0 --port 7860 --workers 1 --no-acce # Celery (one terminal per queue) uv run celery -A bisheng.worker.main worker -l info -c 20 -P threads -Q knowledge_celery -n knowledge@%h uv run celery -A bisheng.worker.main worker -l info -c 100 -P threads -Q workflow_celery -n workflow@%h +uv run celery -A bisheng.worker.main worker -l info -c 20 -P threads -Q points_award_celery -n points_award@%h +# Local points award only: `sh entrypoint.sh points_award` (or the command above). +# Compose `entrypoint.sh worker` already includes points_award_celery. uv run celery -A bisheng.worker.main beat -l info # Linsight Worker (optional) @@ -238,7 +241,8 @@ Independent Worker process, decoupled from API via Redis queue. | `knowledge_celery` | 20 threads | Document parsing, embedding, vector writes | | `knowledge_pdf_celery` | 2 threads | Unified PDF validation/conversion; independent low-concurrency Worker (`sh entrypoint.sh pdf`) | | `workflow_celery` | 100 threads | Workflow DAG execution | -| `celery` (default) | 100 threads | Telemetry stats | +| `points_award_celery` | 20 threads | Points async award (`process_points_award_event`); `sh entrypoint.sh points_award` for local-only | +| `celery` (default) | 100 threads | Telemetry stats, points rank/monthly/reconcile, org_sync, permission, etc. | Beat tasks: telemetry sync at 00:30, intelligence center articles at 05:30 daily. Worker heartbeat: writes to Redis (`celery_worker_alive_queues`) every 5 seconds. diff --git a/src/backend/bisheng/api/router.py b/src/backend/bisheng/api/router.py index 0550b0a74..23ce8362c 100644 --- a/src/backend/bisheng/api/router.py +++ b/src/backend/bisheng/api/router.py @@ -56,6 +56,7 @@ from bisheng.open_endpoints.api.router import ( from bisheng.org_sync.api.endpoints.relink import router as relink_router from bisheng.org_sync.api.router import router as org_sync_router from bisheng.permission.api.router import router as permission_router +from bisheng.points.api.router import router as points_router from bisheng.qa_expert.api.router import router as qa_expert_router from bisheng.role.api.router import router as role_router from bisheng.sensitive_word.api.router import router as sensitive_word_policy_router @@ -109,6 +110,7 @@ router.include_router(message_router) router.include_router(department_router) router.include_router(user_group_router) router.include_router(permission_router) +router.include_router(points_router) router.include_router(role_router) router.include_router(org_sync_router) router.include_router(sso_sync_router) diff --git a/src/backend/bisheng/approval/domain/services/shougang_approval_handler.py b/src/backend/bisheng/approval/domain/services/shougang_approval_handler.py index a352065e2..a5eae1899 100644 --- a/src/backend/bisheng/approval/domain/services/shougang_approval_handler.py +++ b/src/backend/bisheng/approval/domain/services/shougang_approval_handler.py @@ -613,6 +613,30 @@ class KnowledgeSpaceFilePublishApprovalHandler: result.target_space_id, result.idempotent, ) + # 积分旁路:发布入库按目标库 level 发分;幂等重放由账本键兜底。 + try: + from bisheng.points.domain.services.points_award_hooks import notify_space_files_ready + + source_entry = await KnowledgeFileDao.query_by_id( + int(payload_snapshot["source_entry_id"]) + ) + uploader_id = ( + int(source_entry.user_id) + if source_entry is not None and getattr(source_entry, "user_id", None) + else int(payload_snapshot["applicant_user_id"]) + ) + await notify_space_files_ready( + tenant_id=int(payload_snapshot["tenant_id"]), + space_id=int(result.target_space_id), + files=[SimpleNamespace(id=int(result.manager_file_id))], + uploader_id=uploader_id, + publisher_id=int(payload_snapshot["applicant_user_id"]), + ) + except Exception: + logger.exception( + "points.award.hooks publish notify failed document_id=%s", + result.document_id, + ) return { "document_id": result.document_id, "file_id": result.manager_file_id, @@ -798,6 +822,24 @@ class KnowledgeSpaceFilePublishApprovalHandler: tenant_id=int(payload_snapshot["tenant_id"]), ) await self._enqueue_content_statistics([int(copied_file.id)]) + # 积分旁路:旧版复制发布成功后按目标库发分。 + try: + from bisheng.points.domain.services.points_award_hooks import notify_space_files_ready + + await notify_space_files_ready( + tenant_id=int(payload_snapshot["tenant_id"]), + space_id=int(target_space_id), + files=[copied_file], + uploader_id=int(source_file.user_id) + if getattr(source_file, "user_id", None) + else int(payload_snapshot["applicant_user_id"]), + publisher_id=int(payload_snapshot["applicant_user_id"]), + ) + except Exception: + logger.exception( + "points.award.hooks legacy publish notify failed file_id=%s", + copied_file.id, + ) return { "file_id": int(copied_file.id), "target_space_id": target_space_id, @@ -1008,6 +1050,31 @@ class KnowledgeSpaceFileShareApprovalHandler: result.target_space_id, result.idempotent, ) + # 积分旁路:库间 SHARE(非外链)走 G7。 + try: + from bisheng.points.domain.services.points_award_hooks import notify_document_shared + + source_entry = await KnowledgeFileDao.query_by_id( + int(payload_snapshot["source_entry_id"]) + ) + uploader_id = ( + int(source_entry.user_id) + if source_entry is not None and getattr(source_entry, "user_id", None) + else int(payload_snapshot["applicant_user_id"]) + ) + await notify_document_shared( + tenant_id=int(payload_snapshot["tenant_id"]), + share_entry_id=int(result.share_entry_id), + source_space_id=int(payload_snapshot["source_space_id"]), + target_space_id=int(result.target_space_id), + uploader_id=uploader_id, + sharer_id=int(payload_snapshot["applicant_user_id"]), + ) + except Exception: + logger.exception( + "points.award.hooks share notify failed share_entry_id=%s", + result.share_entry_id, + ) return { "document_id": result.document_id, "file_id": result.share_entry_id, diff --git a/src/backend/bisheng/common/errcode/points.py b/src/backend/bisheng/common/errcode/points.py new file mode 100644 index 000000000..a43b102cb --- /dev/null +++ b/src/backend/bisheng/common/errcode/points.py @@ -0,0 +1,38 @@ +"""积分模块业务错误码。""" + +from bisheng.common.errcode.base import BaseErrorCode + + +class PointsPermissionDeniedError(BaseErrorCode): + """当前用户不是平台超级管理员。""" + Code, Msg = 18201, "无积分管理权限" + + +class PointsInvalidAdjustError(BaseErrorCode): + """调分参数不满足产品约束。""" + Code, Msg = 18202, "积分调整参数不合法" + + +class PointsRuleConflictError(BaseErrorCode): + """规则编码重复或受益人配置不匹配。""" + Code, Msg = 18203, "积分规则配置冲突" + + +class PointsRuleNotFoundError(BaseErrorCode): + """扣减时指定的启用规则不存在。""" + Code, Msg = 18204, "积分规则不存在或未启用" + + +class PointsCompanyRootConflictError(BaseErrorCode): + """禁止在已有公司子树内嵌套设置公司根(或子树内已有其他公司)。""" + Code, Msg = 18205, "不能在已有公司组织下嵌套设置公司" + + +class PointsNotCompanyRootError(BaseErrorCode): + """仅当前公司根节点可取消组织层级标签。""" + Code, Msg = 18207, "当前组织不是公司根节点" + + +class PointsIdempotentReplayError(BaseErrorCode): + """调用方显式要求报告的重复记账。""" + Code, Msg = 18206, "积分操作已处理" diff --git a/src/backend/bisheng/config_3002.yaml b/src/backend/bisheng/config_3002.yaml index 616bab1cd..fdef30a52 100644 --- a/src/backend/bisheng/config_3002.yaml +++ b/src/backend/bisheng/config_3002.yaml @@ -48,6 +48,8 @@ celery_task: queue: knowledge_celery bisheng.worker.knowledge.pdf_artifact_worker.generate_knowledge_file_pdf_celery: queue: knowledge_pdf_celery + bisheng.worker.points.tasks.process_points_award_event: + queue: points_award_celery bisheng.worker.knowledge.*: # 其他知识库后台任务 queue: celery bisheng.worker.workflow.*: # 工作流相关任务 diff --git a/src/backend/bisheng/config_3003.yaml b/src/backend/bisheng/config_3003.yaml index aec07fb3c..a0e50d61a 100644 --- a/src/backend/bisheng/config_3003.yaml +++ b/src/backend/bisheng/config_3003.yaml @@ -48,6 +48,8 @@ celery_task: queue: knowledge_celery bisheng.worker.knowledge.pdf_artifact_worker.generate_knowledge_file_pdf_celery: queue: knowledge_pdf_celery + bisheng.worker.points.tasks.process_points_award_event: + queue: points_award_celery bisheng.worker.knowledge.*: # 其他知识库后台任务 queue: celery bisheng.worker.workflow.*: # 工作流相关任务 diff --git a/src/backend/bisheng/core/config/celery_queues.py b/src/backend/bisheng/core/config/celery_queues.py index c59459d4f..de645a6f4 100644 --- a/src/backend/bisheng/core/config/celery_queues.py +++ b/src/backend/bisheng/core/config/celery_queues.py @@ -9,6 +9,7 @@ DEFAULT_CELERY_QUEUE = "celery" KNOWLEDGE_PARSE_QUEUE = "knowledge_celery" KNOWLEDGE_PDF_QUEUE = "knowledge_pdf_celery" WORKFLOW_CELERY_QUEUE = "workflow_celery" +POINTS_AWARD_QUEUE = "points_award_celery" KNOWLEDGE_PARSE_TASKS = frozenset( { @@ -23,6 +24,7 @@ KNOWLEDGE_PARSE_COMPAT_TASKS = frozenset( ) KNOWLEDGE_PARSE_ROUTED_TASKS = KNOWLEDGE_PARSE_TASKS | KNOWLEDGE_PARSE_COMPAT_TASKS PDF_ARTIFACT_TASK = "bisheng.worker.knowledge.pdf_artifact_worker.generate_knowledge_file_pdf_celery" +POINTS_AWARD_TASK = "bisheng.worker.points.tasks.process_points_award_event" _DEFAULT_QUEUE_PATTERNS = ( "bisheng.worker.knowledge.*", @@ -62,5 +64,9 @@ def build_celery_task_routes(configured_routes: Mapping[str, Any] | None) -> dic routes[task_pattern] = _normalize_configured_route(route) routes[PDF_ARTIFACT_TASK] = {"queue": KNOWLEDGE_PDF_QUEUE} - routes.update({task_name: {"queue": KNOWLEDGE_PARSE_QUEUE} for task_name in sorted(KNOWLEDGE_PARSE_ROUTED_TASKS)}) + # 异步发分专用队列:避免共享 Broker 上远端 default Worker 抢走未注册任务。 + routes[POINTS_AWARD_TASK] = {"queue": POINTS_AWARD_QUEUE} + routes.update( + {task_name: {"queue": KNOWLEDGE_PARSE_QUEUE} for task_name in sorted(KNOWLEDGE_PARSE_ROUTED_TASKS)} + ) return routes diff --git a/src/backend/bisheng/core/config/settings.py b/src/backend/bisheng/core/config/settings.py index f4117d781..faa3ac2ca 100644 --- a/src/backend/bisheng/core/config/settings.py +++ b/src/backend/bisheng/core/config/settings.py @@ -388,6 +388,41 @@ class CeleryConf(BaseModel): "schedule": crontab.from_string("0 2 * * *"), } + # F070: 积分排行快照,每小时第 5 分钟刷新(受 points.rank_cron_enabled 控制)。 + if "points_refresh_rank_snapshots" not in self.beat_schedule: + self.beat_schedule["points_refresh_rank_snapshots"] = { + "task": "bisheng.worker.points.tasks.refresh_points_rank_snapshots", + "schedule": crontab.from_string("5 * * * *"), + } + + # F070: 管理员月奖,每月 1 日 00:05 Asia/Shanghai(结算上月;受 monthly_reward_enabled 控制)。 + if "points_monthly_admin_rewards" not in self.beat_schedule: + self.beat_schedule["points_monthly_admin_rewards"] = { + "task": "bisheng.worker.points.tasks.run_monthly_admin_rewards", + "schedule": crontab.from_string("5 0 1 * *"), + } + + # F070: 积分日对账(AC-04);只告警,禁止静默改流水。 + if "points_reconcile_balances" not in self.beat_schedule: + self.beat_schedule["points_reconcile_balances"] = { + "task": "bisheng.worker.points.tasks.reconcile_point_balances", + "schedule": crontab.from_string("30 2 * * *"), + } + + # F070: 外部同步 outbox drain(AC-23);受 points.sync_outbox_enabled 控制。 + if "points_drain_sync_outbox" not in self.beat_schedule: + self.beat_schedule["points_drain_sync_outbox"] = { + "task": "bisheng.worker.points.tasks.drain_points_sync_outbox", + "schedule": crontab.from_string("0 3 * * *"), + } + + # F070: 违规删除后扣分失败的补扣队列(每 5 分钟)。 + if "points_drain_pending_deduct" not in self.beat_schedule: + self.beat_schedule["points_drain_pending_deduct"] = { + "task": "bisheng.worker.points.tasks.drain_points_pending_deduct", + "schedule": crontab.from_string("*/5 * * * *"), + } + # F049: automotive sheet intro sync daily fan-out (00:00 Asia/Shanghai). if "automotive_sheet_intro_sync_daily" not in self.beat_schedule: self.beat_schedule["automotive_sheet_intro_sync_daily"] = { @@ -898,6 +933,20 @@ class PortalHotSearchConf(BaseModel): llm_sample_max_chars: int = Field(default=2000, description="Max chars of LLM I/O persisted for diagnostics") +class PointsConf(BaseModel): + """积分自动发放与旁路任务开关。""" + + enabled: bool = Field(default=False, description="是否允许业务事件自动发放积分") + award_async_enabled: bool = Field( + default=True, + description="自动发分是否走 Celery;false 时 hooks 内同步入账(回滚开关)", + ) + notify_enabled: bool = Field(default=True, description="是否发送积分变动站内信") + sync_outbox_enabled: bool = Field(default=False, description="是否消费外部同步发件箱") + rank_cron_enabled: bool = Field(default=True, description="是否刷新积分榜快照") + monthly_reward_enabled: bool = Field(default=True, description="是否执行月度管理员奖励") + + class Settings(BaseModel): """Application Settings""" @@ -962,6 +1011,7 @@ class Settings(BaseModel): in_app_message_forwarding: InAppMessageForwardingConf = InAppMessageForwardingConf() database_pool: DatabasePoolConf = Field(default_factory=DatabasePoolConf) portal_hot_search: PortalHotSearchConf = Field(default_factory=PortalHotSearchConf) + points: PointsConf = Field(default_factory=PointsConf) @field_validator("database_url") @classmethod diff --git a/src/backend/bisheng/core/database/alembic/versions/v2_6_0_f078_points_system.py b/src/backend/bisheng/core/database/alembic/versions/v2_6_0_f078_points_system.py new file mode 100644 index 000000000..c7b405870 --- /dev/null +++ b/src/backend/bisheng/core/database/alembic/versions/v2_6_0_f078_points_system.py @@ -0,0 +1,53 @@ +"""重建积分表的幂等迁移。 + +Revision ID: f078_points_system +Revises: f077_knowledge_folder_sort_weight +""" + +from alembic import op +import sqlalchemy as sa + +from bisheng.points.domain.constants.seed_rules import SEED_COPIES, SEED_RULES +from bisheng.points.domain.models import PointCopy, PointFavoriteTierAward, PointRankSnapshot, PointRule, PointSyncOutbox, UserPointAccount, UserPointLog + +revision = "f078_points_system" +down_revision = "f077_knowledge_folder_sort_weight" +branch_labels = None +depends_on = None + + +def table_exists(bind, name: str) -> bool: + """跨数据库检查表是否已存在。""" + return name in sa.inspect(bind).get_table_names() + + +def column_exists(bind, table: str, column: str) -> bool: + """跨数据库检查列是否已存在。""" + return any(item["name"] == column for item in sa.inspect(bind).get_columns(table)) + + +def upgrade(): + """仅补齐缺失对象;已处于 f078 的数据库可安全执行此文件。""" + bind = op.get_bind() + if table_exists(bind, "department") and not column_exists(bind, "department", "org_level"): + op.add_column("department", sa.Column("org_level", sa.String(16), nullable=True)) + op.create_index("ix_department_tenant_org_level", "department", ["tenant_id", "org_level"]) + for model in (UserPointAccount, UserPointLog, PointRule, PointCopy, PointRankSnapshot, PointFavoriteTierAward, PointSyncOutbox): + if not table_exists(bind, model.__tablename__): + model.__table__.create(bind, checkfirst=True) + if table_exists(bind, "point_rule"): + count = bind.execute(sa.text("SELECT COUNT(*) FROM point_rule WHERE tenant_id = 1")).scalar() or 0 + if not count: + bind.execute( + sa.insert(PointRule.__table__), + [{"tenant_id": 1, "remark": None, **rule} for rule in SEED_RULES], + ) + bind.execute(sa.insert(PointCopy.__table__), [{"tenant_id": 1, **copy} for copy in SEED_COPIES]) + + +def downgrade(): + """开发环境逆向删除积分表;生产环境不应删除审计流水。""" + bind = op.get_bind() + for name in ("point_sync_outbox", "point_favorite_tier_award", "point_rank_snapshot", "point_copy", "point_rule", "user_point_log", "user_point_account"): + if table_exists(bind, name): + op.drop_table(name) diff --git a/src/backend/bisheng/core/database/alembic/versions/v2_6_0_f079_points_seed_g1g7_m1m8.py b/src/backend/bisheng/core/database/alembic/versions/v2_6_0_f079_points_seed_g1g7_m1m8.py new file mode 100644 index 000000000..c3d6eacd3 --- /dev/null +++ b/src/backend/bisheng/core/database/alembic/versions/v2_6_0_f079_points_seed_g1g7_m1m8.py @@ -0,0 +1,79 @@ +"""补种积分规则:确保 G1–G7、R1–R3、M1–M8 均在 point_rule 中。 + +Revision ID: f079_points_seed_g1g7_m1m8 +Revises: f078_points_system + +仅插入缺失的 (tenant_id, rule_code);不更新已有行,避免覆盖运营改过的分值/名称。 +""" + +from alembic import op +import sqlalchemy as sa + +from bisheng.points.domain.constants.seed_rules import SEED_COPIES, SEED_RULES +from bisheng.points.domain.models import PointCopy, PointRule + +revision = "f079_points_seed_g1g7_m1m8" +down_revision = "f078_points_system" +branch_labels = None +depends_on = None + + +def table_exists(bind, name: str) -> bool: + """跨数据库检查表是否已存在。""" + return name in sa.inspect(bind).get_table_names() + + +def _tenant_ids(bind) -> list[int]: + """有规则的租户;若表空则至少补 tenant=1。""" + rows = bind.execute(sa.text("SELECT DISTINCT tenant_id FROM point_rule")).fetchall() + ids = sorted({int(r[0]) for r in rows}) + return ids or [1] + + +def upgrade(): + """按种子补齐缺失规则与文案。""" + bind = op.get_bind() + if not table_exists(bind, "point_rule"): + return + + for tenant_id in _tenant_ids(bind): + for rule in SEED_RULES: + code = rule["rule_code"] + exists = bind.execute( + sa.text( + "SELECT 1 FROM point_rule WHERE tenant_id = :tid AND rule_code = :code LIMIT 1" + ), + {"tid": tenant_id, "code": code}, + ).first() + if exists: + continue + bind.execute( + sa.insert(PointRule.__table__), + [ + { + "tenant_id": tenant_id, + "remark": None, + **rule, + } + ], + ) + + if table_exists(bind, "point_copy"): + for copy in SEED_COPIES: + exists = bind.execute( + sa.text( + "SELECT 1 FROM point_copy WHERE tenant_id = :tid AND copy_key = :key LIMIT 1" + ), + {"tid": tenant_id, "key": copy["copy_key"]}, + ).first() + if exists: + continue + bind.execute( + sa.insert(PointCopy.__table__), + [{"tenant_id": tenant_id, **copy}], + ) + + +def downgrade(): + """不删除已写入的规则(可能已被运营使用或产生流水)。""" + pass diff --git a/src/backend/bisheng/core/database/alembic/versions/v2_6_0_f080_points_rule_default_status.py b/src/backend/bisheng/core/database/alembic/versions/v2_6_0_f080_points_rule_default_status.py new file mode 100644 index 000000000..9b1fb50d9 --- /dev/null +++ b/src/backend/bisheng/core/database/alembic/versions/v2_6_0_f080_points_rule_default_status.py @@ -0,0 +1,44 @@ +"""按产品约定同步 point_rule 默认启停。 + +Revision ID: f080_points_rule_default_status +Revises: f079_points_seed_g1g7_m1m8 + +默认 enabled:G1–G4、R1–R3、M1/M4/M6。 +种子中其余编码(G5–G7、M2/M3/M5/M7/M8)设为 disabled。 +不改名称/分值等运营字段。 +""" + +from alembic import op +import sqlalchemy as sa + +from bisheng.points.domain.constants.seed_rules import SEED_RULES + +revision = "f080_points_rule_default_status" +down_revision = "f079_points_seed_g1g7_m1m8" +branch_labels = None +depends_on = None + + +def table_exists(bind, name: str) -> bool: + """跨数据库检查表是否已存在。""" + return name in sa.inspect(bind).get_table_names() + + +def upgrade(): + """将种子规则的 status 写回各租户已有行。""" + bind = op.get_bind() + if not table_exists(bind, "point_rule"): + return + for rule in SEED_RULES: + bind.execute( + sa.text( + "UPDATE point_rule SET status = :status " + "WHERE rule_code = :code" + ), + {"status": rule["status"], "code": rule["rule_code"]}, + ) + + +def downgrade(): + """不回滚启停(运营可能已再次调整)。""" + pass diff --git a/src/backend/bisheng/core/database/alembic/versions/v2_6_0_f081_points_copy_guide_only.py b/src/backend/bisheng/core/database/alembic/versions/v2_6_0_f081_points_copy_guide_only.py new file mode 100644 index 000000000..854fc583a --- /dev/null +++ b/src/backend/bisheng/core/database/alembic/versions/v2_6_0_f081_points_copy_guide_only.py @@ -0,0 +1,64 @@ +"""point_copy 仅保留单条文案 guide。 + +Revision ID: f081_points_copy_guide_only +Revises: f080_points_rule_default_status + +删除 earn_intro / deduct_intro 等历史多 key 行;若缺 guide 则按种子补种。 +""" + +from alembic import op +import sqlalchemy as sa + +from bisheng.points.domain.constants.seed_rules import SEED_COPIES +from bisheng.points.domain.models.points import PointCopy + +revision = "f081_points_copy_guide_only" +down_revision = "f080_points_rule_default_status" +branch_labels = None +depends_on = None + +GUIDE_KEY = "guide" + + +def table_exists(bind, name: str) -> bool: + """Cross-dialect table existence check.""" + return name in sa.inspect(bind).get_table_names() + + +def upgrade(): + """Drop non-guide copies; ensure each tenant has the guide seed row.""" + bind = op.get_bind() + if not table_exists(bind, "point_copy"): + return + + bind.execute(sa.text("DELETE FROM point_copy WHERE copy_key <> :key"), {"key": GUIDE_KEY}) + + guide = next((c for c in SEED_COPIES if c["copy_key"] == GUIDE_KEY), None) + if not guide: + return + + tenant_ids = [ + int(r[0]) + for r in bind.execute(sa.text("SELECT DISTINCT tenant_id FROM point_rule")).fetchall() + ] + if not tenant_ids: + tenant_ids = [1] + + for tenant_id in tenant_ids: + exists = bind.execute( + sa.text( + "SELECT 1 FROM point_copy WHERE tenant_id = :tid AND copy_key = :key LIMIT 1" + ), + {"tid": tenant_id, "key": GUIDE_KEY}, + ).first() + if exists: + continue + bind.execute( + sa.insert(PointCopy.__table__), + [{"tenant_id": tenant_id, **guide}], + ) + + +def downgrade(): + """Irreversible data cleanup — no restore of legacy multi-key copies.""" + pass diff --git a/src/backend/bisheng/core/database/alembic/versions/v2_6_0_f082_points_perf_indexes.py b/src/backend/bisheng/core/database/alembic/versions/v2_6_0_f082_points_perf_indexes.py new file mode 100644 index 000000000..63e69fc18 --- /dev/null +++ b/src/backend/bisheng/core/database/alembic/versions/v2_6_0_f082_points_perf_indexes.py @@ -0,0 +1,75 @@ +"""积分模块性能索引:排行快照补索引,流水表补概览/审计索引。 + +Revision ID: f082_points_perf_indexes +Revises: f081_points_copy_guide_only + +背景:point_rank_snapshot 建表时未声明任何二级索引,榜单读取、「我的排名」 +以及每小时刷新的整桶 DELETE 全部退化为全表扫描;user_point_log 上运营概览的 +SUM(delta) 与审计列表的 source 过滤同样无可用索引。 + +本迁移只做加索引与扩索引,不改数据,可安全回滚。 +""" + +from alembic import op +import sqlalchemy as sa + +revision = "f082_points_perf_indexes" +down_revision = "f081_points_copy_guide_only" +branch_labels = None +depends_on = None + +# 新建索引:(表名, 索引名, 列) +NEW_INDEXES = [ + ("point_rank_snapshot", "ix_prs_bucket", ["tenant_id", "period", "scope", "period_key", "scope_id", "rank_no"]), + ("point_rank_snapshot", "ix_prs_user", ["tenant_id", "period", "period_key", "user_id", "scope", "scope_id"]), + ("point_rank_snapshot", "ix_prs_refresh", ["tenant_id", "period", "period_key", "refreshed_at"]), + ("user_point_log", "ix_upl_source_time", ["tenant_id", "source", "occurred_at", "id"]), +] + +# 扩列索引:末尾补 delta 后 SUM(delta) 可纯索引扫描 +DIR_INDEX_TABLE = "user_point_log" +DIR_INDEX_NAME = "ix_upl_tenant_dir_time" +DIR_INDEX_OLD = ["tenant_id", "direction", "occurred_at"] +DIR_INDEX_NEW = ["tenant_id", "direction", "occurred_at", "delta"] + + +def table_exists(bind, name: str) -> bool: + """跨数据库检查表是否存在。""" + return name in sa.inspect(bind).get_table_names() + + +def index_exists(bind, table: str, name: str) -> bool: + """跨数据库检查索引是否存在。""" + return any(item["name"] == name for item in sa.inspect(bind).get_indexes(table)) + + +def _create(bind, table: str, name: str, columns: list[str]) -> None: + """幂等建索引:表缺失或索引已存在时跳过。""" + if not table_exists(bind, table) or index_exists(bind, table, name): + return + op.create_index(name, table, columns) + + +def _drop(bind, table: str, name: str) -> None: + """幂等删索引。""" + if table_exists(bind, table) and index_exists(bind, table, name): + op.drop_index(name, table_name=table) + + +def upgrade(): + """补齐缺失索引;重复执行安全。""" + bind = op.get_bind() + for table, name, columns in NEW_INDEXES: + _create(bind, table, name, columns) + # 扩列需先删后建(DM8 不支持原地改索引定义)。 + _drop(bind, DIR_INDEX_TABLE, DIR_INDEX_NAME) + _create(bind, DIR_INDEX_TABLE, DIR_INDEX_NAME, DIR_INDEX_NEW) + + +def downgrade(): + """回退到 f081 的索引形态。""" + bind = op.get_bind() + _drop(bind, DIR_INDEX_TABLE, DIR_INDEX_NAME) + _create(bind, DIR_INDEX_TABLE, DIR_INDEX_NAME, DIR_INDEX_OLD) + for table, name, _columns in reversed(NEW_INDEXES): + _drop(bind, table, name) diff --git a/src/backend/bisheng/core/database/alembic/versions/v2_6_0_f083_points_pending_deduct.py b/src/backend/bisheng/core/database/alembic/versions/v2_6_0_f083_points_pending_deduct.py new file mode 100644 index 000000000..2fb3317eb --- /dev/null +++ b/src/backend/bisheng/core/database/alembic/versions/v2_6_0_f083_points_pending_deduct.py @@ -0,0 +1,53 @@ +"""积分补扣队列:违规删除后扣分失败可重试。 + +Revision ID: f083_points_pending_deduct +Revises: f082_points_perf_indexes +""" + +from alembic import op +import sqlalchemy as sa +from sqlalchemy import inspect + +revision = "f083_points_pending_deduct" +down_revision = "f082_points_perf_indexes" +branch_labels = None +depends_on = None + +TABLE = "point_pending_deduct" + + +def upgrade() -> None: + """创建 point_pending_deduct;表已存在则跳过。""" + bind = op.get_bind() + if TABLE in inspect(bind).get_table_names(): + return + op.create_table( + TABLE, + sa.Column("id", sa.BigInteger(), autoincrement=True, nullable=False), + sa.Column("tenant_id", sa.Integer(), server_default=sa.text("1"), nullable=False), + sa.Column("user_id", sa.Integer(), nullable=False), + sa.Column("rule_code", sa.String(length=32), nullable=False), + sa.Column("biz_type", sa.String(length=32), nullable=False), + sa.Column("biz_id", sa.String(length=64), nullable=False), + sa.Column("idempotency_key", sa.String(length=128), nullable=False), + sa.Column("operator_id", sa.Integer(), nullable=True), + sa.Column("remark", sa.String(length=200), nullable=True), + sa.Column("status", sa.String(length=16), server_default=sa.text("'pending'"), nullable=False), + sa.Column("retry_count", sa.Integer(), server_default=sa.text("0"), nullable=False), + sa.Column("next_retry_at", sa.DateTime(), nullable=True), + sa.Column("last_error", sa.Text(), nullable=True), + sa.Column("create_time", sa.DateTime(), server_default=sa.text("CURRENT_TIMESTAMP"), nullable=False), + sa.Column("update_time", sa.DateTime(), server_default=sa.text("CURRENT_TIMESTAMP"), nullable=False), + sa.PrimaryKeyConstraint("id"), + sa.UniqueConstraint("tenant_id", "idempotency_key", name="uk_ppd_tenant_idem"), + ) + op.create_index("ix_ppd_due", TABLE, ["status", "next_retry_at", "id"]) + + +def downgrade() -> None: + """删除补扣队列表。""" + bind = op.get_bind() + if TABLE not in inspect(bind).get_table_names(): + return + op.drop_index("ix_ppd_due", table_name=TABLE) + op.drop_table(TABLE) diff --git a/src/backend/bisheng/core/database/alembic/versions/v2_6_0_f084_points_column_comments.py b/src/backend/bisheng/core/database/alembic/versions/v2_6_0_f084_points_column_comments.py new file mode 100644 index 000000000..7015c0fb0 --- /dev/null +++ b/src/backend/bisheng/core/database/alembic/versions/v2_6_0_f084_points_column_comments.py @@ -0,0 +1,134 @@ +"""为全部积分表列补齐中文 COMMENT(库侧字段说明)。 + +Revision ID: f084_points_column_comments +Revises: f083_points_pending_deduct + +无新增/删除列;仅更新 COMMENT。注释文案以 ORM ``Column(comment=...)`` 为准。 +""" + +from __future__ import annotations + +import sqlalchemy as sa +from alembic import op +from sqlalchemy import inspect + +from bisheng.points.domain.models import ( + PointCopy, + PointFavoriteTierAward, + PointPendingDeduct, + PointRankSnapshot, + PointRule, + PointSyncOutbox, + UserPointAccount, + UserPointLog, +) + +revision = "f084_points_column_comments" +down_revision = "f083_points_pending_deduct" +branch_labels = None +depends_on = None + +_POINT_MODELS = ( + UserPointAccount, + UserPointLog, + PointRule, + PointCopy, + PointRankSnapshot, + PointFavoriteTierAward, + PointPendingDeduct, + PointSyncOutbox, +) + + +def _escape_sql_literal(value: str) -> str: + """转义单引号,供 COMMENT 字面量使用。""" + return value.replace("'", "''") + + +def _apply_mysql_comment(bind, table: str, column: str, comment: str) -> None: + """MySQL/MariaDB:用 information_schema 拼 MODIFY,避免 alter_column 二次转义 DEFAULT。 + + inspector 返回的 default 形如 ``'1'``(已含引号);若原样传给 + ``existing_server_default`` 会变成 ``DEFAULT '''1'''`` 并触发 1067。 + 同时保留 EXTRA(如 ON UPDATE CURRENT_TIMESTAMP / auto_increment)。 + """ + row = bind.execute( + sa.text( + """ + SELECT COLUMN_TYPE, IS_NULLABLE, COLUMN_DEFAULT, EXTRA, COLUMN_COMMENT + FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = :table + AND COLUMN_NAME = :column + """ + ), + {"table": table, "column": column}, + ).mappings().first() + if row is None: + return + # 已是目标文案则跳过,支持中断后重跑 + if (row["COLUMN_COMMENT"] or "") == comment: + return + + null_sql = "NULL" if row["IS_NULLABLE"] == "YES" else "NOT NULL" + default = row["COLUMN_DEFAULT"] + # MySQL 8 EXTRA 可能含 DEFAULT_GENERATED,不能原样拼进 MODIFY + extra_l = (row["EXTRA"] or "").lower() + parts = [f"ALTER TABLE `{table}` MODIFY COLUMN `{column}` {row['COLUMN_TYPE']} {null_sql}"] + + if default is not None: + # CURRENT_TIMESTAMP 等函数默认值不能再加引号;普通标量按字符串字面量写入 + upper = str(default).upper() + if upper in ("CURRENT_TIMESTAMP", "CURRENT_TIMESTAMP()", "NULL") or upper.startswith( + "CURRENT_TIMESTAMP" + ): + parts.append(f"DEFAULT {default}") + else: + parts.append(f"DEFAULT '{_escape_sql_literal(str(default))}'") + if "auto_increment" in extra_l: + parts.append("AUTO_INCREMENT") + if "on update current_timestamp" in extra_l: + parts.append("ON UPDATE CURRENT_TIMESTAMP") + parts.append(f"COMMENT '{_escape_sql_literal(comment)}'") + op.execute(sa.text(" ".join(parts))) + + +def _apply_dm_comment(table: str, column: str, comment: str) -> None: + """达梦/Oracle 风格:COMMENT ON COLUMN。""" + escaped = _escape_sql_literal(comment) + op.execute(sa.text(f'COMMENT ON COLUMN "{table}"."{column}" IS \'{escaped}\'')) + + +def upgrade() -> None: + """按 ORM 中的 Column.comment 为已存在的积分表列补注释。""" + bind = op.get_bind() + dialect = bind.dialect.name + if dialect == "sqlite": + # SQLite 无稳定列注释语义,跳过 + return + + inspector = inspect(bind) + existing_tables = set(inspector.get_table_names()) + + for model in _POINT_MODELS: + table = model.__tablename__ + if table not in existing_tables: + continue + col_by_name = {c["name"]: c for c in inspector.get_columns(table)} + for column in model.__table__.columns: + comment = column.comment + if not comment: + continue + name = column.name + if name not in col_by_name: + continue + if dialect in ("dm", "oracle"): + _apply_dm_comment(table, name, comment) + else: + # mysql / mariadb + _apply_mysql_comment(bind, table, name, comment) + + +def downgrade() -> None: + """注释为文档元数据,downgrade 不强制清空(避免误伤手工维护的库注释)。""" + return diff --git a/src/backend/bisheng/core/database/alembic/versions/v2_6_0_f085_merge_points_dept_short_name.py b/src/backend/bisheng/core/database/alembic/versions/v2_6_0_f085_merge_points_dept_short_name.py new file mode 100644 index 000000000..792965c9d --- /dev/null +++ b/src/backend/bisheng/core/database/alembic/versions/v2_6_0_f085_merge_points_dept_short_name.py @@ -0,0 +1,30 @@ +"""Merge alembic heads: points f084 + department short_name f082. + +Revision ID: f085_merge_points_dept_short_name +Revises: f084_points_column_comments, f082_department_short_name +Create Date: 2026-08-10 + +积分链路与 feat/2.5.0-sg 的部门简称链路自 f077 分叉后形成双 head,本空合并 revision 收口。 +""" + +from __future__ import annotations + +from collections.abc import Sequence + +revision: str = "f085_merge_points_dept_short_name" +down_revision: str | Sequence[str] | None = ( + "f084_points_column_comments", + "f082_department_short_name", +) +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + """空合并:两侧 schema 已由各自 revision 完成。""" + pass + + +def downgrade() -> None: + """空合并无反向 DDL。""" + pass diff --git a/src/backend/bisheng/database/models/department.py b/src/backend/bisheng/database/models/department.py index e59610f7e..44402c586 100644 --- a/src/backend/bisheng/database/models/department.py +++ b/src/backend/bisheng/database/models/department.py @@ -210,6 +210,16 @@ class Department(SQLModelSerializable, table=True): comment="Dept-wide max concurrent daily-mode chat users; 0=unlimited (F030)", ), ) + # 积分部门榜使用的业务标签;不参与部门树拓扑和用户挂载。 + org_level: str | None = Field( + default=None, + sa_column=Column( + String(16), + nullable=True, + index=True, + comment="Points org level: company/dept/office/squad", + ), + ) create_user: int | None = Field( default=None, sa_column=Column(Integer, nullable=True, comment="Creator user ID"), diff --git a/src/backend/bisheng/department/api/endpoints/department_org_level.py b/src/backend/bisheng/department/api/endpoints/department_org_level.py new file mode 100644 index 000000000..57f22f435 --- /dev/null +++ b/src/backend/bisheng/department/api/endpoints/department_org_level.py @@ -0,0 +1,55 @@ +"""组织层级标签 API(Platform 部门树)。 + +字面路径须挂在 ``GET /{dept_id}`` 之前,避免被路径参数吞掉。 +""" + +from fastapi import APIRouter, Depends + +from bisheng.common.dependencies.user_deps import UserPayload +from bisheng.common.errcode.base import BaseErrorCode +from bisheng.common.schemas.api import resp_200 +from bisheng.points.domain.schemas.points_schema import SetCompanyRootRequest +from bisheng.points.domain.services.department_org_level_service import DepartmentOrgLevelService + +router = APIRouter() +_service = DepartmentOrgLevelService() + + +@router.get("/org-levels") +async def list_org_levels( + login_user: UserPayload = Depends(UserPayload.get_login_user), +): + """返回当前租户活跃部门的 org_level 列表。""" + try: + rows = await _service.list_org_levels(login_user) + return resp_200([row.model_dump() for row in rows]) + except BaseErrorCode as exc: + return exc.return_resp_instance() + + +@router.post("/{dept_id}/set-company-root") +async def set_company_root( + dept_id: str, + body: SetCompanyRootRequest | None = None, + login_user: UserPayload = Depends(UserPayload.get_login_user), +): + """指定唯一公司根并级联打标;非超管 18201,冲突 18205。""" + _ = body # confirm 预留;当前一次调用即执行。 + try: + data = await _service.set_company_root(login_user, dept_id) + return resp_200(data.model_dump()) + except BaseErrorCode as exc: + return exc.return_resp_instance() + + +@router.post("/{dept_id}/clear-company-root") +async def clear_company_root( + dept_id: str, + login_user: UserPayload = Depends(UserPayload.get_login_user), +): + """取消公司根并清空本租户组织层级标签;非公司根 18207。""" + try: + data = await _service.clear_company_root(login_user, dept_id) + return resp_200(data.model_dump()) + except BaseErrorCode as exc: + return exc.return_resp_instance() diff --git a/src/backend/bisheng/department/api/router.py b/src/backend/bisheng/department/api/router.py index fd7b9539e..6922c1f10 100644 --- a/src/backend/bisheng/department/api/router.py +++ b/src/backend/bisheng/department/api/router.py @@ -7,9 +7,12 @@ from fastapi import APIRouter from bisheng.department.api.endpoints.department import router as department_router from bisheng.department.api.endpoints.department_member import router as member_router +from bisheng.department.api.endpoints.department_org_level import router as org_level_router router = APIRouter(prefix='/departments', tags=['Department']) # 先挂 member:``/{dept_id}/members/...`` 等多段路径;department 内字面路由(含 ``/search/global-members``)须在 ``GET /{dept_id}`` 之前定义。 # 全组织搜索已改为 ``/search/global-members``(两段),避免与 ``GET /{dept_id}`` 将 ``global-members`` 误解析为部门 id。 +# F070 org-levels / set-company-root 须在 ``GET /{dept_id}`` 之前,避免 ``org-levels`` 被当成 dept_id。 router.include_router(member_router) +router.include_router(org_level_router) router.include_router(department_router) diff --git a/src/backend/bisheng/knowledge/domain/services/knowledge_space_service.py b/src/backend/bisheng/knowledge/domain/services/knowledge_space_service.py index 03714320a..f830c962d 100644 --- a/src/backend/bisheng/knowledge/domain/services/knowledge_space_service.py +++ b/src/backend/bisheng/knowledge/domain/services/knowledge_space_service.py @@ -4667,6 +4667,26 @@ class KnowledgeSpaceService(KnowledgeUtils): canonical_document_id=canonical_document_id, ) await KnowledgeDao.async_update_knowledge_update_time_by_id(int(fav_space.id)) + # 积分旁路:首次收藏计入 G3 去重人数;已存在收藏的 early-return 不触发。 + try: + from bisheng.points.domain.services.points_award_hooks import notify_favorite_changed + + await notify_favorite_changed( + tenant_id=int( + getattr(source_file, "tenant_id", None) + or getattr(source_space, "tenant_id", None) + or self.login_user.tenant_id + or 1 + ), + source_file_id=source_file_id, + source_space_id=int(req.source_space_id), + uploader_id=int(source_file.user_id) if getattr(source_file, "user_id", None) else None, + ) + except Exception: + _logger.exception( + "points.award.hooks favorite notify failed source_file_id=%s", + source_file_id, + ) title = Path(ref_file.file_name or source_file.file_name or "").stem return ShougangPortalFavoriteCreateResp( favorite_file_id=int(ref_file.id), @@ -15889,6 +15909,22 @@ class KnowledgeSpaceService(KnowledgeUtils): ) await self.update_folder_update_time(file_level_path) await KnowledgeDao.async_update_knowledge_update_time_by_id(knowledge_id) + # 积分旁路:上传成功记分,失败不影响返回。 + try: + from bisheng.points.domain.services.points_award_hooks import notify_space_files_ready + + await notify_space_files_ready( + tenant_id=int(getattr(db_knowledge, "tenant_id", None) or self.login_user.tenant_id or 1), + space_id=int(knowledge_id), + files=process_files, + uploader_id=int(self.login_user.user_id), + is_favorite_space=bool(getattr(db_knowledge, "is_favorite", False)), + ) + except Exception: + _logger.exception( + "points.award.hooks add_file notify failed knowledge_id=%s", + knowledge_id, + ) return failed_files + process_files @staticmethod diff --git a/src/backend/bisheng/points/__init__.py b/src/backend/bisheng/points/__init__.py new file mode 100644 index 000000000..2dcf5262c --- /dev/null +++ b/src/backend/bisheng/points/__init__.py @@ -0,0 +1 @@ +"""积分领域模块。""" diff --git a/src/backend/bisheng/points/api/dependencies.py b/src/backend/bisheng/points/api/dependencies.py new file mode 100644 index 000000000..62540ab9f --- /dev/null +++ b/src/backend/bisheng/points/api/dependencies.py @@ -0,0 +1,64 @@ +"""积分 API 依赖注入。""" + +from fastapi import Depends +from sqlmodel.ext.asyncio.session import AsyncSession + +from bisheng.common.dependencies.core_deps import get_db_session +from bisheng.common.dependencies.user_deps import UserPayload +from bisheng.core.context.tenant import DEFAULT_TENANT_ID, get_current_tenant_id +from bisheng.message.api.dependencies import get_message_service +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.points.domain.services.points_query_service import PointsQueryService +from bisheng.points.domain.services.points_rule_service import PointsRuleService + + +def get_login_user(user: UserPayload = Depends(UserPayload.get_login_user)) -> UserPayload: + """统一暴露当前已登录用户。""" + return user + + +def resolve_tenant_id(user: UserPayload) -> int: + """解析当前请求租户;优先中间件注入的 tenant context。""" + return int(get_current_tenant_id() or user.tenant_id or DEFAULT_TENANT_ID) + + +async def get_points_repository( + session: AsyncSession = Depends(get_db_session), +) -> PointsRepository: + """提供积分仓储。""" + return PointsRepository(session) + + +async def get_points_notify_service( + message_service=Depends(get_message_service), +) -> PointsNotifyService: + """提供积分站内信服务。""" + return PointsNotifyService(message_service=message_service) + + +async def get_points_ledger_service( + repository: PointsRepository = Depends(get_points_repository), + notify: PointsNotifyService = Depends(get_points_notify_service), +) -> PointsLedgerService: + """提供账本服务。""" + return PointsLedgerService(repository, notify_service=notify) + + +async def get_points_query_service( + session: AsyncSession = Depends(get_db_session), + repository: PointsRepository = Depends(get_points_repository), + ledger: PointsLedgerService = Depends(get_points_ledger_service), + notify: PointsNotifyService = Depends(get_points_notify_service), +) -> PointsQueryService: + """提供查询与管理端调分服务。""" + return PointsQueryService(session, repository, ledger, notify) + + +async def get_points_rule_service( + session: AsyncSession = Depends(get_db_session), + repository: PointsRepository = Depends(get_points_repository), +) -> PointsRuleService: + """提供规则与文案服务。""" + return PointsRuleService(session, repository) diff --git a/src/backend/bisheng/points/api/endpoints/admin.py b/src/backend/bisheng/points/api/endpoints/admin.py new file mode 100644 index 000000000..82decdfba --- /dev/null +++ b/src/backend/bisheng/points/api/endpoints/admin.py @@ -0,0 +1,180 @@ +"""积分运营后台接口;所有入口都要求平台管理员。""" + +from fastapi import APIRouter, Depends, Query + +from bisheng.common.dependencies.user_deps import UserPayload +from bisheng.common.errcode.base import BaseErrorCode +from bisheng.common.schemas.api import resp_200 +from bisheng.points.api.dependencies import ( + get_points_query_service, + get_points_rule_service, + resolve_tenant_id, +) +from bisheng.points.domain.schemas.points_schema import ( + PointAdjustRequest, + PointCopiesUpdateRequest, + PointDeductRequest, + PointRuleRequest, +) +from bisheng.points.domain.services.points_query_service import PointsQueryService +from bisheng.points.domain.services.points_rule_service import PointsRuleService + +router = APIRouter() + + +@router.get("/admin/overview") +async def overview( + login_user: UserPayload = Depends(UserPayload.get_login_user), + service: PointsQueryService = Depends(get_points_query_service), +): + """返回积分运营的三项绝对数。""" + try: + data = await service.overview(resolve_tenant_id(login_user), login_user) + return resp_200(data.model_dump()) + except BaseErrorCode as exc: + return exc.return_resp_instance() + + +@router.get("/admin/rules") +async def list_rules( + rule_type: str | None = Query(None), + status: str | None = Query(None), + login_user: UserPayload = Depends(UserPayload.get_login_user), + service: PointsRuleService = Depends(get_points_rule_service), +): + """列出当前租户的规则。""" + try: + rows = await service.list_rules( + resolve_tenant_id(login_user), login_user, rule_type=rule_type, status=status + ) + return resp_200([r.model_dump() for r in rows]) + except BaseErrorCode as exc: + return exc.return_resp_instance() + + +@router.post("/admin/rules") +async def create_rule( + body: PointRuleRequest, + login_user: UserPayload = Depends(UserPayload.get_login_user), + service: PointsRuleService = Depends(get_points_rule_service), +): + """创建规则;不提供 DELETE,停用走 PUT status=disabled。""" + try: + row = await service.create_rule(resolve_tenant_id(login_user), login_user, body) + return resp_200(row.model_dump()) + except BaseErrorCode as exc: + return exc.return_resp_instance() + + +@router.put("/admin/rules/{rule_id}") +async def update_rule( + rule_id: int, + body: PointRuleRequest, + login_user: UserPayload = Depends(UserPayload.get_login_user), + service: PointsRuleService = Depends(get_points_rule_service), +): + """编辑或启停规则。""" + try: + row = await service.update_rule(resolve_tenant_id(login_user), login_user, rule_id, body) + return resp_200(row.model_dump()) + except BaseErrorCode as exc: + return exc.return_resp_instance() + + +@router.get("/admin/copies") +async def list_copies( + login_user: UserPayload = Depends(UserPayload.get_login_user), + service: PointsRuleService = Depends(get_points_rule_service), +): + """管理端说明文案列表。""" + try: + rows = await service.list_copies(resolve_tenant_id(login_user), login_user) + return resp_200([r.model_dump() for r in rows]) + except BaseErrorCode as exc: + return exc.return_resp_instance() + + +@router.put("/admin/copies") +async def save_copies( + body: PointCopiesUpdateRequest, + login_user: UserPayload = Depends(UserPayload.get_login_user), + service: PointsRuleService = Depends(get_points_rule_service), +): + """批量更新前台说明文案。""" + try: + rows = await service.update_copies(resolve_tenant_id(login_user), login_user, body) + return resp_200([r.model_dump() for r in rows]) + except BaseErrorCode as exc: + return exc.return_resp_instance() + + +@router.post("/admin/adjust") +async def adjust( + body: PointAdjustRequest, + login_user: UserPayload = Depends(UserPayload.get_login_user), + service: PointsQueryService = Depends(get_points_query_service), +): + """平台超管手动调分。""" + try: + log = await service.admin_adjust(resolve_tenant_id(login_user), login_user, body) + return resp_200(log.model_dump()) + except BaseErrorCode as exc: + return exc.return_resp_instance() + + +@router.post("/admin/deduct") +async def deduct( + body: PointDeductRequest, + login_user: UserPayload = Depends(UserPayload.get_login_user), + service: PointsQueryService = Depends(get_points_query_service), +): + """平台超管按 R 规则扣减。""" + try: + log = await service.admin_deduct(resolve_tenant_id(login_user), login_user, body) + return resp_200(log.model_dump()) + except BaseErrorCode as exc: + return exc.return_resp_instance() + + +@router.get("/admin/users") +async def list_users( + keyword: str | None = Query(None), + page: int = Query(1, ge=1), + page_size: int = Query(20, ge=1, le=100), + login_user: UserPayload = Depends(UserPayload.get_login_user), + service: PointsQueryService = Depends(get_points_query_service), +): + """用户积分列表(姓名/部门/余额/本月净变动)。""" + try: + data = await service.admin_list_users( + resolve_tenant_id(login_user), + login_user, + keyword=keyword, + page=page, + page_size=page_size, + ) + return resp_200(data.model_dump()) + except BaseErrorCode as exc: + return exc.return_resp_instance() + + +@router.get("/admin/audit-logs") +async def list_audit_logs( + page: int = Query(1, ge=1), + page_size: int = Query(20, ge=1, le=100), + user_id: int | None = Query(None), + login_user: UserPayload = Depends(UserPayload.get_login_user), + service: PointsQueryService = Depends(get_points_query_service), +): + """操作记录:手动调分与 R* 扣减流水。""" + try: + data = await service.admin_list_audit_logs( + resolve_tenant_id(login_user), + login_user, + page=page, + page_size=page_size, + user_id=user_id, + ) + return resp_200(data.model_dump()) + except BaseErrorCode as exc: + return exc.return_resp_instance() diff --git a/src/backend/bisheng/points/api/endpoints/me.py b/src/backend/bisheng/points/api/endpoints/me.py new file mode 100644 index 000000000..811963ad3 --- /dev/null +++ b/src/backend/bisheng/points/api/endpoints/me.py @@ -0,0 +1,103 @@ +"""积分用户端只读接口。""" + +from datetime import datetime, timedelta + +from fastapi import APIRouter, Depends, Query + +from bisheng.common.dependencies.user_deps import UserPayload +from bisheng.common.errcode.base import BaseErrorCode +from bisheng.common.schemas.api import resp_200 +from bisheng.points.api.dependencies import ( + get_points_query_service, + get_points_rule_service, + resolve_tenant_id, +) +from bisheng.points.domain.services.points_query_service import PointsQueryService +from bisheng.points.domain.services.points_rule_service import PointsRuleService + +router = APIRouter() + + +def _parse_day_start(value: str | None) -> datetime | None: + """将 YYYY-MM-DD 解析为当日 00:00:00(naive)。""" + if not value: + return None + return datetime.strptime(value, "%Y-%m-%d") + + +def _parse_day_end_exclusive(value: str | None) -> datetime | None: + """将 YYYY-MM-DD 解析为次日 00:00:00,用作开区间上界。""" + start = _parse_day_start(value) + return start + timedelta(days=1) if start else None + + +@router.get("/me/summary") +async def get_summary( + login_user: UserPayload = Depends(UserPayload.get_login_user), + service: PointsQueryService = Depends(get_points_query_service), +): + """返回当前用户积分摘要;无账户时余额与当月收支为零。""" + try: + data = await service.my_summary(resolve_tenant_id(login_user), int(login_user.user_id)) + return resp_200(data.model_dump()) + except BaseErrorCode as exc: + return exc.return_resp_instance() + + +@router.get("/me/logs") +async def get_logs( + direction: str = Query("all"), + page: int = Query(1, ge=1), + page_size: int = Query(20, ge=1, le=100), + from_date: str | None = Query(None, description="起始日 YYYY-MM-DD,含当日"), + to_date: str | None = Query(None, description="结束日 YYYY-MM-DD,含当日"), + login_user: UserPayload = Depends(UserPayload.get_login_user), + service: PointsQueryService = Depends(get_points_query_service), +): + """分页返回当前用户积分流水。""" + try: + from_time = _parse_day_start(from_date) + to_time = _parse_day_end_exclusive(to_date) + items, total = await service.my_logs( + resolve_tenant_id(login_user), + int(login_user.user_id), + direction=direction, + page=page, + page_size=page_size, + from_time=from_time, + to_time=to_time, + ) + return resp_200({"data": [i.model_dump() for i in items], "total": total}) + except ValueError: + from bisheng.common.errcode.points import PointsInvalidAdjustError + + return PointsInvalidAdjustError(msg="日期格式须为 YYYY-MM-DD").return_resp_instance() + except BaseErrorCode as exc: + return exc.return_resp_instance() + +@router.get("/rules/public") +async def get_public_rules( + login_user: UserPayload = Depends(UserPayload.get_login_user), + service: PointsRuleService = Depends(get_points_rule_service), +): + """返回前台可展示规则和说明文案,不暴露月奖规则。""" + try: + return resp_200(await service.public_rules(resolve_tenant_id(login_user))) + except BaseErrorCode as exc: + return exc.return_resp_instance() + + +@router.get("/leaderboard") +async def get_leaderboard( + period: str = Query("month", pattern="^(month|year|all)$"), + login_user: UserPayload = Depends(UserPayload.get_login_user), + service: PointsQueryService = Depends(get_points_query_service), +): + """读取小时快照排行榜。""" + try: + data = await service.leaderboard( + resolve_tenant_id(login_user), period, int(login_user.user_id) + ) + return resp_200(data.model_dump()) + except BaseErrorCode as exc: + return exc.return_resp_instance() diff --git a/src/backend/bisheng/points/api/router.py b/src/backend/bisheng/points/api/router.py new file mode 100644 index 000000000..b80a5bef6 --- /dev/null +++ b/src/backend/bisheng/points/api/router.py @@ -0,0 +1,10 @@ +"""积分模块路由聚合。""" + +from fastapi import APIRouter + +from bisheng.points.api.endpoints.admin import router as admin_router +from bisheng.points.api.endpoints.me import router as me_router + +router = APIRouter(prefix="/points", tags=["points"]) +router.include_router(me_router) +router.include_router(admin_router) diff --git a/src/backend/bisheng/points/domain/constants/beneficiary.py b/src/backend/bisheng/points/domain/constants/beneficiary.py new file mode 100644 index 000000000..904d17613 --- /dev/null +++ b/src/backend/bisheng/points/domain/constants/beneficiary.py @@ -0,0 +1,12 @@ +"""按规则编码约束可配置的积分受益人。""" + +BENEFICIARY_OPTIONS = { + "G1": ("uploader", "publisher"), "G2": ("uploader", "publisher"), + "G5": ("uploader", "publisher"), "G6": ("uploader", "publisher"), + "G7": ("uploader", "sharer"), "G3": ("uploader",), "G4": ("answerer",), +} + + +def allowed_beneficiaries(rule_code: str) -> tuple[str, ...]: + """返回规则允许的受益人;月奖固定为事件主体。""" + return ("subject",) if rule_code.startswith("M") else BENEFICIARY_OPTIONS.get(rule_code, ()) diff --git a/src/backend/bisheng/points/domain/constants/monthly_reward_rules.py b/src/backend/bisheng/points/domain/constants/monthly_reward_rules.py new file mode 100644 index 000000000..1c6292acd --- /dev/null +++ b/src/backend/bisheng/points/domain/constants/monthly_reward_rules.py @@ -0,0 +1,57 @@ +"""月奖 M* 与 space.level / 成员角色的匹配表。 + +矩阵(与 seed_rules M1–M8 对齐): + public → M1 creator / M2 admin + department → M3 creator / M4 admin + team_ks → M5 creator / M6 admin + team → M7 creator / M8 admin +多角色命中时取最高分(见 pick_highest_reward)。 +""" + +from __future__ import annotations + +from dataclasses import dataclass + + +@dataclass(frozen=True) +class MonthlyRuleMatcher: + """单个 M* 规则对应的空间等级与成员角色集合。""" + + levels: tuple[str, ...] + roles: tuple[str, ...] + + +MONTHLY_RULE_MATCHERS: dict[str, MonthlyRuleMatcher] = { + "M1": MonthlyRuleMatcher(levels=("public",), roles=("creator",)), + "M2": MonthlyRuleMatcher(levels=("public",), roles=("admin",)), + "M3": MonthlyRuleMatcher(levels=("department",), roles=("creator",)), + "M4": MonthlyRuleMatcher(levels=("department",), roles=("admin",)), + "M5": MonthlyRuleMatcher(levels=("team_ks",), roles=("creator",)), + "M6": MonthlyRuleMatcher(levels=("team_ks",), roles=("admin",)), + "M7": MonthlyRuleMatcher(levels=("team",), roles=("creator",)), + "M8": MonthlyRuleMatcher(levels=("team",), roles=("admin",)), +} + + +def fixed_score(score_expr: dict | None) -> int: + """读取 fixed 模式分值;非 fixed 或非法返回 0。""" + 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 pick_highest_reward( + candidates: list[tuple[str, int]], +) -> tuple[str, int] | None: + """多角色取最高分;同分取规则编码字典序以保证稳定。""" + if not candidates: + return None + candidates = [(code, int(score)) for code, score in candidates if int(score) > 0] + if not candidates: + return None + candidates.sort(key=lambda item: (-item[1], item[0])) + return candidates[0] diff --git a/src/backend/bisheng/points/domain/constants/notify_templates.py b/src/backend/bisheng/points/domain/constants/notify_templates.py new file mode 100644 index 000000000..ea3258adc --- /dev/null +++ b/src/backend/bisheng/points/domain/constants/notify_templates.py @@ -0,0 +1,11 @@ +"""积分站内信模板;文案随代码发布,不允许后台动态修改。""" + +NOTIFY_TEMPLATES = { + "earn_publish": "你因「{rule_name}」获得 {delta} 积分。", + "earn_share": "你因知识库间分享获得 {delta} 积分。", + "earn_favorite": "你的文档被收藏,获得 {delta} 积分。", + "earn_adopt": "你的回答被采纳,获得 {delta} 积分。", + "deduct_admin": "因「{rule_name}」扣减 {delta} 积分。原因:{reason}", + "adjust_admin": "管理员调整了你的积分 {delta}。原因:{reason}", +} +POINTS_CHANGED_ACTION_CODE = "points_changed" diff --git a/src/backend/bisheng/points/domain/constants/org_levels.py b/src/backend/bisheng/points/domain/constants/org_levels.py new file mode 100644 index 000000000..6cba5a14d --- /dev/null +++ b/src/backend/bisheng/points/domain/constants/org_levels.py @@ -0,0 +1,42 @@ +"""积分组织四级标签常量与相对深度映射。""" + +ORG_LEVEL_COMPANY = "company" +ORG_LEVEL_DEPT = "dept" +ORG_LEVEL_OFFICE = "office" +ORG_LEVEL_SQUAD = "squad" + +ORG_LEVELS = ( + ORG_LEVEL_COMPANY, + ORG_LEVEL_DEPT, + ORG_LEVEL_OFFICE, + ORG_LEVEL_SQUAD, +) + + +def path_depth(path: str | None) -> int: + """统计 materialized path 段数(`/1/2/3/` → 3)。""" + if not path: + return 0 + return len([part for part in str(path).strip("/").split("/") if part]) + + +def relative_depth(company_path: str | None, node_path: str | None) -> int | None: + """计算节点相对公司根的深度;不在子树内返回 None。""" + if not company_path or not node_path: + return None + company = str(company_path) + node = str(node_path) + if not node.startswith(company): + return None + return path_depth(node) - path_depth(company) + + +def org_level_for_relative_depth(rel: int) -> str: + """相对深度 → 标签:0 company / 1 dept / 2 office / ≥3 squad。""" + if rel <= 0: + return ORG_LEVEL_COMPANY + if rel == 1: + return ORG_LEVEL_DEPT + if rel == 2: + return ORG_LEVEL_OFFICE + return ORG_LEVEL_SQUAD diff --git a/src/backend/bisheng/points/domain/constants/seed_rules.py b/src/backend/bisheng/points/domain/constants/seed_rules.py new file mode 100644 index 000000000..a0afc0056 --- /dev/null +++ b/src/backend/bisheng/points/domain/constants/seed_rules.py @@ -0,0 +1,210 @@ +"""积分规则与说明文案种子。 + +默认启用(系统内置): +- G1–G4:获取类内置规则 +- M1 / M4 / M6:月奖内置规则 +- R1–R3:扣减默认规则 + +其余 G5–G7、M2/M3/M5/M7/M8 仍入库,默认 disabled,可由运营启用。 +迁移补种按 rule_code 幂等插入;状态同步见 f080。 +""" + +# --- 获取 G* --- +SEED_RULES = [ + { + "rule_code": "G1", + "rule_type": "earn", + "name": "上传公共库文档", + "score_expr": {"mode": "fixed", "score": 3}, + "daily_cap": 15, + "beneficiary": "uploader", + "status": "enabled", + "sort_order": 1, + }, + { + "rule_code": "G2", + "rule_type": "earn", + "name": "上传部门库文档", + "score_expr": {"mode": "fixed", "score": 2}, + "daily_cap": 10, + "beneficiary": "uploader", + "status": "enabled", + "sort_order": 2, + }, + # G3:终身档位上限在 score_expr.lifetime_cap;daily_cap 不承载终身 15(P3)。 + { + "rule_code": "G3", + "rule_type": "earn", + "name": "文档被收藏", + "score_expr": { + "mode": "tier", + "tiers": [ + {"threshold": 75, "score": 5}, + {"threshold": 150, "score": 10}, + {"threshold": 300, "score": 15}, + ], + "lifetime_cap": 15, + }, + "daily_cap": None, + "beneficiary": "uploader", + "status": "enabled", + "sort_order": 3, + }, + { + "rule_code": "G4", + "rule_type": "earn", + "name": "回答被采纳", + "score_expr": {"mode": "fixed", "score": 3}, + "daily_cap": 15, + "beneficiary": "answerer", + "status": "enabled", + "sort_order": 4, + }, + { + "rule_code": "G5", + "rule_type": "earn", + "name": "上传团队库文档", + "score_expr": {"mode": "fixed", "score": 2}, + "daily_cap": 10, + "beneficiary": "uploader", + "status": "disabled", + "sort_order": 5, + }, + { + "rule_code": "G6", + "rule_type": "earn", + "name": "上传科室库文档", + "score_expr": {"mode": "fixed", "score": 2}, + "daily_cap": 10, + "beneficiary": "uploader", + "status": "disabled", + "sort_order": 6, + }, + { + "rule_code": "G7", + "rule_type": "earn", + "name": "文档库间分享", + "score_expr": {"mode": "fixed", "score": 2}, + "daily_cap": 10, + "beneficiary": "uploader", + "status": "disabled", + "sort_order": 7, + }, + # --- 扣减 R*(系统默认)--- + *[ + { + "rule_code": f"R{i}", + "rule_type": "deduct", + "name": f"违规扣减 R{i}", + "score_expr": {"mode": "fixed", "score": 100}, + "daily_cap": None, + "beneficiary": None, + "status": "enabled", + "sort_order": 20 + i, + } + for i in range(1, 4) + ], + # --- 月奖 M*:仅 M1/M4/M6 默认启用 --- + { + "rule_code": "M1", + "rule_type": "admin_reward", + "name": "公共库所有者月奖", + "score_expr": {"mode": "fixed", "score": 200}, + "daily_cap": None, + "beneficiary": "subject", + "status": "enabled", + "sort_order": 31, + }, + { + "rule_code": "M2", + "rule_type": "admin_reward", + "name": "公共库管理员月奖", + "score_expr": {"mode": "fixed", "score": 150}, + "daily_cap": None, + "beneficiary": "subject", + "status": "disabled", + "sort_order": 32, + }, + { + "rule_code": "M3", + "rule_type": "admin_reward", + "name": "部门库所有者月奖", + "score_expr": {"mode": "fixed", "score": 120}, + "daily_cap": None, + "beneficiary": "subject", + "status": "disabled", + "sort_order": 33, + }, + { + "rule_code": "M4", + "rule_type": "admin_reward", + "name": "部门库管理员月奖", + "score_expr": {"mode": "fixed", "score": 100}, + "daily_cap": None, + "beneficiary": "subject", + "status": "enabled", + "sort_order": 34, + }, + { + "rule_code": "M5", + "rule_type": "admin_reward", + "name": "科室库所有者月奖", + "score_expr": {"mode": "fixed", "score": 80}, + "daily_cap": None, + "beneficiary": "subject", + "status": "disabled", + "sort_order": 35, + }, + { + "rule_code": "M6", + "rule_type": "admin_reward", + "name": "科室库管理员月奖", + "score_expr": {"mode": "fixed", "score": 50}, + "daily_cap": None, + "beneficiary": "subject", + "status": "enabled", + "sort_order": 36, + }, + { + "rule_code": "M7", + "rule_type": "admin_reward", + "name": "团队库所有者月奖", + "score_expr": {"mode": "fixed", "score": 70}, + "daily_cap": None, + "beneficiary": "subject", + "status": "disabled", + "sort_order": 37, + }, + { + "rule_code": "M8", + "rule_type": "admin_reward", + "name": "团队库管理员月奖", + "score_expr": {"mode": "fixed", "score": 40}, + "daily_cap": None, + "beneficiary": "subject", + "status": "disabled", + "sort_order": 38, + }, +] + +# 系统默认启用的编码(便于迁移/脚本对齐)。 +DEFAULT_ENABLED_RULE_CODES = frozenset( + {"G1", "G2", "G3", "G4", "R1", "R2", "R3", "M1", "M4", "M6"} +) + +# Single rich-text guide for the public rules modal (Portal admin edits `guide` only). +SEED_COPIES = [ + { + "copy_key": "guide", + "content": ( + "

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='' # 业务 dept_id 或内部 id +``` + +才会跑可选打标用例。可选环境变量:`E2E_PLATFORM_BASE_URL`(默认 `http://127.0.0.1:3001`)。 + +Failures keep screenshot/trace under `test-results/`. **Gate red → do not start the next milestone.** diff --git a/src/backend/test/e2e_ui/points/_reports/accept_test11_20260808_1854.md b/src/backend/test/e2e_ui/points/_reports/accept_test11_20260808_1854.md new file mode 100644 index 000000000..504fbe95d --- /dev/null +++ b/src/backend/test/e2e_ui/points/_reports/accept_test11_20260808_1854.md @@ -0,0 +1,125 @@ +# 测试1-1 积分规则 Playwright 高标准验收报告 + +| 项 | 内容 | +|----|------| +| 时间 | 2026-08-08 18:51–18:54(CST) | +| 组织 | 测试1-1(dept_id=7,业务 ID `BS@5413b4`) | +| 入口 | Portal `http://127.0.0.1:5173`;Backend `:7860`;BFF `:8010` | +| 套件 | [`gates/accept_test11.spec.ts`](../gates/accept_test11.spec.ts) | +| 原始结果 | `/tmp/accept_test11.log`(Playwright JSON);截图/trace 见 `test-results/` | +| 总体判定 | **有条件通过** | + +## 1. 结论摘要 + +- **G1–G4 发分、R1–R3 管理端扣减、M1/M4/M6 管理端规则展示**:通过(覆盖 4 名组织用户 + admin)。 +- **月度引擎实发(M*)**:环境阻塞(ES 缺 `mid_user_daily_participation_fact`),记 **BLOCKED**,不判定产品逻辑失败。 +- **前台「积分规则」弹窗文案**:隐藏 M* 通过;「申诉 / 线下」关键字 **FAIL**(公开 guide 未露出期望文案)。 + +主链路可验收上线;文案缺口与月度登录事实依赖需单独跟进。 + +## 2. 环境与账号 + +| 服务 | 端口 | 状态 | +|------|------|------| +| bisheng-backend | 7860 | up | +| portal-bff | 8010 | up | +| portal-web | 5173 | up | +| bisheng-client | 4001 | up | +| bisheng-platform | 3001 | up | +| Celery `points_award_local` | — | 进程存在(Gate 强制 `POINTS_GATE_FORCE_SYNC=1`) | + +| 账号 | user_id | 角色 | +|------|---------|------| +| admin | 1 | 平台超管(规则配置 / 扣减 / M Tab) | +| gzx0022 | 33 | 测试1-1;R1 被扣;我的积分 | +| gzx0023 | 34 | 测试1-1;G1 publisher 受益 / G3 / R2 | +| gzx0024 | 35 | 测试1-1;G4 / R3 | +| gzx0025 | 36 | 测试1-1;G2 + 幂等 | + +密码:`E2E_POINTS_PASSWORD`(未写入本报告)。 + +## 3. 规则启用快照 + +`accept_test11_trigger.py rules_snapshot`: + +| code | status | name | 备注 | +|------|--------|------|------| +| G1 | enabled | 发布/上传到公共库 | fixed 3 | +| G2 | enabled | 发布/上传到部门库 | fixed 2 | +| G3 | enabled | 文档被收藏 | tier + lifetime_cap 15 | +| G4 | enabled | 问答被采纳 | fixed 3 | +| R1 | enabled | 色情低俗/暴力违法 | -100 | +| R2 | enabled | 不当言论 | -100 | +| R3 | enabled | 其他违反规范 | -100 | +| M1 | enabled | 公共库所有者月奖 | 200 | +| M4 | enabled | 部门管理员月奖 | 100 | +| M6 | enabled | 科室管理员月奖 | 50 | + +未纳入本次范围:G5/G6(disabled)、G7(enabled 但不在矩阵)、M2/M3/M5/M7/M8(disabled)。 + +## 4. 用例结果矩阵 + +| ID | 场景 | 用户 | 结果 | 证据要点 | +|----|------|------|------|----------| +| P0 | 目标规则全部 enabled | — | **PASS** | rules_snapshot ok | +| G1 | beneficiary=publisher,发分落 publisher | admin UI + gzx0022/23 | **PASS** | logs.user_id=34;gzx0023 我的积分可见 | +| G2 | 部门库上传发分 + 同 file_id 幂等 | gzx0025 | **PASS** | count/balance 重放不变;Portal 明细可见 | +| G2- | 超管上传不发 G2 | admin | **PASS** | skipped=true | +| G3 | 收藏阶梯发分 | gzx0023 | **PASS** | factory award_g3;Portal 明细 | +| G4 | 问答采纳发分 | gzx0024 | **PASS** | factory award_g4;Portal 明细 | +| R1 | UI 违规扣减 | gzx0022 | **PASS** | 按用户名精确选行;latest_deduct 负向 | +| R2 | UI 违规扣减 | gzx0023 | **PASS** | 同上 | +| R3 | UI 违规扣减 | gzx0024 | **PASS** | 同上;操作记录见 R1/R2/R3 | +| M-UI | 管理员奖励 Tab 展示 M1/M4/M6 启用 | admin | **PASS** | 行内「启用」 | +| M-ENG | 月度引擎 dry-run | tenant=1 | **BLOCKED** | awarded=0 skipped=10 error=`login_query_failed`;缺 ES index `mid_user_daily_participation_fact`;period=`2026-07` | +| X1 | ≥3 用户打开我的积分 | 22/23/24 | **PASS** | `/points` 摘要+明细 | +| C1 | 规则弹窗隐藏 M* + 申诉/线下 | gzx0022 | **FAIL** | 隐藏 M* 已满足;`/申诉\|线下/` 不可见 | +| C2 | 规则弹窗「线下」关键字(strict) | gzx0022 | **FAIL** | 单独复跑确认;无「线下」 | + +套件统计(主跑):expected=10,unexpected=1,skipped=1(serial 在 C1 失败后跳过 C2;C2 已单独复跑确认 FAIL)。 + +## 5. 失败 / 阻塞根因 + +### 5.1 C1/C2 规则弹窗文案(产品缺口) + +- 期望:公开规则说明含申诉指引,高标准含「线下」关键字(对齐历史 GM4)。 +- 实际:弹窗可开、不展示月度 M* 名称,但 DOM 中无「申诉」「线下」可见文本(guide 可能未下发/未渲染到可访问文本)。 +- 附件: + `test-results/accept_test11-accept-test1-6c496-des-M-and-shows-appeal-copy-points-gates/` + `test-results/accept_test11-accept-test1-6903f-line-appeal-keyword-strict--points-gates/` + +### 5.2 M* 月末实发(环境阻塞) + +- `PointsMonthlyRewardService.run_for_tenant(1)` → `login_query_failed`。 +- ES:`index_not_found_exception` for `mid_user_daily_participation_fact`。 +- 规则启用与管理端展示已验;**实发需补齐统计 ES 索引后再验**。 + +## 6. 相对历史 Gate 的改善 + +| 历史问题 | 本次 | +|----------|------| +| GM2 幂等 balance 漂移 | accept_test11 G2 在 `POINTS_GATE_FORCE_SYNC=1` 下 PASS | +| GM4 UI 扣减 latest_deduct 找不到(疑似误点行) | 按 **user_name + user_id** 精确选行后 R1–R3 全 PASS | +| 「线下」文案 | 仍 FAIL(未修产品文案) | + +## 7. 建议跟进(不在本次改代码范围) + +1. 核对公开积分 guide(DB `point_copy` / 管理端「文案」)是否含申诉说明,并决定是否必须出现「线下」。 +2. 补齐/配置统计 ES 索引 `mid_user_daily_participation_fact` 后重跑 M* 实发。 +3. 需要全绿发布门禁时,将 C1/C2 与 M-ENG 解阻后再跑 `npm run test:accept11`。 + +## 8. 复现命令 + +```bash +cd src/backend/test/e2e_ui/points +export E2E_POINTS_RUN_GATES=1 \ + E2E_PORTAL_BASE_URL=http://127.0.0.1:5173 \ + E2E_POINTS_PASSWORD='…' \ + E2E_POINTS_ADMIN=admin \ + POINTS_GATE_FORCE_SYNC=1 +npm run test:accept11 +``` + +--- + +**签署口径**:业务主路径(G1–G4 / R1–R3 / M 规则配置可见)在「测试1-1」多用户场景下验收通过;公开规则申诉文案与月度实发环境未达高标准全绿 → **有条件通过**。 diff --git a/src/backend/test/e2e_ui/points/_reports/accept_test11_20260808_1918.md b/src/backend/test/e2e_ui/points/_reports/accept_test11_20260808_1918.md new file mode 100644 index 000000000..68c0e0efe --- /dev/null +++ b/src/backend/test/e2e_ui/points/_reports/accept_test11_20260808_1918.md @@ -0,0 +1,88 @@ +# 测试1-1 积分规则验收报告(第 2 轮 · 全规则 + M* 实发) + +| 项 | 内容 | +|----|------| +| 时间 | 2026-08-08 19:17–19:23(CST) | +| 组织 | 测试1-1(dept 7 / `BS@5413b4`) | +| 套件 | `gates/accept_test11.spec.ts` | +| Playwright | **14 passed / 2 failed**(约 4.6 min) | +| 总体判定 | **有条件通过** | + +## 1. 本轮完成的环境动作 + +1. **创建 ES 索引** `mid_user_daily_participation_fact`,并为候选人/测试1-1 用户写入 **2026-07** 登录事实(`logged_in=true`)。 +2. **启用全部禁用的 G*/M***:G5、G6 及 M2/M3/M5/M7/M8(原已启用的保持启用)→ 当前 G1–G7、R1–R3、M1–M8 **全部 enabled**。 +3. **修复月度登录查询 bug**:`DailyParticipationFact._index_name` 类访问得到 Pydantic `ModelPrivateAttr`,ES 去查字面量 `default='mid_user_daily_participation_fact'` → 一直 `login_query_failed`。已改为取实例字段([`points_monthly_reward_service.py`](../../../../bisheng/points/domain/services/points_monthly_reward_service.py))。 +4. **月末实发**:`run_for_tenant(1, period_key='2026-07')` → **`awarded=16, skipped=2`**(幂等重跑流水仍在)。 + +## 2. 账号与覆盖 + +| 账号 | id | 本轮用途 | +|------|----|----------| +| admin | 1 | 规则启用展示 / 扣减 / M1 候选人但超管跳过 | +| gzx0022 | 33 | G5、R1、我的积分 | +| gzx0023 | 34 | G1 publisher / G3 / G7 / R2 | +| gzx0024 | 35 | G4 / R3 | +| gzx0025 | 36 | G2 幂等 / G6 | + +## 3. 用例矩阵 + +| 场景 | 结果 | 说明 | +|------|------|------| +| 全规则 enabled 快照 | **PASS** | G1–G7 / R1–R3 / M1–M8 | +| G1 publisher 受益 | **PASS** | 落 gzx0023 | +| G2 发分 + 幂等 | **PASS** | gzx0025 | +| G2 超管跳过 | **PASS** | | +| G3 收藏阶梯 | **PASS** | gzx0023 | +| G4 采纳 | **PASS** | gzx0024 | +| G5 团队库上传 | **PASS** | gzx0022 | +| G6 科室库上传 | **PASS** | gzx0025 | +| G7 库间分享 | **PASS** | gzx0023 | +| R1/R2/R3 UI 扣减 | **PASS** | 按用户名精确选行 | +| 管理端 G1–G7 启用展示 | **PASS** | | +| 管理端 M1–M8 启用展示 | **PASS** | | +| M* 月末引擎 2026-07 | **PASS** | 流水 ≥3 种规则;幂等重跑 | +| ≥3 用户我的积分 | **PASS** | | +| 规则弹窗隐藏 M* + 申诉/线下 | **FAIL** | 无「申诉」「线下」可见文案 | +| 规则弹窗「线下」strict | **FAIL** | 同上 | + +### 3.1 月度实发流水(2026-07) + +| 规则 | 笔数(示例用户) | +|------|------------------| +| M4 | 8(gzx02/gzx03/gzx0052 等) | +| M5 | 3 | +| M6 | 1 | +| M7 | 3 | +| M8 | 1 | +| M1 | 0(候选人 admin 被超管排除 → skipped) | +| M2/M3 | 0(当前环境无匹配空间角色候选人) | + +## 4. 失败项 + +- **公开积分规则弹窗文案**:仍无「申诉 / 线下」可访问文本(产品 guide 缺口,非本轮造数问题)。 +- 截图:`test-results/accept_test11-*-appeal*` / `*-offline-*`。 + +## 5. 代码改动(验收相关) + +| 文件 | 变更 | +|------|------| +| `bisheng/points/.../points_monthly_reward_service.py` | 修复 `_index_name` 类访问 bug | +| `test/e2e_ui/points/helpers/factory_trigger.py` | 新增 `award_g5` / `award_g6` | +| `test/e2e_ui/points/helpers/accept_test11_trigger.py` | 全规则快照;月度 probe 带 period + 流水汇总 | +| `test/e2e_ui/points/gates/accept_test11.spec.ts` | 扩展 G5–G7、M1–M8、月末实发 | + +## 6. 判定 + +- **主路径(G1–G7 发分、R1–R3 扣减、M 规则启用、M* 月末实发)**:通过。 +- **文案缺口**:未通过。 +→ **有条件通过**(业务验收达标约 90%+ 主场景;文案待补)。 + +## 7. 复现 + +```bash +cd src/backend/test/e2e_ui/points +export E2E_POINTS_RUN_GATES=1 E2E_PORTAL_BASE_URL=http://127.0.0.1:5173 \ + E2E_POINTS_PASSWORD='…' POINTS_GATE_FORCE_SYNC=1 +npm run test:accept11 +``` diff --git a/src/backend/test/e2e_ui/points/fixtures/auth.ts b/src/backend/test/e2e_ui/points/fixtures/auth.ts new file mode 100644 index 000000000..c29d5519a --- /dev/null +++ b/src/backend/test/e2e_ui/points/fixtures/auth.ts @@ -0,0 +1,78 @@ +import { expect, type Page } from '@playwright/test'; + +/** + * 从环境变量读取积分 Gate 账号(密码不写死在 spec 里)。 + * @param kind admin=平台超管 / user=普通用户 + */ +export function pointsCredentials(kind: 'admin' | 'user') { + const password = process.env.E2E_POINTS_PASSWORD; + if (!password) { + throw new Error('E2E_POINTS_PASSWORD is required for Gate runs'); + } + if (kind === 'admin') { + return { + username: process.env.E2E_POINTS_ADMIN || 'admin', + password, + }; + } + return { + username: process.env.E2E_POINTS_USER || 'gzx01', + password, + }; +} + +/** + * 使用指定用户名登录 Portal(多用户验收用)。 + * @param page Playwright 页面 + * @param username 门户账号(如 gzx0022) + */ +export async function loginPortalAs(page: Page, username: string): Promise { + const password = process.env.E2E_POINTS_PASSWORD; + if (!password) { + throw new Error('E2E_POINTS_PASSWORD is required for Gate runs'); + } + // Portal 登录页在 /login;首页仅有入口按钮,无账号输入框。 + await page.goto('/login'); + const userInput = page.getByRole('textbox', { name: '账号' }); + const passInput = page.getByRole('textbox', { name: '密码' }); + await expect(userInput).toBeVisible({ timeout: 30_000 }); + await userInput.fill(username); + await passInput.fill(password); + await page.getByRole('button', { name: '登录', exact: true }).click(); + await page.waitForURL((url) => !url.pathname.includes('/login'), { timeout: 30_000 }); +} + +/** + * 登录 Portal。 + * @param page Playwright 页面 + * @param kind 账号类型 + */ +export async function loginPortal(page: Page, kind: 'admin' | 'user'): Promise { + const { username } = pointsCredentials(kind); + await loginPortalAs(page, username); +} + +/** + * 登录 Platform(组织打标 UI 在 /sys → 组织与成员)。 + * Platform 登录页常在 `/`(pathname 不含 login),不能用 URL 判断成功。 + * @param page Playwright 页面 + * @param kind 账号类型(G-M3 打标入口仅超管可见) + */ +export async function loginPlatform(page: Page, kind: 'admin' | 'user' = 'admin'): Promise { + const { username, password } = pointsCredentials(kind); + const platformBase = process.env.E2E_PLATFORM_BASE_URL || 'http://127.0.0.1:3001'; + await page.goto(`${platformBase}/`); + const account = page.locator('#email'); + const pwd = page.locator('#pwd').first(); + await expect(account).toBeVisible({ timeout: 30_000 }); + await account.fill(username); + await pwd.fill(password); + // zh-Hans loginButton is "登 录" (space between characters). + await page.getByRole('button', { name: /登\s*录|Login/i }).click(); + // 登录成功后账号框消失,并进入业务页(常见 /build 或 /sys)。 + await expect(account).toBeHidden({ timeout: 60_000 }); + await page.waitForURL( + (url) => !url.pathname.includes('login') && url.pathname !== '/', + { timeout: 60_000 }, + ); +} diff --git a/src/backend/test/e2e_ui/points/gates/accept_test11.spec.ts b/src/backend/test/e2e_ui/points/gates/accept_test11.spec.ts new file mode 100644 index 000000000..1dc39283d --- /dev/null +++ b/src/backend/test/e2e_ui/points/gates/accept_test11.spec.ts @@ -0,0 +1,419 @@ +import { test, expect, type Page } from '@playwright/test'; +import { loginPortal, loginPortalAs } from '../fixtures/auth'; +import { runAccept11Trigger } from '../helpers/accept_test11'; +import { runFactory } from '../helpers/factory'; +import { runGm2Trigger } from '../helpers/gm2'; +import { runGm4Trigger } from '../helpers/gm4'; + +/** + * 测试1-1 组织高标准验收:G1–G7 / R1–R3 / M1–M8 全启用场景(约 90% 主路径)。 + * 用户:gzx0022(33)、gzx0023(34)、gzx0024(35)、gzx0025(36);admin 管运维。 + * + * 运行: + * E2E_POINTS_RUN_GATES=1 E2E_POINTS_PASSWORD=… npx playwright test gates/accept_test11.spec.ts + */ + +const USERS = { + u22: { name: 'gzx0022', id: 33 }, + u23: { name: 'gzx0023', id: 34 }, + u24: { name: 'gzx0024', id: 35 }, + u25: { name: 'gzx0025', id: 36 }, +} as const; + +const deptSpaceId = Number(process.env.E2E_POINTS_DEPT_SPACE_ID || '10'); +const teamSpaceId = Number(process.env.E2E_POINTS_TEAM_SPACE_ID || '11'); +const teamKsSpaceId = Number(process.env.E2E_POINTS_TEAM_KS_SPACE_ID || '12'); +const adminUserId = Number(process.env.E2E_POINTS_ADMIN_USER_ID || '1'); + +const ALL_RULES = [ + 'G1', + 'G2', + 'G3', + 'G4', + 'G5', + 'G6', + 'G7', + 'R1', + 'R2', + 'R3', + 'M1', + 'M2', + 'M3', + 'M4', + 'M5', + 'M6', + 'M7', + 'M8', +] as const; + +/** 清 Cookie 后回到登录页,避免多用户串号。 */ +async function resetPortalSession(page: Page): Promise { + await page.context().clearCookies(); + await page.goto('/login'); +} + +/** 打开积分管理并切到用户列表。 */ +async function openPointsAdminUsers(page: Page): Promise { + await page.goto('/admin'); + await page.getByText('积分管理').click(); + await expect(page.getByText('用户积分管理')).toBeVisible({ timeout: 30_000 }); + await page.getByRole('tab', { name: '用户积分列表' }).click(); +} + +/** + * 按用户名精确点开「违规扣减」,并选中指定 R* 规则。 + * @param page Playwright 页面 + * @param userName 门户用户名 + * @param userId 用户 ID(用于行过滤,避免同名误点) + * @param ruleCode R1|R2|R3 + */ +async function deductViaUi( + page: Page, + userName: string, + userId: number, + ruleCode: 'R1' | 'R2' | 'R3', +): Promise { + await page.locator('#points-user-search').fill(userName); + const row = page + .locator('tr', { hasText: userName }) + .filter({ hasText: String(userId) }) + .first(); + await expect(row).toBeVisible({ timeout: 30_000 }); + await row.getByRole('button', { name: '违规扣减' }).click(); + const dialog = page.getByRole('dialog', { name: '违规扣减' }); + await expect(dialog).toBeVisible(); + // 校验弹窗目标用户,避免 GM4 误点其它行(CSS Modules 类名不可靠) + await expect(dialog.locator('input').first()).toHaveValue(userName); + + const ruleSelect = page.locator('#points-deduct-rule'); + await expect(ruleSelect.locator(`option[value="${ruleCode}"]`)).toHaveCount(1, { + timeout: 15_000, + }); + await ruleSelect.evaluate((el, code) => { + const select = el as HTMLSelectElement; + select.value = code; + select.dispatchEvent(new Event('change', { bubbles: true })); + }, ruleCode); + await expect(ruleSelect).toHaveValue(ruleCode); + await page.locator('#points-deduct-remark').fill(`accept_test11 ${ruleCode} ${userName}`); + await page.getByRole('button', { name: '确认扣减' }).click(); + await expect(page.getByText(/扣减成功/)).toBeVisible({ timeout: 30_000 }); +} + +/** 断言「我的积分」页可见摘要与明细区。 */ +async function assertMyPointsSummary(page: Page): Promise { + await page.goto('/points'); + await expect(page.getByText('我的积分').first()).toBeVisible({ timeout: 30_000 }); + await expect(page.getByText('积分明细')).toBeVisible(); +} + +test.describe('accept_test11 测试1-1 积分验收', () => { + // 主链路串行,避免多用户 Cookie / G1 beneficiary 互相干扰。 + test.describe.configure({ mode: 'serial' }); + test.skip(!process.env.E2E_POINTS_RUN_GATES, 'Set E2E_POINTS_RUN_GATES=1 when stack is up'); + + test('preflight: all G*/R*/M* rules enabled', () => { + const snap = runAccept11Trigger(['rules_snapshot']); + expect(snap.ok).toBeTruthy(); + for (const code of ALL_RULES) { + expect(snap.enabled).toEqual(expect.arrayContaining([code])); + } + }); + + test('G1: publisher beneficiary awards to gzx0023', async ({ page }) => { + test.setTimeout(120_000); + const before = runGm4Trigger(['get_beneficiary', 'G1']); + expect(before.ok).toBeTruthy(); + const prevBeneficiary = String(before.beneficiary || 'uploader'); + + try { + await loginPortal(page, 'admin'); + await page.goto('/admin'); + await page.getByText('积分管理').click(); + await expect(page.getByText('积分规则配置')).toBeVisible({ timeout: 30_000 }); + await page.getByRole('tab', { name: '积分获取规则' }).click(); + + const g1Row = page.locator('tr', { hasText: 'G1' }).first(); + await expect(g1Row).toBeVisible({ timeout: 30_000 }); + await g1Row.getByRole('button', { name: '编辑' }).click(); + await expect(page.getByRole('heading', { name: /编辑规则 G1/ })).toBeVisible(); + await page.locator('select').selectOption('publisher'); + await page.getByRole('button', { name: '保存' }).click(); + await expect(page.getByText(/已保存 G1/)).toBeVisible({ timeout: 30_000 }); + + // uploader=33 gzx0022,publisher=34 gzx0023 → 流水落在 34 + const award = runGm4Trigger([ + 'award_g1_split', + String(USERS.u22.id), + String(USERS.u23.id), + ]); + expect(award.ok).toBeTruthy(); + expect(award.skipped).toBeFalsy(); + expect(award.beneficiary_config).toBe('publisher'); + const logs = (award.logs as Array<{ user_id: number; beneficiary_role: string }>) || []; + expect(logs.length).toBe(1); + expect(logs[0].user_id).toBe(USERS.u23.id); + expect(logs[0].beneficiary_role).toBe('publisher'); + + await resetPortalSession(page); + await loginPortalAs(page, USERS.u23.name); + await assertMyPointsSummary(page); + await expect(page.getByText(/发布|公共库/).first()).toBeVisible({ timeout: 30_000 }); + } finally { + runGm4Trigger(['set_beneficiary', 'G1', prevBeneficiary]); + } + }); + + test('G2: department upload awards gzx0025 and is idempotent', async ({ page }) => { + test.setTimeout(120_000); + // 独立 file_id,避免与历史 Gate 冲突;Gate 默认强制同步入账 + const fileId = 940_000_000 + Math.floor(Math.random() * 90_000); + const first = runGm2Trigger([ + 'award_g2', + String(USERS.u25.id), + String(fileId), + String(deptSpaceId), + ]); + const after = first.after as { count: number; balance: number; recent: { title: string }[] }; + const before = first.before as { count: number; balance: number }; + expect(after.count).toBe(before.count + 1); + expect(after.balance).toBeGreaterThanOrEqual(before.balance + 1); + + const second = runGm2Trigger([ + 'award_g2', + String(USERS.u25.id), + String(fileId), + String(deptSpaceId), + ]); + const after2 = second.after as { count: number; balance: number }; + expect(after2.count).toBe(after.count); + expect(after2.balance).toBe(after.balance); + + await loginPortalAs(page, USERS.u25.name); + await assertMyPointsSummary(page); + const title = after.recent[0]?.title || '部门'; + await expect(page.getByText(title).first()).toBeVisible({ timeout: 30_000 }); + }); + + test('G2 negative: platform super-admin upload skipped', () => { + const fileId = 941_000_000 + Math.floor(Math.random() * 90_000); + const result = runGm2Trigger(['award_admin_g2', String(adminUserId), String(fileId)]); + expect(result.skipped).toBeTruthy(); + }); + + test('G3: favorite tier awards gzx0023', async ({ page }) => { + test.setTimeout(120_000); + const fileId = 942_000_000 + Math.floor(Math.random() * 90_000); + const award = runFactory(['award_g3', String(USERS.u23.id), String(fileId)]); + expect(award.ok).toBeTruthy(); + expect(award.skipped).toBeFalsy(); + const before = award.before as { count: number }; + const after = award.after as { count: number; recent: { title: string }[] }; + expect(after.count).toBe(before.count + 1); + + await loginPortalAs(page, USERS.u23.name); + await assertMyPointsSummary(page); + const title = after.recent[0]?.title || '收藏'; + await expect(page.getByText(title).first()).toBeVisible({ timeout: 30_000 }); + }); + + test('G4: answer adopted awards gzx0024', async ({ page }) => { + test.setTimeout(120_000); + const answerId = 943_000_000 + Math.floor(Math.random() * 90_000); + const award = runFactory(['award_g4', String(USERS.u24.id), String(answerId)]); + expect(award.ok).toBeTruthy(); + expect(award.skipped).toBeFalsy(); + const before = award.before as { count: number }; + const after = award.after as { count: number; recent: { title: string }[] }; + expect(after.count).toBe(before.count + 1); + + await loginPortalAs(page, USERS.u24.name); + await assertMyPointsSummary(page); + const title = after.recent[0]?.title || '采纳'; + await expect(page.getByText(title).first()).toBeVisible({ timeout: 30_000 }); + }); + + test('G5: team library upload awards gzx0022', async ({ page }) => { + test.setTimeout(120_000); + const fileId = 944_000_000 + Math.floor(Math.random() * 90_000); + const award = runFactory([ + 'award_g5', + String(USERS.u22.id), + String(fileId), + String(teamSpaceId), + ]); + expect(award.ok).toBeTruthy(); + const before = award.before as { count: number }; + const after = award.after as { count: number; recent: { title: string }[] }; + expect(after.count).toBe(before.count + 1); + + await loginPortalAs(page, USERS.u22.name); + await assertMyPointsSummary(page); + const title = after.recent[0]?.title || '团队'; + await expect(page.getByText(title).first()).toBeVisible({ timeout: 30_000 }); + }); + + test('G6: team_ks upload awards gzx0025', async ({ page }) => { + test.setTimeout(120_000); + const fileId = 945_000_000 + Math.floor(Math.random() * 90_000); + const award = runFactory([ + 'award_g6', + String(USERS.u25.id), + String(fileId), + String(teamKsSpaceId), + ]); + expect(award.ok).toBeTruthy(); + const before = award.before as { count: number }; + const after = award.after as { count: number; recent: { title: string }[] }; + expect(after.count).toBe(before.count + 1); + + await loginPortalAs(page, USERS.u25.name); + await assertMyPointsSummary(page); + const title = after.recent[0]?.title || '科室'; + await expect(page.getByText(title).first()).toBeVisible({ timeout: 30_000 }); + }); + + test('G7: inter-space share awards gzx0023', async ({ page }) => { + test.setTimeout(120_000); + const shareId = 946_000_000 + Math.floor(Math.random() * 90_000); + const award = runFactory(['award_g7', String(USERS.u23.id), String(shareId)]); + expect(award.ok).toBeTruthy(); + const before = award.before as { count: number }; + const after = award.after as { count: number; recent: { title: string }[] }; + expect(after.count).toBe(before.count + 1); + + await loginPortalAs(page, USERS.u23.name); + await assertMyPointsSummary(page); + const title = after.recent[0]?.title || '分享'; + await expect(page.getByText(title).first()).toBeVisible({ timeout: 30_000 }); + }); + + test('R1–R3: admin UI deduct for three org users', async ({ page }) => { + test.setTimeout(180_000); + await loginPortal(page, 'admin'); + await openPointsAdminUsers(page); + + const cases: Array<{ user: (typeof USERS)[keyof typeof USERS]; rule: 'R1' | 'R2' | 'R3' }> = + [ + { user: USERS.u22, rule: 'R1' }, + { user: USERS.u23, rule: 'R2' }, + { user: USERS.u24, rule: 'R3' }, + ]; + + for (const c of cases) { + await deductViaUi(page, c.user.name, c.user.id, c.rule); + const latest = runGm4Trigger(['latest_deduct', String(c.user.id), c.rule]); + expect(latest.ok).toBeTruthy(); + expect(latest.found).toBeTruthy(); + expect(Number(latest.delta)).toBeLessThan(0); + expect(latest.direction).toBe('deduct'); + expect(latest.source).toBe('manual_deduct'); + // 关闭弹窗后列表仍在;清空搜索以便下一轮 + await page.locator('#points-user-search').fill(''); + await page.waitForTimeout(400); + } + + await page.getByRole('tab', { name: '操作记录' }).click(); + await expect(page.getByText('R1').first()).toBeVisible({ timeout: 30_000 }); + await expect(page.getByText('R2').first()).toBeVisible({ timeout: 15_000 }); + await expect(page.getByText('R3').first()).toBeVisible({ timeout: 15_000 }); + }); + + test('M1–M8: admin reward tab shows all enabled rules', async ({ page }) => { + test.setTimeout(120_000); + await loginPortal(page, 'admin'); + await page.goto('/admin'); + await page.getByText('积分管理').click(); + await expect(page.getByText('积分规则配置')).toBeVisible({ timeout: 30_000 }); + await page.getByRole('tab', { name: '管理员奖励' }).click(); + + for (const code of ['M1', 'M2', 'M3', 'M4', 'M5', 'M6', 'M7', 'M8'] as const) { + const row = page.locator('tr', { hasText: code }).first(); + await expect(row).toBeVisible({ timeout: 30_000 }); + await expect(row.getByText(/启用|enabled/i).first()).toBeVisible(); + } + }); + + test('G1–G7: admin earn tab shows all enabled rules', async ({ page }) => { + test.setTimeout(120_000); + await loginPortal(page, 'admin'); + await page.goto('/admin'); + await page.getByText('积分管理').click(); + await expect(page.getByText('积分规则配置')).toBeVisible({ timeout: 30_000 }); + await page.getByRole('tab', { name: '积分获取规则' }).click(); + for (const code of ['G1', 'G2', 'G3', 'G4', 'G5', 'G6', 'G7'] as const) { + const row = page.locator('tr', { hasText: code }).first(); + await expect(row).toBeVisible({ timeout: 30_000 }); + await expect(row.getByText(/启用|enabled/i).first()).toBeVisible(); + } + }); + + test('M*: monthly engine awards for 2026-07 (idempotent re-run)', () => { + const probe = runAccept11Trigger(['monthly_probe', '1', '2026-07']); + expect(probe.ok).toBeTruthy(); + expect(probe.blocked).toBeFalsy(); + const result = probe.result as { + awarded?: number; + skipped?: number; + error?: string; + period_key?: string; + }; + const ledger = (probe.ledger_by_rule || {}) as Record< + string, + { count: number; sum_delta: number } + >; + test.info().annotations.push({ + type: 'monthly_probe', + description: JSON.stringify({ + blocked: probe.blocked, + awarded: result.awarded, + skipped: result.skipped, + error: result.error, + period_key: result.period_key || probe.period_key, + ledger_by_rule: ledger, + }), + }); + // 首跑已实发;重跑应为幂等(awarded 可为 0,但流水仍在) + expect(result.error).toBeFalsy(); + const totalLedger = Object.values(ledger).reduce((n, v) => n + Number(v.count || 0), 0); + expect(totalLedger).toBeGreaterThan(0); + // 至少覆盖多种 M*(环境无候选人的规则可缺失,但不能全空) + expect(Object.keys(ledger).length).toBeGreaterThanOrEqual(3); + }); + + test('cross-cut: three org users open my-points', async ({ page }) => { + test.setTimeout(180_000); + for (const u of [USERS.u22, USERS.u23, USERS.u24]) { + await resetPortalSession(page); + await loginPortalAs(page, u.name); + await assertMyPointsSummary(page); + } + }); + +}); + +test.describe('accept_test11 规则弹窗文案', () => { + test.skip(!process.env.E2E_POINTS_RUN_GATES, 'Set E2E_POINTS_RUN_GATES=1 when stack is up'); + + test('rules modal hides M* and shows appeal copy', async ({ page }) => { + await loginPortalAs(page, USERS.u22.name); + await page.goto('/points'); + await expect(page.getByText('我的积分').first()).toBeVisible({ timeout: 30_000 }); + await page.getByRole('button', { name: '积分规则' }).click(); + await expect(page.getByRole('heading', { name: '积分规则' })).toBeVisible({ + timeout: 30_000, + }); + await expect(page.locator('#points-rules-title')).toBeVisible(); + await expect(page.getByText(/管理员月奖|月度奖励 M|公共库所有者月奖/)).toHaveCount(0); + // 产品文案当前为「申诉」;「线下」为高标准期望,缺失则本用例失败并记入报告 + await expect(page.getByText(/申诉|线下/)).toBeVisible({ timeout: 5_000 }); + }); + + test('rules modal offline-appeal keyword (strict)', async ({ page }) => { + await loginPortalAs(page, USERS.u22.name); + await page.goto('/points'); + await page.getByRole('button', { name: '积分规则' }).click(); + await expect(page.locator('#points-rules-title')).toBeVisible({ timeout: 30_000 }); + await expect(page.getByText(/线下/)).toBeVisible({ timeout: 5_000 }); + }); +}); diff --git a/src/backend/test/e2e_ui/points/gates/gm1.spec.ts b/src/backend/test/e2e_ui/points/gates/gm1.spec.ts new file mode 100644 index 000000000..98a0b62fb --- /dev/null +++ b/src/backend/test/e2e_ui/points/gates/gm1.spec.ts @@ -0,0 +1,75 @@ +import { test, expect } from '@playwright/test'; +import { loginPortal, pointsCredentials } from '../fixtures/auth'; + +/** + * G-M1:账本与管理端可读可调(Portal UI + API)。 + * 运行:E2E_POINTS_RUN_GATES=1 npm run test:gm1 + */ +test.describe('G-M1 points ledger & admin', () => { + test.skip(!process.env.E2E_POINTS_RUN_GATES, 'Set E2E_POINTS_RUN_GATES=1 when stack is up'); + + test('admin opens points admin and sees seeded rules', async ({ page }) => { + await loginPortal(page, 'admin'); + await page.goto('/admin'); + await page.getByText('积分管理').click(); + await expect(page.getByText('G1')).toBeVisible({ timeout: 30_000 }); + await expect(page.getByText('积分规则配置')).toBeVisible(); + }); + + test('admin adjusts user points and balance updates', async ({ page, request }) => { + const targetUserId = Number(process.env.E2E_POINTS_TARGET_USER_ID || '4'); + await loginPortal(page, 'admin'); + await page.goto('/admin'); + await page.getByText('积分管理').click(); + await expect(page.getByText('用户积分管理')).toBeVisible({ timeout: 30_000 }); + + // 用户列表直接调分(不选 R* 扣减规则) + await page.getByRole('tab', { name: '用户积分列表' }).click(); + await page.locator('#points-user-search').fill(String(targetUserId)); + await expect(page.getByRole('button', { name: '调整积分' }).first()).toBeVisible({ + timeout: 30_000, + }); + await page.getByRole('button', { name: '调整积分' }).first().click(); + await expect(page.getByRole('heading', { name: '调整用户积分' })).toBeVisible(); + await page.locator('#points-adjust-delta').fill('10'); + await page.locator('#points-adjust-remark').fill('联调调分验证'); + await page.getByRole('button', { name: '确认调整' }).click(); + await expect(page.getByText(/调分成功/)).toBeVisible({ timeout: 30_000 }); + + // API 复核:目标用户流水可见正 delta + const cookies = await page.context().cookies(); + const cookieHeader = cookies.map((c) => `${c.name}=${c.value}`).join('; '); + const res = await request.get( + `${process.env.E2E_PORTAL_BASE_URL || 'http://127.0.0.1:5173'}/workspace/api/v1/points/admin/overview`, + { headers: { Cookie: cookieHeader } }, + ); + expect(res.ok()).toBeTruthy(); + const body = await res.json(); + expect(body.status_code).toBe(200); + expect(Number(body.data?.total_issued)).toBeGreaterThanOrEqual(10); + }); + + test('user opens my-points summary', async ({ page }) => { + await loginPortal(page, 'user'); + await page.goto('/points'); + await expect(page.getByText('我的积分').first()).toBeVisible({ timeout: 30_000 }); + await expect(page.getByText('本月获得')).toBeVisible(); + await expect(page.getByText('积分明细')).toBeVisible(); + }); + + test('non-admin adjust API is denied with 18201', async ({ request }) => { + const { username, password } = pointsCredentials('user'); + const loginRes = await request.post('http://127.0.0.1:8010/api/v1/auth/login', { + data: { account: username, password }, + }); + expect(loginRes.ok()).toBeTruthy(); + const res = await request.post( + `${process.env.E2E_PORTAL_BASE_URL || 'http://127.0.0.1:5173'}/workspace/api/v1/points/admin/adjust`, + { + data: { user_id: 4, delta: 1, remark: '无权调分测试' }, + }, + ); + const body = await res.json(); + expect(body.status_code).toBe(18201); + }); +}); diff --git a/src/backend/test/e2e_ui/points/gates/gm2.spec.ts b/src/backend/test/e2e_ui/points/gates/gm2.spec.ts new file mode 100644 index 000000000..6d1ff002f --- /dev/null +++ b/src/backend/test/e2e_ui/points/gates/gm2.spec.ts @@ -0,0 +1,70 @@ +import { test, expect } from '@playwright/test'; +import { loginPortal } from '../fixtures/auth'; +import { runGm2Trigger } from '../helpers/gm2'; + +/** + * G-M2:自动发放主路径(API 造数 + Portal「我的积分」明细断言)。 + * 运行:E2E_POINTS_RUN_GATES=1 E2E_POINTS_PASSWORD=… npm run test:gm2 + */ +test.describe('G-M2 auto award', () => { + test.skip(!process.env.E2E_POINTS_RUN_GATES, 'Set E2E_POINTS_RUN_GATES=1 when stack is up'); + + const userId = Number(process.env.E2E_POINTS_TARGET_USER_ID || '4'); + const adminId = Number(process.env.E2E_POINTS_ADMIN_USER_ID || '1'); + const deptSpaceId = Number(process.env.E2E_POINTS_DEPT_SPACE_ID || '10'); + + test.beforeAll(() => { + const ensured = runGm2Trigger(['ensure_g7']); + expect(ensured.ok).toBeTruthy(); + }); + + test('department upload awards G2 and is idempotent', async ({ page }) => { + const fileId = 970000 + Math.floor(Math.random() * 9000); + const first = runGm2Trigger(['award_g2', String(userId), String(fileId), String(deptSpaceId)]); + const after = first.after as { count: number; balance: number; recent: { title: string; delta: number }[] }; + const before = first.before as { count: number; balance: number }; + expect(after.count).toBe(before.count + 1); + expect(after.balance).toBeGreaterThanOrEqual(before.balance + 1); + expect(after.recent[0]?.delta).toBeGreaterThan(0); + + const second = runGm2Trigger(['award_g2', String(userId), String(fileId), String(deptSpaceId)]); + const after2 = second.after as { count: number; balance: number }; + expect(after2.count).toBe(after.count); + expect(after2.balance).toBe(after.balance); + + await loginPortal(page, 'user'); + await page.goto('/points'); + await expect(page.getByText('我的积分').first()).toBeVisible({ timeout: 30_000 }); + await expect(page.getByText('积分明细')).toBeVisible(); + // DB 中 G2 名称可能被运营改过;用流水标题或「部门」关键字兜底。 + const title = after.recent[0]?.title || '部门'; + await expect(page.getByText(title).first()).toBeVisible({ timeout: 30_000 }); + }); + + test('inter-space SHARE awards G7 when rule enabled', async ({ page }) => { + const shareId = 980000 + Math.floor(Math.random() * 9000); + const result = runGm2Trigger(['award_g7', String(userId), String(shareId)]); + const after = result.after as { count: number; recent: { title: string }[] }; + const before = result.before as { count: number }; + expect(after.count).toBe(before.count + 1); + + await loginPortal(page, 'user'); + await page.goto('/points'); + await expect(page.getByText(after.recent[0]?.title || '文档库间分享').first()).toBeVisible({ + timeout: 30_000, + }); + }); + + test('outer share-link does not create G7 ledger by link id', async () => { + const fakeLinkId = 990000 + Math.floor(Math.random() * 9000); + // 外链路径未挂 Facade;仅断言不会出现 earn:G7:{link_id}。 + const snap = runGm2Trigger(['share_link_neg', String(userId), String(fakeLinkId)]); + expect(snap.count).toBe(0); + }); + + test('platform super-admin upload gets no auto G2', async () => { + const fileId = 991000 + Math.floor(Math.random() * 9000); + const result = runGm2Trigger(['award_admin_g2', String(adminId), String(fileId)]); + expect(result.skipped).toBeTruthy(); + }); +}); diff --git a/src/backend/test/e2e_ui/points/gates/gm3.spec.ts b/src/backend/test/e2e_ui/points/gates/gm3.spec.ts new file mode 100644 index 000000000..9f9e29384 --- /dev/null +++ b/src/backend/test/e2e_ui/points/gates/gm3.spec.ts @@ -0,0 +1,114 @@ +import { test, expect } from '@playwright/test'; +import { loginPlatform, loginPortal } from '../fixtures/auth'; +import { runGm3Trigger } from '../helpers/gm3'; + +/** + * G-M3:排行榜 + 组织四级标签(Portal UI + Platform UI + API)。 + * 运行:E2E_POINTS_RUN_GATES=1 npm run test:gm3 + * + * 破坏性「设为公司根」默认跳过;需同时: + * E2E_POINTS_ALLOW_ORG_MUTATE=1 + * E2E_POINTS_COMPANY_DEPT_ID= + */ +test.describe('G-M3 ranks & org labels', () => { + test.skip(!process.env.E2E_POINTS_RUN_GATES, 'Set E2E_POINTS_RUN_GATES=1 after T014–T024'); + + test.beforeAll(() => { + const refreshed = runGm3Trigger(['refresh_ranks']); + expect(refreshed.ok).toBeTruthy(); + }); + + test('org_level cascade is consistent for each company root', () => { + const snap = runGm3Trigger(['org_levels']); + expect(snap.ok).toBeTruthy(); + // 允许多公司;company_count 仅作观测,不再要求唯一根。 + test.info().annotations.push({ + type: 'note', + description: `company_count=${String(snap.company_count ?? 0)}`, + }); + const verified = runGm3Trigger(['verify_cascade']); + expect(verified.ok).toBeTruthy(); + if (verified.skipped) { + test.info().annotations.push({ + type: 'note', + description: String(verified.reason || 'no company root — cascade verification skipped'), + }); + return; + } + expect(verified.mismatches).toEqual([]); + expect(Number(verified.checked)).toBeGreaterThan(0); + expect(Number(verified.company_count)).toBeGreaterThan(0); + }); + + test('optional set-company-root only when mutate flag set', () => { + const allow = process.env.E2E_POINTS_ALLOW_ORG_MUTATE === '1'; + const deptId = process.env.E2E_POINTS_COMPANY_DEPT_ID; + test.skip(!allow || !deptId, 'Set E2E_POINTS_ALLOW_ORG_MUTATE=1 and E2E_POINTS_COMPANY_DEPT_ID'); + const result = runGm3Trigger(['set_company_root', String(deptId)]); + expect(result.ok).toBeTruthy(); + expect(Number(result.labeled_count)).toBeGreaterThan(0); + const verified = runGm3Trigger(['verify_cascade']); + expect(verified.ok).toBeTruthy(); + expect(verified.skipped).toBeFalsy(); + expect(verified.mismatches).toEqual([]); + }); + + test('platform admin sees org-level section and set-company-root action', async ({ browser }) => { + test.setTimeout(120_000); + const platformBase = process.env.E2E_PLATFORM_BASE_URL || 'http://127.0.0.1:3001'; + const context = await browser.newContext({ baseURL: platformBase }); + const page = await context.newPage(); + try { + await loginPlatform(page, 'admin'); + await page.goto(`${platformBase}/sys`); + await page.getByRole('tab', { name: '组织与成员' }).click(); + // 树加载可能较慢;右侧默认选中根部门后切到「部门设置」。 + await expect(page.getByRole('tab', { name: '部门设置' })).toBeVisible({ timeout: 90_000 }); + await page.getByRole('tab', { name: '部门设置' }).click(); + await expect(page.getByText('组织层级标签')).toBeVisible({ timeout: 30_000 }); + await expect(page.getByText('当前层级')).toBeVisible(); + // 可编辑时为下拉(选项仅「公司」),经底部保存提交,不再有独立设公司按钮。 + await expect(page.getByRole('button', { name: /设为公司/ })).toHaveCount(0); + await expect(page.getByRole('button', { name: '保存' }).first()).toBeVisible(); + } finally { + await context.close(); + } + }); + + test('portal homepage leaderboard tabs stay within TOP10 without sticky me row', async ({ + page, + }) => { + await loginPortal(page, 'user'); + await page.goto('/'); + await expect(page.getByText('积分榜单').first()).toBeVisible({ timeout: 30_000 }); + const tablist = page.getByRole('tablist', { name: '积分榜周期' }); + await expect(tablist).toBeVisible(); + for (const label of ['本月', '本年', '总榜']) { + await tablist.getByRole('tab', { name: label, exact: true }).click(); + await expect(tablist.getByRole('tab', { name: label, exact: true })).toHaveAttribute( + 'aria-selected', + 'true', + ); + // 空态或列表均可;禁止「我」置底行文案。 + await expect(page.getByText(/^我$/)).toHaveCount(0); + const empty = page.getByText('暂无积分榜数据'); + const loading = page.getByText('加载积分榜'); + if (await empty.isVisible().catch(() => false)) { + continue; + } + if (await loading.isVisible().catch(() => false)) { + await expect(loading).toBeHidden({ timeout: 30_000 }); + } + } + }); + + test('my points shows department and global rank placeholders', async ({ page }) => { + await loginPortal(page, 'user'); + await page.goto('/points'); + await expect(page.getByText('我的积分').first()).toBeVisible({ timeout: 30_000 }); + const summary = page.getByLabel('积分摘要'); + await expect(summary.getByText('排名', { exact: true })).toBeVisible(); + await expect(summary.getByText(/部门/)).toBeVisible(); + await expect(summary.getByText(/总榜/)).toBeVisible(); + }); +}); diff --git a/src/backend/test/e2e_ui/points/gates/gm4.spec.ts b/src/backend/test/e2e_ui/points/gates/gm4.spec.ts new file mode 100644 index 000000000..70a6c2e2f --- /dev/null +++ b/src/backend/test/e2e_ui/points/gates/gm4.spec.ts @@ -0,0 +1,110 @@ +import { test, expect } from '@playwright/test'; +import { loginPortal } from '../fixtures/auth'; +import { runGm4Trigger } from '../helpers/gm4'; + +/** + * G-M4:运营配置与 R* 扣减(Portal UI + API)。 + * 运行:E2E_POINTS_RUN_GATES=1 npm run test:gm4 + * + * T025 Client 文档页入口延后;本门禁用 Portal 管理端「违规扣减」作为浏览器扣减路径。 + */ +test.describe('G-M4 ops console & deduct', () => { + test.skip(!process.env.E2E_POINTS_RUN_GATES, 'Set E2E_POINTS_RUN_GATES=1 after T021–T025'); + + const targetUserId = Number(process.env.E2E_POINTS_TARGET_USER_ID || '4'); + const publisherUserId = Number(process.env.E2E_POINTS_PUBLISHER_USER_ID || '5'); + + test('admin sets G1 beneficiary to publisher and award lands on publisher', async ({ + page, + }) => { + test.setTimeout(120_000); + const before = runGm4Trigger(['get_beneficiary', 'G1']); + expect(before.ok).toBeTruthy(); + const prevBeneficiary = String(before.beneficiary || 'uploader'); + + try { + await loginPortal(page, 'admin'); + await page.goto('/admin'); + await page.getByText('积分管理').click(); + await expect(page.getByText('积分规则配置')).toBeVisible({ timeout: 30_000 }); + await page.getByRole('tab', { name: '积分获取规则' }).click(); + + const g1Row = page.locator('tr', { hasText: 'G1' }).first(); + await expect(g1Row).toBeVisible({ timeout: 30_000 }); + await g1Row.getByRole('button', { name: '编辑' }).click(); + await expect(page.getByRole('heading', { name: /编辑规则 G1/ })).toBeVisible(); + await page.locator('select').selectOption('publisher'); + await page.getByRole('button', { name: '保存' }).click(); + await expect(page.getByText(/已保存 G1/)).toBeVisible({ timeout: 30_000 }); + await expect(g1Row.getByText('发布人')).toBeVisible(); + + const award = runGm4Trigger([ + 'award_g1_split', + String(targetUserId), + String(publisherUserId), + ]); + expect(award.ok).toBeTruthy(); + expect(award.skipped).toBeFalsy(); + expect(award.beneficiary_config).toBe('publisher'); + const logs = (award.logs as Array<{ user_id: number; beneficiary_role: string }>) || []; + expect(logs.length).toBe(1); + expect(logs[0].user_id).toBe(publisherUserId); + expect(logs[0].beneficiary_role).toBe('publisher'); + } finally { + // Always restore so shared env does not stay on publisher. + runGm4Trigger(['set_beneficiary', 'G1', prevBeneficiary]); + } + }); + + test('admin deducts via R* rule and audit shows deduct', async ({ page }) => { + test.setTimeout(120_000); + await loginPortal(page, 'admin'); + await page.goto('/admin'); + await page.getByText('积分管理').click(); + await expect(page.getByText('用户积分管理')).toBeVisible({ timeout: 30_000 }); + + await page.getByRole('tab', { name: '用户积分列表' }).click(); + await page.locator('#points-user-search').fill(String(targetUserId)); + await expect(page.getByRole('button', { name: '违规扣减' }).first()).toBeVisible({ + timeout: 30_000, + }); + await page.getByRole('button', { name: '违规扣减' }).first().click(); + await expect(page.getByRole('heading', { name: '违规扣减' })).toBeVisible(); + const ruleSelect = page.locator('#points-deduct-rule'); + // Default is first enabled R*;用原生赋值避开 Playwright selectOption 的 label 解析问题。 + await expect(ruleSelect.locator('option[value="R1"]')).toHaveCount(1, { timeout: 15_000 }); + await ruleSelect.evaluate((el) => { + const select = el as HTMLSelectElement; + select.value = 'R1'; + select.dispatchEvent(new Event('change', { bubbles: true })); + }); + await expect(ruleSelect).toHaveValue('R1'); + await page.locator('#points-deduct-remark').fill('G-M4 联调违规扣减'); + await page.getByRole('button', { name: '确认扣减' }).click(); + await expect(page.getByText(/扣减成功/)).toBeVisible({ timeout: 30_000 }); + + const latest = runGm4Trigger(['latest_deduct', String(targetUserId), 'R1']); + expect(latest.ok).toBeTruthy(); + expect(latest.found).toBeTruthy(); + expect(Number(latest.delta)).toBeLessThan(0); + expect(latest.direction).toBe('deduct'); + expect(latest.source).toBe('manual_deduct'); + + await page.getByRole('tab', { name: '操作记录' }).click(); + await expect(page.getByText('R1').first()).toBeVisible({ timeout: 30_000 }); + }); + + test('my-points rules modal hides M* and shows offline appeal copy', async ({ page }) => { + await loginPortal(page, 'user'); + await page.goto('/points'); + await expect(page.getByText('我的积分').first()).toBeVisible({ timeout: 30_000 }); + await page.getByRole('button', { name: '积分规则' }).click(); + await expect(page.getByRole('heading', { name: '积分规则' })).toBeVisible({ + timeout: 30_000, + }); + // Public rules API already filters M*; assert modal copy does not surface monthly rewards. + await expect(page.locator('#points-rules-title')).toBeVisible(); + await expect(page.getByText(/管理员月奖|月度奖励 M/)).toHaveCount(0); + await expect(page.getByText(/线下/)).toBeVisible(); + }); +}); diff --git a/src/backend/test/e2e_ui/points/gates/gm5.spec.ts b/src/backend/test/e2e_ui/points/gates/gm5.spec.ts new file mode 100644 index 000000000..09ef342c8 --- /dev/null +++ b/src/backend/test/e2e_ui/points/gates/gm5.spec.ts @@ -0,0 +1,85 @@ +import { test, expect } from '@playwright/test'; +import { loginPortal } from '../fixtures/auth'; +import { runFactory } from '../helpers/factory'; + +/** + * G-M5:发布前冒烟 — 对账 / 开关负例 / 入口可读。 + * 完整回归请用:`npm run test:gm5`(串行 gm1–gm4 + 本文件)。 + * + * 运行:E2E_POINTS_RUN_GATES=1 E2E_POINTS_PASSWORD=… npm run test:gm5 + */ +test.describe('G-M5 release smoke', () => { + test.skip(!process.env.E2E_POINTS_RUN_GATES, 'Set E2E_POINTS_RUN_GATES=1 before release'); + + const userId = Number(process.env.E2E_POINTS_TARGET_USER_ID || '4'); + + test('schema + seed tables are readable', () => { + const schema = runFactory(['schema_check']); + expect(schema.ok).toBeTruthy(); + expect(schema.department_org_level).toBeTruthy(); + }); + + test('reconcile has no balance mismatch', () => { + const out = runFactory(['reconcile', '1']); + expect(out.ok).toBeTruthy(); + expect(Number(out.checked)).toBeGreaterThan(0); + expect(Number(out.mismatches)).toBe(0); + }); + + test('points.enabled=false skips new department upload award', () => { + const out = runFactory(['award_disabled', String(userId)]); + expect(out.ok).toBeTruthy(); + expect(out.skipped).toBeTruthy(); + expect(out.reason).toBe('points_disabled'); + expect(out.no_new_log).toBeTruthy(); + }); + + test('sync outbox drain is safe when disabled or without adapter', () => { + const out = runFactory(['outbox_drain']); + expect(out.ok).toBeTruthy(); + // Either feature-flag skipped, or drained without raising. + if (out.skipped) { + expect(String(out.reason || '')).toMatch(/disabled|no_adapter|adapter/i); + } else { + expect(Number(out.processed ?? out.drained ?? 0)).toBeGreaterThanOrEqual(0); + } + }); + + test('factory can seed G3/G4 paths for reuse', () => { + const g3 = runFactory(['award_g3', String(userId)]); + expect(g3.ok).toBeTruthy(); + // First hit at threshold 75 should apply or skip only if already granted same file. + expect(g3.skipped === true || g3.skipped === false).toBeTruthy(); + + const g4 = runFactory(['award_g4', String(userId)]); + expect(g4.ok).toBeTruthy(); + expect(g4.skipped).toBeFalsy(); + const after = g4.after as { count: number }; + const before = g4.before as { count: number }; + expect(after.count).toBe(before.count + 1); + }); + + test('portal my-points page still loads', async ({ page }) => { + await loginPortal(page, 'user'); + await page.goto('/points'); + await expect(page.getByText('我的积分').first()).toBeVisible({ timeout: 30_000 }); + await expect(page.getByText('本月获得')).toBeVisible(); + }); + + test('portal admin points overview still loads', async ({ browser }) => { + // Fresh context so user cookies from prior test cannot block admin login. + const context = await browser.newContext({ + baseURL: process.env.E2E_PORTAL_BASE_URL || 'http://127.0.0.1:5173', + }); + const page = await context.newPage(); + try { + await loginPortal(page, 'admin'); + await page.goto('/admin'); + await page.getByText('积分管理', { exact: true }).click(); + await expect(page.getByText('平台总积分发放')).toBeVisible({ timeout: 30_000 }); + await expect(page.getByText('违规扣减合计')).toBeVisible(); + } finally { + await context.close(); + } + }); +}); diff --git a/src/backend/test/e2e_ui/points/helpers/accept_test11.ts b/src/backend/test/e2e_ui/points/helpers/accept_test11.ts new file mode 100644 index 000000000..405df5d55 --- /dev/null +++ b/src/backend/test/e2e_ui/points/helpers/accept_test11.ts @@ -0,0 +1,32 @@ +import { execFileSync } from 'node:child_process'; +import path from 'node:path'; + +const backendRoot = path.resolve(__dirname, '../../../..'); +const python = path.join(backendRoot, '.venv/bin/python'); +const script = path.join(__dirname, 'accept_test11_trigger.py'); + +/** + * 调用测试1-1 验收 Python 辅助(规则快照 / 月度探测)。 + * @param args CLI 参数,如 ['rules_snapshot'] + */ +export function runAccept11Trigger(args: string[]): Record { + const stdout = execFileSync(python, [script, ...args], { + cwd: backendRoot, + env: { + ...process.env, + config: 'config.yaml', + PYTHONPATH: backendRoot, + }, + encoding: 'utf-8', + maxBuffer: 4 * 1024 * 1024, + }); + const lines = stdout + .trim() + .split('\n') + .filter((line) => line.startsWith('{') || line.startsWith('[')); + const last = lines[lines.length - 1]; + if (!last) { + throw new Error(`accept_test11_trigger produced no JSON: ${stdout.slice(-500)}`); + } + return JSON.parse(last) as Record; +} diff --git a/src/backend/test/e2e_ui/points/helpers/accept_test11_trigger.py b/src/backend/test/e2e_ui/points/helpers/accept_test11_trigger.py new file mode 100644 index 000000000..8881fbb49 --- /dev/null +++ b/src/backend/test/e2e_ui/points/helpers/accept_test11_trigger.py @@ -0,0 +1,132 @@ +#!/usr/bin/env python3 +"""测试1-1 验收辅助:规则快照 / 月度引擎 dry-run。 + +用法: + cd src/backend && PYTHONPATH=. config=config.yaml \\ + .venv/bin/python test/e2e_ui/points/helpers/accept_test11_trigger.py +""" + +from __future__ import annotations + +import asyncio +import json +import sys +from pathlib import Path + +BACKEND_ROOT = Path(__file__).resolve().parents[4] +if str(BACKEND_ROOT) not in sys.path: + sys.path.insert(0, str(BACKEND_ROOT)) + +# 全量 G/R/M(本轮验收要求全部启用) +TARGET_CODES = ( + "G1", + "G2", + "G3", + "G4", + "G5", + "G6", + "G7", + "R1", + "R2", + "R3", + "M1", + "M2", + "M3", + "M4", + "M5", + "M6", + "M7", + "M8", +) + + +async def _rules_snapshot(tenant_id: int = 1) -> dict: + """拉取目标规则启用状态与分值表达式。""" + from bisheng.core.database import get_async_db_session + from bisheng.points.domain.repositories.points_repository import PointsRepository + + async with get_async_db_session() as session: + repo = PointsRepository(session) + rules = await repo.list_rules(tenant_id) + by_code = {r.rule_code: r for r in rules} + items = [] + missing = [] + for code in TARGET_CODES: + rule = by_code.get(code) + if not rule: + missing.append(code) + continue + items.append( + { + "rule_code": rule.rule_code, + "status": rule.status, + "name": rule.name, + "rule_type": rule.rule_type, + "score_expr": rule.score_expr, + "beneficiary": rule.beneficiary, + } + ) + enabled = [i["rule_code"] for i in items if i["status"] == "enabled"] + return { + "ok": not missing and set(enabled) == set(TARGET_CODES), + "items": items, + "missing": missing, + "enabled": enabled, + } + + +async def _monthly_probe(tenant_id: int = 1, period_key: str | None = None) -> dict: + """调用月度奖励引擎一次(会真实尝试结算;缺 ES 登录事实时返回 error)。""" + from sqlalchemy import text + + from bisheng.core.database import get_async_db_session + from bisheng.points.domain.services.points_monthly_reward_service import ( + PointsMonthlyRewardService, + previous_month_key, + ) + + svc = PointsMonthlyRewardService() + key = period_key or previous_month_key() + out = await svc.run_for_tenant(int(tenant_id), period_key=key) + blocked = out.get("error") == "login_query_failed" + async with get_async_db_session() as session: + rows = ( + await session.execute( + text( + "select rule_code, count(*) c, coalesce(sum(delta),0) s " + "from user_point_log where tenant_id=:t and source='monthly_reward' " + "and biz_id=:p group by rule_code order by rule_code" + ), + {"t": int(tenant_id), "p": key}, + ) + ).all() + by_rule = {str(r[0]): {"count": int(r[1]), "sum_delta": int(r[2])} for r in rows} + return { + "ok": True, + "blocked": blocked, + "result": out, + "ledger_by_rule": by_rule, + "period_key": key, + } + + +async def main() -> int: + if len(sys.argv) < 2: + print(__doc__) + return 2 + action = sys.argv[1] + if action == "rules_snapshot": + out = await _rules_snapshot() + elif action == "monthly_probe": + tid = int(sys.argv[2]) if len(sys.argv) > 2 else 1 + period = sys.argv[3] if len(sys.argv) > 3 else None + out = await _monthly_probe(tid, period) + else: + print(json.dumps({"error": f"unknown action {action}"})) + return 2 + print(json.dumps(out, ensure_ascii=False)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(asyncio.run(main())) diff --git a/src/backend/test/e2e_ui/points/helpers/factory.ts b/src/backend/test/e2e_ui/points/helpers/factory.ts new file mode 100644 index 000000000..00096b154 --- /dev/null +++ b/src/backend/test/e2e_ui/points/helpers/factory.ts @@ -0,0 +1,32 @@ +import { execFileSync } from 'node:child_process'; +import path from 'node:path'; + +const backendRoot = path.resolve(__dirname, '../../../..'); +const python = path.join(backendRoot, '.venv/bin/python'); +const script = path.join(__dirname, 'factory_trigger.py'); + +/** + * 调用统一 Gate 数据工厂(造数 / 对账 / 开关负例)。 + * @param args CLI 参数,如 ['award_g2', '4', '123', '10'] + */ +export function runFactory(args: string[]): Record { + const stdout = execFileSync(python, [script, ...args], { + cwd: backendRoot, + env: { + ...process.env, + config: 'config.yaml', + PYTHONPATH: backendRoot, + }, + encoding: 'utf-8', + maxBuffer: 4 * 1024 * 1024, + }); + const lines = stdout + .trim() + .split('\n') + .filter((line) => line.startsWith('{') || line.startsWith('[')); + const last = lines[lines.length - 1]; + if (!last) { + throw new Error(`factory_trigger produced no JSON: ${stdout.slice(-500)}`); + } + return JSON.parse(last) as Record; +} diff --git a/src/backend/test/e2e_ui/points/helpers/factory_trigger.py b/src/backend/test/e2e_ui/points/helpers/factory_trigger.py new file mode 100644 index 000000000..fd62d6d8c --- /dev/null +++ b/src/backend/test/e2e_ui/points/helpers/factory_trigger.py @@ -0,0 +1,452 @@ +#!/usr/bin/env python3 +"""F070 Gate 统一数据工厂:造数 / 对账 / 开关负例,供 G-M2~G-M5 复用。 + +用法: + cd src/backend && PYTHONPATH=. config=config.yaml \\ + .venv/bin/python test/e2e_ui/points/helpers/factory_trigger.py ... +""" + +from __future__ import annotations + +import asyncio +import json +import os +import sys +import time +from contextlib import contextmanager +from pathlib import Path +from types import SimpleNamespace +from unittest.mock import patch + +BACKEND_ROOT = Path(__file__).resolve().parents[4] +if str(BACKEND_ROOT) not in sys.path: + sys.path.insert(0, str(BACKEND_ROOT)) + + +@contextmanager +def _gate_award_mode(): + """Gate 默认强制同步入账,避免依赖 Celery worker;POINTS_GATE_FORCE_SYNC=0 时走异步+轮询。""" + force_sync = os.environ.get("POINTS_GATE_FORCE_SYNC", "1") != "0" + if force_sync: + with patch( + "bisheng.points.domain.services.points_award_hooks._award_async_enabled", + return_value=False, + ): + yield "sync" + else: + yield "async" + + +async def _wait_ledger(user_id: int, rule_code: str, before: dict, *, timeout_s: float = 15.0) -> dict: + """轮询直到 earn 笔数或余额变化,或超时返回最后快照。""" + deadline = time.time() + timeout_s + after = before + while time.time() < deadline: + after = await _snapshot(user_id, rule_code) + if after["count"] != before["count"] or after["balance"] != before["balance"]: + return after + await asyncio.sleep(0.3) + return after + + +async def _snapshot(user_id: int, rule_code: str) -> dict: + from sqlalchemy import text + from bisheng.core.database import get_async_db_session + + async with get_async_db_session() as session: + bal_row = ( + await session.execute( + text( + "select coalesce(balance,0) from user_point_account " + "where tenant_id=1 and user_id=:u" + ), + {"u": user_id}, + ) + ).first() + cnt_row = ( + await session.execute( + text( + "select count(*) from user_point_log " + "where tenant_id=1 and user_id=:u and rule_code=:r and direction='earn'" + ), + {"u": user_id, "r": rule_code}, + ) + ).first() + titles = ( + await session.execute( + text( + "select title, delta, idempotency_key from user_point_log " + "where tenant_id=1 and user_id=:u and rule_code=:r and direction='earn' " + "order by id desc limit 5" + ), + {"u": user_id, "r": rule_code}, + ) + ).all() + return { + "user_id": user_id, + "rule_code": rule_code, + "balance": int(bal_row[0]) if bal_row else 0, + "count": int(cnt_row[0]) if cnt_row else 0, + "recent": [{"title": r[0], "delta": int(r[1]), "key": r[2]} for r in titles], + } + + +async def _ensure_g7(tenant_id: int = 1) -> dict: + from bisheng.core.database import get_async_db_session + from bisheng.points.domain.models import PointRule + from bisheng.points.domain.repositories.points_repository import PointsRepository + + async with get_async_db_session() as session: + repo = PointsRepository(session) + existing = await repo.get_rule(tenant_id, "G7") + if existing: + if existing.status != "enabled": + existing.status = "enabled" + await repo.save_rule(existing) + await session.commit() + return {"ok": True, "created": False, "id": existing.id} + rule = PointRule( + tenant_id=tenant_id, + rule_code="G7", + rule_type="earn", + name="文档库间分享", + score_expr={"mode": "fixed", "score": 2}, + daily_cap=10, + beneficiary="uploader", + status="enabled", + sort_order=7, + ) + saved = await repo.save_rule(rule) + await session.commit() + return {"ok": True, "created": True, "id": saved.id} + + +async def _award_g2(user_id: int, file_id: int, space_id: int) -> dict: + from bisheng.points.domain.services.points_award_hooks import notify_space_files_ready + + before = await _snapshot(user_id, "G2") + with _gate_award_mode() as mode: + await notify_space_files_ready( + tenant_id=1, + space_id=int(space_id), + files=[SimpleNamespace(id=int(file_id))], + uploader_id=int(user_id), + is_favorite_space=False, + space_level="department", + ) + after = ( + await _snapshot(user_id, "G2") + if mode == "sync" + else await _wait_ledger(user_id, "G2", before) + ) + return {"ok": True, "before": before, "after": after, "mode": mode} + + +async def _award_by_level( + user_id: int, + file_id: int, + space_id: int, + *, + space_level: str, + rule_code: str, +) -> dict: + """按空间等级触发入库发分(G5=team / G6=team_ks)。""" + from bisheng.points.domain.services.points_award_hooks import notify_space_files_ready + + before = await _snapshot(user_id, rule_code) + with _gate_award_mode() as mode: + await notify_space_files_ready( + tenant_id=1, + space_id=int(space_id), + files=[SimpleNamespace(id=int(file_id))], + uploader_id=int(user_id), + is_favorite_space=False, + space_level=space_level, + ) + after = ( + await _snapshot(user_id, rule_code) + if mode == "sync" + else await _wait_ledger(user_id, rule_code, before) + ) + return { + "ok": True, + "rule_code": rule_code, + "space_level": space_level, + "before": before, + "after": after, + "mode": mode, + } + + +async def _award_g7(user_id: int, share_entry_id: int) -> dict: + from bisheng.points.domain.services.points_award_hooks import notify_document_shared + + before = await _snapshot(user_id, "G7") + with _gate_award_mode() as mode: + await notify_document_shared( + tenant_id=1, + share_entry_id=int(share_entry_id), + source_space_id=10, + target_space_id=12, + uploader_id=int(user_id), + sharer_id=int(user_id), + ) + after = ( + await _snapshot(user_id, "G7") + if mode == "sync" + else await _wait_ledger(user_id, "G7", before) + ) + return {"ok": True, "before": before, "after": after, "mode": mode} + + +async def _award_g3(user_id: int, file_id: int | None = None, favoriter_count: int = 75) -> dict: + """直接经 Facade 造 G3 阶梯(不依赖真实收藏表)。""" + from bisheng.core.database import get_async_db_session + from bisheng.points.domain.repositories.points_repository import PointsRepository + from bisheng.points.domain.services.points_award_facade import FavoriteChangedEvent, PointsAwardFacade + from bisheng.points.domain.services.points_ledger_service import PointsLedgerService + + fid = int(file_id or (int(time.time()) % 1_000_000 + 920_000_000)) + before = await _snapshot(user_id, "G3") + async with get_async_db_session() as session: + repo = PointsRepository(session) + facade = PointsAwardFacade(repo, PointsLedgerService(repo), enabled=True) + outcome = await facade.on_favorite_changed( + FavoriteChangedEvent( + tenant_id=1, + file_id=fid, + uploader_id=int(user_id), + unique_favoriter_count=int(favoriter_count), + space_manager_ids=frozenset(), + ) + ) + await session.commit() + after = await _snapshot(user_id, "G3") + return { + "ok": True, + "file_id": fid, + "skipped": outcome.skipped, + "reason": outcome.reason, + "before": before, + "after": after, + } + + +async def _award_g4(user_id: int, answer_id: int | None = None) -> dict: + from bisheng.core.database import get_async_db_session + from bisheng.points.domain.repositories.points_repository import PointsRepository + from bisheng.points.domain.services.points_award_facade import AnswerAdoptedEvent, PointsAwardFacade + from bisheng.points.domain.services.points_ledger_service import PointsLedgerService + + aid = int(answer_id or (int(time.time()) % 1_000_000 + 930_000_000)) + before = await _snapshot(user_id, "G4") + async with get_async_db_session() as session: + repo = PointsRepository(session) + facade = PointsAwardFacade(repo, PointsLedgerService(repo), enabled=True) + outcome = await facade.on_answer_adopted( + AnswerAdoptedEvent(tenant_id=1, answer_id=aid, answerer_id=int(user_id)) + ) + await session.commit() + after = await _snapshot(user_id, "G4") + return { + "ok": True, + "answer_id": aid, + "skipped": outcome.skipped, + "reason": outcome.reason, + "before": before, + "after": after, + } + + +async def _award_admin_g2(admin_uid: int, file_id: int) -> dict: + from bisheng.points.domain.services.points_award_hooks import notify_space_files_ready + + before = await _snapshot(admin_uid, "G2") + with _gate_award_mode() as mode: + await notify_space_files_ready( + tenant_id=1, + space_id=10, + files=[SimpleNamespace(id=int(file_id))], + uploader_id=int(admin_uid), + is_favorite_space=False, + space_level="department", + ) + after = ( + await _snapshot(admin_uid, "G2") + if mode == "sync" + else await _wait_ledger(admin_uid, "G2", before) + ) + return { + "ok": True, + "before": before, + "after": after, + "mode": mode, + "skipped": after["count"] == before["count"] and after["balance"] == before["balance"], + } + + +async def _share_link_neg(user_id: int, fake_link_id: int) -> dict: + from sqlalchemy import text + from bisheng.core.database import get_async_db_session + + key = f"earn:G7:{fake_link_id}" + async with get_async_db_session() as session: + row = ( + await session.execute( + text( + "select count(*) from user_point_log " + "where tenant_id=1 and user_id=:u and idempotency_key=:k" + ), + {"u": user_id, "k": key}, + ) + ).first() + return {"ok": True, "user_id": user_id, "key": key, "count": int(row[0]) if row else 0} + + +async def _award_disabled(user_id: int, file_id: int | None = None) -> dict: + """points.enabled=false 路径:Facade 覆盖关闭后不应入账。""" + from bisheng.core.database import get_async_db_session + from bisheng.points.domain.repositories.points_repository import PointsRepository + from bisheng.points.domain.services.points_award_facade import PointsAwardFacade, SpaceFileReadyEvent + from bisheng.points.domain.services.points_ledger_service import PointsLedgerService + + fid = int(file_id or (int(time.time()) % 1_000_000 + 940_000_000)) + before = await _snapshot(user_id, "G2") + async with get_async_db_session() as session: + repo = PointsRepository(session) + facade = PointsAwardFacade(repo, PointsLedgerService(repo), enabled=False) + outcome = await facade.on_space_file_ready( + SpaceFileReadyEvent( + tenant_id=1, + space_id=10, + space_level="department", + file_id=fid, + uploader_id=int(user_id), + is_favorite_space=False, + space_manager_ids=frozenset(), + ) + ) + await session.commit() + after = await _snapshot(user_id, "G2") + return { + "ok": True, + "file_id": fid, + "skipped": outcome.skipped, + "reason": outcome.reason, + "before": before, + "after": after, + "no_new_log": after["count"] == before["count"], + } + + +async def _reconcile(tenant_id: int = 1) -> dict: + from bisheng.points.domain.services.points_reconcile_service import PointsReconcileService + + out = await PointsReconcileService().reconcile_tenant(int(tenant_id)) + return {"ok": True, **out} + + +async def _outbox_drain() -> dict: + from bisheng.points.domain.services.points_sync_outbox_service import PointsSyncOutboxService + + out = await PointsSyncOutboxService().drain() + return {"ok": True, **out} + + +async def _schema_check() -> dict: + """MySQL 联调库:积分核心表是否可读。""" + from sqlalchemy import text + from bisheng.core.database import get_async_db_session + + required = [ + "user_point_account", + "user_point_log", + "point_rule", + "point_copy", + "point_rank_snapshot", + "point_favorite_tier_award", + "point_sync_outbox", + ] + missing: list[str] = [] + counts: dict[str, int] = {} + has_org = False + async with get_async_db_session() as session: + for name in required: + try: + row = (await session.execute(text(f"select count(*) from {name}"))).first() + counts[name] = int(row[0]) if row else 0 + except Exception: + missing.append(name) + # Dialect-safe: probe column via SELECT (no information_schema). + try: + await session.execute(text("select org_level from department limit 1")) + has_org = True + except Exception: + has_org = False + return { + "ok": not missing and has_org, + "missing_tables": missing, + "counts": counts, + "department_org_level": has_org, + } + + +async def main() -> int: + if len(sys.argv) < 2: + print(__doc__) + return 2 + action = sys.argv[1] + if action == "ensure_g7": + out = await _ensure_g7() + elif action == "award_g2": + out = await _award_g2(int(sys.argv[2]), int(sys.argv[3]), int(sys.argv[4])) + elif action == "award_g5": + # argv: user_id file_id space_id + out = await _award_by_level( + int(sys.argv[2]), + int(sys.argv[3]), + int(sys.argv[4]), + space_level="team", + rule_code="G5", + ) + elif action == "award_g6": + out = await _award_by_level( + int(sys.argv[2]), + int(sys.argv[3]), + int(sys.argv[4]), + space_level="team_ks", + rule_code="G6", + ) + elif action == "award_g7": + out = await _award_g7(int(sys.argv[2]), int(sys.argv[3])) + elif action == "award_g3": + fid = int(sys.argv[3]) if len(sys.argv) > 3 else None + out = await _award_g3(int(sys.argv[2]), fid) + elif action == "award_g4": + aid = int(sys.argv[3]) if len(sys.argv) > 3 else None + out = await _award_g4(int(sys.argv[2]), aid) + elif action == "award_admin_g2": + out = await _award_admin_g2(int(sys.argv[2]), int(sys.argv[3])) + elif action == "share_link_neg": + out = await _share_link_neg(int(sys.argv[2]), int(sys.argv[3])) + elif action == "award_disabled": + fid = int(sys.argv[3]) if len(sys.argv) > 3 else None + out = await _award_disabled(int(sys.argv[2]), fid) + elif action == "reconcile": + tid = int(sys.argv[2]) if len(sys.argv) > 2 else 1 + out = await _reconcile(tid) + elif action == "outbox_drain": + out = await _outbox_drain() + elif action == "schema_check": + out = await _schema_check() + elif action == "count_logs": + out = await _snapshot(int(sys.argv[2]), sys.argv[3]) + else: + print(json.dumps({"error": f"unknown action {action}"})) + return 2 + print(json.dumps(out, ensure_ascii=False)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(asyncio.run(main())) diff --git a/src/backend/test/e2e_ui/points/helpers/gm2.ts b/src/backend/test/e2e_ui/points/helpers/gm2.ts new file mode 100644 index 000000000..d64844d40 --- /dev/null +++ b/src/backend/test/e2e_ui/points/helpers/gm2.ts @@ -0,0 +1,31 @@ +import { execFileSync } from 'node:child_process'; +import path from 'node:path'; + +const backendRoot = path.resolve(__dirname, '../../../..'); +const python = path.join(backendRoot, '.venv/bin/python'); +const script = path.join(__dirname, 'gm2_trigger.py'); + +/** + * 调用 Python 辅助脚本触发/查询积分(经 Facade hooks)。 + */ +export function runGm2Trigger(args: string[]): Record { + const stdout = execFileSync(python, [script, ...args], { + cwd: backendRoot, + env: { + ...process.env, + config: 'config.yaml', + PYTHONPATH: backendRoot, + }, + encoding: 'utf-8', + maxBuffer: 4 * 1024 * 1024, + }); + const lines = stdout + .trim() + .split('\n') + .filter((line) => line.startsWith('{') || line.startsWith('[')); + const last = lines[lines.length - 1]; + if (!last) { + throw new Error(`gm2_trigger produced no JSON: ${stdout.slice(-500)}`); + } + return JSON.parse(last) as Record; +} diff --git a/src/backend/test/e2e_ui/points/helpers/gm2_trigger.py b/src/backend/test/e2e_ui/points/helpers/gm2_trigger.py new file mode 100644 index 000000000..1d7b0015f --- /dev/null +++ b/src/backend/test/e2e_ui/points/helpers/gm2_trigger.py @@ -0,0 +1,215 @@ +#!/usr/bin/env python3 +"""G-M2 API 辅助:经 hooks/Facade 造数,供 Playwright 断言明细。 + +用法: + cd src/backend && PYTHONPATH=. .venv/bin/python test/e2e_ui/points/helpers/gm2_trigger.py ... +""" + +from __future__ import annotations + +import asyncio +import json +import sys +from pathlib import Path +from types import SimpleNamespace + +BACKEND_ROOT = Path(__file__).resolve().parents[4] +if str(BACKEND_ROOT) not in sys.path: + sys.path.insert(0, str(BACKEND_ROOT)) + +# 复用 factory 的 Gate 同步/轮询约定,避免 G-M2 在异步默认开启时读到旧余额。 +from test.e2e_ui.points.helpers.factory_trigger import ( # noqa: E402 + _gate_award_mode, + _wait_ledger, +) + + +async def _ensure_g7(tenant_id: int = 1) -> dict: + from bisheng.core.database import get_async_db_session + from bisheng.points.domain.models import PointRule + from bisheng.points.domain.repositories.points_repository import PointsRepository + + async with get_async_db_session() as session: + repo = PointsRepository(session) + existing = await repo.get_rule(tenant_id, "G7") + if existing: + if existing.status != "enabled": + existing.status = "enabled" + await repo.save_rule(existing) + await session.commit() + return {"ok": True, "created": False, "id": existing.id} + rule = PointRule( + tenant_id=tenant_id, + rule_code="G7", + rule_type="earn", + name="文档库间分享", + score_expr={"mode": "fixed", "score": 2}, + daily_cap=10, + beneficiary="uploader", + status="enabled", + sort_order=7, + ) + saved = await repo.save_rule(rule) + await session.commit() + return {"ok": True, "created": True, "id": saved.id} + + +async def _snapshot(user_id: int, rule_code: str) -> dict: + from sqlalchemy import text + from bisheng.core.database import get_async_db_session + + async with get_async_db_session() as session: + bal_row = ( + await session.execute( + text( + "select coalesce(balance,0) from user_point_account " + "where tenant_id=1 and user_id=:u" + ), + {"u": user_id}, + ) + ).first() + cnt_row = ( + await session.execute( + text( + "select count(*) from user_point_log " + "where tenant_id=1 and user_id=:u and rule_code=:r and direction='earn'" + ), + {"u": user_id, "r": rule_code}, + ) + ).first() + titles = ( + await session.execute( + text( + "select title, delta, idempotency_key from user_point_log " + "where tenant_id=1 and user_id=:u and rule_code=:r and direction='earn' " + "order by id desc limit 5" + ), + {"u": user_id, "r": rule_code}, + ) + ).all() + balance = int(bal_row[0]) if bal_row else 0 + count = int(cnt_row[0]) if cnt_row else 0 + return { + "user_id": user_id, + "rule_code": rule_code, + "balance": balance, + "count": count, + "recent": [{"title": r[0], "delta": int(r[1]), "key": r[2]} for r in titles], + } + + +async def _award_g2(user_id: int, file_id: int, space_id: int) -> dict: + from bisheng.points.domain.services.points_award_hooks import notify_space_files_ready + + before = await _snapshot(user_id, "G2") + with _gate_award_mode() as mode: + await notify_space_files_ready( + tenant_id=1, + space_id=int(space_id), + files=[SimpleNamespace(id=int(file_id))], + uploader_id=int(user_id), + is_favorite_space=False, + space_level="department", + ) + after = ( + await _snapshot(user_id, "G2") + if mode == "sync" + else await _wait_ledger(user_id, "G2", before) + ) + return {"before": before, "after": after, "mode": mode} + + +async def _award_g7(user_id: int, share_entry_id: int) -> dict: + from bisheng.points.domain.services.points_award_hooks import notify_document_shared + + before = await _snapshot(user_id, "G7") + with _gate_award_mode() as mode: + await notify_document_shared( + tenant_id=1, + share_entry_id=int(share_entry_id), + source_space_id=10, + target_space_id=12, + uploader_id=int(user_id), + sharer_id=int(user_id), + ) + after = ( + await _snapshot(user_id, "G7") + if mode == "sync" + else await _wait_ledger(user_id, "G7", before) + ) + return {"before": before, "after": after, "mode": mode} + + +async def _award_admin_g2(admin_uid: int, file_id: int) -> dict: + from bisheng.points.domain.services.points_award_hooks import notify_space_files_ready + + before = await _snapshot(admin_uid, "G2") + with _gate_award_mode() as mode: + await notify_space_files_ready( + tenant_id=1, + space_id=10, + files=[SimpleNamespace(id=int(file_id))], + uploader_id=int(admin_uid), + is_favorite_space=False, + space_level="department", + ) + after = ( + await _snapshot(admin_uid, "G2") + if mode == "sync" + else await _wait_ledger(admin_uid, "G2", before) + ) + return { + "before": before, + "after": after, + "mode": mode, + "skipped": after["count"] == before["count"] and after["balance"] == before["balance"], + } + + +async def _share_link_negative(user_id: int, fake_link_id: int) -> dict: + """外链分享未挂 hooks:仅断言不会因 link_id 产生 earn:G7:{link_id} 流水。""" + from sqlalchemy import text + from bisheng.core.database import get_async_db_session + + key = f"earn:G7:{fake_link_id}" + async with get_async_db_session() as session: + row = ( + await session.execute( + text( + "select count(*) from user_point_log " + "where tenant_id=1 and user_id=:u and idempotency_key=:k" + ), + {"u": user_id, "k": key}, + ) + ).first() + return {"user_id": user_id, "key": key, "count": int(row[0]) if row else 0} + + +async def main() -> int: + if len(sys.argv) < 2: + print(__doc__) + return 2 + action = sys.argv[1] + if action == "ensure_g7": + out = await _ensure_g7() + elif action == "award_g2": + out = await _award_g2(int(sys.argv[2]), int(sys.argv[3]), int(sys.argv[4])) + elif action == "award_g7": + out = await _award_g7(int(sys.argv[2]), int(sys.argv[3])) + elif action == "award_admin_g2": + out = await _award_admin_g2(int(sys.argv[2]), int(sys.argv[3])) + elif action == "share_link_neg": + out = await _share_link_negative(int(sys.argv[2]), int(sys.argv[3])) + elif action == "count_logs": + out = await _snapshot(int(sys.argv[2]), sys.argv[3]) + elif action == "balance": + out = await _snapshot(int(sys.argv[2]), "G2") + else: + print(json.dumps({"error": f"unknown action {action}"})) + return 2 + print(json.dumps(out, ensure_ascii=False)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(asyncio.run(main())) diff --git a/src/backend/test/e2e_ui/points/helpers/gm3.ts b/src/backend/test/e2e_ui/points/helpers/gm3.ts new file mode 100644 index 000000000..c179560de --- /dev/null +++ b/src/backend/test/e2e_ui/points/helpers/gm3.ts @@ -0,0 +1,31 @@ +import { execFileSync } from 'node:child_process'; +import path from 'node:path'; + +const backendRoot = path.resolve(__dirname, '../../../..'); +const python = path.join(backendRoot, '.venv/bin/python'); +const script = path.join(__dirname, 'gm3_trigger.py'); + +/** + * 调用 Python 辅助脚本:排行刷新 / org_level 只读 / 可选打标。 + */ +export function runGm3Trigger(args: string[]): Record { + const stdout = execFileSync(python, [script, ...args], { + cwd: backendRoot, + env: { + ...process.env, + config: 'config.yaml', + PYTHONPATH: backendRoot, + }, + encoding: 'utf-8', + maxBuffer: 4 * 1024 * 1024, + }); + const lines = stdout + .trim() + .split('\n') + .filter((line) => line.startsWith('{') || line.startsWith('[')); + const last = lines[lines.length - 1]; + if (!last) { + throw new Error(`gm3_trigger produced no JSON: ${stdout.slice(-500)}`); + } + return JSON.parse(last) as Record; +} diff --git a/src/backend/test/e2e_ui/points/helpers/gm3_trigger.py b/src/backend/test/e2e_ui/points/helpers/gm3_trigger.py new file mode 100644 index 000000000..a1ae185aa --- /dev/null +++ b/src/backend/test/e2e_ui/points/helpers/gm3_trigger.py @@ -0,0 +1,182 @@ +#!/usr/bin/env python3 +"""G-M3 API 辅助:排行快照刷新 / org_level 只读检查 / 可选打标(需显式旗标)。 + +用法: + cd src/backend && PYTHONPATH=. .venv/bin/python test/e2e_ui/points/helpers/gm3_trigger.py ... + +破坏性 set_company_root 仅当同时满足: + E2E_POINTS_ALLOW_ORG_MUTATE=1 + E2E_POINTS_COMPANY_DEPT_ID= +""" + +from __future__ import annotations + +import asyncio +import json +import os +import sys +from pathlib import Path + +BACKEND_ROOT = Path(__file__).resolve().parents[4] +if str(BACKEND_ROOT) not in sys.path: + sys.path.insert(0, str(BACKEND_ROOT)) + + +async def _org_levels(tenant_id: int = 1) -> dict: + from sqlalchemy import text + from bisheng.core.database import get_async_db_session + + async with get_async_db_session() as session: + rows = ( + await session.execute( + text( + "select id, dept_id, name, parent_id, path, org_level " + "from department where tenant_id=:t and status='active' " + "and org_level is not null order by path" + ), + {"t": tenant_id}, + ) + ).all() + items = [ + { + "id": int(r[0]), + "dept_id": r[1], + "name": r[2], + "parent_id": r[3], + "path": r[4], + "org_level": r[5], + } + for r in rows + ] + companies = [i for i in items if i["org_level"] == "company"] + return { + "ok": True, + "count": len(items), + "company_count": len(companies), + "companies": companies, + "levels": { + "company": sum(1 for i in items if i["org_level"] == "company"), + "dept": sum(1 for i in items if i["org_level"] == "dept"), + "office": sum(1 for i in items if i["org_level"] == "office"), + "squad": sum(1 for i in items if i["org_level"] == "squad"), + }, + "sample": items[:20], + } + + +async def _verify_cascade(tenant_id: int = 1) -> dict: + """只读:对每个 company 根校验其子树相对深度映射(支持多公司)。""" + from bisheng.points.domain.constants.org_levels import org_level_for_relative_depth, relative_depth + from sqlalchemy import text + from bisheng.core.database import get_async_db_session + + snap = await _org_levels(tenant_id) + companies = snap["companies"] + if not companies: + return { + "ok": True, + "skipped": True, + "reason": "no company root — cascade verification skipped", + "company_count": 0, + } + mismatches: list[dict] = [] + checked = 0 + company_ids: list[int] = [] + async with get_async_db_session() as session: + for company in companies: + company_ids.append(int(company["id"])) + root_path = company["path"] or f"/{company['id']}/" + rows = ( + await session.execute( + text( + "select id, path, org_level from department " + "where tenant_id=:t and status='active' and path like :pfx " + "and org_level is not null" + ), + {"t": tenant_id, "pfx": f"{root_path}%"}, + ) + ).all() + for row in rows: + depth = relative_depth(root_path, row[1] or f"/{row[0]}/") + if depth is None: + continue + expected = org_level_for_relative_depth(depth) + checked += 1 + if row[2] != expected: + mismatches.append( + { + "id": int(row[0]), + "company_id": int(company["id"]), + "org_level": row[2], + "expected": expected, + "depth": depth, + } + ) + return { + "ok": len(mismatches) == 0, + "skipped": False, + "company_ids": company_ids, + "company_count": len(company_ids), + "checked": checked, + "mismatches": mismatches[:10], + } + + +async def _refresh_ranks(tenant_id: int = 1) -> dict: + from bisheng.core.context.tenant import set_current_tenant_id + from bisheng.points.domain.services.points_rank_service import PointsRankService + + set_current_tenant_id(tenant_id) + out = await PointsRankService().refresh_rank_snapshots(tenant_id) + return {"ok": True, **out} + + +async def _set_company_root(dept_id: str) -> dict: + allow = os.environ.get("E2E_POINTS_ALLOW_ORG_MUTATE", "") == "1" + if not allow: + return { + "ok": False, + "skipped": True, + "reason": "set E2E_POINTS_ALLOW_ORG_MUTATE=1 to label a company subtree (scoped; nested company rejected)", + } + from types import SimpleNamespace + from bisheng.core.context.tenant import set_current_tenant_id + from bisheng.points.domain.services.department_org_level_service import ( + DepartmentOrgLevelService, + ) + + set_current_tenant_id(1) + admin = SimpleNamespace(is_admin=lambda: True, is_global_super=True, user_id=1) + result = await DepartmentOrgLevelService().set_company_root(admin, dept_id) + return {"ok": True, **result} + + +async def main(argv: list[str]) -> int: + if not argv: + print(json.dumps({"ok": False, "error": "missing action"})) + return 1 + action = argv[0] + if action == "org_levels": + out = await _org_levels() + elif action == "verify_cascade": + out = await _verify_cascade() + elif action == "refresh_ranks": + out = await _refresh_ranks() + elif action == "set_company_root": + dept_id = argv[1] if len(argv) > 1 else os.environ.get("E2E_POINTS_COMPANY_DEPT_ID", "") + if not dept_id: + out = { + "ok": False, + "skipped": True, + "reason": "missing dept_id / E2E_POINTS_COMPANY_DEPT_ID", + } + else: + out = await _set_company_root(str(dept_id)) + else: + out = {"ok": False, "error": f"unknown action: {action}"} + print(json.dumps(out, ensure_ascii=False, default=str)) + return 0 if out.get("ok") else 1 + + +if __name__ == "__main__": + raise SystemExit(asyncio.run(main(sys.argv[1:]))) diff --git a/src/backend/test/e2e_ui/points/helpers/gm4.ts b/src/backend/test/e2e_ui/points/helpers/gm4.ts new file mode 100644 index 000000000..e5f82e036 --- /dev/null +++ b/src/backend/test/e2e_ui/points/helpers/gm4.ts @@ -0,0 +1,29 @@ +import { execFileSync } from 'node:child_process'; +import path from 'node:path'; + +const backendRoot = path.resolve(__dirname, '../../../..'); +const python = path.join(backendRoot, '.venv/bin/python'); +const script = path.join(__dirname, 'gm4_trigger.py'); + +/** 调用 G-M4 Python 辅助脚本。 */ +export function runGm4Trigger(args: string[]): Record { + const stdout = execFileSync(python, [script, ...args], { + cwd: backendRoot, + env: { + ...process.env, + config: 'config.yaml', + PYTHONPATH: backendRoot, + }, + encoding: 'utf-8', + maxBuffer: 4 * 1024 * 1024, + }); + const lines = stdout + .trim() + .split('\n') + .filter((line) => line.startsWith('{') || line.startsWith('[')); + const last = lines[lines.length - 1]; + if (!last) { + throw new Error(`gm4_trigger produced no JSON: ${stdout.slice(-500)}`); + } + return JSON.parse(last) as Record; +} diff --git a/src/backend/test/e2e_ui/points/helpers/gm4_trigger.py b/src/backend/test/e2e_ui/points/helpers/gm4_trigger.py new file mode 100644 index 000000000..0156356b7 --- /dev/null +++ b/src/backend/test/e2e_ui/points/helpers/gm4_trigger.py @@ -0,0 +1,168 @@ +#!/usr/bin/env python3 +"""G-M4 API 辅助:受益人发分校验 / 规则查询 / 恢复 G1 beneficiary。 + +用法: + cd src/backend && PYTHONPATH=. .venv/bin/python test/e2e_ui/points/helpers/gm4_trigger.py ... +""" + +from __future__ import annotations + +import asyncio +import json +import sys +import time +from pathlib import Path + +BACKEND_ROOT = Path(__file__).resolve().parents[4] +if str(BACKEND_ROOT) not in sys.path: + sys.path.insert(0, str(BACKEND_ROOT)) + + +async def _get_rule_beneficiary(rule_code: str, tenant_id: int = 1) -> dict: + from bisheng.core.database import get_async_db_session + from bisheng.points.domain.repositories.points_repository import PointsRepository + + async with get_async_db_session() as session: + repo = PointsRepository(session) + rule = await repo.get_rule(tenant_id, rule_code.upper()) + if not rule: + return {"ok": False, "error": "rule_not_found"} + return { + "ok": True, + "rule_code": rule.rule_code, + "beneficiary": rule.beneficiary, + "status": rule.status, + } + + +async def _set_rule_beneficiary(rule_code: str, beneficiary: str, tenant_id: int = 1) -> dict: + """测试辅助:直接改库 beneficiary(Gate 收尾恢复用)。""" + from bisheng.core.database import get_async_db_session + from bisheng.points.domain.repositories.points_repository import PointsRepository + + async with get_async_db_session() as session: + repo = PointsRepository(session) + rule = await repo.get_rule(tenant_id, rule_code.upper()) + if not rule: + return {"ok": False, "error": "rule_not_found"} + prev = rule.beneficiary + rule.beneficiary = beneficiary + await repo.save_rule(rule) + await session.commit() + return {"ok": True, "rule_code": rule.rule_code, "prev": prev, "beneficiary": beneficiary} + + +async def _award_g1_split(uploader_id: int, publisher_id: int, file_id: int | None = None) -> dict: + """按当前 G1 beneficiary 发分;uploader 与 publisher 不同,用于断言落点。""" + from bisheng.core.database import get_async_db_session + from bisheng.points.domain.repositories.points_repository import PointsRepository + from bisheng.points.domain.services.points_award_facade import PointsAwardFacade, SpaceFileReadyEvent + from bisheng.points.domain.services.points_ledger_service import PointsLedgerService + from sqlalchemy import text + + fid = int(file_id or (int(time.time()) % 1_000_000 + 910_000_000)) + space_id = 91001 + + async with get_async_db_session() as session: + repo = PointsRepository(session) + rule = await repo.get_rule(1, "G1") + if not rule or rule.status != "enabled": + return {"ok": False, "error": "g1_missing_or_disabled"} + facade = PointsAwardFacade(repo, PointsLedgerService(repo), enabled=True) + outcome = await facade.on_space_file_ready( + SpaceFileReadyEvent( + tenant_id=1, + space_id=space_id, + space_level="public", + file_id=fid, + uploader_id=int(uploader_id), + publisher_id=int(publisher_id), + is_favorite_space=False, + space_manager_ids=frozenset(), + ) + ) + await session.commit() + key = f"earn:G1:{fid}:{space_id}" + rows = ( + await session.execute( + text( + "select user_id, delta, beneficiary_role from user_point_log " + "where tenant_id=1 and idempotency_key=:k" + ), + {"k": key}, + ) + ).all() + + return { + "ok": True, + "file_id": fid, + "space_id": space_id, + "key": key, + "beneficiary_config": rule.beneficiary, + "skipped": outcome.skipped, + "reason": outcome.reason, + "logs": [ + { + "user_id": int(r[0]), + "delta": int(r[1]), + "beneficiary_role": r[2], + } + for r in rows + ], + } + + +async def _latest_deduct(user_id: int, rule_code: str = "R1") -> dict: + from sqlalchemy import text + from bisheng.core.database import get_async_db_session + + async with get_async_db_session() as session: + row = ( + await session.execute( + text( + "select id, delta, direction, source, balance_after, remark " + "from user_point_log " + "where tenant_id=1 and user_id=:u and rule_code=:r " + "and source='manual_deduct' order by id desc limit 1" + ), + {"u": int(user_id), "r": rule_code.upper()}, + ) + ).first() + if not row: + return {"ok": True, "found": False} + return { + "ok": True, + "found": True, + "id": int(row[0]), + "delta": int(row[1]), + "direction": row[2], + "source": row[3], + "balance_after": int(row[4]), + "remark": row[5], + } + + +async def main() -> int: + if len(sys.argv) < 2: + print(__doc__) + return 2 + action = sys.argv[1] + if action == "get_beneficiary": + out = await _get_rule_beneficiary(sys.argv[2]) + elif action == "set_beneficiary": + out = await _set_rule_beneficiary(sys.argv[2], sys.argv[3]) + elif action == "award_g1_split": + file_id = int(sys.argv[4]) if len(sys.argv) > 4 else None + out = await _award_g1_split(int(sys.argv[2]), int(sys.argv[3]), file_id) + elif action == "latest_deduct": + code = sys.argv[3] if len(sys.argv) > 3 else "R1" + out = await _latest_deduct(int(sys.argv[2]), code) + else: + print(json.dumps({"error": f"unknown action {action}"})) + return 2 + print(json.dumps(out, ensure_ascii=False)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(asyncio.run(main())) diff --git a/src/backend/test/e2e_ui/points/package-lock.json b/src/backend/test/e2e_ui/points/package-lock.json new file mode 100644 index 000000000..0e5e135f8 --- /dev/null +++ b/src/backend/test/e2e_ui/points/package-lock.json @@ -0,0 +1,78 @@ +{ + "name": "bisheng-points-gates", + "version": "0.0.1", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "bisheng-points-gates", + "version": "0.0.1", + "devDependencies": { + "@playwright/test": "^1.54.2" + } + }, + "node_modules/@playwright/test": { + "version": "1.62.1", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.62.1.tgz", + "integrity": "sha512-DTcUc8qii+cpHvtOwggMtBRMjKZHXYWdw8syRYu2vtzuq4Wxphqq4NfCs5Zt44L6mA8rfDfj+PHnxFc/FeK6mQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.62.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/playwright": { + "version": "1.62.1", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.62.1.tgz", + "integrity": "sha512-0M+L3LAD8/nm554LOla9Ayx0j0tmFZ0FBcoQ7F1VuVHpM/XpiC8RcDzBQB8W5+hA8L22THxELzeF+2WcUzvcLg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.62.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=20" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.62.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.62.1.tgz", + "integrity": "sha512-wPYSwEBJY9GHraISXqyqtx0na0LpO3XEX7jNDhntbex7tzUS7kLnZsOlFruFJB4Hi/rhDMjXGqHewDZ68nYZVw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=20" + } + } + } +} diff --git a/src/backend/test/e2e_ui/points/package.json b/src/backend/test/e2e_ui/points/package.json new file mode 100644 index 000000000..a6561e74b --- /dev/null +++ b/src/backend/test/e2e_ui/points/package.json @@ -0,0 +1,21 @@ +{ + "name": "bisheng-points-gates", + "private": true, + "version": "0.0.1", + "description": "F070 points system Playwright acceptance gates (headless)", + "scripts": { + "test": "playwright test", + "test:list": "playwright test --list", + "test:gm1": "playwright test gates/gm1.spec.ts", + "test:gm2": "playwright test gates/gm2.spec.ts", + "test:gm3": "playwright test gates/gm3.spec.ts", + "test:gm4": "playwright test gates/gm4.spec.ts", + "test:gm5": "playwright test gates/gm1.spec.ts gates/gm2.spec.ts gates/gm3.spec.ts gates/gm4.spec.ts gates/gm5.spec.ts", + "test:gm5:only": "playwright test gates/gm5.spec.ts", + "test:accept11": "playwright test gates/accept_test11.spec.ts", + "install:browsers": "playwright install chromium" + }, + "devDependencies": { + "@playwright/test": "^1.54.2" + } +} diff --git a/src/backend/test/e2e_ui/points/playwright.config.ts b/src/backend/test/e2e_ui/points/playwright.config.ts new file mode 100644 index 000000000..c9c2e95b4 --- /dev/null +++ b/src/backend/test/e2e_ui/points/playwright.config.ts @@ -0,0 +1,32 @@ +import { defineConfig, devices } from '@playwright/test'; + +/** + * F070 积分阶段性验收门禁(Portal UI)。 + * 中间件:192.168.106.171;应用本机起栈(见 README)。 + */ +const portalBase = process.env.E2E_PORTAL_BASE_URL || 'http://127.0.0.1:5173'; + +export default defineConfig({ + testDir: './gates', + fullyParallel: false, + forbidOnly: !!process.env.CI, + retries: process.env.CI ? 1 : 0, + workers: 1, + reporter: [['list'], ['html', { open: 'never', outputFolder: 'playwright-report' }]], + outputDir: 'test-results', + use: { + baseURL: portalBase, + headless: true, + trace: 'retain-on-failure', + screenshot: 'only-on-failure', + video: 'off', + locale: 'zh-CN', + }, + projects: [ + { + name: 'points-gates', + // Prefer installed Google Chrome to avoid flaky chromium zip downloads. + use: { ...devices['Desktop Chrome'], channel: 'chrome' }, + }, + ], +}); diff --git a/src/backend/test/points/perf_admin_write.py b/src/backend/test/points/perf_admin_write.py new file mode 100644 index 000000000..5da502b4f --- /dev/null +++ b/src/backend/test/points/perf_admin_write.py @@ -0,0 +1,323 @@ +#!/usr/bin/env python3 +"""积分管理员写路径压测:同步 POST /admin/deduct 与 /admin/adjust。 + +用法(cwd: src/backend,需 Backend :7860): + E2E_POINTS_PASSWORD=… \\ + .venv/bin/python test/points/perf_admin_write.py --scenario P7 --n 20 --concurrency 10 + +环境变量: + E2E_POINTS_ADMIN(默认 admin) + E2E_POINTS_PASSWORD(必填) + POINTS_PERF_BASE_URL(默认 http://127.0.0.1:7860) +""" + +from __future__ import annotations + +import argparse +import json +import os +import sys +import time +import uuid +from concurrent.futures import ThreadPoolExecutor, as_completed +from dataclasses import asdict, dataclass, field +from pathlib import Path +from typing import Any + +try: + import httpx +except ImportError as exc: # pragma: no cover + raise SystemExit("httpx required") from exc + +BACKEND_ROOT = Path(__file__).resolve().parents[2] + +USER_GZX01 = 4 +USER_GZX02 = 5 +RULES = ("R1", "R2", "R3") + + +@dataclass +class Sample: + """单次 HTTP 写样本。""" + + scenario: str + http_ms: float + status: int + ok: bool + error: str | None = None + rule_code: str | None = None + user_id: int | None = None + + +@dataclass +class ScenarioReport: + """场景汇总。""" + + scenario: str + n: int + concurrency: int + network: str = "vpn_remote_middleware" + ok_count: int = 0 + failed: int = 0 + http_p50_ms: float = 0.0 + http_p95_ms: float = 0.0 + pass_http: bool = False + pass_correctness: bool = False + samples: list[dict[str, Any]] = field(default_factory=list) + + +def _pct(values: list[float], p: float) -> float: + if not values: + return 0.0 + ordered = sorted(values) + idx = min(len(ordered) - 1, max(0, int(round((p / 100.0) * (len(ordered) - 1))))) + return ordered[idx] + + +def _login_token(base_url: str, username: str, password: str) -> str: + """登录一次拿 JWT,避免压测时每请求重复登录抬高 HTTP P95。""" + with httpx.Client(base_url=base_url, timeout=30.0) as client: + resp = client.post( + "/api/v1/user/login", + json={"user_name": username, "password": password, "force_login": True}, + ) + resp.raise_for_status() + body = resp.json() + token = (body.get("data") or {}).get("access_token") or client.cookies.get( + "access_token_cookie" + ) + if not token: + raise RuntimeError(f"login missing token: {body}") + return str(token) + + +def _client_with_token(base_url: str, token: str) -> httpx.Client: + """构造已带 access_token_cookie 的短生命周期 client。""" + client = httpx.Client(base_url=base_url, timeout=30.0) + client.cookies.set("access_token_cookie", token) + return client + + +def _deduct( + client: httpx.Client, + *, + user_id: int, + rule_code: str, + remark: str, +) -> Sample: + t0 = time.perf_counter() + try: + resp = client.post( + "/api/v1/points/admin/deduct", + json={ + "user_id": user_id, + "rule_code": rule_code, + "remark": remark, + }, + ) + ms = (time.perf_counter() - t0) * 1000.0 + ok = resp.status_code == 200 and int(resp.json().get("status_code", 200)) == 200 + return Sample( + scenario="deduct", + http_ms=ms, + status=resp.status_code, + ok=ok, + error=None if ok else resp.text[:300], + rule_code=rule_code, + user_id=user_id, + ) + except Exception as exc: # noqa: BLE001 + return Sample( + scenario="deduct", + http_ms=(time.perf_counter() - t0) * 1000.0, + status=0, + ok=False, + error=str(exc), + rule_code=rule_code, + user_id=user_id, + ) + + +def _adjust( + client: httpx.Client, + *, + user_id: int, + delta: int, + remark: str, +) -> Sample: + t0 = time.perf_counter() + try: + resp = client.post( + "/api/v1/points/admin/adjust", + json={"user_id": user_id, "delta": delta, "remark": remark}, + ) + ms = (time.perf_counter() - t0) * 1000.0 + ok = resp.status_code == 200 and int(resp.json().get("status_code", 200)) == 200 + return Sample( + scenario="adjust", + http_ms=ms, + status=resp.status_code, + ok=ok, + error=None if ok else resp.text[:300], + user_id=user_id, + ) + except Exception as exc: # noqa: BLE001 + return Sample( + scenario="adjust", + http_ms=(time.perf_counter() - t0) * 1000.0, + status=0, + ok=False, + error=str(exc), + user_id=user_id, + ) + + +def run_scenario( + scenario: str, + *, + n: int, + concurrency: int, + base_url: str, + run_id: str, +) -> ScenarioReport: + username = os.environ.get("E2E_POINTS_ADMIN", "admin") + password = os.environ.get("E2E_POINTS_PASSWORD") + if not password: + raise SystemExit("E2E_POINTS_PASSWORD is required") + + token = _login_token(base_url, username, password) + + jobs: list[tuple[str, dict]] = [] + if scenario == "P7": + for i in range(n): + jobs.append( + ( + "deduct", + { + "user_id": USER_GZX01, + "rule_code": "R1", + "remark": f"perf-deduct-{run_id}-p7-{i:04d}", + }, + ) + ) + elif scenario == "P8": + users = (USER_GZX01, USER_GZX02) + for i in range(n): + jobs.append( + ( + "deduct", + { + "user_id": users[i % 2], + "rule_code": RULES[i % 3], + "remark": f"perf-deduct-{run_id}-p8-{i:04d}", + }, + ) + ) + elif scenario == "P9": + for i in range(n): + jobs.append( + ( + "adjust", + { + "user_id": USER_GZX01, + "delta": -1, + "remark": f"perf-deduct-{run_id}-p9-{i:04d}", + }, + ) + ) + else: + raise SystemExit(f"unknown scenario {scenario} (use P7/P8/P9)") + + samples: list[Sample] = [] + + def _run_one(kind: str, payload: dict) -> Sample: + client = _client_with_token(base_url, token) + try: + if kind == "deduct": + return _deduct(client, **payload) + return _adjust(client, **payload) + finally: + client.close() + + with ThreadPoolExecutor(max_workers=concurrency) as pool: + futs = [pool.submit(_run_one, kind, payload) for kind, payload in jobs] + for fut in as_completed(futs): + samples.append(fut.result()) + + http_ms = [s.http_ms for s in samples] + ok_count = sum(1 for s in samples if s.ok) + failed = len(samples) - ok_count + p95 = _pct(http_ms, 95) + # VPN 档:deduct/adjust P95 < 2s + return ScenarioReport( + scenario=scenario, + n=n, + concurrency=concurrency, + ok_count=ok_count, + failed=failed, + http_p50_ms=_pct(http_ms, 50), + http_p95_ms=p95, + pass_http=p95 < 2000.0, + pass_correctness=failed == 0, + samples=[asdict(s) for s in samples], + ) + + +def _write_report(report: ScenarioReport, out_dir: Path) -> Path: + out_dir.mkdir(parents=True, exist_ok=True) + ts = time.strftime("%Y%m%d_%H%M%S") + path = out_dir / f"perf_admin_{report.scenario}_{ts}.json" + path.write_text(json.dumps(asdict(report), ensure_ascii=False, indent=2), encoding="utf-8") + md = out_dir / f"perf_admin_{report.scenario}_{ts}.md" + md.write_text( + "\n".join( + [ + f"# Points admin write perf — {report.scenario}", + "", + f"- network: `{report.network}`", + f"- n={report.n} concurrency={report.concurrency}", + f"- http P50/P95: {report.http_p50_ms:.1f} / {report.http_p95_ms:.1f} ms", + f"- ok={report.ok_count} failed={report.failed}", + f"- pass_http={report.pass_http} pass_correctness={report.pass_correctness}", + "", + ] + ), + encoding="utf-8", + ) + return path + + +def main() -> None: + parser = argparse.ArgumentParser(description="Points admin deduct/adjust perf") + parser.add_argument("--scenario", default="P7", choices=["P7", "P8", "P9"]) + parser.add_argument("--n", type=int, default=20) + parser.add_argument("--concurrency", type=int, default=10) + parser.add_argument( + "--base-url", + default=os.environ.get("POINTS_PERF_BASE_URL", "http://127.0.0.1:7860"), + ) + parser.add_argument( + "--out-dir", + default=str(BACKEND_ROOT / "test/points/_perf_reports"), + ) + parser.add_argument("--run-id", default="") + args = parser.parse_args() + run_id = args.run_id or uuid.uuid4().hex[:12] + + report = run_scenario( + args.scenario, + n=args.n, + concurrency=args.concurrency, + base_url=args.base_url.rstrip("/"), + run_id=run_id, + ) + path = _write_report(report, Path(args.out_dir)) + summary = {k: v for k, v in asdict(report).items() if k != "samples"} + print("PERF_SUMMARY " + json.dumps(summary, ensure_ascii=False)) + print(f"report={path}") + if not (report.pass_http and report.pass_correctness): + sys.exit(2) + + +if __name__ == "__main__": + main() diff --git a/src/backend/test/points/perf_award_async.py b/src/backend/test/points/perf_award_async.py new file mode 100644 index 000000000..43234170d --- /dev/null +++ b/src/backend/test/points/perf_award_async.py @@ -0,0 +1,629 @@ +#!/usr/bin/env python3 +"""积分自动发分压测(路径 A):直调 notify_* / _dispatch,测 enqueue 与到账延迟。 + +用法(cwd: src/backend): + PYTHONPATH=. config=config.yaml \\ + .venv/bin/python test/points/perf_award_async.py --scenario P1 --n 20 --concurrency 10 + +环境: + - points.enabled / award_async_enabled=true(默认) + - Celery worker:celery -A bisheng.worker.main worker -Q celery -c 20 -P threads + - --sync 强制同步入账作对照 +""" + +from __future__ import annotations + +import argparse +import asyncio +import json +import os +import sys +import time +import uuid +from dataclasses import asdict, dataclass, field +from pathlib import Path +from typing import Any +from unittest.mock import patch + +BACKEND_ROOT = Path(__file__).resolve().parents[2] +if str(BACKEND_ROOT) not in sys.path: + sys.path.insert(0, str(BACKEND_ROOT)) + +# 固定测试账号(与实施方案 C.3 一致) +USER_GZX01 = 4 +USER_GZX02 = 5 +SPACE_GZX01 = 4 +SPACE_GZX02 = 5 +TENANT_ID = 1 + + +@dataclass +class Sample: + """单次发分样本。""" + + scenario: str + enqueue_ms: float + ledger_ms: float | None + ok: bool + skipped_cap: bool = False + double_credit: bool = False + error: str | None = None + idempotency_key: str | None = None + + +@dataclass +class ScenarioReport: + """场景汇总。""" + + scenario: str + n: int + concurrency: int + sync: bool + network: str = "vpn_remote_middleware" + applied: int = 0 + skipped_cap: int = 0 + failed: int = 0 + double_credit: int = 0 + enqueue_p50_ms: float = 0.0 + enqueue_p95_ms: float = 0.0 + ledger_p50_ms: float | None = None + ledger_p95_ms: float | None = None + pass_enqueue: bool = False + pass_ledger: bool = False + pass_correctness: bool = False + samples: list[dict[str, Any]] = field(default_factory=list) + + +def _pct(values: list[float], p: float) -> float: + """简单百分位(nearest-rank)。""" + if not values: + return 0.0 + ordered = sorted(values) + idx = min(len(ordered) - 1, max(0, int(round((p / 100.0) * (len(ordered) - 1))))) + return ordered[idx] + + +def _file_id_base(run_id: str) -> int: + """生成高位 file_id 前缀(字符串幂等键场景,如 G2/G4)。""" + h = abs(hash(run_id)) % 10_000_000 + return 9_000_000_000_000_000 + h * 10_000 + + +def _safe_int_file_id(run_id: str, offset: int) -> int: + """生成落在 signed INT 内的 file_id(G3 会写入 point_favorite_tier_award.file_id)。""" + h = abs(hash(run_id)) % 1_000_000 + # 2_147_483_647 上限;1.7e9+ 避开真实业务自增 + return 1_700_000_000 + h * 100 + int(offset) + + +async def _count_logs_by_key(idem_key: str) -> int: + from sqlalchemy import text + + from bisheng.core.database import get_async_db_session + + async with get_async_db_session() as session: + row = ( + await session.execute( + text( + "select count(*) from user_point_log " + "where tenant_id=:t and idempotency_key=:k" + ), + {"t": TENANT_ID, "k": idem_key}, + ) + ).first() + return int(row[0]) if row else 0 + + +async def _today_earn_sum(user_id: int, rule_code: str) -> int: + """今日该规则已入账正分合计(用于识别 daily_cap 跳过)。""" + from sqlalchemy import text + + from bisheng.core.database import get_async_db_session + + async with get_async_db_session() as session: + row = ( + await session.execute( + text( + "select coalesce(sum(delta),0) from user_point_log " + "where tenant_id=:t and user_id=:u and rule_code=:r " + "and direction='earn' and create_time >= curdate()" + ), + {"t": TENANT_ID, "u": user_id, "r": rule_code}, + ) + ).first() + return int(row[0]) if row else 0 + + +async def _wait_log(idem_key: str, *, timeout_s: float) -> tuple[bool, float]: + """轮询流水直到可见;返回 (可见, 耗时 ms)。""" + t0 = time.perf_counter() + deadline = t0 + timeout_s + while time.perf_counter() < deadline: + if await _count_logs_by_key(idem_key) >= 1: + return True, (time.perf_counter() - t0) * 1000.0 + await asyncio.sleep(0.2) + return False, (time.perf_counter() - t0) * 1000.0 + + +async def _award_upload( + *, + user_id: int, + space_id: int, + file_id: int, + sync: bool, + wait_timeout_s: float, +) -> Sample: + from bisheng.points.domain.services import points_award_hooks as hooks + + # 直调 _dispatch:部门库 G2;managers 置空避免受益人是空间管理员被 skip。 + idem = f"earn:G2:{file_id}:{space_id}" + ctx = ( + patch.object(hooks, "_award_async_enabled", return_value=False) + if sync + else nullcontext() + ) + t0 = time.perf_counter() + try: + with ctx: + await hooks._dispatch( + "space_file_ready", + { + "tenant_id": TENANT_ID, + "space_id": space_id, + "space_level": "department", + "file_id": file_id, + "uploader_id": user_id, + "publisher_id": None, + "is_favorite_space": False, + "space_manager_ids": [], + }, + ) + enqueue_ms = (time.perf_counter() - t0) * 1000.0 + visible, ledger_ms = await _wait_log(idem, timeout_s=wait_timeout_s) + cnt = await _count_logs_by_key(idem) + # G2 daily_cap=10:触顶时 Worker 不写流水,记 skipped_cap 而非失败。 + skipped_cap = False + if not visible: + earned = await _today_earn_sum(user_id, "G2") + if earned >= 10: + skipped_cap = True + return Sample( + scenario="upload", + enqueue_ms=enqueue_ms, + ledger_ms=ledger_ms if visible else None, + ok=visible or skipped_cap, + skipped_cap=skipped_cap, + double_credit=cnt > 1, + idempotency_key=idem, + error=None if (visible or skipped_cap) else "ledger_timeout", + ) + except Exception as exc: # noqa: BLE001 — 压测汇总用 + return Sample( + scenario="upload", + enqueue_ms=(time.perf_counter() - t0) * 1000.0, + ledger_ms=None, + ok=False, + error=str(exc), + idempotency_key=idem, + ) + + +async def _award_favorite( + *, + user_id: int, + file_id: int, + unique_count: int, + sync: bool, + wait_timeout_s: float, +) -> Sample: + from bisheng.points.domain.services import points_award_hooks as hooks + + # G3 阶梯:count 足够触发第一档;幂等键含 s_target,先按常见首档 1 分估键, + # 实际以 DB 中 earn:G3:{file_id}:* 出现为准。 + ctx = ( + patch.object(hooks, "_award_async_enabled", return_value=False) + if sync + else nullcontext() + ) + t0 = time.perf_counter() + try: + with ctx: + await hooks._dispatch( + "favorite_changed", + { + "tenant_id": TENANT_ID, + "file_id": file_id, + "uploader_id": user_id, + "unique_favoriter_count": unique_count, + "space_manager_ids": [], + }, + ) + enqueue_ms = (time.perf_counter() - t0) * 1000.0 + # 轮询任意 G3 流水(该 file) + visible, ledger_ms = await _wait_g3(file_id, timeout_s=wait_timeout_s) + return Sample( + scenario="favorite", + enqueue_ms=enqueue_ms, + ledger_ms=ledger_ms if visible else None, + ok=visible, + error=None if visible else "ledger_timeout", + idempotency_key=f"earn:G3:{file_id}:*", + ) + except Exception as exc: # noqa: BLE001 + return Sample( + scenario="favorite", + enqueue_ms=(time.perf_counter() - t0) * 1000.0, + ledger_ms=None, + ok=False, + error=str(exc), + ) + + +async def _wait_g3(file_id: int, *, timeout_s: float) -> tuple[bool, float]: + from sqlalchemy import text + + from bisheng.core.database import get_async_db_session + + t0 = time.perf_counter() + deadline = t0 + timeout_s + prefix = f"earn:G3:{file_id}:" + while time.perf_counter() < deadline: + async with get_async_db_session() as session: + row = ( + await session.execute( + text( + "select count(*) from user_point_log " + "where tenant_id=:t and idempotency_key like :p" + ), + {"t": TENANT_ID, "p": prefix + "%"}, + ) + ).first() + if row and int(row[0]) >= 1: + return True, (time.perf_counter() - t0) * 1000.0 + await asyncio.sleep(0.2) + return False, (time.perf_counter() - t0) * 1000.0 + + +async def _award_adopt( + *, + user_id: int, + answer_id: int, + sync: bool, + wait_timeout_s: float, +) -> Sample: + from bisheng.points.domain.services.points_award_hooks import notify_answer_adopted + + idem = f"earn:G4:{answer_id}" + ctx = ( + patch( + "bisheng.points.domain.services.points_award_hooks._award_async_enabled", + return_value=False, + ) + if sync + else nullcontext() + ) + t0 = time.perf_counter() + try: + with ctx: + await notify_answer_adopted( + tenant_id=TENANT_ID, + answer_id=answer_id, + answerer_id=user_id, + ) + enqueue_ms = (time.perf_counter() - t0) * 1000.0 + visible, ledger_ms = await _wait_log(idem, timeout_s=wait_timeout_s) + cnt = await _count_logs_by_key(idem) + return Sample( + scenario="adopt", + enqueue_ms=enqueue_ms, + ledger_ms=ledger_ms if visible else None, + ok=visible, + double_credit=cnt > 1, + error=None if visible else "ledger_timeout", + idempotency_key=idem, + ) + except Exception as exc: # noqa: BLE001 + return Sample( + scenario="adopt", + enqueue_ms=(time.perf_counter() - t0) * 1000.0, + ledger_ms=None, + ok=False, + error=str(exc), + idempotency_key=idem, + ) + + +class nullcontext: + """无操作上下文(兼容未装 contextlib.nullcontext 的旧环境)。""" + + def __enter__(self): + return None + + def __exit__(self, *args): + return False + + +async def _run_pool(jobs: list, concurrency: int) -> list[Sample]: + """有界并发执行协程工厂列表。""" + sem = asyncio.Semaphore(concurrency) + results: list[Sample] = [] + + async def _one(factory): + async with sem: + return await factory() + + for batch_start in range(0, len(jobs), concurrency): + batch = jobs[batch_start : batch_start + concurrency] + results.extend(await asyncio.gather(*[_one(j) for j in batch])) + return results + + +def _summarize( + scenario: str, + samples: list[Sample], + *, + n: int, + concurrency: int, + sync: bool, +) -> ScenarioReport: + # 丢弃首笔冷启动 enqueue(导入 Celery/Broker),避免 VPN 档误杀。 + enqueue_src = samples[1:] if len(samples) > 1 else samples + enqueue = [s.enqueue_ms for s in enqueue_src] + ledger = [s.ledger_ms for s in samples if s.ledger_ms is not None] + failed = sum(1 for s in samples if not s.ok) + double = sum(1 for s in samples if s.double_credit) + applied = sum(1 for s in samples if s.ok and not s.skipped_cap) + enqueue_p95 = _pct(enqueue, 95) + ledger_p95 = _pct(ledger, 95) if ledger else None + # VPN 联调档:enqueue <200ms;ledger <15s;双发=0;cap skip 不算失败 + pass_enqueue = enqueue_p95 < 200.0 if not sync else True + # 若全部触顶跳过则无 ledger 样本,仍视为 ledger 门禁通过 + all_cap = bool(samples) and all(s.skipped_cap for s in samples) + pass_ledger = ( + sync + or all_cap + or (ledger_p95 is not None and ledger_p95 < 15_000.0) + ) + pass_correctness = double == 0 and failed == 0 + return ScenarioReport( + scenario=scenario, + n=n, + concurrency=concurrency, + sync=sync, + applied=applied, + skipped_cap=sum(1 for s in samples if s.skipped_cap), + failed=failed, + double_credit=double, + enqueue_p50_ms=_pct(enqueue, 50), + enqueue_p95_ms=enqueue_p95, + ledger_p50_ms=_pct(ledger, 50) if ledger else None, + ledger_p95_ms=ledger_p95, + pass_enqueue=pass_enqueue, + pass_ledger=pass_ledger, + pass_correctness=pass_correctness, + samples=[asdict(s) for s in samples], + ) + + +async def run_scenario( + scenario: str, + *, + n: int, + concurrency: int, + sync: bool, + wait_timeout_s: float, + run_id: str, +) -> ScenarioReport: + """执行单个场景矩阵项。""" + if not sync: + # 预热 Celery 客户端导入,避免首笔 enqueue 含冷启动。 + from bisheng.points.domain.services import points_award_hooks as hooks + + try: + hooks._enqueue_award_event( + { + "event_type": "answer_adopted", + "tenant_id": TENANT_ID, + "answer_id": 1, + "answerer_id": USER_GZX01, + } + ) + except Exception: # noqa: BLE001 + pass + await asyncio.sleep(0.5) + + base = _file_id_base(run_id) + jobs = [] + + if scenario == "P1": + for i in range(n): + fid = base + i + jobs.append( + lambda fid=fid: _award_upload( + user_id=USER_GZX01, + space_id=SPACE_GZX01, + file_id=fid, + sync=sync, + wait_timeout_s=wait_timeout_s, + ) + ) + elif scenario == "P2": + users = [(USER_GZX01, SPACE_GZX01), (USER_GZX02, SPACE_GZX02)] + for i in range(n): + uid, sid = users[i % 2] + fid = base + i + jobs.append( + lambda fid=fid, uid=uid, sid=sid: _award_upload( + user_id=uid, + space_id=sid, + file_id=fid, + sync=sync, + wait_timeout_s=wait_timeout_s, + ) + ) + elif scenario == "P3": + for i in range(n): + fid = _safe_int_file_id(run_id, i) + jobs.append( + lambda fid=fid: _award_favorite( + user_id=USER_GZX01, + file_id=fid, + # 现网 G3 首档 threshold=75 + unique_count=80, + sync=sync, + wait_timeout_s=wait_timeout_s, + ) + ) + elif scenario == "P4": + for i in range(n): + aid = base + 200_000 + i + jobs.append( + lambda aid=aid: _award_adopt( + user_id=USER_GZX01, + answer_id=aid, + sync=sync, + wait_timeout_s=wait_timeout_s, + ) + ) + elif scenario == "P5": + # 混合:上传 / 收藏 / 采纳各约 n/3 + for i in range(n): + kind = i % 3 + if kind == 0: + fid = base + i + jobs.append( + lambda fid=fid: _award_upload( + user_id=USER_GZX01, + space_id=SPACE_GZX01, + file_id=fid, + sync=sync, + wait_timeout_s=wait_timeout_s, + ) + ) + elif kind == 1: + fid = _safe_int_file_id(run_id, 50_000 + i) + jobs.append( + lambda fid=fid: _award_favorite( + user_id=USER_GZX01, + file_id=fid, + unique_count=80, + sync=sync, + wait_timeout_s=wait_timeout_s, + ) + ) + else: + aid = base + 200_000 + i + jobs.append( + lambda aid=aid: _award_adopt( + user_id=USER_GZX01, + answer_id=aid, + sync=sync, + wait_timeout_s=wait_timeout_s, + ) + ) + elif scenario == "P6": + # 同 payload 重放:每对两次,期望仅一笔 ledger + samples: list[Sample] = [] + for i in range(n): + fid = base + 300_000 + i + first = await _award_upload( + user_id=USER_GZX01, + space_id=SPACE_GZX01, + file_id=fid, + sync=sync, + wait_timeout_s=wait_timeout_s, + ) + second = await _award_upload( + user_id=USER_GZX01, + space_id=SPACE_GZX01, + file_id=fid, + sync=sync, + wait_timeout_s=wait_timeout_s, + ) + cnt = await _count_logs_by_key(f"earn:G2:{fid}:{SPACE_GZX01}") + first.double_credit = cnt > 1 + second.ok = True # 幂等重放成功不算失败 + second.double_credit = cnt > 1 + samples.extend([first, second]) + return _summarize("P6", samples, n=n, concurrency=1, sync=sync) + else: + raise SystemExit(f"unknown scenario {scenario}") + + samples = await _run_pool(jobs, concurrency) + return _summarize(scenario, samples, n=n, concurrency=concurrency, sync=sync) + + +def _write_report(report: ScenarioReport, out_dir: Path) -> Path: + out_dir.mkdir(parents=True, exist_ok=True) + ts = time.strftime("%Y%m%d_%H%M%S") + path = out_dir / f"perf_award_{report.scenario}_{ts}.json" + path.write_text(json.dumps(asdict(report), ensure_ascii=False, indent=2), encoding="utf-8") + md = out_dir / f"perf_award_{report.scenario}_{ts}.md" + md.write_text( + "\n".join( + [ + f"# Points award perf — {report.scenario}", + "", + f"- network: `{report.network}`", + f"- sync: `{report.sync}`", + f"- n={report.n} concurrency={report.concurrency}", + f"- enqueue P50/P95: {report.enqueue_p50_ms:.1f} / {report.enqueue_p95_ms:.1f} ms", + f"- ledger P50/P95: {report.ledger_p50_ms} / {report.ledger_p95_ms} ms", + f"- applied={report.applied} failed={report.failed} " + f"double={report.double_credit} cap_skip={report.skipped_cap}", + f"- pass_enqueue={report.pass_enqueue} pass_ledger={report.pass_ledger} " + f"pass_correctness={report.pass_correctness}", + "", + ] + ), + encoding="utf-8", + ) + return path + + +def main() -> None: + parser = argparse.ArgumentParser(description="Points async award perf harness") + parser.add_argument( + "--scenario", + default="P1", + choices=["P1", "P2", "P3", "P4", "P5", "P6"], + ) + parser.add_argument("--n", type=int, default=20) + parser.add_argument("--concurrency", type=int, default=10) + parser.add_argument("--sync", action="store_true", help="强制同步入账对照") + parser.add_argument("--wait-timeout", type=float, default=20.0) + parser.add_argument( + "--out-dir", + default=str(BACKEND_ROOT / "test/points/_perf_reports"), + ) + parser.add_argument("--run-id", default="") + parser.add_argument( + "--queue", + default=os.environ.get("POINTS_AWARD_CELERY_QUEUE", "points_award_local"), + help="异步发分队列;共享 Broker 时需本地 Worker 单独监听,避免旧节点抢走任务", + ) + args = parser.parse_args() + run_id = args.run_id or uuid.uuid4().hex[:12] + if not args.sync and args.queue: + os.environ["POINTS_AWARD_CELERY_QUEUE"] = args.queue + + report = asyncio.run( + run_scenario( + args.scenario, + n=args.n, + concurrency=args.concurrency, + sync=args.sync, + wait_timeout_s=args.wait_timeout, + run_id=run_id, + ) + ) + path = _write_report(report, Path(args.out_dir)) + summary = {k: v for k, v in asdict(report).items() if k != "samples"} + print("PERF_SUMMARY " + json.dumps(summary, ensure_ascii=False)) + print(f"report={path}") + if not (report.pass_correctness and report.pass_ledger and (report.pass_enqueue or report.sync)): + sys.exit(2) + + +if __name__ == "__main__": + main() diff --git a/src/backend/test/points/perf_run_matrix.py b/src/backend/test/points/perf_run_matrix.py new file mode 100644 index 000000000..046aa91a7 --- /dev/null +++ b/src/backend/test/points/perf_run_matrix.py @@ -0,0 +1,189 @@ +#!/usr/bin/env python3 +"""按实施方案 C.7 顺序跑压测场景并汇总 Markdown 报告。 + +用法: + E2E_POINTS_PASSWORD=… \\ + .venv/bin/python test/points/perf_run_matrix.py --quick + +--quick:各场景缩小规模(VPN 联调推荐) +不加 --quick:接近计划矩阵下限 +""" + +from __future__ import annotations + +import argparse +import json +import os +import subprocess +import sys +import time +from pathlib import Path + +BACKEND = Path(__file__).resolve().parents[2] +PY = BACKEND / ".venv/bin/python" +OUT = BACKEND / "test/points/_perf_reports" + + +def _run(cmd: list[str]) -> dict: + print("+", " ".join(cmd), flush=True) + proc = subprocess.run(cmd, cwd=str(BACKEND), capture_output=True, text=True) + if proc.stdout: + print(proc.stdout) + if proc.stderr: + print(proc.stderr, file=sys.stderr) + # 2=门禁未过仍产出摘要;其它非 0 记入结果但继续后续场景 + if proc.returncode not in (0, 2): + data = {"_exit": proc.returncode, "error": (proc.stderr or "")[-500:]} + for ln in (proc.stdout or "").splitlines(): + if ln.startswith("PERF_SUMMARY "): + data.update(json.loads(ln[len("PERF_SUMMARY ") :])) + break + return data + data: dict = {"_exit": proc.returncode} + for ln in proc.stdout.splitlines(): + if ln.startswith("PERF_SUMMARY "): + data.update(json.loads(ln[len("PERF_SUMMARY ") :])) + break + return data + + +def main() -> None: + parser = argparse.ArgumentParser() + parser.add_argument("--quick", action="store_true") + parser.add_argument("--skip-admin", action="store_true") + parser.add_argument("--skip-award", action="store_true") + parser.add_argument("--sync-baseline", action="store_true") + args = parser.parse_args() + + if args.quick: + award = [ + ("P1", 20, 10), + ("P2", 20, 10), + ("P3", 10, 5), + ("P4", 20, 10), + ("P5", 15, 10), + ("P6", 5, 1), + ] + admin = [ + ("P7", 20, 10), + ("P8", 20, 10), + ("P9", 20, 10), + ] + else: + award = [ + ("P1", 50, 20), + ("P2", 50, 20), + ("P3", 30, 10), + ("P4", 50, 20), + ("P5", 30, 20), + ("P6", 10, 1), + ] + admin = [ + ("P7", 50, 10), + ("P8", 50, 20), + ("P9", 50, 20), + ] + + OUT.mkdir(parents=True, exist_ok=True) + # 共享 Redis Broker 上有远端旧 Worker;压测默认走本地专用队列。 + os.environ.setdefault("POINTS_AWARD_CELERY_QUEUE", "points_award_local") + rows: list[dict] = [] + env_note = { + "network": "vpn_remote_middleware", + "quick": args.quick, + "award_queue": os.environ.get("POINTS_AWARD_CELERY_QUEUE"), + "ts": time.strftime("%Y-%m-%d %H:%M:%S"), + } + + if args.sync_baseline and not args.skip_award: + cmd = [ + str(PY), + "test/points/perf_award_async.py", + "--scenario", + "P1", + "--n", + "20", + "--concurrency", + "10", + "--sync", + ] + rows.append({"kind": "award_sync_baseline", **_run(cmd)}) + + if not args.skip_award: + for scenario, n, conc in award: + cmd = [ + str(PY), + "test/points/perf_award_async.py", + "--scenario", + scenario, + "--n", + str(n), + "--concurrency", + str(conc), + ] + rows.append({"kind": "award", **_run(cmd)}) + + if not args.skip_admin: + if not os.environ.get("E2E_POINTS_PASSWORD"): + print("WARN: skip admin scenarios; E2E_POINTS_PASSWORD unset", file=sys.stderr) + else: + for scenario, n, conc in admin: + cmd = [ + str(PY), + "test/points/perf_admin_write.py", + "--scenario", + scenario, + "--n", + str(n), + "--concurrency", + str(conc), + ] + rows.append({"kind": "admin", **_run(cmd)}) + + ts = time.strftime("%Y%m%d_%H%M%S") + summary_json = OUT / f"perf_matrix_{ts}.json" + summary_md = OUT / f"perf_matrix_{ts}.md" + summary_json.write_text( + json.dumps({"env": env_note, "rows": rows}, ensure_ascii=False, indent=2), + encoding="utf-8", + ) + + lines = [ + "# Points perf matrix summary", + "", + f"- network: `{env_note['network']}`", + f"- time: {env_note['ts']}", + f"- quick: {env_note['quick']}", + "", + "| kind | scenario | n | conc | enqueue/http P95 | ledger P95 | ok/fail | gates |", + "|---|---|---:|---:|---:|---:|---:|---|", + ] + for r in rows: + scen = r.get("scenario", "?") + n = r.get("n", "") + conc = r.get("concurrency", "") + if "enqueue_p95_ms" in r: + p95 = f"{r.get('enqueue_p95_ms', 0):.1f}ms" + ledger = r.get("ledger_p95_ms") + ledger_s = f"{ledger:.1f}ms" if isinstance(ledger, (int, float)) else "-" + ok = f"{r.get('applied', 0)}/{r.get('failed', 0)}" + gates = ( + f"enq={r.get('pass_enqueue')} led={r.get('pass_ledger')} " + f"corr={r.get('pass_correctness')}" + ) + else: + p95 = f"{r.get('http_p95_ms', 0):.1f}ms" + ledger_s = "-" + ok = f"{r.get('ok_count', 0)}/{r.get('failed', 0)}" + gates = f"http={r.get('pass_http')} corr={r.get('pass_correctness')}" + lines.append( + f"| {r.get('kind')} | {scen} | {n} | {conc} | {p95} | {ledger_s} | {ok} | {gates} |" + ) + lines.append("") + summary_md.write_text("\n".join(lines), encoding="utf-8") + print(summary_md.read_text(encoding="utf-8")) + print(f"summary={summary_md}") + + +if __name__ == "__main__": + main() diff --git a/src/backend/test/points/test_beneficiary.py b/src/backend/test/points/test_beneficiary.py new file mode 100644 index 000000000..08076cdbe --- /dev/null +++ b/src/backend/test/points/test_beneficiary.py @@ -0,0 +1,16 @@ +"""积分受益人白名单测试。""" + +import pytest + +from bisheng.common.errcode.points import PointsRuleConflictError +from bisheng.points.domain.services.points_rule_service import PointsRuleService + + +def test_g7_allows_uploader_or_sharer(): + PointsRuleService.validate_beneficiary("G7", "earn", "uploader") + PointsRuleService.validate_beneficiary("G7", "earn", "sharer") + + +def test_g4_rejects_configurable_uploader(): + with pytest.raises(PointsRuleConflictError): + PointsRuleService.validate_beneficiary("G4", "earn", "uploader") diff --git a/src/backend/test/points/test_department_org_level.py b/src/backend/test/points/test_department_org_level.py new file mode 100644 index 000000000..5d744205d --- /dev/null +++ b/src/backend/test/points/test_department_org_level.py @@ -0,0 +1,259 @@ +"""组织四级标签:深度映射、多公司作用域、嵌套拒绝与鉴权。""" + +from types import SimpleNamespace +from unittest.mock import AsyncMock, patch + +import pytest + +from bisheng.common.errcode.points import ( + PointsCompanyRootConflictError, + PointsNotCompanyRootError, + PointsPermissionDeniedError, +) +from bisheng.points.domain.constants.org_levels import ( + org_level_for_relative_depth, + relative_depth, +) +from bisheng.points.domain.services.department_org_level_service import DepartmentOrgLevelService +from bisheng.points.domain.services.points_auth import require_platform_admin + + +def test_relative_depth_mapping(): + assert relative_depth("/1/", "/1/") == 0 + assert relative_depth("/1/", "/1/2/") == 1 + assert relative_depth("/1/", "/1/2/3/") == 2 + assert relative_depth("/1/", "/1/2/3/4/") == 3 + assert relative_depth("/1/", "/9/") is None + assert org_level_for_relative_depth(0) == "company" + assert org_level_for_relative_depth(1) == "dept" + assert org_level_for_relative_depth(2) == "office" + assert org_level_for_relative_depth(3) == "squad" + assert org_level_for_relative_depth(5) == "squad" + + +def test_non_admin_denied(): + with pytest.raises(PointsPermissionDeniedError): + require_platform_admin(SimpleNamespace(is_admin=lambda: False, is_global_super=False)) + + +def test_global_super_allowed(): + require_platform_admin(SimpleNamespace(is_admin=lambda: False, is_global_super=True)) + + +class _FakeResult: + def __init__(self, rows): + self._rows = rows + + def all(self): + return self._rows + + +@pytest.mark.asyncio +async def test_set_company_root_rejects_nested_under_existing_company(): + """目标落在已有公司子树内时拒绝嵌套。""" + service = DepartmentOrgLevelService() + nested = SimpleNamespace(id=10, path="/99/10/", dept_id="BS@10", status="active") + parent_company = SimpleNamespace(id=99, path="/99/", org_level="company", status="active") + + class FakeSession: + async def exec(self, _stmt): + return _FakeResult([parent_company]) + + async def __aenter__(self): + return self + + async def __aexit__(self, *_): + return False + + with ( + patch.object(service, "_resolve_department", AsyncMock(return_value=nested)), + patch( + "bisheng.points.domain.services.department_org_level_service.get_async_db_session", + return_value=FakeSession(), + ), + ): + with pytest.raises(PointsCompanyRootConflictError): + await service.set_company_root( + SimpleNamespace(is_admin=lambda: True, is_global_super=False), + "10", + ) + + +@pytest.mark.asyncio +async def test_set_company_root_allows_sibling_company(): + """同级另一公司已存在时,仍可设本公司并只打本子树。""" + service = DepartmentOrgLevelService() + company = SimpleNamespace(id=10, path="/10/", dept_id="BS@10", status="active", org_level=None) + sibling = SimpleNamespace(id=99, path="/99/", org_level="company", status="active") + dept = SimpleNamespace(id=11, path="/10/11/", status="active", org_level=None) + nodes = [company, dept] + cleared_paths: list[str] = [] + + class ScriptedSession: + def __init__(self): + self.queue = [ + _FakeResult([sibling]), # existing companies + "update", + _FakeResult(nodes), # subtree + ] + self._i = 0 + + async def exec(self, stmt): + item = self.queue[self._i] + self._i += 1 + if item == "update": + cleared_paths.append("subtree") + return _FakeResult([]) + return item + + async def commit(self): + return None + + def add(self, *_): + return None + + async def __aenter__(self): + return self + + async def __aexit__(self, *_): + return False + + with ( + patch.object(service, "_resolve_department", AsyncMock(return_value=company)), + patch( + "bisheng.points.domain.services.department_org_level_service.get_async_db_session", + return_value=ScriptedSession(), + ), + ): + result = await service.set_company_root( + SimpleNamespace(is_admin=lambda: True, is_global_super=False), + "10", + ) + + assert cleared_paths == ["subtree"] + assert company.org_level == "company" + assert dept.org_level == "dept" + assert result.company_id == 10 + assert result.labeled_count == 2 + + +@pytest.mark.asyncio +async def test_set_company_root_labels_subtree_by_depth(): + service = DepartmentOrgLevelService() + company = SimpleNamespace(id=1, path="/1/", dept_id="BS@1", status="active", org_level=None) + dept = SimpleNamespace(id=2, path="/1/2/", status="active", org_level=None) + office = SimpleNamespace(id=3, path="/1/2/3/", status="active", org_level=None) + squad = SimpleNamespace(id=4, path="/1/2/3/4/", status="active", org_level=None) + nodes = [company, dept, office, squad] + cleared = {"ok": False} + + class ScriptedSession: + def __init__(self): + self.queue = [ + _FakeResult([]), # existing companies + "update", + _FakeResult(nodes), # subtree + ] + self._i = 0 + + async def exec(self, _stmt): + item = self.queue[self._i] + self._i += 1 + if item == "update": + cleared["ok"] = True + return _FakeResult([]) + return item + + async def commit(self): + return None + + def add(self, *_): + return None + + async def __aenter__(self): + return self + + async def __aexit__(self, *_): + return False + + with ( + patch.object(service, "_resolve_department", AsyncMock(return_value=company)), + patch( + "bisheng.points.domain.services.department_org_level_service.get_async_db_session", + return_value=ScriptedSession(), + ), + ): + result = await service.set_company_root( + SimpleNamespace(is_admin=lambda: True, is_global_super=False), + "1", + ) + + assert cleared["ok"] is True + assert company.org_level == "company" + assert dept.org_level == "dept" + assert office.org_level == "office" + assert squad.org_level == "squad" + assert result.company_id == 1 + assert result.labeled_count == 4 + assert result.levels == {"company": 1, "dept": 1, "office": 1, "squad": 1} + + +@pytest.mark.asyncio +async def test_clear_company_root_clears_subtree_only(): + service = DepartmentOrgLevelService() + company = SimpleNamespace( + id=1, path="/1/", dept_id="BS@1", status="active", org_level="company" + ) + labeled = [ + SimpleNamespace(id=1, org_level="company"), + SimpleNamespace(id=2, org_level="dept"), + ] + cleared = {"ok": False} + + class FakeSession: + def __init__(self): + self.queue = [_FakeResult(labeled), "update"] + + async def exec(self, _stmt): + item = self.queue.pop(0) + if item == "update": + cleared["ok"] = True + return _FakeResult([]) + return item + + async def commit(self): + return None + + async def __aenter__(self): + return self + + async def __aexit__(self, *_): + return False + + with ( + patch.object(service, "_resolve_department", AsyncMock(return_value=company)), + patch( + "bisheng.points.domain.services.department_org_level_service.get_async_db_session", + return_value=FakeSession(), + ), + ): + result = await service.clear_company_root( + SimpleNamespace(is_admin=lambda: True, is_global_super=False), + "1", + ) + + assert cleared["ok"] is True + assert result.cleared_count == 2 + + +@pytest.mark.asyncio +async def test_clear_company_root_rejects_non_company(): + service = DepartmentOrgLevelService() + dept = SimpleNamespace(id=2, path="/1/2/", dept_id="BS@2", status="active", org_level="dept") + + with patch.object(service, "_resolve_department", AsyncMock(return_value=dept)): + with pytest.raises(PointsNotCompanyRootError): + await service.clear_company_root( + SimpleNamespace(is_admin=lambda: True, is_global_super=False), + "2", + ) diff --git a/src/backend/test/points/test_notify_templates.py b/src/backend/test/points/test_notify_templates.py new file mode 100644 index 000000000..b5f518a23 --- /dev/null +++ b/src/backend/test/points/test_notify_templates.py @@ -0,0 +1,8 @@ +"""积分通知模板测试。""" + +from bisheng.points.domain.constants.notify_templates import NOTIFY_TEMPLATES, POINTS_CHANGED_ACTION_CODE + + +def test_templates_cover_supported_business_events(): + assert {"earn_publish", "earn_share", "earn_favorite", "earn_adopt", "deduct_admin", "adjust_admin"} <= set(NOTIFY_TEMPLATES) + assert POINTS_CHANGED_ACTION_CODE == "points_changed" diff --git a/src/backend/test/points/test_points_admin_lists.py b/src/backend/test/points/test_points_admin_lists.py new file mode 100644 index 000000000..c8ccb60ee --- /dev/null +++ b/src/backend/test/points/test_points_admin_lists.py @@ -0,0 +1,74 @@ +"""管理端用户列表 / 审计列表读路径。""" + +from types import SimpleNamespace +from unittest.mock import AsyncMock, patch + +import pytest + +from bisheng.points.domain.services.points_query_service import PointsQueryService + + +@pytest.mark.asyncio +async def test_admin_list_users_enriches_name_and_dept(): + repo = SimpleNamespace( + list_accounts_page=AsyncMock( + return_value=([SimpleNamespace(user_id=4, balance=28)], 1) + ), + sum_deltas_by_users=AsyncMock(return_value={4: 12}), + ) + service = PointsQueryService(session=None, repository=repo, ledger=None) + with patch.object( + PointsQueryService, + "_leaderboard_display_maps", + AsyncMock(return_value=({4: "gzx01"}, {4: "测试111"})), + ): + out = await service.admin_list_users( + 1, SimpleNamespace(is_admin=lambda: True, is_global_super=True), page=1, page_size=20 + ) + assert out.total == 1 + assert out.data[0].user_name == "gzx01" + assert out.data[0].dept_name == "测试111" + assert out.data[0].month_score == 12 + # 月度聚合必须收敛到当页用户,不能退回全租户 GROUP BY。 + assert repo.sum_deltas_by_users.await_args.args[1] == [4] + + +@pytest.mark.asyncio +async def test_admin_list_audit_logs_filters_manual_sources(): + repo = SimpleNamespace( + list_audit_logs=AsyncMock( + return_value=( + [ + SimpleNamespace( + id=9, + user_id=4, + title="管理员调分", + delta=10, + balance_after=38, + direction="earn", + rule_code="MANUAL", + source="manual_adjust", + operator_id=1, + remark="联调调分验证", + occurred_at=None, + ) + ], + 1, + ) + ) + ) + service = PointsQueryService(session=None, repository=repo, ledger=None) + with patch.object( + PointsQueryService, + "_leaderboard_display_maps", + AsyncMock(return_value=({4: "gzx01"}, {})), + ): + out = await service.admin_list_audit_logs( + 1, SimpleNamespace(is_admin=lambda: True, is_global_super=True) + ) + assert out.total == 1 + assert out.data[0].source == "manual_adjust" + assert out.data[0].user_name == "gzx01" + repo.list_audit_logs.assert_awaited() + kwargs = repo.list_audit_logs.await_args.kwargs + assert kwargs["sources"] == ["manual_adjust", "manual_deduct"] diff --git a/src/backend/test/points/test_points_award_facade.py b/src/backend/test/points/test_points_award_facade.py new file mode 100644 index 000000000..2fc040afb --- /dev/null +++ b/src/backend/test/points/test_points_award_facade.py @@ -0,0 +1,276 @@ +"""PointsAwardFacade 单元测试:豁免、受益人、开关与异常吞没。""" + +from types import SimpleNamespace + +import pytest + +from bisheng.points.domain.services.points_award_facade import ( + AnswerAdoptedEvent, + DocumentSharedEvent, + FavoriteChangedEvent, + PointsAwardFacade, + SpaceFileReadyEvent, +) +from bisheng.points.domain.services.points_ledger_service import PointsLedgerService + + +class FakeAwardRepository: + """内存规则与 G3 档位进度,供 Facade 单测使用。""" + + def __init__(self, rules: dict[str, SimpleNamespace] | None = None): + self.rules = rules or {} + self.tier_awards: dict[int, SimpleNamespace] = {} + self.account = SimpleNamespace(balance=0, version=0, lifetime_earned=0, lifetime_deducted=0) + self.logs = {} + self.today = 0 + + async def get_rule(self, _tenant_id, rule_code): + return self.rules.get(rule_code) + + async def get_favorite_tier_award(self, _tenant_id, file_id): + return self.tier_awards.get(file_id) + + async def upsert_favorite_tier_award(self, _tenant_id, file_id, *, highest_tier, points_granted_total): + row = self.tier_awards.get(file_id) + if row is None: + row = SimpleNamespace(highest_tier=highest_tier, points_granted_total=points_granted_total) + self.tier_awards[file_id] = row + else: + row.highest_tier = max(row.highest_tier, highest_tier) + row.points_granted_total = max(row.points_granted_total, points_granted_total) + return row + + async def lock_or_create_account(self, *_): + return self.account + + async def get_log_by_idempotency(self, _, key): + return self.logs.get(key) + + async def sum_earn_today(self, *_): + return self.today + + async def append_log(self, log): + log.id = len(self.logs) + 1 + self.logs[log.idempotency_key] = log + return log + + async def add_outbox(self, *_): + pass + + +def _rule(code="G1", *, status="enabled", beneficiary="uploader", score=3, daily_cap=15, score_expr=None): + return SimpleNamespace( + rule_code=code, + rule_type="earn", + name=f"rule-{code}", + status=status, + beneficiary=beneficiary, + daily_cap=daily_cap, + score_expr=score_expr or {"mode": "fixed", "score": score}, + ) + + +def _facade(repo, *, enabled=True, super_admins: set[int] | None = None) -> PointsAwardFacade: + supers = super_admins or set() + + async def is_super(uid: int) -> bool: + return uid in supers + + return PointsAwardFacade( + repo, + PointsLedgerService(repo), + enabled=enabled, + is_platform_super_admin=is_super, + ) + + +def _file_event(**kwargs) -> SpaceFileReadyEvent: + base = dict( + tenant_id=1, + space_id=10, + space_level="public", + file_id=100, + uploader_id=4, + publisher_id=None, + is_favorite_space=False, + space_manager_ids=frozenset(), + ) + base.update(kwargs) + return SpaceFileReadyEvent(**base) + + +@pytest.mark.asyncio +async def test_skips_when_points_disabled(): + repo = FakeAwardRepository({"G1": _rule()}) + outcome = await _facade(repo, enabled=False).on_space_file_ready(_file_event()) + assert outcome.skipped and outcome.reason == "points_disabled" + assert repo.account.balance == 0 + + +@pytest.mark.asyncio +async def test_skips_personal_and_favorite_space(): + repo = FakeAwardRepository({"G1": _rule()}) + facade = _facade(repo) + personal = await facade.on_space_file_ready(_file_event(space_level="personal")) + favorite = await facade.on_space_file_ready(_file_event(is_favorite_space=True)) + assert personal.reason == "personal_or_unmapped_level" + assert favorite.reason == "favorite_space" + assert repo.account.balance == 0 + + +@pytest.mark.asyncio +async def test_p7b_skips_when_payee_is_space_manager_not_operator(): + """管理员上传、受益人为 uploader → skip;操作人身份不参与判断。""" + repo = FakeAwardRepository({"G1": _rule(beneficiary="uploader")}) + outcome = await _facade(repo).on_space_file_ready( + _file_event(uploader_id=9, space_manager_ids=frozenset({9})) + ) + assert outcome.reason == "space_manager_payee" + assert repo.account.balance == 0 + + +@pytest.mark.asyncio +async def test_p7b_awards_when_operator_is_manager_but_payee_is_not(): + """他人发布、受益人为 publisher(非管理员)→ 应发分。""" + repo = FakeAwardRepository({"G1": _rule(beneficiary="publisher", score=3)}) + outcome = await _facade(repo).on_space_file_ready( + _file_event( + uploader_id=4, + publisher_id=5, + space_manager_ids=frozenset({9}), # 管理员是别人 + ) + ) + assert not outcome.skipped + assert repo.account.balance == 3 + assert outcome.result.applied_delta == 3 + + +@pytest.mark.asyncio +async def test_skips_platform_super_admin_payee(): + repo = FakeAwardRepository({"G1": _rule()}) + outcome = await _facade(repo, super_admins={4}).on_space_file_ready(_file_event(uploader_id=4)) + assert outcome.reason == "super_admin" + assert repo.account.balance == 0 + + +@pytest.mark.asyncio +async def test_beneficiary_publisher_resolves_payee(): + repo = FakeAwardRepository({"G1": _rule(beneficiary="publisher", score=2)}) + outcome = await _facade(repo).on_space_file_ready( + _file_event(uploader_id=4, publisher_id=8) + ) + assert not outcome.skipped + log = next(iter(repo.logs.values())) + assert log.user_id == 8 + assert log.beneficiary_role == "publisher" + assert log.delta == 2 + + +@pytest.mark.asyncio +async def test_disabled_rule_skips_without_error(): + repo = FakeAwardRepository({"G1": _rule(status="disabled")}) + outcome = await _facade(repo).on_space_file_ready(_file_event()) + assert outcome.reason == "rule_disabled" + assert repo.account.balance == 0 + + +@pytest.mark.asyncio +async def test_daily_cap_skips_entire_delta(): + repo = FakeAwardRepository({"G1": _rule(score=3, daily_cap=10)}) + repo.today = 8 + outcome = await _facade(repo).on_space_file_ready(_file_event()) + assert outcome.reason == "daily_cap" + assert outcome.result.skipped_cap + assert repo.account.balance == 0 + assert not repo.logs + + +@pytest.mark.asyncio +async def test_award_uses_idempotent_key_with_file_and_space(): + repo = FakeAwardRepository({"G1": _rule(score=3)}) + facade = _facade(repo) + first = await facade.on_space_file_ready(_file_event(file_id=11, space_id=22)) + second = await facade.on_space_file_ready(_file_event(file_id=11, space_id=22)) + assert not first.skipped and second.result.replayed + assert repo.account.balance == 3 + assert "earn:G1:11:22" in repo.logs + + +@pytest.mark.asyncio +async def test_exceptions_are_swallowed(): + class BoomRepo(FakeAwardRepository): + async def get_rule(self, *_): + raise RuntimeError("db down") + + outcome = await _facade(BoomRepo()).on_space_file_ready(_file_event()) + assert outcome.skipped and outcome.reason == "error" + + +@pytest.mark.asyncio +async def test_g7_awards_sharer_and_skips_manager_payee(): + repo = FakeAwardRepository({"G7": _rule("G7", beneficiary="sharer", score=4, daily_cap=20)}) + facade = _facade(repo) + ok = await facade.on_document_shared( + DocumentSharedEvent( + tenant_id=1, + share_entry_id=77, + uploader_id=4, + sharer_id=5, + related_manager_ids=frozenset({9}), + ) + ) + assert not ok.skipped and repo.account.balance == 4 + skipped = await facade.on_document_shared( + DocumentSharedEvent( + tenant_id=1, + share_entry_id=78, + uploader_id=4, + sharer_id=9, + related_manager_ids=frozenset({9}), + ) + ) + assert skipped.reason == "space_manager_payee" + + +@pytest.mark.asyncio +async def test_g3_tier_differential_and_lifetime_cap(): + expr = { + "mode": "tier", + "tiers": [ + {"threshold": 75, "score": 5}, + {"threshold": 150, "score": 10}, + {"threshold": 300, "score": 15}, + ], + "lifetime_cap": 15, + } + repo = FakeAwardRepository({"G3": _rule("G3", score_expr=expr, daily_cap=None)}) + facade = _facade(repo) + first = await facade.on_favorite_changed( + FavoriteChangedEvent(tenant_id=1, file_id=1, uploader_id=4, unique_favoriter_count=80) + ) + assert not first.skipped and repo.account.balance == 5 + second = await facade.on_favorite_changed( + FavoriteChangedEvent(tenant_id=1, file_id=1, uploader_id=4, unique_favoriter_count=160) + ) + assert not second.skipped and repo.account.balance == 10 + third = await facade.on_favorite_changed( + FavoriteChangedEvent(tenant_id=1, file_id=1, uploader_id=4, unique_favoriter_count=400) + ) + assert not third.skipped and repo.account.balance == 15 + # 取消收藏再达阈值:进度不回退,不再补发。 + again = await facade.on_favorite_changed( + FavoriteChangedEvent(tenant_id=1, file_id=1, uploader_id=4, unique_favoriter_count=400) + ) + assert again.reason == "tier_already_granted" + assert repo.account.balance == 15 + + +@pytest.mark.asyncio +async def test_g4_answer_adopted(): + repo = FakeAwardRepository({"G4": _rule("G4", beneficiary="answerer", score=3)}) + outcome = await _facade(repo).on_answer_adopted( + AnswerAdoptedEvent(tenant_id=1, answer_id=55, answerer_id=6) + ) + assert not outcome.skipped + assert "earn:G4:55" in repo.logs + assert repo.account.balance == 3 diff --git a/src/backend/test/points/test_points_award_hooks.py b/src/backend/test/points/test_points_award_hooks.py new file mode 100644 index 000000000..7ddfe50a3 --- /dev/null +++ b/src/backend/test/points/test_points_award_hooks.py @@ -0,0 +1,159 @@ +"""积分旁路 hooks:同步路径、异步投递与 enqueue fallback。""" + +from types import SimpleNamespace +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +from bisheng.core.config.celery_queues import POINTS_AWARD_QUEUE +from bisheng.points.domain.services import points_award_hooks as hooks + + +@pytest.mark.asyncio +async def test_notify_space_files_ready_sync_when_async_disabled(): + """award_async_enabled=false 时走同一会话 Facade。""" + award = AsyncMock(return_value=SimpleNamespace(skipped=True, reason="points_disabled")) + facade = SimpleNamespace(on_space_file_ready=award) + + async def fake_run(action): + await action(facade) + + with ( + patch.object(hooks, "_award_async_enabled", return_value=False), + patch.object(hooks, "_run_with_facade", side_effect=fake_run), + patch.object(hooks, "resolve_space_level", AsyncMock(return_value="public")), + patch.object(hooks, "resolve_space_manager_ids", AsyncMock(return_value=frozenset())), + ): + await hooks.notify_space_files_ready( + tenant_id=1, + space_id=10, + files=[SimpleNamespace(id=100)], + uploader_id=4, + is_favorite_space=False, + ) + award.assert_awaited_once() + + +@pytest.mark.asyncio +async def test_notify_space_files_ready_enqueues_one_task_per_file(): + """异步开启时一文件一 Celery 任务。""" + enqueue = MagicMock() + with ( + patch.object(hooks, "_award_async_enabled", return_value=True), + patch.object(hooks, "resolve_space_level", AsyncMock(return_value="public")), + patch.object(hooks, "resolve_space_manager_ids", AsyncMock(return_value=frozenset({9}))), + patch.object(hooks, "_enqueue_award_event", enqueue), + ): + await hooks.notify_space_files_ready( + tenant_id=1, + space_id=10, + files=[SimpleNamespace(id=100), SimpleNamespace(id=101)], + uploader_id=4, + is_favorite_space=False, + space_level="public", + ) + assert enqueue.call_count == 2 + first = enqueue.call_args_list[0].args[0] + assert first["event_type"] == "space_file_ready" + assert first["file_id"] == 100 + assert first["space_manager_ids"] == [9] + + +@pytest.mark.asyncio +async def test_dispatch_falls_back_to_sync_when_enqueue_fails(): + """Broker 投递失败时同步入账,不丢事件。""" + sync = AsyncMock() + with ( + patch.object(hooks, "_award_async_enabled", return_value=True), + patch.object(hooks, "_run_payload_sync", sync), + patch.object(hooks, "_enqueue_award_event", side_effect=RuntimeError("broker down")), + ): + await hooks._dispatch( + "answer_adopted", + {"tenant_id": 1, "answer_id": 2, "answerer_id": 4}, + ) + sync.assert_awaited_once() + assert sync.await_args.args[0]["event_type"] == "answer_adopted" + + +def test_resolve_award_queue_defaults_to_points_award_celery(monkeypatch): + """未设 POINTS_AWARD_CELERY_QUEUE 时解析为正式发分队列。""" + monkeypatch.delenv("POINTS_AWARD_CELERY_QUEUE", raising=False) + assert hooks._resolve_award_queue() == POINTS_AWARD_QUEUE + + +def test_resolve_award_queue_respects_env_override(monkeypatch): + """POINTS_AWARD_CELERY_QUEUE 可覆盖正式队列名(压测隔离)。""" + monkeypatch.setenv("POINTS_AWARD_CELERY_QUEUE", "points_award_local") + assert hooks._resolve_award_queue() == "points_award_local" + + +@pytest.mark.asyncio +async def test_notify_space_files_ready_swallows_errors(): + with ( + patch.object(hooks, "_award_async_enabled", return_value=False), + patch.object(hooks, "_run_with_facade", AsyncMock(side_effect=RuntimeError("boom"))), + ): + await hooks.notify_space_files_ready( + tenant_id=1, + space_id=10, + files=[SimpleNamespace(id=1)], + uploader_id=4, + is_favorite_space=False, + space_level="public", + ) + + +@pytest.mark.asyncio +async def test_notify_document_shared_builds_g7_event(): + award = AsyncMock() + facade = SimpleNamespace(on_document_shared=award) + + async def fake_run(action): + await action(facade) + + with ( + patch.object(hooks, "_award_async_enabled", return_value=False), + patch.object(hooks, "_run_with_facade", side_effect=fake_run), + patch.object(hooks, "resolve_space_manager_ids", AsyncMock(return_value=frozenset({9}))), + ): + await hooks.notify_document_shared( + tenant_id=1, + share_entry_id=77, + source_space_id=1, + target_space_id=2, + uploader_id=4, + sharer_id=5, + ) + event = award.await_args.args[0] + assert event.share_entry_id == 77 + assert event.related_manager_ids == frozenset({9}) + + +@pytest.mark.asyncio +async def test_run_payload_sync_space_file_ready_maps_event(): + """Worker/fallback 共用的 payload 分发能还原 Facade 事件。""" + award = AsyncMock() + facade = SimpleNamespace(on_space_file_ready=award) + + async def fake_run(action): + await action(facade) + + with patch.object(hooks, "_run_with_facade", side_effect=fake_run): + await hooks._run_payload_sync( + { + "event_type": "space_file_ready", + "tenant_id": 1, + "space_id": 10, + "space_level": "public", + "file_id": 100, + "uploader_id": 4, + "publisher_id": None, + "is_favorite_space": False, + "space_manager_ids": [9], + } + ) + award.assert_awaited_once() + event = award.await_args.args[0] + assert event.file_id == 100 + assert event.space_manager_ids == frozenset({9}) diff --git a/src/backend/test/points/test_points_dual_db_smoke.py b/src/backend/test/points/test_points_dual_db_smoke.py new file mode 100644 index 000000000..5a6a1fa93 --- /dev/null +++ b/src/backend/test/points/test_points_dual_db_smoke.py @@ -0,0 +1,100 @@ +"""T027:积分双库兼容冒烟(AC-25)。 + +- 静态:ORM/迁移使用 dialect_helpers,禁止 MySQL 专属 JSON/ON UPDATE。 +- 运行时(MySQL):核心表存在且可查询。 +- DM8:macOS 无驱动,标记 skip;真实校验在 CI/Linux。 +""" + +from __future__ import annotations + +import ast +import os +import sys +from pathlib import Path + +import pytest + +BACKEND = Path(__file__).resolve().parents[2] +MODELS = BACKEND / "bisheng/points/domain/models/points.py" +MIGRATION = BACKEND / "bisheng/core/database/alembic/versions/v2_6_0_f078_points_system.py" + +# Resolve DSN the same way local uvicorn/gates do (cwd-relative config.yaml). +_cfg_candidates = [ + BACKEND / "config.yaml", + BACKEND / "bisheng" / "config.yaml", + Path("config.yaml").resolve(), +] +for _cfg in _cfg_candidates: + if _cfg.is_file(): + os.environ.setdefault("config", str(_cfg)) + break +else: + os.environ.setdefault("config", "config.yaml") + + +def test_points_models_use_dialect_helpers(): + """ORM 必须用 JsonType / LargeText / UPDATE_TIME_SERVER_DEFAULT。""" + src = MODELS.read_text(encoding="utf-8") + assert "from bisheng.core.database.dialect_helpers import" in src + assert "JsonType" in src + assert "LargeText" in src + assert "UPDATE_TIME_SERVER_DEFAULT" in src + # 禁止直接绑 MySQL JSON / ON UPDATE 字面量。 + assert "sqlalchemy.JSON" not in src + assert "mysql.JSON" not in src + assert "ON UPDATE CURRENT_TIMESTAMP" not in src + + +def test_points_migration_is_dialect_safe(): + """迁移用 inspect,不碰 information_schema / DATABASE()。""" + src = MIGRATION.read_text(encoding="utf-8") + assert "inspect(bind)" in src or "sa.inspect" in src + assert "information_schema" not in src.lower() + assert "DATABASE()" not in src + assert "JSON_EXTRACT" not in src + assert "ON UPDATE CURRENT_TIMESTAMP" not in src + assert "mysql.JSON" not in src + # Parse succeeds (syntax); table create delegates to ORM models. + ast.parse(src) + + +@pytest.mark.asyncio +async def test_mysql_points_schema_readable(): + """联调 MySQL:积分核心表可读(需 config.yaml 指向测试库)。""" + from sqlalchemy import text + from bisheng.core.database import get_async_db_session + + required = [ + "user_point_account", + "user_point_log", + "point_rule", + "point_copy", + "point_rank_snapshot", + "point_favorite_tier_award", + "point_sync_outbox", + ] + try: + async with get_async_db_session() as session: + for name in required: + row = (await session.execute(text(f"select count(*) from {name}"))).first() + assert row is not None + rules = ( + await session.execute( + text("select count(*) from point_rule where tenant_id = 1") + ) + ).first() + assert int(rules[0]) >= 1 + except Exception as exc: + pytest.skip(f"MySQL smoke skipped (DSN/config unavailable): {exc}") + + +@pytest.mark.skipif( + sys.platform == "darwin", + reason="DM8 driver (dmPython) not installed on macOS; validate on CI/Linux only", +) +def test_dm8_driver_importable_on_linux(): + """Linux/CI:确认 DM8 驱动可导入(真实连库由 CI workflow 负责)。""" + import importlib.util + + mod = importlib.util.find_spec("dmPython") or importlib.util.find_spec("dmAsync") + assert mod is not None, "DM8 driver missing on Linux CI image" diff --git a/src/backend/test/points/test_points_leaderboard.py b/src/backend/test/points/test_points_leaderboard.py new file mode 100644 index 000000000..9417c7a99 --- /dev/null +++ b/src/backend/test/points/test_points_leaderboard.py @@ -0,0 +1,63 @@ +"""排行榜读路径:按当前用户公司隔离 + 展示字段补齐。""" + +from types import SimpleNamespace +from unittest.mock import AsyncMock, patch + +import pytest + +from bisheng.points.domain.services.points_query_service import PointsQueryService + + +@pytest.mark.asyncio +async def test_leaderboard_enriches_user_and_dept_names(): + repo = SimpleNamespace( + list_top_ranks=AsyncMock( + return_value=[ + SimpleNamespace( + rank_no=1, user_id=10, balance=100, period_score=40, dept_id=2 + ) + ] + ), + latest_rank_refreshed_at=AsyncMock(return_value=None), + ) + service = PointsQueryService(session=None, repository=repo, ledger=None) + with ( + patch.object( + PointsQueryService, + "_resolve_user_company_id", + AsyncMock(return_value=100), + ), + patch.object( + PointsQueryService, + "_leaderboard_display_maps", + AsyncMock(return_value=({10: "张三"}, {10: "炼铁作业部"})), + ), + ): + out = await service.leaderboard(1, "month", user_id=10) + + assert out.period == "month" + assert len(out.items) == 1 + assert out.items[0].user_name == "张三" + assert out.items[0].dept_name == "炼铁作业部" + assert out.items[0].period_score == 40 + repo.list_top_ranks.assert_awaited_once() + assert repo.list_top_ranks.await_args.args[3] == 100 # scope_id=company_id + + +@pytest.mark.asyncio +async def test_leaderboard_empty_when_user_has_no_company(): + """无公司归属:首页榜空态,不查 TOP 快照。""" + repo = SimpleNamespace( + list_top_ranks=AsyncMock(return_value=[]), + latest_rank_refreshed_at=AsyncMock(return_value=None), + ) + service = PointsQueryService(session=None, repository=repo, ledger=None) + with patch.object( + PointsQueryService, + "_resolve_user_company_id", + AsyncMock(return_value=None), + ): + out = await service.leaderboard(1, "month", user_id=10) + + assert out.items == [] + repo.list_top_ranks.assert_not_awaited() diff --git a/src/backend/test/points/test_points_ledger_service.py b/src/backend/test/points/test_points_ledger_service.py new file mode 100644 index 000000000..04298ecd7 --- /dev/null +++ b/src/backend/test/points/test_points_ledger_service.py @@ -0,0 +1,43 @@ +"""积分账本核心规则测试。""" + +from types import SimpleNamespace + +from bisheng.points.domain.services.points_ledger_service import PointsLedgerService + + +class FakeRepository: + """以最小内存状态模拟账本仓储。""" + def __init__(self): + self.account = SimpleNamespace(balance=0, version=0, lifetime_earned=0, lifetime_deducted=0) + self.logs = {} + self.today = 0 + + async def lock_or_create_account(self, *_): return self.account + async def get_log_by_idempotency(self, _, key): return self.logs.get(key) + async def sum_earn_today(self, *_): return self.today + async def append_log(self, log): + log.id = len(self.logs) + 1 + self.logs[log.idempotency_key] = log + return log + async def add_outbox(self, *_): pass + + +async def test_award_skips_entire_delta_when_cap_remaining_is_insufficient(): + repo = FakeRepository() + repo.today = 8 + result = await PointsLedgerService(repo).award(tenant_id=1, user_id=1, delta=3, title="测试", rule_code="G1", idempotency_key="a", daily_cap=10) + assert result.skipped_cap and result.applied_delta == 0 and repo.account.balance == 0 + + +async def test_award_is_idempotent(): + repo = FakeRepository() + service = PointsLedgerService(repo) + first = await service.award(tenant_id=1, user_id=1, delta=3, title="测试", rule_code="G1", idempotency_key="a") + replay = await service.award(tenant_id=1, user_id=1, delta=3, title="测试", rule_code="G1", idempotency_key="a") + assert first.balance == replay.balance == 3 and replay.replayed + + +async def test_deduct_allows_negative_balance(): + repo = FakeRepository() + result = await PointsLedgerService(repo).deduct(tenant_id=1, user_id=1, delta=-100, title="违规", rule_code="R1", idempotency_key="d", operator_id=2) + assert result.balance == -100 diff --git a/src/backend/test/points/test_points_monthly_reward.py b/src/backend/test/points/test_points_monthly_reward.py new file mode 100644 index 000000000..f957d181d --- /dev/null +++ b/src/backend/test/points/test_points_monthly_reward.py @@ -0,0 +1,101 @@ +"""月奖:结算月、取最高档与登录过滤。""" + +from datetime import datetime +from types import SimpleNamespace +from unittest.mock import AsyncMock, patch +from zoneinfo import ZoneInfo + +import pytest + +from bisheng.points.domain.constants.monthly_reward_rules import ( + fixed_score, + pick_highest_reward, +) +from bisheng.points.domain.services.points_monthly_reward_service import ( + PointsMonthlyRewardService, + month_local_date_bounds, + previous_month_key, +) + +SHANGHAI = ZoneInfo("Asia/Shanghai") + + +def test_previous_month_key_on_first_day(): + now = datetime(2026, 8, 1, 0, 5, tzinfo=SHANGHAI) + assert previous_month_key(now) == "2026-07" + + +def test_month_local_date_bounds(): + assert month_local_date_bounds("2026-07") == ("2026-07-01", "2026-07-31") + assert month_local_date_bounds("2026-02") == ("2026-02-01", "2026-02-28") + + +def test_pick_highest_reward(): + assert pick_highest_reward([("M6", 50), ("M1", 200), ("M4", 100)]) == ("M1", 200) + assert pick_highest_reward([("M4", 100), ("M6", 100)]) == ("M4", 100) + assert pick_highest_reward([]) is None + assert fixed_score({"mode": "fixed", "score": 200}) == 200 + assert fixed_score({"mode": "tier"}) == 0 + + +@pytest.mark.asyncio +async def test_run_for_tenant_skips_no_login_and_awards_highest(): + service = PointsMonthlyRewardService( + login_users_fn=AsyncMock(return_value={10}), + ) + m1 = SimpleNamespace( + rule_code="M1", + name="公共库所有者月奖", + score_expr={"mode": "fixed", "score": 200}, + status="enabled", + rule_type="admin_reward", + ) + m4 = SimpleNamespace( + rule_code="M4", + name="部门管理员月奖", + score_expr={"mode": "fixed", "score": 100}, + status="enabled", + rule_type="admin_reward", + ) + + async def fake_collect(matchers, rule_by_code): + return { + 10: ("M1", 200), # logged in + 11: ("M4", 100), # no login + } + + with ( + patch( + "bisheng.points.domain.services.points_monthly_reward_service.get_async_db_session" + ) as session_factory, + patch.object(service, "_collect_user_candidates", side_effect=fake_collect), + patch.object(service, "_load_super_admin_ids", AsyncMock(return_value=set())), + patch.object(service, "_award_one", AsyncMock(return_value=True)) as award, + ): + + class _Session: + async def __aenter__(self): + return self + + async def __aexit__(self, *_): + return False + + session_factory.return_value = _Session() + + class _Repo: + async def list_rules(self, *_a, **_k): + return [m1, m4] + + with patch( + "bisheng.points.domain.services.points_monthly_reward_service.PointsRepository", + return_value=_Repo(), + ): + out = await service.run_for_tenant(1, period_key="2026-07") + + assert out["awarded"] == 1 + assert out["skipped"] == 1 + award.assert_awaited_once() + kwargs = award.await_args.kwargs + assert kwargs["user_id"] == 10 + assert kwargs["rule_code"] == "M1" + assert kwargs["score"] == 200 diff --git a/src/backend/test/points/test_points_overview_cache.py b/src/backend/test/points/test_points_overview_cache.py new file mode 100644 index 000000000..1c3f260b4 --- /dev/null +++ b/src/backend/test/points/test_points_overview_cache.py @@ -0,0 +1,107 @@ +"""运营概览缓存(AC-19:允许 5min 陈旧)。 + +三个指标均为 user_point_log / user_point_account 上的全历史聚合,耗时随流水量增长, +因此加 Redis 缓存;缓存故障必须退化为直查库而不是报错。 +""" + +from types import SimpleNamespace +from unittest.mock import AsyncMock, patch + +import pytest + +from bisheng.points.domain.services import points_query_service as mod +from bisheng.points.domain.services.points_query_service import PointsQueryService + +ADMIN = SimpleNamespace(is_admin=lambda: True, is_global_super=True) + + +def _repo() -> SimpleNamespace: + """返回带计数的假仓储,用于断言是否真的查了库。""" + return SimpleNamespace( + sum_total_issued=AsyncMock(return_value=100), + sum_total_balance=AsyncMock(return_value=70), + sum_violation_deducted=AsyncMock(return_value=-30), + ) + + +@pytest.mark.asyncio +async def test_overview_caches_and_serves_second_call_from_cache(): + """首次查库并回写缓存,二次命中缓存后不再查库。""" + repo = _repo() + service = PointsQueryService(session=None, repository=repo, ledger=None) + store: dict[str, dict] = {} + + async def fake_get(key): + return store.get(key) + + async def fake_set(key, payload): + store[key] = payload + + with ( + patch.object(PointsQueryService, "_overview_cache_get", staticmethod(fake_get)), + patch.object(PointsQueryService, "_overview_cache_set", staticmethod(fake_set)), + ): + first = await service.overview(1, ADMIN) + second = await service.overview(1, ADMIN) + + assert first.total_issued == 100 + assert first.total_balance == 70 + assert first.total_violation_deducted == -30 + assert second == first + # 第二次必须来自缓存:三个聚合各自只被调用一次。 + assert repo.sum_total_issued.await_count == 1 + assert repo.sum_total_balance.await_count == 1 + assert repo.sum_violation_deducted.await_count == 1 + assert store[f"{mod.OVERVIEW_CACHE_PREFIX}1"]["total_issued"] == 100 + + +@pytest.mark.asyncio +async def test_overview_cache_is_scoped_per_tenant(): + """不同租户不能串用同一份概览缓存。""" + repo = _repo() + service = PointsQueryService(session=None, repository=repo, ledger=None) + store: dict[str, dict] = {} + + async def fake_get(key): + return store.get(key) + + async def fake_set(key, payload): + store[key] = payload + + with ( + patch.object(PointsQueryService, "_overview_cache_get", staticmethod(fake_get)), + patch.object(PointsQueryService, "_overview_cache_set", staticmethod(fake_set)), + ): + await service.overview(1, ADMIN) + await service.overview(2, ADMIN) + + assert set(store) == {f"{mod.OVERVIEW_CACHE_PREFIX}1", f"{mod.OVERVIEW_CACHE_PREFIX}2"} + assert repo.sum_total_issued.await_count == 2 + + +@pytest.mark.asyncio +async def test_overview_falls_back_to_db_when_redis_unavailable(caplog): + """Redis 不可用时概览仍可返回,不向上抛错。""" + repo = _repo() + service = PointsQueryService(session=None, repository=repo, ledger=None) + client = AsyncMock(side_effect=RuntimeError("redis down")) + + with patch("bisheng.core.cache.redis_manager.get_redis_client", client): + with caplog.at_level("WARNING", logger=mod.__name__): + out = await service.overview(1, ADMIN) + + assert out.total_issued == 100 + assert repo.sum_total_issued.await_count == 1 + # 读、写各降级一次,确认真的进了异常分支而非碰巧命中。 + assert client.await_count == 2 + assert sum("cache" in r.message for r in caplog.records) == 2 + + +@pytest.mark.asyncio +async def test_overview_requires_platform_admin(): + """非管理员不得读运营概览。""" + repo = _repo() + service = PointsQueryService(session=None, repository=repo, ledger=None) + with pytest.raises(Exception): + await service.overview(1, SimpleNamespace(is_admin=lambda: False, is_global_super=False)) + assert repo.sum_total_issued.await_count == 0 diff --git a/src/backend/test/points/test_points_pending_deduct.py b/src/backend/test/points/test_points_pending_deduct.py new file mode 100644 index 000000000..4cc9d9349 --- /dev/null +++ b/src/backend/test/points/test_points_pending_deduct.py @@ -0,0 +1,60 @@ +"""补扣队列:稳定幂等键与失败入队语义。""" + +from __future__ import annotations + +import pytest + +from bisheng.points.domain.services.points_pending_deduct_service import ( + DeductAttemptResult, + PointsPendingDeductService, + stable_deduct_idempotency_key, +) + + +def test_stable_deduct_idempotency_key_normalizes_rule(): + """同一内容+规则生成稳定键,规则编码大写。""" + key = stable_deduct_idempotency_key("r1", "qa_question", "12") + assert key == "deduct:R1:qa_question:12" + assert key == stable_deduct_idempotency_key("R1", "qa_question", "12") + + +@pytest.mark.asyncio +async def test_deduct_or_enqueue_enqueues_when_ledger_raises(monkeypatch): + """立即扣分抛错时必须入补扣队列且不向外抛。""" + calls: list[dict] = [] + + async def fake_enqueue(**kwargs): + calls.append(kwargs) + + service = PointsPendingDeductService() + monkeypatch.setattr(service, "_enqueue", fake_enqueue) + + async def boom(*_a, **_k): + raise RuntimeError("db down") + + # 强制进入异常分支:伪造 get_async_db_session 上下文失败 + class _BoomCM: + async def __aenter__(self): + raise RuntimeError("db down") + + async def __aexit__(self, *args): + return False + + monkeypatch.setattr( + "bisheng.points.domain.services.points_pending_deduct_service.get_async_db_session", + lambda: _BoomCM(), + ) + + result = await service.deduct_or_enqueue( + tenant_id=1, + user_id=9, + rule_code="R1", + biz_type="qa_comment", + biz_id="77", + operator_id=1, + remark="违规", + ) + assert isinstance(result, DeductAttemptResult) + assert result.applied is False + assert result.pending is True + assert calls and calls[0]["idempotency_key"] == "deduct:R1:qa_comment:77" diff --git a/src/backend/test/points/test_points_perf_indexes_migration.py b/src/backend/test/points/test_points_perf_indexes_migration.py new file mode 100644 index 000000000..31cba35d6 --- /dev/null +++ b/src/backend/test/points/test_points_perf_indexes_migration.py @@ -0,0 +1,130 @@ +"""f082 积分性能索引迁移:元数据、索引定义与幂等性。""" + +from contextlib import contextmanager +from types import SimpleNamespace +from unittest.mock import call, patch + +from bisheng.core.database.alembic.versions import ( + v2_6_0_f082_points_perf_indexes as migration, +) +from bisheng.points.domain.models.points import PointRankSnapshot, UserPointLog + + +def test_migration_metadata_extends_current_head() -> None: + assert migration.revision == "f082_points_perf_indexes" + assert migration.down_revision == "f081_points_copy_guide_only" + + +def test_upgrade_creates_missing_indexes_and_widens_direction_index() -> None: + """索引缺失时全部补建,并把 ix_upl_tenant_dir_time 扩到带 delta。""" + connection = SimpleNamespace() + with ( + patch.object(migration.op, "get_bind", return_value=connection), + patch.object(migration, "table_exists", return_value=True), + patch.object(migration, "index_exists", return_value=False), + patch.object(migration.op, "create_index") as create_index, + patch.object(migration.op, "drop_index") as drop_index, + ): + migration.upgrade() + + assert create_index.call_args_list == [ + call("ix_prs_bucket", "point_rank_snapshot", + ["tenant_id", "period", "scope", "period_key", "scope_id", "rank_no"]), + call("ix_prs_user", "point_rank_snapshot", + ["tenant_id", "period", "period_key", "user_id", "scope", "scope_id"]), + call("ix_prs_refresh", "point_rank_snapshot", + ["tenant_id", "period", "period_key", "refreshed_at"]), + call("ix_upl_source_time", "user_point_log", + ["tenant_id", "source", "occurred_at", "id"]), + call("ix_upl_tenant_dir_time", "user_point_log", + ["tenant_id", "direction", "occurred_at", "delta"]), + ] + # index_exists 为 False 时旧索引视为不存在,因此不会触发 drop。 + assert drop_index.call_args_list == [] + + +@contextmanager +def fake_schema(existing: dict[str, list[str]]): + """用可变字典模拟库里的索引,让 drop 后紧接的 create 能被正确观察到。 + + 真实库上 index_exists 会随 drop / create 变化,固定返回值会让「先删后建」 + 的扩列逻辑被误判为跳过。yield 出的字典即迁移结束后的索引形态。 + """ + schema = dict(existing) + + def _exists(_bind, _table, name): + return name in schema + + def _create(name, _table, columns): + schema[name] = list(columns) + + def _drop(name, table_name=None): # noqa: ARG001 - 与 op.drop_index 签名对齐 + schema.pop(name, None) + + with ( + patch.object(migration.op, "get_bind", return_value=SimpleNamespace()), + patch.object(migration, "table_exists", return_value=True), + patch.object(migration, "index_exists", side_effect=_exists), + patch.object(migration.op, "create_index", side_effect=_create), + patch.object(migration.op, "drop_index", side_effect=_drop), + ): + yield schema + + +def test_upgrade_reaches_same_state_when_rerun() -> None: + """重复执行 upgrade 得到同一套索引,不重复建也不丢索引。""" + expected = { + "ix_prs_bucket": ["tenant_id", "period", "scope", "period_key", "scope_id", "rank_no"], + "ix_prs_user": ["tenant_id", "period", "period_key", "user_id", "scope", "scope_id"], + "ix_prs_refresh": ["tenant_id", "period", "period_key", "refreshed_at"], + "ix_upl_source_time": ["tenant_id", "source", "occurred_at", "id"], + "ix_upl_tenant_dir_time": ["tenant_id", "direction", "occurred_at", "delta"], + } + start = {"ix_upl_tenant_dir_time": ["tenant_id", "direction", "occurred_at"]} + + with fake_schema(start) as schema: + migration.upgrade() + assert schema == expected + migration.upgrade() + assert schema == expected + + +def test_upgrade_skips_when_tables_absent() -> None: + """表还没建时迁移必须静默跳过,不能报错。""" + connection = SimpleNamespace() + with ( + patch.object(migration.op, "get_bind", return_value=connection), + patch.object(migration, "table_exists", return_value=False), + patch.object(migration.op, "create_index") as create_index, + patch.object(migration.op, "drop_index") as drop_index, + ): + migration.upgrade() + + assert create_index.call_args_list == [] + assert drop_index.call_args_list == [] + + +def test_downgrade_returns_to_f081_index_shape() -> None: + """upgrade → downgrade 必须回到 f081 形态(三列 dir 索引,无快照索引)。""" + start = {"ix_upl_tenant_dir_time": ["tenant_id", "direction", "occurred_at"]} + with fake_schema(start) as schema: + migration.upgrade() + migration.downgrade() + assert schema == start + + +def test_migration_matches_orm_table_args() -> None: + """迁移里的索引定义必须与 ORM __table_args__ 一致,避免两边漂移。""" + def orm_indexes(model) -> dict[str, list[str]]: + return { + item.name: [c.name for c in item.columns] + for item in model.__table__.indexes + } + + snapshot = orm_indexes(PointRankSnapshot) + ledger = orm_indexes(UserPointLog) + declared = {name: cols for _table, name, cols in migration.NEW_INDEXES} + + for name, cols in declared.items(): + assert snapshot.get(name) == cols or ledger.get(name) == cols, name + assert ledger[migration.DIR_INDEX_NAME] == migration.DIR_INDEX_NEW diff --git a/src/backend/test/points/test_points_rank_service.py b/src/backend/test/points/test_points_rank_service.py new file mode 100644 index 000000000..ee586a3c4 --- /dev/null +++ b/src/backend/test/points/test_points_rank_service.py @@ -0,0 +1,169 @@ +"""积分排行快照:周期键、公司/部门桶解析、稠密名次与按公司刷榜。""" + +from datetime import datetime +from types import SimpleNamespace +from unittest.mock import AsyncMock, patch +from zoneinfo import ZoneInfo + +import pytest + +from bisheng.points.domain.services.points_rank_service import ( + PointsRankService, + build_ranked_rows, + period_keys, + resolve_company_id, + resolve_dept_bucket_id, +) + +SHANGHAI = ZoneInfo("Asia/Shanghai") + + +def test_period_keys_shanghai(): + now = datetime(2026, 8, 7, 10, 0, tzinfo=SHANGHAI) + assert period_keys(now) == {"month": "2026-08", "year": "2026", "all": "all"} + + +def test_resolve_company_id_walks_path_to_nearest_company(): + company = SimpleNamespace(id=1, path="/1/", org_level="company") + dept = SimpleNamespace(id=2, path="/1/2/", org_level="dept") + office = SimpleNamespace(id=3, path="/1/2/3/", org_level="office") + departments = {1: company, 2: dept, 3: office} + primary = SimpleNamespace(id=3, path="/1/2/3/") + assert resolve_company_id(primary, departments) == 1 + + +def test_resolve_company_id_none_when_unlabeled(): + leaf = SimpleNamespace(id=9, path="/9/", org_level=None) + departments = {9: leaf} + primary = SimpleNamespace(id=9, path="/9/") + assert resolve_company_id(primary, departments) is None + assert resolve_company_id(None, departments) is None + + +def test_resolve_dept_bucket_walks_path_to_nearest_dept(): + company = SimpleNamespace(id=1, path="/1/", org_level="company") + dept = SimpleNamespace(id=2, path="/1/2/", org_level="dept") + office = SimpleNamespace(id=3, path="/1/2/3/", org_level="office") + departments = {1: company, 2: dept, 3: office} + primary = SimpleNamespace(id=3, path="/1/2/3/") + assert resolve_dept_bucket_id(primary, departments) == 2 + + +def test_resolve_dept_bucket_none_when_unlabeled(): + """AC-22: 无向上 dept 标签时不进部门榜。""" + company = SimpleNamespace(id=1, path="/1/", org_level="company") + leaf = SimpleNamespace(id=9, path="/1/9/", org_level=None) + departments = {1: company, 9: leaf} + primary = SimpleNamespace(id=9, path="/1/9/") + assert resolve_dept_bucket_id(primary, departments) is None + assert resolve_dept_bucket_id(None, departments) is None + + +def test_build_ranked_rows_dense_ties_and_excludes_admins(): + """同分稠密同名次:100,100,50 → rank 1,1,2;列表序按 user_id。""" + refreshed = datetime(2026, 8, 7, 5, 0) + rows = build_ranked_rows( + tenant_id=1, + period="month", + scope="global", + scope_id=100, + period_key="2026-08", + scores={10: 100, 11: 100, 12: 50, 99: 999}, + balances={10: 200, 11: 150, 12: 50, 99: 999}, + dept_ids={10: 2, 11: 2, 12: None, 99: 2}, + exclude_user_ids={99}, + refreshed_at=refreshed, + ) + assert [r.user_id for r in rows] == [10, 11, 12] + assert [r.rank_no for r in rows] == [1, 1, 2] + assert rows[0].period_score == 100 + assert rows[0].scope_id == 100 + assert rows[2].dept_id is None + + +@pytest.mark.asyncio +async def test_refresh_scopes_by_company_and_trims_inactive(): + """月/年不补 0 分账户;总榜按 lifetime_earned>0;按公司写 global/dept 桶。""" + accounts = [ + SimpleNamespace(user_id=1, balance=100, lifetime_earned=120), + SimpleNamespace(user_id=2, balance=0, lifetime_earned=40), + SimpleNamespace(user_id=3, balance=50, lifetime_earned=0), + SimpleNamespace(user_id=4, balance=80, lifetime_earned=90), + ] + # user1/2 公司 A;user4 公司 B;user3 无公司且无本月流水 + month_scores = {1: 30, 2: 0, 4: 20} + year_scores = {1: 80, 2: 5, 4: 40} + + inserted: list[list] = [] + cleared: list[tuple] = [] + + repo = SimpleNamespace( + list_accounts=AsyncMock(return_value=accounts), + sum_deltas_by_user=AsyncMock(side_effect=[month_scores, year_scores]), + clear_period_rank_snapshots=AsyncMock( + side_effect=lambda *a, **_k: cleared.append(a) or None + ), + bulk_insert_rank_snapshots=AsyncMock( + side_effect=lambda rows: inserted.append(list(rows)) or len(rows) + ), + ) + + with ( + patch.object(PointsRankService, "_load_super_admin_ids", AsyncMock(return_value=set())), + patch.object( + PointsRankService, + "_load_company_and_dept_buckets", + AsyncMock( + return_value=( + {1: 100, 2: 100, 3: None, 4: 200}, + {1: 10, 2: 10, 3: None, 4: 20}, + ) + ), + ), + ): + out = await PointsRankService(repository=repo)._refresh_with_repo(repo, 1) + + assert out["rows"] > 0 + assert out["companies"] == 2 + assert len(cleared) == 3 # month / year / all + assert len(inserted) == 3 + + month_rows = inserted[0] + month_global_a = [r for r in month_rows if r.scope == "global" and r.scope_id == 100] + month_global_b = [r for r in month_rows if r.scope == "global" and r.scope_id == 200] + assert {r.user_id for r in month_global_a} == {1, 2} + assert {r.user_id for r in month_global_b} == {4} + assert all(r.user_id != 3 for r in month_rows) + + all_rows = inserted[2] + all_global_a = {r.user_id: r.period_score for r in all_rows if r.scope == "global" and r.scope_id == 100} + all_global_b = {r.user_id: r.period_score for r in all_rows if r.scope == "global" and r.scope_id == 200} + assert all_global_a == {1: 120, 2: 40} + assert all_global_b == {4: 90} + + +@pytest.mark.asyncio +async def test_refresh_no_company_writes_empty_period_batches(): + """全员无公司时仍清桶,但各 period 插入空列表。""" + accounts = [SimpleNamespace(user_id=1, balance=100, lifetime_earned=120)] + inserted: list[list] = [] + repo = SimpleNamespace( + list_accounts=AsyncMock(return_value=accounts), + sum_deltas_by_user=AsyncMock(side_effect=[{1: 10}, {1: 20}]), + clear_period_rank_snapshots=AsyncMock(), + bulk_insert_rank_snapshots=AsyncMock( + side_effect=lambda rows: inserted.append(list(rows)) or len(rows) + ), + ) + with ( + patch.object(PointsRankService, "_load_super_admin_ids", AsyncMock(return_value=set())), + patch.object( + PointsRankService, + "_load_company_and_dept_buckets", + AsyncMock(return_value=({1: None}, {1: None})), + ), + ): + out = await PointsRankService(repository=repo)._refresh_with_repo(repo, 1) + + assert out["companies"] == 0 + assert all(len(batch) == 0 for batch in inserted) diff --git a/src/backend/test/points/test_points_reconcile_and_outbox.py b/src/backend/test/points/test_points_reconcile_and_outbox.py new file mode 100644 index 000000000..694fdf53b --- /dev/null +++ b/src/backend/test/points/test_points_reconcile_and_outbox.py @@ -0,0 +1,168 @@ +"""对账与 sync outbox drain。""" + +from contextlib import contextmanager +from types import SimpleNamespace +from unittest.mock import patch + +import pytest + +from bisheng.points.domain.services.points_reconcile_service import PointsReconcileService +from bisheng.points.domain.services.points_sync_outbox_service import PointsSyncOutboxService + + +@pytest.mark.asyncio +async def test_reconcile_reports_mismatch_without_mutating(): + service = PointsReconcileService() + accounts = [SimpleNamespace(user_id=1, balance=10), SimpleNamespace(user_id=2, balance=5)] + + class _Repo: + async def list_accounts(self, _tenant_id): + return accounts + + async def sum_lifetime_deltas_by_user(self, _tenant_id): + return {1: 10, 2: 9} + + class _Session: + async def __aenter__(self): + return self + + async def __aexit__(self, *_): + return False + + with ( + patch( + "bisheng.points.domain.services.points_reconcile_service.get_async_db_session", + return_value=_Session(), + ), + patch( + "bisheng.points.domain.services.points_reconcile_service.PointsRepository", + return_value=_Repo(), + ), + ): + out = await service.reconcile_tenant(1) + + assert out["checked"] == 2 + assert out["mismatches"] == 1 + assert out["details"][0]["user_id"] == 2 + assert out["details"][0]["ledger_sum"] == 9 + + +@pytest.mark.asyncio +async def test_outbox_drain_keeps_pending_when_disabled(): + service = PointsSyncOutboxService() + fake_settings = SimpleNamespace(points=SimpleNamespace(sync_outbox_enabled=False)) + with patch("bisheng.common.services.config_service.settings", fake_settings): + out = await service.drain() + assert out["skipped"] is True + assert out["reason"] == "sync_outbox_disabled" + + +@pytest.mark.asyncio +async def test_outbox_drain_marks_skipped_without_adapter(): + row = SimpleNamespace( + id=1, + status="pending", + retry_count=0, + next_retry_at=None, + last_error=None, + sent_at=None, + payload={"log_id": 1}, + ) + + class _Repo: + async def list_due_sync_outbox(self, *, limit=100, now=None): + return [row] + + async def save_outbox(self, item): + return item + + class _Session: + async def __aenter__(self): + return self + + async def __aexit__(self, *_): + return False + + async def commit(self): + return None + + @contextmanager + def _bypass(): + yield None + + fake_settings = SimpleNamespace(points=SimpleNamespace(sync_outbox_enabled=True)) + service = PointsSyncOutboxService() + with ( + patch("bisheng.common.services.config_service.settings", fake_settings), + patch("bisheng.core.context.tenant.bypass_tenant_filter", _bypass), + patch( + "bisheng.points.domain.services.points_sync_outbox_service.get_async_db_session", + return_value=_Session(), + ), + patch( + "bisheng.points.domain.services.points_sync_outbox_service.PointsRepository", + return_value=_Repo(), + ), + ): + out = await service.drain() + + assert out["processed"] == 1 + assert out["skipped"] == 1 + assert row.status == "skipped" + + +@pytest.mark.asyncio +async def test_outbox_deliver_success_marks_sent(): + row = SimpleNamespace( + id=2, + status="pending", + retry_count=0, + next_retry_at=None, + last_error=None, + sent_at=None, + payload={"log_id": 2}, + ) + + class _Repo: + async def list_due_sync_outbox(self, *, limit=100, now=None): + return [row] + + async def save_outbox(self, item): + return item + + class _Session: + async def __aenter__(self): + return self + + async def __aexit__(self, *_): + return False + + async def commit(self): + return None + + @contextmanager + def _bypass(): + yield None + + async def ok_deliver(_row): + return True + + fake_settings = SimpleNamespace(points=SimpleNamespace(sync_outbox_enabled=True)) + service = PointsSyncOutboxService(deliver=ok_deliver) + with ( + patch("bisheng.common.services.config_service.settings", fake_settings), + patch("bisheng.core.context.tenant.bypass_tenant_filter", _bypass), + patch( + "bisheng.points.domain.services.points_sync_outbox_service.get_async_db_session", + return_value=_Session(), + ), + patch( + "bisheng.points.domain.services.points_sync_outbox_service.PointsRepository", + return_value=_Repo(), + ), + ): + out = await service.drain() + + assert out["sent"] == 1 + assert row.status == "sent" + assert row.sent_at is not None diff --git a/src/backend/test/qa_expert/test_adopt_answer_limit.py b/src/backend/test/qa_expert/test_adopt_answer_limit.py new file mode 100644 index 000000000..bbfa32f6c --- /dev/null +++ b/src/backend/test/qa_expert/test_adopt_answer_limit.py @@ -0,0 +1,142 @@ +"""同题最多 3 个最佳答案:上限拦截、已采纳幂等、软删不计上限。""" + +from types import SimpleNamespace +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +from bisheng.qa_expert.domain.services import ( + AdoptLimitExceededError, + MAX_ADOPTED_ANSWERS_PER_QUESTION, + QuestionService, +) + + +def _question(*, question_id: int = 100, user_id: int = 1) -> SimpleNamespace: + return SimpleNamespace( + id=question_id, + user_id=user_id, + adopted_answer_id=None, + status=0, + ) + + +def _answer( + *, + answer_id: int, + question_id: int = 100, + expert_id: int = 5, + adopted: bool = False, + status: int = 1, +) -> SimpleNamespace: + return SimpleNamespace( + id=answer_id, + question_id=question_id, + expert_id=expert_id, + adopted=adopted, + status=status, + ) + + +def _service() -> QuestionService: + svc = QuestionService() + svc.repository = MagicMock() + svc.answer_repo = MagicMock() + svc.expert_repo = MagicMock() + svc._send_adoption_notification = AsyncMock() + return svc + + +@pytest.mark.asyncio +async def test_adopt_first_three_succeeds(): + svc = _service() + q = _question() + svc.repository.get_by_id = AsyncMock(return_value=q) + svc.repository.update = AsyncMock(return_value=q) + svc.answer_repo.update = AsyncMock() + svc.expert_repo.increment_adoption_count = AsyncMock() + svc.expert_repo.get_by_id = AsyncMock(return_value=SimpleNamespace(id=5, user_id=55)) + + with patch( + "bisheng.points.domain.services.points_award_hooks.notify_answer_adopted", + new_callable=AsyncMock, + ) as notify: + for idx, answer_id in enumerate((11, 12, 13), start=0): + svc.answer_repo.get_by_id = AsyncMock( + return_value=_answer(answer_id=answer_id, adopted=False) + ) + svc.answer_repo.count_adopted_by_question_id = AsyncMock(return_value=idx) + result = await svc.adopt_answer(100, answer_id, operator_id=1) + assert result is q + + assert svc.expert_repo.increment_adoption_count.await_count == 3 + assert notify.await_count == 3 + + +@pytest.mark.asyncio +async def test_adopt_fourth_raises_limit(): + svc = _service() + svc.repository.get_by_id = AsyncMock(return_value=_question()) + svc.answer_repo.get_by_id = AsyncMock(return_value=_answer(answer_id=14, adopted=False)) + svc.answer_repo.count_adopted_by_question_id = AsyncMock( + return_value=MAX_ADOPTED_ANSWERS_PER_QUESTION + ) + svc.repository.update = AsyncMock() + svc.answer_repo.update = AsyncMock() + svc.expert_repo.increment_adoption_count = AsyncMock() + + with pytest.raises(AdoptLimitExceededError): + await svc.adopt_answer(100, 14, operator_id=1) + + svc.repository.update.assert_not_awaited() + svc.expert_repo.increment_adoption_count.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_already_adopted_is_idempotent(): + svc = _service() + q = _question(question_id=100) + svc.repository.get_by_id = AsyncMock(return_value=q) + svc.answer_repo.get_by_id = AsyncMock( + return_value=_answer(answer_id=11, adopted=True, status=1) + ) + svc.answer_repo.count_adopted_by_question_id = AsyncMock() + svc.repository.update = AsyncMock() + svc.answer_repo.update = AsyncMock() + svc.expert_repo.increment_adoption_count = AsyncMock() + + with patch( + "bisheng.points.domain.services.points_award_hooks.notify_answer_adopted", + new_callable=AsyncMock, + ) as notify: + result = await svc.adopt_answer(100, 11, operator_id=1) + + assert result is q + svc.answer_repo.count_adopted_by_question_id.assert_not_awaited() + svc.expert_repo.increment_adoption_count.assert_not_awaited() + svc._send_adoption_notification.assert_not_awaited() + notify.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_soft_deleted_adopted_not_counted_in_limit_path(): + """软删回答不计上限:count 由仓储过滤;此处验证 count=2 时仍可采纳第 3 条。""" + svc = _service() + q = _question() + svc.repository.get_by_id = AsyncMock(return_value=q) + svc.repository.update = AsyncMock(return_value=q) + svc.answer_repo.get_by_id = AsyncMock(return_value=_answer(answer_id=20, adopted=False)) + # 仓储已排除 status=3,故返回 2(含 1 条软删场景下真实未删除已采纳数) + svc.answer_repo.count_adopted_by_question_id = AsyncMock(return_value=2) + svc.answer_repo.update = AsyncMock() + svc.expert_repo.increment_adoption_count = AsyncMock() + svc.expert_repo.get_by_id = AsyncMock(return_value=SimpleNamespace(id=5, user_id=55)) + + with patch( + "bisheng.points.domain.services.points_award_hooks.notify_answer_adopted", + new_callable=AsyncMock, + ): + await svc.adopt_answer(100, 20, operator_id=1) + + svc.repository.update.assert_awaited_once() + svc.expert_repo.increment_adoption_count.assert_awaited_once() diff --git a/src/backend/test/qa_expert/test_moderate_delete_answer.py b/src/backend/test/qa_expert/test_moderate_delete_answer.py new file mode 100644 index 000000000..f0914336b --- /dev/null +++ b/src/backend/test/qa_expert/test_moderate_delete_answer.py @@ -0,0 +1,182 @@ +"""平台超管违规删除回答:作者解析、软删、级联评论、采纳指针与可选扣分。""" + +from types import SimpleNamespace +from unittest.mock import AsyncMock, MagicMock + +import pytest +from pydantic import ValidationError + +from bisheng.common.errcode.points import PointsPermissionDeniedError +from bisheng.qa_expert.domain.moderate_delete_service import ModerateDeleteService +from bisheng.qa_expert.domain.schemas import ModerateDeleteRequest +from bisheng.qa_expert.domain.services import AnswerNotFoundError, ExpertNotFoundError + + +def _admin_user(user_id: int = 1): + return SimpleNamespace(user_id=user_id, is_global_super=True, is_admin=lambda: True) + + +def _normal_user(user_id: int = 2): + return SimpleNamespace(user_id=user_id, is_global_super=False, is_admin=lambda: False) + + +def _service_with_mocks() -> tuple[ModerateDeleteService, MagicMock]: + """构造 service 并替换仓储/扣分依赖为 AsyncMock。""" + svc = ModerateDeleteService() + svc.question_repo = MagicMock() + svc.answer_repo = MagicMock() + svc.comment_repo = MagicMock() + svc.expert_repo = MagicMock() + svc.pending_deduct = MagicMock() + return svc, svc.pending_deduct + + +def test_schema_accepts_answer_target_type(): + req = ModerateDeleteRequest(target_type="answer", target_id=9) + assert req.target_type == "answer" + + +def test_schema_rejects_unknown_target_type(): + with pytest.raises(ValidationError): + ModerateDeleteRequest(target_type="vote", target_id=1) + + +@pytest.mark.asyncio +async def test_non_admin_rejected(): + svc, _ = _service_with_mocks() + with pytest.raises(PointsPermissionDeniedError): + await svc.moderate_delete( + operator=_normal_user(), + target_type="answer", + target_id=1, + ) + + +@pytest.mark.asyncio +async def test_delete_answer_no_rule_cascades_and_clears_adopted(): + svc, pending = _service_with_mocks() + svc.answer_repo.get_by_id = AsyncMock( + return_value=SimpleNamespace( + id=10, + question_id=100, + expert_id=5, + status=2, + ) + ) + svc.expert_repo.get_by_id = AsyncMock( + return_value=SimpleNamespace(id=5, user_id=55) + ) + svc.answer_repo.delete = AsyncMock(return_value=True) + svc.comment_repo.delete_by_answer_id = AsyncMock(return_value=3) + svc.question_repo.get_by_id = AsyncMock( + return_value=SimpleNamespace( + id=100, + answer_count=2, + adopted_answer_id=10, + status=1, + ) + ) + svc.question_repo.update = AsyncMock(return_value=True) + + result = await svc.moderate_delete( + operator=_admin_user(), + target_type="answer", + target_id=10, + rule_code=None, + ) + + assert result.deleted is True + assert result.target_type == "answer" + assert result.target_user_id == 55 + assert result.deducted is False + assert result.reason == "no_rule" + svc.answer_repo.delete.assert_awaited_once_with(10) + svc.comment_repo.delete_by_answer_id.assert_awaited_once_with(10) + svc.question_repo.update.assert_awaited_once_with( + 100, + answer_count=1, + adopted_answer_id=None, + status=0, + ) + pending.deduct_or_enqueue.assert_not_called() + + +@pytest.mark.asyncio +async def test_delete_answer_with_rule_uses_qa_answer_biz_type(): + svc, pending = _service_with_mocks() + svc.answer_repo.get_by_id = AsyncMock( + return_value=SimpleNamespace( + id=11, + question_id=101, + expert_id=6, + status=1, + ) + ) + svc.expert_repo.get_by_id = AsyncMock( + return_value=SimpleNamespace(id=6, user_id=66) + ) + svc.answer_repo.delete = AsyncMock(return_value=True) + svc.comment_repo.delete_by_answer_id = AsyncMock(return_value=0) + svc.question_repo.get_by_id = AsyncMock( + return_value=SimpleNamespace( + id=101, + answer_count=1, + adopted_answer_id=None, + status=0, + ) + ) + svc.question_repo.update = AsyncMock(return_value=True) + pending.deduct_or_enqueue = AsyncMock( + return_value=SimpleNamespace(applied=True, pending=False, reason=None) + ) + + result = await svc.moderate_delete( + operator=_admin_user(user_id=9), + target_type="answer", + target_id=11, + rule_code="r1", + remark="违规", + ) + + assert result.deducted is True + assert result.pending_deduct is False + pending.deduct_or_enqueue.assert_awaited_once() + kwargs = pending.deduct_or_enqueue.await_args.kwargs + assert kwargs["user_id"] == 66 + assert kwargs["rule_code"] == "R1" + assert kwargs["biz_type"] == "qa_answer" + assert kwargs["biz_id"] == "11" + assert kwargs["operator_id"] == 9 + assert kwargs["remark"] == "违规" + svc.question_repo.update.assert_awaited_once_with(101, answer_count=0) + + +@pytest.mark.asyncio +async def test_delete_missing_answer_raises(): + svc, _ = _service_with_mocks() + svc.answer_repo.get_by_id = AsyncMock(return_value=None) + with pytest.raises(AnswerNotFoundError): + await svc.moderate_delete( + operator=_admin_user(), + target_type="answer", + target_id=999, + ) + + +@pytest.mark.asyncio +async def test_delete_answer_without_expert_raises(): + svc, _ = _service_with_mocks() + svc.answer_repo.get_by_id = AsyncMock( + return_value=SimpleNamespace( + id=12, + question_id=102, + expert_id=None, + status=1, + ) + ) + with pytest.raises(ExpertNotFoundError): + await svc.moderate_delete( + operator=_admin_user(), + target_type="answer", + target_id=12, + ) diff --git a/src/frontend/platform/public/locales/en-US/bs.json b/src/frontend/platform/public/locales/en-US/bs.json index bce93c7ab..613b441cb 100644 --- a/src/frontend/platform/public/locales/en-US/bs.json +++ b/src/frontend/platform/public/locales/en-US/bs.json @@ -2511,6 +2511,19 @@ "department.archivedNotice": "This department has been archived and cannot be edited. You may permanently delete it.", "department.archivedTag": "(Archived)", "department.sectionBasic": "Basic information", + "department.sectionOrgLevel": "Organization level labels", + "department.orgLevelLabel": "Current level", + "department.orgLevelUnset": "Unlabeled", + "department.orgLevelClear": "Clear", + "department.orgLevelCurrent": "Level label: {{level}}", + "department.orgLevelBadgeTitle": "Department leaderboard bucket", + "department.orgLevel.company": "Company", + "department.orgLevel.dept": "Department", + "department.orgLevel.office": "Office", + "department.orgLevel.squad": "Squad", + "department.orgLevelHint": "The current-level dropdown only offers Company; choose or clear it, then click Save below to apply. Multiple sibling company roots are allowed; nested companies inside another company subtree are not. After setting a company root, labels cascade by depth (company → department → office → squad) for company/department leaderboard membership. Department topology and user memberships are unchanged.", + "department.setCompanyRootDisabledUnderSubtree": "This node is already labeled under a company subtree and cannot be set as a company; clear the company root first if you need to change it.", + "department.setCompanyRootDisabledOtherCompany": "This node is under another company subtree and cannot be set as a company.", "department.sectionPermissions": "Permissions & roles", "department.adminSelectPlaceholder": "Search and select department admins", "department.searchUsersPlaceholder": "Search by username…", diff --git a/src/frontend/platform/public/locales/ja/bs.json b/src/frontend/platform/public/locales/ja/bs.json index a14bf580e..536eac1e0 100644 --- a/src/frontend/platform/public/locales/ja/bs.json +++ b/src/frontend/platform/public/locales/ja/bs.json @@ -2461,6 +2461,19 @@ "department.archivedNotice": "この部門はアーカイブ済みのため編集できません。完全に削除することができます。", "department.archivedTag": "(アーカイブ済み)", "department.sectionBasic": "基本情報", + "department.sectionOrgLevel": "組織階層ラベル", + "department.orgLevelLabel": "現在の階層", + "department.orgLevelUnset": "未ラベル", + "department.orgLevelClear": "クリア", + "department.orgLevelCurrent": "階層ラベル:{{level}}", + "department.orgLevelBadgeTitle": "部門ランキングの帰属基準", + "department.orgLevel.company": "会社", + "department.orgLevel.dept": "部門", + "department.orgLevel.office": "科室", + "department.orgLevel.squad": "班", + "department.orgLevelHint": "現在の階層ドロップダウンで選べるのは「会社」のみです。選択またはクリア後、下の「保存」で反映します。同階層に複数の会社ルートを設定でき、各サブツリーは独立します。既存の会社配下に会社を入れ子にすることはできません。会社設定後、相対深さでラベルを自動付与します(会社→部門→科室→班)。部門ツリーとユーザー所属は変更しません。", + "department.setCompanyRootDisabledUnderSubtree": "この組織は既に会社配下として自動ラベル付けされており、会社として設定できません。変更する場合は会社ルートでクリアしてください。", + "department.setCompanyRootDisabledOtherCompany": "このノードは他の会社サブツリー内にあり、会社として設定できません。", "department.sectionPermissions": "権限とロール", "department.adminSelectPlaceholder": "検索して部門管理者を選択", "department.searchUsersPlaceholder": "ユーザー名で検索…", diff --git a/src/frontend/platform/public/locales/zh-Hans/bs.json b/src/frontend/platform/public/locales/zh-Hans/bs.json index e658bbffe..d8afb5b57 100644 --- a/src/frontend/platform/public/locales/zh-Hans/bs.json +++ b/src/frontend/platform/public/locales/zh-Hans/bs.json @@ -2461,6 +2461,19 @@ "department.archivedNotice": "该部门已归档,无法编辑。可选择永久删除。", "department.archivedTag": "(已归档)", "department.sectionBasic": "基础信息", + "department.sectionOrgLevel": "组织层级标签", + "department.orgLevelLabel": "当前层级", + "department.orgLevelUnset": "未打标", + "department.orgLevelClear": "清除", + "department.orgLevelCurrent": "层级标签:{{level}}", + "department.orgLevelBadgeTitle": "部门榜归属依据", + "department.orgLevel.company": "公司", + "department.orgLevel.dept": "部门", + "department.orgLevel.office": "科室", + "department.orgLevel.squad": "班组", + "department.orgLevelHint": "当前层级下拉仅可选「公司」;选定或清除后需点击下方「保存」才生效。可设置多个同级公司,各自子树互不影响;禁止在已有公司子树内再设公司。设为公司后按相对深度自动级联标注(公司→部门→科室→班组),用于积分榜公司/部门归属。不修改部门树拓扑与用户挂载。", + "department.setCompanyRootDisabledUnderSubtree": "该组织已在公司子树下自动标注,不可单独设置公司;请到公司根节点清除后再调整。", + "department.setCompanyRootDisabledOtherCompany": "该节点位于其他公司子树内,不可再设为公司。", "department.sectionPermissions": "权限与角色", "department.adminSelectPlaceholder": "搜索并选择部门管理员", "department.searchUsersPlaceholder": "搜索用户名…", diff --git a/src/frontend/platform/src/controllers/API/department.ts b/src/frontend/platform/src/controllers/API/department.ts index 6d717ee1e..2202886dc 100644 --- a/src/frontend/platform/src/controllers/API/department.ts +++ b/src/frontend/platform/src/controllers/API/department.ts @@ -79,6 +79,53 @@ export async function unmountTenantApi( return await axios.delete(`/api/v1/departments/${deptIdInt}/mount-tenant`) } +// ── Points org_level (F070) ───────────────────────────── + +export type DepartmentOrgLevel = "company" | "dept" | "office" | "squad" + +export interface DepartmentOrgLevelItem { + id: number + dept_id: string | null + name: string | null + parent_id: number | null + path: string | null + org_level: DepartmentOrgLevel | null +} + +export interface SetCompanyRootResult { + company_id: number + labeled_count: number + levels: Record +} + +export interface ClearCompanyRootResult { + cleared_count: number +} + +/** 列出当前租户活跃部门的组织层级标签。 */ +export async function getDepartmentOrgLevelsApi(): Promise { + return await axios.get(`/api/v1/departments/org-levels`) +} + +/** + * 指定唯一公司根并级联打标。 + * @param deptId 业务部门 ID(可含 BS@) + */ +export async function setDepartmentCompanyRootApi( + deptId: string +): Promise { + return await axios.post(`/api/v1/departments/${depSeg(deptId)}/set-company-root`, { + confirm: true, + }) +} + +/** 取消公司根并清空本租户组织层级标签。 */ +export async function clearDepartmentCompanyRootApi( + deptId: string +): Promise { + return await axios.post(`/api/v1/departments/${depSeg(deptId)}/clear-company-root`) +} + // ── Move ─────────────────────────────────────────────── export async function moveDepartmentApi( diff --git a/src/frontend/platform/src/pages/DepartmentPage/components/DepartmentSettings.tsx b/src/frontend/platform/src/pages/DepartmentPage/components/DepartmentSettings.tsx index e5b37e336..3d8aaab96 100644 --- a/src/frontend/platform/src/pages/DepartmentPage/components/DepartmentSettings.tsx +++ b/src/frontend/platform/src/pages/DepartmentPage/components/DepartmentSettings.tsx @@ -4,6 +4,13 @@ import { Checkbox } from "@/components/bs-ui/checkBox" import { Label } from "@/components/bs-ui/label" import MultiSelect from "@/components/bs-ui/select/multi" import { Separator } from "@/components/bs-ui/separator" +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/bs-ui/select" import { QuestionTooltip } from "@/components/bs-ui/tooltip" import { toast } from "@/components/bs-ui/toast/use-toast" import DepartmentUsersSelect, { @@ -17,6 +24,8 @@ import { moveDepartmentApi, purgeDepartmentApi, restoreDepartmentApi, + setDepartmentCompanyRootApi, + clearDepartmentCompanyRootApi, unmountTenantApi, updateDepartmentApi, } from "@/controllers/API/department" @@ -30,7 +39,7 @@ import type { DepartmentUpdateForm, } from "@/types/api/department" import { Building2 } from "lucide-react" -import { useCallback, useEffect, useRef, useState } from "react" +import { useCallback, useEffect, useMemo, useRef, useState } from "react" import { useTranslation } from "react-i18next" interface DepartmentSettingsProps { @@ -39,6 +48,12 @@ interface DepartmentSettingsProps { onChanged: (removedDeptId?: string) => void /** Open the "mark as Child Tenant" dialog when this action is available. */ onMarkAsTenant?: (deptId: number, deptName: string) => void + /** F070 当前节点 org_level;null 表示未打标(未落入任何公司子树) */ + orgLevel?: string | null + /** 仅平台超管可设公司根 */ + canSetCompanyRoot?: boolean + /** 打标成功后刷新树侧徽章 */ + onOrgLevelChanged?: () => void } function adminsToOptions(admins: DepartmentAdmin[]): DepartmentUserOption[] { @@ -54,7 +69,24 @@ function sameIdSet(left: Array, right: Array): /** 企业级表单:统一控件最大宽度,右侧对齐 */ const FORM_CONTROL_WIDTH = "w-full max-w-md" -export function DepartmentSettings({ dept, tree, onChanged, onMarkAsTenant }: DepartmentSettingsProps) { +/** Radix Select 不接受空串;用哨兵表示「未设置/取消公司」。 */ +const ORG_LEVEL_UNSET = "__unset__" + +type OrgLevelDraft = "company" | typeof ORG_LEVEL_UNSET + +function toOrgLevelDraft(level: string | null | undefined): OrgLevelDraft { + return level === "company" ? "company" : ORG_LEVEL_UNSET +} + +export function DepartmentSettings({ + dept, + tree, + onChanged, + onMarkAsTenant, + orgLevel = null, + canSetCompanyRoot = false, + onOrgLevelChanged, +}: DepartmentSettingsProps) { const { t } = useTranslation() const [name, setName] = useState(dept.name) const [shortName, setShortName] = useState("") @@ -67,6 +99,8 @@ export function DepartmentSettings({ dept, tree, onChanged, onMarkAsTenant }: De const [parentIdValue, setParentIdValue] = useState(dept.parent_id ?? null) const [parentTreeNodes, setParentTreeNodes] = useState([]) const [isDefaultRootDept, setIsDefaultRootDept] = useState(Boolean(dept.is_default_root)) + const [orgLevelDraft, setOrgLevelDraft] = useState(() => toOrgLevelDraft(orgLevel)) + const adminSelectValueRef = useRef([]) const isSynced = isSyncedSource(dept.source) @@ -77,6 +111,22 @@ export function DepartmentSettings({ dept, tree, onChanged, onMarkAsTenant }: De const canEditPermissions = !isArchived const canEditParent = !isArchived && !isDefaultRootDept + /** + * 可编辑:未落入任何公司子树(未打标),或当前就是公司根(可清除)。 + * 同级可另设公司;子树内自动标注节点只读。 + */ + const canEditOrgLevel = useMemo(() => { + if (!canSetCompanyRoot) return false + if (orgLevel === "company") return true + return orgLevel == null + }, [canSetCompanyRoot, orgLevel]) + + const orgLevelDisabledReason = useMemo(() => { + if (!canSetCompanyRoot || canEditOrgLevel) return null + if (orgLevel != null && orgLevel !== "company") return "underSubtree" as const + return null + }, [canEditOrgLevel, canSetCompanyRoot, orgLevel]) + /** 最近一次从服务端加载成功的快照(父部门变更判断、保存后更新) */ const baselineRef = useRef<{ name: string @@ -84,12 +134,22 @@ export function DepartmentSettings({ dept, tree, onChanged, onMarkAsTenant }: De admins: DepartmentUserOption[] defaultRoleIds: string[] parentId: number | null + orgLevelDraft: OrgLevelDraft } | null>(null) useEffect(() => { adminSelectValueRef.current = adminSelectValue }, [adminSelectValue]) + // 切换部门或服务端标签刷新时,重置草稿并写入 baseline。 + useEffect(() => { + const draft = toOrgLevelDraft(orgLevel) + setOrgLevelDraft(draft) + if (baselineRef.current) { + baselineRef.current = { ...baselineRef.current, orgLevelDraft: draft } + } + }, [dept.dept_id, orgLevel]) + const gatherSubtreeIds = useCallback((node: DepartmentTreeNode | null): Set => { const ids = new Set() if (!node) return ids @@ -169,6 +229,7 @@ export function DepartmentSettings({ dept, tree, onChanged, onMarkAsTenant }: De admins: adminOpts, defaultRoleIds: dr, parentId: pid, + orgLevelDraft: toOrgLevelDraft(orgLevel), } }) .catch(() => { @@ -186,6 +247,7 @@ export function DepartmentSettings({ dept, tree, onChanged, onMarkAsTenant }: De return () => { cancelled = true } + // orgLevel 草稿由下方独立 effect 同步,避免标签刷新时整表重载。 }, [buildParentTreeNodes, dept.dept_id, dept.name, dept.parent_id, t, tree]) const restoreBaseline = useCallback((b = baselineRef.current) => { @@ -196,6 +258,7 @@ export function DepartmentSettings({ dept, tree, onChanged, onMarkAsTenant }: De adminSelectValueRef.current = b.admins setDefaultRoleIds(b.defaultRoleIds) setParentIdValue(b.parentId) + setOrgLevelDraft(b.orgLevelDraft) }, []) const hasUnsavedSettingsChanges = useCallback(() => { @@ -204,6 +267,7 @@ export function DepartmentSettings({ dept, tree, onChanged, onMarkAsTenant }: De if (canEditName && name !== b.name) return true if (canEditShortName && shortName !== b.shortName) return true if (canEditParent && parentIdValue !== b.parentId) return true + if (canEditOrgLevel && orgLevelDraft !== b.orgLevelDraft) return true if ( !sameIdSet( adminSelectValue.map((o) => o.value), @@ -215,10 +279,12 @@ export function DepartmentSettings({ dept, tree, onChanged, onMarkAsTenant }: De }, [ adminSelectValue, canEditName, + canEditOrgLevel, canEditParent, canEditShortName, defaultRoleIds, name, + orgLevelDraft, parentIdValue, shortName, ]) @@ -285,8 +351,10 @@ export function DepartmentSettings({ dept, tree, onChanged, onMarkAsTenant }: De baseline && nextParentId !== null && nextParentId !== baseline.parentId + const orgLevelChanged = + canEditOrgLevel && !!baseline && orgLevelDraft !== baseline.orgLevelDraft - if (!parentChanged && Object.keys(body).length === 0) { + if (!parentChanged && Object.keys(body).length === 0 && !orgLevelChanged) { return } @@ -296,10 +364,31 @@ export function DepartmentSettings({ dept, tree, onChanged, onMarkAsTenant }: De ) if (moveRes === null || moveRes === false) return } - const res = await captureAndAlertRequestErrorHoc( - updateDepartmentApi(dept.dept_id, body) - ) - if (res === null || res === false) return + if (Object.keys(body).length > 0) { + const res = await captureAndAlertRequestErrorHoc( + updateDepartmentApi(dept.dept_id, body) + ) + if (res === null || res === false) return + } else if (parentChanged && !orgLevelChanged) { + // 仅移动父部门时无需再打 update。 + } + + // 组织层级走独立 API;与基础字段同一次「保存」提交,避免单独设公司按钮。 + if (orgLevelChanged) { + if (orgLevelDraft === "company") { + const labelRes = await captureAndAlertRequestErrorHoc( + setDepartmentCompanyRootApi(dept.dept_id) + ) + if (labelRes === null || labelRes === false) return + } else if (baseline?.orgLevelDraft === "company") { + const clearRes = await captureAndAlertRequestErrorHoc( + clearDepartmentCompanyRootApi(dept.dept_id) + ) + if (clearRes === null || clearRes === false) return + } + onOrgLevelChanged?.() + } + toast({ title: t("prompt"), description: t("saved"), @@ -319,6 +408,7 @@ export function DepartmentSettings({ dept, tree, onChanged, onMarkAsTenant }: De admins: adminOpts, defaultRoleIds: [...defaultRoleIds], parentId: nextParentId ?? baselineRef.current?.parentId ?? dept.parent_id ?? null, + orgLevelDraft, } onChanged() } finally { @@ -327,6 +417,7 @@ export function DepartmentSettings({ dept, tree, onChanged, onMarkAsTenant }: De }, [ adminSelectValue, canEditName, + canEditOrgLevel, canEditParent, canEditPermissions, canEditShortName, @@ -336,6 +427,8 @@ export function DepartmentSettings({ dept, tree, onChanged, onMarkAsTenant }: De dept.parent_id, name, onChanged, + onOrgLevelChanged, + orgLevelDraft, parentIdValue, shortName, t, @@ -429,6 +522,75 @@ export function DepartmentSettings({ dept, tree, onChanged, onMarkAsTenant }: De onParentChange={setParentIdValue} /> + {/* 区块:组织层级标签(不改拓扑/挂载;可编辑时经底部保存提交) */} + {!isArchived && ( +
+
+

+ {t("bs:department.sectionOrgLevel")} +

+ +
+

+ {t("bs:department.orgLevelHint")} +

+
+
+ + {orgLevelDisabledReason ? ( + + ) : null} +
+ {canEditOrgLevel ? ( +
+ + {orgLevelDraft === "company" ? ( + + ) : null} +
+ ) : ( + + )} +
+
+ )} + {/* 区块二:权限与角色 */} {!isArchived && (
diff --git a/src/frontend/platform/src/pages/DepartmentPage/components/DepartmentTree.tsx b/src/frontend/platform/src/pages/DepartmentPage/components/DepartmentTree.tsx index 0852728d4..e635120be 100644 --- a/src/frontend/platform/src/pages/DepartmentPage/components/DepartmentTree.tsx +++ b/src/frontend/platform/src/pages/DepartmentPage/components/DepartmentTree.tsx @@ -25,6 +25,24 @@ interface DepartmentTreeProps { /** 父组件请求将某部门行滚入可视区域(如全组织搜索定位);requestId 递增可重复定位同一部门 */ scrollRequest?: { deptId: string; requestId: number } | null onScrollRequestHandled?: () => void + /** F070:部门 id → org_level,用于树行徽章 */ + orgLevelById?: Record +} + +/** org_level 徽章样式(积分部门榜用标签,不改拓扑)。 */ +function orgLevelBadgeClass(level: string | null | undefined): string { + switch (level) { + case "company": + return "bg-amber-100 text-amber-800 dark:bg-amber-950 dark:text-amber-200" + case "dept": + return "bg-sky-100 text-sky-800 dark:bg-sky-950 dark:text-sky-200" + case "office": + return "bg-violet-100 text-violet-800 dark:bg-violet-950 dark:text-violet-200" + case "squad": + return "bg-emerald-100 text-emerald-800 dark:bg-emerald-950 dark:text-emerald-200" + default: + return "bg-muted text-muted-foreground" + } } function findPathToDept( @@ -48,6 +66,7 @@ export function DepartmentTree({ onCreateChild, scrollRequest, onScrollRequestHandled, + orgLevelById, }: DepartmentTreeProps) { const { t } = useTranslation() const [keyword, setKeyword] = useState("") @@ -227,6 +246,17 @@ export function DepartmentTree({ {t("bs:tenant.mountedTag", { defaultValue: "子租户" })} )} + {orgLevelById?.[node.id] ? ( + + {t(`bs:department.orgLevel.${orgLevelById[node.id]}`)} + + ) : null} {/* Quick create child button — hidden for archived departments */} {!isArchived && (