Reapply "feat(points): 恢复 F070 积分系统与专家问答采纳/违规删除" (#2282)
This reverts commitd4c9ee2860, reversing changes made toa5e3b9bffe.
@@ -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
|
||||
|
||||
@@ -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.*: # 工作流相关任务
|
||||
|
||||
@@ -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.*: # 工作流相关任务
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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号00:00;止:本月最后一天24:00(若未到,按当前时间)) | 实时 |
|
||||
| 本月扣减 | 该用户本月累计扣减的积分数(起:本月1号00:00;止:本月最后一天24:00(若未到,按当前时间)) | 实时 |
|
||||
| 部门排名 | 同部门级内,按积分降序名次 | 每小时(需要显示最后更新时间) |
|
||||
| 总榜排名 | 全平台按积分降序名次(999后统一显示999+) | 每小时(需要显示最后更新时间) |
|
||||
|
||||
4.1.1 页面结构
|
||||
功能入口:在每个用户的账号下拉菜单中,如下图所示:
|
||||
|
||||

|
||||
|
||||
点击【我的积分】后打开页面如下图:
|
||||
|
||||

|
||||
|
||||
4.1.3 积分规则
|
||||
用户可点击【积分规则】,查看平台的积分规则说明,界面原型如下:
|
||||
|
||||

|
||||
|
||||
其中积分获取规则、积分扣减规则的内容取自后台积分规则配置的内容,最下方的5条说明文案也取自后台的配置。
|
||||
4.1.4 积分明细
|
||||
以列表形式展示当前登录用户所有的积分获取、扣减等明细,可按类型、时间范围组合筛选,可按时间排序,界面原型如下:
|
||||
|
||||

|
||||
|
||||
表格字段定义:
|
||||
|
||||
| 列名 | 数据类型 | 说明 | 示例 |
|
||||
| --- | --- | --- | --- |
|
||||
| 积分项 | 文本 | 对应规则名称 | 上传文档到公共库 |
|
||||
| 积分变动 | 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页面结构
|
||||
首页右侧最下方展示用户积分排行榜,包括本月、本年度、总榜三个时间维度,可切换展示不同时间维度下的积分排行信息,界面原型如下:
|
||||
|
||||

|
||||
|
||||
4.2.2榜单类型与规则
|
||||
榜单类型:
|
||||
|
||||
| Tab | 排序依据 | 更新频率 | 数据范围 | 表格积分列名<br>(最后一列) |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| 本月 | 当月累计积分降序 | 每小时 | 当月有积分变动的用户 | 本月积分 |
|
||||
| 本年度 | 当年累计积分降序 | 每小时 | 当年有积分变动的用户 | 年度积分 |
|
||||
| 总榜 | 历史总积分降序 | 每小时 | 全平台所有用户 | 当前积分 |
|
||||
|
||||
榜单列表定义:
|
||||
|
||||
| 列名 | 说明 | 数据类型 | 示例 |
|
||||
| --- | --- | --- | --- |
|
||||
| 排名 | 序号1~N,根据当前Tab排序 | int | 1 / 2 / 3 / ... / 15 |
|
||||
| 用户 | 用户姓名 | 文本 | 王五 |
|
||||
| 部门 | 用户所属部门级的组织 | 文本 | 质量管理部 |
|
||||
| 当前积分 | 该用户当前总积分(所有Tab都展示) | int | 4,120 |
|
||||
| 本月/年度 | 根据Tab动态切换列名和数值 | int | 本月:+450/年度:+3600 |
|
||||
|
||||
展示规则:
|
||||
榜单排名展示前10名,按从高到低排序;
|
||||
当前登录用户若在排行榜内,在姓名后加(我)进行标识,如“张三(我)”;
|
||||
数值千分位格式化展示(如18,600);
|
||||
无数据时展示“暂无排名数据”+引导文案“快去上传/发布知识赚取积分吧!”。
|
||||
视觉设计注意:总榜内,因为没有总积分这一列,所以当前积分这一列改到最右侧展示。
|
||||
4.3 积分管理后台(管理端)
|
||||
4.3.1页面结构
|
||||
平台管理员可在后台查看积分统计信息、配置积分规则、管理用户积分,界面原型如下:
|
||||
|
||||

|
||||
|
||||
4.3.2平台积分统计
|
||||
展示平台的总积分发放、当前有效可用积分、违规扣减积分,界面原型如下:
|
||||
|
||||

|
||||
|
||||
统计指标说明:
|
||||
|
||||
| 统计指标 | 指标说明 | 更新频率 |
|
||||
| --- | --- | --- |
|
||||
| 平台总积分发放 | 平台自上线以来,所有渠道发放出去积分的总和(只统计发放动作) | 5min实时 |
|
||||
| 当前有效可用总积分 | 截止统计时刻,所有用户账户内尚未使用、未过期、未冻结,可以正常支配的积分总和。(目前我们平台就是所有用户的总积分) | 5min实时 |
|
||||
| 违规扣减积分 | 平台自上线以来,所有渠道违规扣减积分的总和(只统计扣减动作) | 5min实时 |
|
||||
|
||||
4.3.3积分规则配置
|
||||
积分规则配置包含四个Tab:「积分获取规则」「积分扣减规则」「管理员奖励」「说明文案」。每个Tab下包含系统预置规则,同时支持平台管理员新增规则,界面原型如下:
|
||||
|
||||

|
||||
|
||||
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次不再加积分。
|
||||
规则列表界面原型:
|
||||
|
||||

|
||||
|
||||
新增/编辑原型界面:
|
||||
|
||||

|
||||
|
||||
新增/编辑表单定义:
|
||||
|
||||
| 字段 | 类型 | 必填 | 校验规则 |
|
||||
| --- | --- | --- | --- |
|
||||
| 规则类型 | 单选 | ✅ | 获取/扣减/管理员奖励(新增时可选择,编辑时锁定) |
|
||||
| 规则编码 | 单选 | ✅ | 下拉项见上面的规则表,已生效的置灰不可选 |
|
||||
| 积分项名称 | 文本 | ✅ | 自动获取规则编码里内容,可修改,2~20个中文字符 |
|
||||
| 积分分值 | 文本 | ✅ | 正整数或“+5/+10/+15”(阶梯) |
|
||||
| 每日上限 | 数字 | ❌ | 正整数或不填(不填=不限制) |
|
||||
| 积分获益人 | 单选 | ✅ | 见上面的规则表 |
|
||||
| 备注说明 | 文本域 | ❌ | ≤200字符 |
|
||||
| 状态 | 单选 | ✅ | 启用/禁用,默认启用 |
|
||||
|
||||
4.3.3.2 Tab2—积分扣减规则(默认配置R1~R3)
|
||||
以下为系统预置的扣减规则,可删除,可编辑分值、状态等:
|
||||
|
||||
| 编码 | 规则名称 | 触发方式 | 默认分值 |
|
||||
| --- | --- | --- | --- |
|
||||
| R1 | 色情低俗/暴力违法 | 管理员手动 | -100 |
|
||||
| R2 | 不当言论 | 管理员手动 | -100 |
|
||||
| R3 | 其他违反规范 | 管理员手动 | -100 |
|
||||
|
||||
扣减的功能按钮放置在前台以下地方:知识库文档列表和阅读页,专家问答管理员删除功能的弹窗里。
|
||||
新增/编辑原型界面:
|
||||
|
||||

|
||||
|
||||
新增/编辑表单定义:
|
||||
|
||||
| 字段 | 类型 | 必填 | 校验规则 |
|
||||
| --- | --- | --- | --- |
|
||||
| 规则类型 | 单选 | ✅ | 获取/扣减/管理员奖励(新增时可选择,编辑时锁定) |
|
||||
| 规则编码 | 单选 | ✅ | 见上面的规则表,已生效的置灰不可选 |
|
||||
| 积分项名称 | 文本 | ✅ | 自动获取规则编码里内容,可修改,2~20个中文字符 |
|
||||
| 积分分值 | 文本 | ✅ | 负整数 |
|
||||
| 备注说明 | 文本域 | ❌ | ≤200字符 |
|
||||
| 状态 | 单选 | ✅ | 启用/禁用,默认启用 |
|
||||
|
||||
4.3.3.3 Tab3—管理员月度奖励(默认配置M1、M4、M6)
|
||||
以下M1、M4、M6为系统预置的管理员月度规则,可删除,可编辑分值、状态等,M2、M3、M5、M7、M8为可增加规则:
|
||||
|
||||
| 编码 | 管理角色 | 月奖励 | 发放条件 | 发放时间 | 特殊规则 |
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| M1 | 公共库所有者 | 200分 | 当月登录≥1次<br>当月登录≥1次<br>当月登录≥1次 | 次月1日凌晨 | 同一用户可兼任多个管理角色,月度奖励取最高档,不累加。管理员管辖库内上传/发布不叠加G1/G2奖励。 |
|
||||
| M2 | 公共库管理员 | - | | | |
|
||||
| M3 | 部门库所有者 | - | | | |
|
||||
| M4 | 部门库管理员 | 100分 | | | |
|
||||
| M5 | 科室库所有者 | - | | | |
|
||||
| M6 | 科室库管理员 | 50分 | | | |
|
||||
| M7 | 团队库所有者 | - | | | |
|
||||
| M8 | 团队库管理员 | - | | | |
|
||||
|
||||
新增/编辑原型界面:
|
||||
|
||||

|
||||
|
||||
新增/编辑表单定义:
|
||||
|
||||
| 字段 | 类型 | 必填 | 校验规则 |
|
||||
| --- | --- | --- | --- |
|
||||
| 规则类型 | 单选 | ✅ | 获取/扣减/管理员奖励(新增时可选择,编辑时锁定) |
|
||||
| 编码 | 单选 | ✅ | 见上面的规则表,已生效的置灰不可选 |
|
||||
| 积分项名称 | 文本 | ✅ | 自动获取规则编码里内容,可修改,2~20个中文字符 |
|
||||
| 积分分值 | 文本 | ✅ | 正整数 |
|
||||
| 备注说明 | 文本域 | ❌ | ≤200字符 |
|
||||
| 状态 | 单选 | ✅ | 启用/禁用,默认启用 |
|
||||
|
||||
编辑规则:编辑保存后立即生效,历史记录不受影响。
|
||||
4.3.3.4 Tab4—说明文案
|
||||
积分规则的说明文案可在后台配置,以下5条说明文案为系统预置的,可修改,修改保存后立即生效。
|
||||
预置文案:
|
||||
1. 积分获取:用户通过发布优质内容、参与互动等方式获取积分,每日设有上限,防止刷分行为。
|
||||
2. 积分扣减:对于违反平台规范的行为,将扣除相应积分作为惩戒,严重违规将额外处理。
|
||||
3. 管理员奖励:不同层级的管理员根据其管理职责,每月可获得固定积分奖励。
|
||||
4. 积分用途:本平台积分会定期发送到协同办公平台,用于党群礼物兑换。
|
||||
5. 申诉机制:如对积分变动有异议,可在7个工作日内向管理员提出申诉。
|
||||
4.3.4用户积分管理
|
||||
用户积分管理包含两个Tab:「用户积分列表」和「操作记录」。平台管理员可在此搜索用户、查看积分概况、手动调整积分、查看明细等,界面原型如下:
|
||||
|
||||

|
||||
|
||||
4.3.4.1 Tab1—用户积分列表
|
||||
默认选中此Tab,支持搜索姓名和部门,支持按部门、用户类型删选,列表按当前积分降序排列。
|
||||
列表字段定义:
|
||||
|
||||
| 字段名 | 说明 | 示例 |
|
||||
| --- | --- | --- |
|
||||
| 用户姓名 | 组织架构中姓名 | 王五 |
|
||||
| 部门 | 用户所属部门级组织 | 设备管理部 |
|
||||
| 用户类型 | 用户当前角色类型(普通用户+管理员月奖励配置中生效的角色) | 普通用户 |
|
||||
| 当前积分 | 该用户积分余额(千分位格式) | 4,120 |
|
||||
| 本月积分 | 当月净增减,正数绿色+前缀“+”,负数红色+前缀“-” | +450(绿)/ -50(红) |
|
||||
| 操作 | 两个操作入口 | [调整积分]按钮+[查看详情]按钮 |
|
||||
|
||||
筛选与搜索:
|
||||
|
||||
| 筛选控件 | 类型 | 选项/行为 |
|
||||
| --- | --- | --- |
|
||||
| 搜索框 | 文本输入 | 支持搜索用户姓名和部门,输入后实时筛选 |
|
||||
| 部门下拉 | 单选下拉 | 默认“全部部门”,选项来自部门树,选择后列表刷新 |
|
||||
| 角色下拉 | 单选下拉 | 默认“全部角色”,选项:全部角色/普通用户/科室库管理员/部门库管理员/公共库管理员 |
|
||||
|
||||
4.3.4.2 Tab2—操作记录(审计日志)
|
||||
点击“操作记录”Tab切换到操作记录列表,记录平台管理员所有积分调整操作,不可编辑,仅可查看,按时间倒序排列。
|
||||
界面原型:
|
||||
|
||||

|
||||
|
||||
列表字段定义:
|
||||
|
||||
| 字段名 | 说明 | 示例 |
|
||||
| --- | --- | --- |
|
||||
| 操作人 | 执行调整的管理员姓名 | 张三 |
|
||||
| 操作类型 | 中文展示操作类别 | 积分调整/积分扣减 |
|
||||
| 用户 | 被操作用户姓名 | 王五 |
|
||||
| 积分变动 | 正数绿色+前缀"+",负数红色+前缀"-" | +100(绿)/-50(红) |
|
||||
| 备注 | 操作备注文字 | 月度优秀贡献奖励 |
|
||||
| 时间 | 操作发生时间,精确到分 | 2026-07-28 10:00 |
|
||||
|
||||
4.3.4.3调整用户积分
|
||||
点击「调整积分」按钮弹出调整用户积分表单,界面原型如下:
|
||||
|
||||

|
||||
|
||||
表单字段定义:
|
||||
|
||||
| 字段名 | 必填 | 校验规则 | 错误提示 |
|
||||
| --- | --- | --- | --- |
|
||||
| 用户名 | ✅ | 锁死不可编辑 | |
|
||||
| 当前积分 | ✅ | 锁死不可编辑 | |
|
||||
| 调整分值 | ✅ | 整数,范围 -10000 ~ +10000,不可为0 | 请输入非零整数(-10000~10000) |
|
||||
| 调整原因 | ✅ | 5~100字符,不能为纯空格 | 调整原因需填写5~100个字符 |
|
||||
|
||||
4.3.4.4用户积分详情
|
||||
点击「查看详情」按钮弹出用户积分详情表单,可查看该用户最近的积分变动明细,可下拉选择时间范围:近七天,近一个月,自定义时间,默认近一个月,界面原型如下:
|
||||
|
||||

|
||||
|
||||
积分详情字段定义:
|
||||
|
||||
| 列名 | 说明 | 示例 |
|
||||
| --- | --- | --- |
|
||||
| 积分项 | 规则名称(中文) | 发布知识文档/文档被收藏/管理员月度奖励 |
|
||||
| 积分变动 | 正数绿色+前缀"+",负数红色+前缀"-" | +50(绿)/-100(红) |
|
||||
| 余额 | 变动后的积分余额(千分位) | 2,580 |
|
||||
| 时间 | 积分变动时间,精确到秒 | 2026-07-28 10:00 |
|
||||
|
||||
五、外部系统接口需求
|
||||
本平台需每日向协同办公平台推送用户积分增量数据,实现两个平台积分数据互通。
|
||||
同步方式:定时任务,每日执行一次增量推送;
|
||||
同步数据范围:上一同步周期内,所有用户产生的积分变动增量(区分加分、扣减);
|
||||
数据来源:user_point_log 用户积分明细流水表;
|
||||
推送方向:知识管理平台主动调用协同办公平台接收接口;
|
||||
数据字段、鉴权、地址、报文格式等待双方对接后确认。
|
||||
六、性能与安全需求
|
||||
性能:积分列表、明细支持千人级用户无卡顿加载(≤1.5秒);
|
||||
数据安全:管理员操作日志禁止删除、修改;积分相关表每日全量备份,保留30天。
|
||||
|
After Width: | Height: | Size: 91 KiB |
|
After Width: | Height: | Size: 85 KiB |
|
After Width: | Height: | Size: 355 KiB |
|
After Width: | Height: | Size: 82 KiB |
|
After Width: | Height: | Size: 118 KiB |
|
After Width: | Height: | Size: 68 KiB |
|
After Width: | Height: | Size: 36 KiB |
|
After Width: | Height: | Size: 79 KiB |
|
After Width: | Height: | Size: 59 KiB |
|
After Width: | Height: | Size: 57 KiB |
|
After Width: | Height: | Size: 76 KiB |
|
After Width: | Height: | Size: 70 KiB |
|
After Width: | Height: | Size: 46 KiB |
|
After Width: | Height: | Size: 36 KiB |
|
After Width: | Height: | Size: 57 KiB |
@@ -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)。
|
||||
|
After Width: | Height: | Size: 79 KiB |
|
After Width: | Height: | Size: 118 KiB |
|
After Width: | Height: | Size: 91 KiB |
|
After Width: | Height: | Size: 91 KiB |
|
After Width: | Height: | Size: 57 KiB |
|
After Width: | Height: | Size: 76 KiB |
|
After Width: | Height: | Size: 70 KiB |
|
After Width: | Height: | Size: 46 KiB |
|
After Width: | Height: | Size: 36 KiB |
|
After Width: | Height: | Size: 57 KiB |
|
After Width: | Height: | Size: 85 KiB |
|
After Width: | Height: | Size: 355 KiB |
|
After Width: | Height: | Size: 82 KiB |
|
After Width: | Height: | Size: 118 KiB |
|
After Width: | Height: | Size: 68 KiB |
|
After Width: | Height: | Size: 36 KiB |
|
After Width: | Height: | Size: 79 KiB |
|
After Width: | Height: | Size: 59 KiB |
@@ -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 开工实现。
|
||||
@@ -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)
|
||||
@@ -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 运营后台)。
|
||||
@@ -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 |
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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, "积分操作已处理"
|
||||
@@ -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.*: # 工作流相关任务
|
||||
|
||||
@@ -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.*: # 工作流相关任务
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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"),
|
||||
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
"""积分领域模块。"""
|
||||
@@ -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)
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
@@ -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, ())
|
||||
@@ -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]
|
||||
@@ -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"
|
||||
@@ -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
|
||||
@@ -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": (
|
||||
"<p><strong>1. 积分获取:</strong>用户通过发布优质内容、参与互动等方式获取积分,"
|
||||
"每日设有上限,防止刷分行为。</p>"
|
||||
"<p><strong>2. 积分扣减:</strong>对于违反平台规范的行为,将扣除相应积分作为惩戒,"
|
||||
"严重违规将额外处理。</p>"
|
||||
"<p><strong>3. 管理员奖励:</strong>不同层级的管理员根据其管理职责,每月可获得固定积分奖励。</p>"
|
||||
"<p><strong>4. 积分用途:</strong>本平台积分会定期发送到协同办公平台,用于党群礼物兑换。</p>"
|
||||
"<p><strong>5. 申诉机制:</strong>如对积分变动有异议,可在7个工作日内向管理员提出申诉。</p>"
|
||||
),
|
||||
"sort_order": 1,
|
||||
},
|
||||
]
|
||||
@@ -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))
|
||||
@@ -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",
|
||||
]
|
||||
@@ -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="更新时间",
|
||||
),
|
||||
)
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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)
|
||||
@@ -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}
|
||||
@@ -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("积分通知发送失败")
|
||||
@@ -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"
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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],
|
||||
}
|
||||
@@ -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],
|
||||
}
|
||||
@@ -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"
|
||||
@@ -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",
|
||||
)
|
||||
|
||||
@@ -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"])
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
@@ -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]:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||