feat(commission): 完整返佣系统实现(后端 + 前端) - #6539
Conversation
- P0-1: 确认常量已定义(AntiSpamEnabled, MaxDailyInvites, CommissionEnabled等) - P0-2: 返佣路由已注册到 SetRouter(之前已完成) - P0-3: 用户端认证中间件 TokenAuth → UserAuth,transfer 加 CriticalRateLimit - P0-4: 表迁移注册 CommissionLog/CommissionRule,默认规则 IsActive=false,SeedCommissionRules - P0-5: 消费触发挂钩 model/hooks.go + service/commission_init.go,单例模式,gopool.Go 所有修复通过 go build ./... 编译验证
- P1-1: 修复 aff_history_quota → aff_history 列名错误(返佣事务100%失败的根因) - P1-2: 幂等控制 - CommissionLog 添加 SourceKey 字段+唯一索引,OnConflict DoNothing,RowsAffected判定 两个关键资金安全问题已修复,go build 验证通过
- P1-3: ProcessCommission/RefundCommission/TransferAffQuotaToQuota 事务成功后调用 InvalidateUserCache - P1-6: 金额计算改用 math.Round 四舍五入,避免小额消费返佣恒为0,0值跳过 go build 验证通过
- checkInvitationFrequency: DATE(created_at)=? → Unix秒范围 [dayStart, dayEnd) - DetectSuspiciousActivity: time.Time → Unix秒 int64 - 查询失败改为 fail-closed(返回错误拒绝,而非放行) 修复防刷检查在 PG/MySQL 下失效的问题,go build 验证通过
- 新增 checkDailyLimitTx/checkMonthlyLimitTx:接受tx参数,使用Unix秒范围 - 修复限额查询的时间戳问题(DATE(created_at) → Unix范围) - Tx版本已就绪,待集成到ProcessCommission事务内(需进一步重构) go build 验证通过
- 事务内先锁邀请人行(clause.Locking{Strength: "UPDATE"})
- 事务内复核限额(checkDailyLimitTx/checkMonthlyLimitTx)
- 使用 tx 而非 model.DB,保证读写一致性
- 防止并发超发(TOCTOU问题)
P1批次全部完成,go build 验证通过
- ProcessCommission 根据 CommissionRealTimeSettle 决定状态 - true: 写 settled + 事务内加钱(原有行为) - false: 写 pending,不加钱 - AdminSettleCommission 重写: - 分批处理(每批 500 条) - 事务内 pending → settled 并同步加钱 - 条件保护 WHERE status='pending' - 行锁串行化(clause.Locking) go build 验证通过
- User 模型新增 RegisterIP 字段(varchar(45),AutoMigrate自动加列) - checkSameIPDevice 改为查库:同 register_ip + inviter_id 下 ≥5 用户时拒绝 - RecordIPDevice 改为空实现(待集成到注册流程) - 删除内存 tracker 依赖(重启丢失、多节点不共享问题) go build 验证通过
- getInviterChain:禁用邀请人 break → continue(跳过该级但继续上溯) - 语义:单级资格问题不应影响上级返佣 - 邀请链结构客观存在,禁用只是跳过该级 go build 验证通过
V1: InviterID 添加 uniqueIndex:uk_source_inviter,priority:2(修复多级返佣被幂等误杀) V2: 事务前获取规则,失败直接报错(fail-closed,不再跳过限额) V3: AdminSettle 事务后失效用户缓存 V4: Details 按 InviterID 升序排序(避免交叉链死锁) V5: AdminSettle 改用游标分页(避免死循环) V6: 请求解析失败返回400(不再默认全量结算) V7: CommissionEnabled 默认值改为 false go build 验证通过
- Register 函数创建用户时设置 RegisterIP = c.ClientIP() - constants.go 新增 CommissionSameIPLimit = 5 - checkSameIPDevice 使用常量替代硬编码 IP防刷机制现在可以正常工作,go build 验证通过
- 删除旧版 checkDailyLimit/checkMonthlyLimit(使用DATE(),索引失效) - CalculateCommission 预检改用 checkDailyLimitTx/checkMonthlyLimitTx - 统一使用 Unix 秒范围,三方言兼容 消除双实现,go build 验证通过
A部分 - 后端: - A1: /api/status 暴露 commission_enabled 和 commission_max_level - A2: 用户端返佣接口总开关守卫(关闭时返回 success:false) B部分 - 管理后台: - 新建 features/system-settings/commission/ 模块 - 基础设置:总开关、层级选择、实时结算 - 防刷设置:防刷开关、邀请上限、IP 限制 - 导航注册到系统设置侧栏 D部分 - 用户端自适应: - D1: 侧栏返佣中心入口根据 commission_enabled 显隐 - D2: /commission 路由守卫(关闭时重定向到钱包) - D3: 钱包邀请卡双模式(返佣模式 vs 经典模式) 其他: - 新增 use-commission-config hook 读取配置 - 扩展 StatusApiResponse 接口 - 删除过时测试脚本和文档 - 清理 .gitignore 后端编译验证:✅ go build ./...
从 /d/newapi/ 同步完整的 commission 实现: 用户端(features/commission/): - api.ts: 返佣 API 绑定 - hooks.ts: 自定义 hooks - types.ts: TypeScript 类型定义 - components/: 4 个组件 - commission-overview-card: 概览卡片 - commission-logs-table: 明细表格 - commission-stats-panel: 统计面板 - commission-transfer-dialog: 转移对话框 管理端(features/system-settings/commission/): - rule-api.ts: 规则管理 API 绑定 - rules-section.tsx: 规则列表+编辑 UI (14.9K) - commission-settings-section.tsx: 设置面板 路由更新: - 用户端 /commission 路由 - 管理端 /system-settings/commission 路由 Store 更新: - system-config-store: 添加 commissionMaxLevel 字段 - use-sidebar-data: 返佣中心侧栏入口 - use-system-config: 读取 commission 配置 - system-settings.config: 导航注册 删除: - use-commission-config.ts(改用 useSystemConfigStore)
WalkthroughAdds a multi-level commission system with backend processing, anti-spam checks, settlement APIs, frontend pages and settings, integration documentation, and tests. It also adds a HappyHorse video-generation adaptor, channel registration, task polling, and compatible video routes. ChangesCommission system
HappyHorse video channel
Estimated code review effort: 5 (Critical) | ~120 minutes Possibly related PRs
Poem
🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✨ Finishing Touches🧪 Generate unit tests (beta)
Warning Tools execution failed with the following error: Failed to run tools: Stream initialization permanently failed: 8 RESOURCE_EXHAUSTED: Received message larger than max (236074135 vs 209715200) Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
Actionable comments posted: 7
Note
Due to the large number of review comments, Critical severity comments were prioritized as inline comments.
🟠 Major comments (29)
relay/channel/task/happyhorse/adaptor.go-170-186 (1)
170-186: 🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick winBill the same duration and resolution sent upstream.
BuildRequestBodyhonorsSecondswhenDurationis unset and acceptsmetadata.resolution, but this estimator charges the five-second baseline and omitsResolutionRatios. A 10-second 1080P request is therefore sent upstream as 10 seconds/1080P but billed as five baseline units. Normalize the same inputs here and include the resolution multiplier.Proposed direction
seconds := taskReq.Duration if seconds <= 0 { - seconds = DefaultDuration + if parsed, err := strconv.Atoi(taskReq.Seconds); err == nil && parsed > 0 { + seconds = parsed + } else { + seconds = DefaultDuration + } } +resolution := Resolution720P +if value, ok := taskReq.Metadata["resolution"].(string); ok { + resolution = value +} +resolutionRatio := ResolutionRatios[resolution] + otherRatios := map[string]float64{ - "seconds": float64(seconds), + "seconds": float64(seconds), + "resolution": resolutionRatio, }🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@relay/channel/task/happyhorse/adaptor.go` around lines 170 - 186, Update EstimateBilling to normalize duration using the same Seconds fallback honored by BuildRequestBody, rather than defaulting to DefaultDuration when Duration is unset. Read metadata.resolution using the existing request parsing conventions, apply the corresponding ResolutionRatios multiplier, and include it in the returned otherRatios alongside seconds so billing matches the upstream request.relay/channel/task/happyhorse/constants.go-13-19 (1)
13-19: 🎯 Functional Correctness | 🟠 Major | ⚡ Quick winMap HappyHorse to downstream state strings before comparing task status.
HappyHorse API responses use
SUCCEEDED/FAILED, including an optionalFAILEDfor tasks that completed with an error. If this adaptor returns those terminal strings directly toservice/task_polling.go’sParseTaskResult, matching cases like Downstreamsucceeded/failedwill miss them and terminal tasks can stay marked in-progress. Map at the provider boundary (SUCCEEDED/FAILED→succeeded/failed) and cover every terminal state in a table test.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@relay/channel/task/happyhorse/constants.go` around lines 13 - 19, Map HappyHorse terminal statuses from SUCCEEDED and FAILED to the downstream TaskStatusSucceeded and TaskStatusFailed values at the provider boundary before ParseTaskResult compares them. Preserve pending and running handling, and add table-driven coverage for both terminal mappings, including FAILED responses for errored completions.web/default/src/features/commission/components/commission-stats-panel.tsx-21-27 (1)
21-27: 🎯 Functional Correctness | 🟠 Major | ⚡ Quick winDo not render failed statistics as zero or stale data.
A rejected request or
{ success: false }is silently ignored; initial failures render zero values, while later failures retain values for the previous period. Route failures throughhandleServerError, show a localized error, and expose an error state instead. As per coding guidelines, “服务端错误统一使用handleServerError;错误提示使用 i18n。”🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@web/default/src/features/commission/components/commission-stats-panel.tsx` around lines 21 - 27, Update fetchStats in the commission stats panel to handle both rejected requests and unsuccessful responses through handleServerError, using i18n for the localized error message. Add and reset an explicit error state around each request, set it on failure, and ensure the render path displays the error state instead of zero defaults or stale statistics from the previous period.Source: Coding guidelines
COMMISSION_SYSTEM_DESIGN.md-255-260 (1)
255-260: 🎯 Functional Correctness | 🟠 Major | ⚡ Quick winAlign the documented
ProcessCommissionreturn contract.The design defines
ProcessCommissionas returning onlyerror, while both integration examples assign(result, err). Copying either example will not compile. Choose one API shape and update all documentation and callers consistently.
COMMISSION_SYSTEM_DESIGN.md#L255-L260: define the intended return signature.COMMISSION_QUICK_START.md#L76-L76: update the example assignment.INTEGRATION_GUIDE.md#L21-L21: update the example assignment and subsequentTotalCommissionusage.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@COMMISSION_SYSTEM_DESIGN.md` around lines 255 - 260, Align the documented ProcessCommission API consistently across COMMISSION_SYSTEM_DESIGN.md:255-260, COMMISSION_QUICK_START.md:76-76, and INTEGRATION_GUIDE.md:21-21: choose the intended return shape, update the ProcessCommission signature documentation accordingly, and revise both example assignments and INTEGRATION_GUIDE.md’s subsequent TotalCommission usage to match.COMMISSION_SYSTEM_DESIGN.md-395-417 (1)
395-417: 🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick winResolve the
user_idresponse-contract contradiction.The design’s commission-log response includes
user_id, while the final report says that field was removed. Frontend and API consumers cannot safely rely on both contracts.
COMMISSION_SYSTEM_DESIGN.md#L395-L417: removeuser_idif the privacy change is authoritative, or mark it as retained.FINAL_REPORT.md#L188-L193: update the endpoint summary to match the actual response schema.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@COMMISSION_SYSTEM_DESIGN.md` around lines 395 - 417, Resolve the commission-log response contract consistently: in COMMISSION_SYSTEM_DESIGN.md lines 395-417, remove user_id if the privacy change is authoritative, or explicitly mark it as retained; then update FINAL_REPORT.md lines 188-193 to describe the same endpoint schema and user_id decision.INTEGRATION_GUIDE.md-109-128 (1)
109-128: 🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy liftInitialize every persistence object required by the design.
This migration only creates
CommissionLogandCommissionRule, while the design also defines settlement and configuration storage. Align the migration with the actual service dependencies or remove those tables from the design/API documentation.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@INTEGRATION_GUIDE.md` around lines 109 - 128, Update migrateCommission to AutoMigrate every persistence model required by the settlement and configuration service dependencies, not only CommissionLog and CommissionRule; alternatively, remove any unsupported settlement/configuration tables from the design and API documentation. Keep initCommissionRules focused on default rule initialization.INTEGRATION_GUIDE.md-68-76 (1)
68-76: 🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick winHandle invitation lookup errors instead of silently using inviter ID zero.
Ignoring the error from
GetUserIdByAffCodecan create the user without an inviter when the lookup fails, permanently losing attribution. Distinguish “code not found” from database failure and abort on the latter.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@INTEGRATION_GUIDE.md` around lines 68 - 76, Update the invitation lookup near GetUserIdByAffCode to handle its returned error instead of discarding it. Distinguish a missing invitation code from a database or other lookup failure, preserving the intended behavior for not-found codes while aborting user creation and propagating the failure for operational errors; only assign InviterId when the lookup succeeds.COMMISSION_SYSTEM_DESIGN.md-641-648 (1)
641-648: 🔒 Security & Privacy | 🟠 Major | ⚡ Quick winFail closed when cycle-detection lookups fail.
A database error breaks the loop and returns
false, allowing an invitation to pass anti-fraud validation without a complete chain check. Propagate the error or return a distinct “unable to verify” result that blocks the operation.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@COMMISSION_SYSTEM_DESIGN.md` around lines 641 - 648, Update the cycle-detection logic around the user lookup and its caller so database errors are propagated or converted into a distinct verification failure that blocks the invitation; do not break and return false from the lookup loop. Preserve normal false results only for successfully verified chains without a detected cycle.COMMISSION_QUICK_START.md-47-50 (1)
47-50: 🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick winPropagate migration errors when initializing default rules.
Both
CountandCreateerrors are ignored, so the migration can return success while default commission rules were never created. Check every database operation and return the error.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@COMMISSION_QUICK_START.md` around lines 47 - 50, Update the default-rule initialization flow around CommissionRule to check and propagate errors from both DB.Model(...).Count and DB.Create(&rule). Return immediately when either operation fails, ensuring migration success is reported only after all default rules are successfully processed.COMMISSION_SYSTEM_DESIGN.md-314-328 (1)
314-328: 🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick winDo not treat inviter-chain lookup errors as an empty chain.
Breaking on any database error and returning a partial chain silently underpays commissions. Return the lookup error unless the user is definitively missing or has no inviter.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@COMMISSION_SYSTEM_DESIGN.md` around lines 314 - 328, Update the inviter-chain lookup loop to propagate database errors instead of breaking and returning a partial chain. In the lookup around s.db.Select("inviter_id").First and the chain-building function, distinguish a definitively missing user or user with no inviter from other errors: only terminate normally for those expected cases, and return the encountered lookup error for all other failures.COMMISSION_QUICK_START.md-245-262 (1)
245-262: 🩺 Stability & Availability | 🟠 Major | 🏗️ Heavy liftDo not label the system production-ready while required validation remains incomplete.
The quick-start test checklist is entirely unchecked, and the final report explicitly acknowledges missing integration tests before concluding that the system is production-ready. Either complete the stated validation or qualify the release status.
COMMISSION_QUICK_START.md#L245-L262: update the checklist or remove the production-use claim.COMMISSION_QUICK_START.md#L300-L321: qualify the “ready for production” wording.FINAL_REPORT.md#L323-L349: align the conclusion with the documented test gaps.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@COMMISSION_QUICK_START.md` around lines 245 - 262, Align the release documentation with the incomplete validation: in COMMISSION_QUICK_START.md lines 245-262, complete the test checklist only for validations actually performed or leave unchecked items accurately documented; in COMMISSION_QUICK_START.md lines 300-321, qualify or remove the production-ready claim; and in FINAL_REPORT.md lines 323-349, revise the conclusion to explicitly reflect the remaining integration and other test gaps.COMMISSION_QUICK_START.md-67-80 (1)
67-80: 🩺 Stability & Availability | 🟠 Major | 🏗️ Heavy liftDo not use fire-and-forget goroutines for commission settlement without durable retry.
A process crash after consumption but before the goroutine commits will lose the commission permanently, and transient database failures are only logged. Use an outbox/queue with retries and idempotency, or document a synchronous transactional hook.
COMMISSION_QUICK_START.md#L67-L80: replace the unbounded goroutine with durable dispatch.INTEGRATION_GUIDE.md#L38-L51: apply the same reliable processing pattern inconsumeQuota.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@COMMISSION_QUICK_START.md` around lines 67 - 80, Replace the fire-and-forget goroutine around CommissionService.ProcessCommission in COMMISSION_QUICK_START.md:67-80 with durable dispatch using the project’s outbox or queue, including retries and idempotency; do not merely log transient failures. Apply the same reliable processing pattern in consumeQuota at INTEGRATION_GUIDE.md:38-51, or explicitly document and implement a synchronous transactional hook if that is the established integration mechanism.COMMISSION_QUICK_START.md-220-226 (1)
220-226: 🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy liftReconcile commission configuration names and defaults.
The quick-start example enables commission by default, the design lists
commission.enabled=true, while the final report listsCommissionEnabled=falseand uses different option names such asCommissionRealTimeSettleEnabledandCommissionAntiSpamEnabled. This can cause operators to configure the wrong keys or unintentionally enable/disable payouts.
COMMISSION_QUICK_START.md#L220-L226: use the canonical option names and defaults.COMMISSION_SYSTEM_DESIGN.md#L569-L576: align the documented keys with the implementation.FINAL_REPORT.md#L173-L184: document the same names and defaults as the runtime.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@COMMISSION_QUICK_START.md` around lines 220 - 226, Reconcile commission configuration names and defaults across all three documentation sites: in COMMISSION_QUICK_START.md lines 220-226, COMMISSION_SYSTEM_DESIGN.md lines 569-576, and FINAL_REPORT.md lines 173-184, document the canonical runtime options CommissionEnabled, CommissionRealTimeSettle, CommissionMaxLevel, and AntiSpamEnabled with defaults matching common/constants.go, including commission enabled by default. Remove inconsistent names such as CommissionRealTimeSettleEnabled and CommissionAntiSpamEnabled and ensure all sections describe the same settings.COMMISSION_SYSTEM_DESIGN.md-233-239 (1)
233-239: 🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy liftMove limit validation into the transaction or reserve quota atomically.
Daily and monthly checks run before
ProcessCommissionopens its transaction. Concurrent commissions can both observe remaining capacity and exceed the configured limits. Re-check under row/advisory locking or use an atomic conditional update.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@COMMISSION_SYSTEM_DESIGN.md` around lines 233 - 239, Move the daily and monthly quota validation currently using checkDailyLimit and checkMonthlyLimit into the transaction opened by ProcessCommission, protecting the relevant quota rows with row or advisory locks before evaluating limits. Alternatively, reserve quota through atomic conditional updates, and ensure commission processing proceeds only when both reservations succeed without allowing concurrent commissions to exceed rule.DailyLimit or rule.MonthlyLimit.COMMISSION_SYSTEM_DESIGN.md-347-352 (1)
347-352: 🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy liftReplace dialect-specific
GREATESTwith a cross-database-safe update.The documented project supports SQLite, MySQL, and PostgreSQL, but SQLite does not provide the same
GREATESTbehavior. Calculate the clamped value in Go after locking the user row, or provide explicit dialect branches.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@COMMISSION_SYSTEM_DESIGN.md` around lines 347 - 352, Update the user quota adjustment transaction around the User model update to remove the dialect-specific GREATEST expressions. After locking and loading the inviter row, calculate quota and aff_quota in Go with a minimum of zero, then persist the clamped values through the existing transaction so SQLite, MySQL, and PostgreSQL behave consistently.COMMISSION_SYSTEM_DESIGN.md-26-62 (1)
26-62: 🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick winAvoid presenting MySQL-only SQL as executable schema migrations.
These blocks use MySQL dialect features (
AUTO_INCREMENT,ENGINE=InnoDB, column comments,ON UPDATE) without SQLite/PostgreSQL migration variants inCOMMISSION_SYSTEM_DESIGN.md. Either provide cross-dialect migration variants, or clearly annotate these as illustrative MySQL examples only.Also applies to the schema blocks at lines 67-98 and 103-149.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@COMMISSION_SYSTEM_DESIGN.md` around lines 26 - 62, Clarify the schema blocks for commission_logs and the additional schemas at lines 67–98 and 103–149 as illustrative MySQL-only examples, explicitly stating they are not directly executable cross-dialect migrations. Alternatively, provide equivalent SQLite and PostgreSQL migration variants, while preserving the documented schema semantics.web/default/src/features/commission/index.tsx-20-22 (1)
20-22: 🎯 Functional Correctness | 🟠 Major | ⚡ Quick winWire an action that opens the transfer dialog.
transferOpenis initialized tofalse, and its setter is only passed back to the closed dialog.CommissionOverviewCardreceives no open callback, so users cannot reach the transfer flow.Pass
onTransfer={() => setTransferOpen(true)}to the card (or render a dialog trigger) and invoke it from the transfer action.Also applies to: 60-60, 68-68
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@web/default/src/features/commission/index.tsx` around lines 20 - 22, Wire the transfer action in the commission view by passing an onTransfer callback to CommissionOverviewCard that sets transferOpen to true, ensuring the card’s transfer control can open CommissionTransferDialog. Keep the existing setTransferOpen wiring and transfer state behavior unchanged.web/default/src/features/system-settings/commission/rules-section.tsx-118-124 (1)
118-124: 📐 Maintainability & Code Quality | 🟠 Major | ⚡ Quick winNested ternary violates the no-nested-ternary guideline.
loading ? ... : rules.length === 0 ? ... : ...is a two-level nested ternary inside JSX.As per coding guidelines, "禁止两层及以上嵌套三元表达式;复杂逻辑应拆分为小函数".♻️ Proposed refactor
- <TableBody> - {loading ? ( - Array.from({ length: 3 }).map((_, i) => ( - <TableRow key={i}>{Array.from({ length: 10 }).map((_, j) => <TableCell key={j}><Skeleton className='h-4 w-12' /></TableCell>)}</TableRow> - )) - ) : rules.length === 0 ? ( - <TableRow><TableCell colSpan={10} className='text-muted-foreground py-8 text-center'><AlertCircle className='mx-auto mb-2 size-5 opacity-50' />{t('暂无规则')}</TableCell></TableRow> - ) : ( - rules.map((rule) => ( + <TableBody> + {renderTableBody()}Extract a
renderTableBody()helper that early-returns the skeleton rows, then the empty state, then the mapped rows.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@web/default/src/features/system-settings/commission/rules-section.tsx` around lines 118 - 124, Refactor the JSX table-body conditional in the component to remove the nested ternary. Extract a renderTableBody helper that returns loading skeleton rows first, the empty state when rules is empty, and the mapped rule rows otherwise, then invoke it from the table body.Source: Coding guidelines
web/default/src/features/system-settings/commission/rules-section.tsx-48-52 (1)
48-52: 🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick winEditing a rule silently overwrites its
rule_typeto'percentage'.
openEditalways setsrule_type: 'percentage'instead ofrule.rule_type. SinceCommissionRuleFormis sent in full toupdateCommissionRule, editing and saving any rule whose actual type is'fixed'or'hybrid'(perCommissionRule's union type) will silently rewrite it to'percentage', corrupting the rule's semantics.🐛 Proposed fix
- setForm({ rule_code: rule.rule_code, rule_name: rule.rule_name, rule_type: 'percentage', level1_rate: rule.level1_rate, level2_rate: rule.level2_rate, level3_rate: rule.level3_rate, min_consumption: rule.min_consumption, max_commission: rule.max_commission, daily_limit: rule.daily_limit, monthly_limit: rule.monthly_limit, applicable_models: rule.applicable_models || '', excluded_models: rule.excluded_models || '', is_active: rule.is_active, priority: rule.priority }) + setForm({ rule_code: rule.rule_code, rule_name: rule.rule_name, rule_type: rule.rule_type, level1_rate: rule.level1_rate, level2_rate: rule.level2_rate, level3_rate: rule.level3_rate, min_consumption: rule.min_consumption, max_commission: rule.max_commission, daily_limit: rule.daily_limit, monthly_limit: rule.monthly_limit, applicable_models: rule.applicable_models || '', excluded_models: rule.excluded_models || '', is_active: rule.is_active, priority: rule.priority })This also requires widening
CommissionRuleForm.rule_typeinrule-api.ts:- rule_type: 'percentage' + rule_type: 'percentage' | 'fixed' | 'hybrid'🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@web/default/src/features/system-settings/commission/rules-section.tsx` around lines 48 - 52, Update openEdit to initialize form.rule_type from rule.rule_type rather than hardcoding 'percentage', preserving the existing rule type when editing. Widen CommissionRuleForm.rule_type in rule-api.ts to accept the full CommissionRule union ('percentage', 'fixed', and 'hybrid') so updateCommissionRule receives the correct type.service/commission_limit_test.go-10-75 (1)
10-75: 📐 Maintainability & Code Quality | 🟠 Major | ⚡ Quick winUse
require/assertand check all setup errors.Unchecked deletes, inserts, and reads can leave stale or zero-valued state and obscure the real failure. Replace
t.Fatal/t.Fatalfwithrequirefor setup/fatal assertions andassertfor non-fatal checks.As per coding guidelines, “New or substantially rewritten Go backend tests must use
testify/requirefor setup and fatal assertions andtestify/assertfor non-fatal checks.”🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@service/commission_limit_test.go` around lines 10 - 75, Update the test helpers seedLimitRule, seedPair, and quotaOfUser to check every database Delete, Create, and First error with testify/require, replacing unchecked setup operations and t.Fatal calls. Replace the quota expectations in TestDailyLimitBlocks, TestDailyLimitExactBoundary, and TestMonthlyLimitBlocks with testify/assert checks, while keeping setup failures fatal via require.Source: Coding guidelines
controller/user.go-193-198 (1)
193-198: 📐 Maintainability & Code Quality | 🟠 Major | ⚡ Quick winUse the project JSON wrapper for registration decoding.
This new application-level
encoding/jsondecode bypasses the requiredcommon/json.gowrapper. Reuse the shared decoder/helper here while keeping the existing invalid-parameter response path.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@controller/user.go` around lines 193 - 198, Update the registration decoding flow in the handler using registerRequest to call the project’s shared JSON decoder/helper from common/json.go instead of encoding/json directly. Preserve the existing error check and common.ApiErrorI18n(c, i18n.MsgInvalidParams) response when decoding fails.Sources: Coding guidelines, Learnings
model/commission_rule.go-127-152 (1)
127-152: 🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick winFail closed on malformed model selectors.
ApplicableModelsandExcludedModelsignore decode errors, so malformed selector JSON can bypass scoped commission rules: malformedApplicableModelsmatches every model, while malformedExcludedModelsexcludes none. Decode throughcommon/json.goand treat decode failures as non-matches; reject invalid selector JSON on rule writes.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@model/commission_rule.go` around lines 127 - 152, Update the model-selector handling in the rule matching method to decode ApplicableModels and ExcludedModels through the helpers in common/json.go, and return a non-match whenever either non-empty selector fails to decode. Preserve normal inclusion/exclusion behavior for valid selectors, and add validation on rule writes so invalid selector JSON is rejected before persistence.Sources: Coding guidelines, Learnings
service/commission_guard.go-131-144 (1)
131-144: 🎯 Functional Correctness | 🟠 Major | ⚡ Quick winIP 限额取 0 时会拦截全部返佣,而系统其他限额约定
<=0表示不限制。 共同根因是防刷守卫对零值/未配置的语义与仓库惯例相反,且配置校验放行了这些值。checkSameIPDevice中count >= CommissionSameIPLimit在限额为 0 时恒真;globalCount > CommissionGlobalIPLimit的统计包含当前用户自身,限额为 0 时同样恒真。而MaxDailyInvites、rule.DailyLimit等都以<= 0表示"不限制",配置写成 0 或非法字符串(strconv.Atoi忽略错误后落为 0)时行为会从"关闭限制"突变为"全量拦截",且两处比较符>=与>也不一致。
service/commission_guard.go#L131-L144:对两个 IP 限额补上<= 0 直接 return nil(不限制)的前置判断,并统一比较语义(同 IP 关联数与全局注册数都用>,全局统计排除当前用户自身)。controller/option.go#L334-L348:把CommissionGlobalIPLimit、CommissionMaxLevel一并纳入范围校验(层级 1..3、IP 上限为非负整数),避免非法输入静默变成 0。🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@service/commission_guard.go` around lines 131 - 144, Update service/commission_guard.go in the checkSameIPDevice flow to treat both IP limits as disabled when their values are <= 0, compare both same-IP and global counts using >, and exclude the current user from the global registration count. Update controller/option.go to validate CommissionGlobalIPLimit as a non-negative integer and CommissionMaxLevel within 1..3, rejecting invalid values instead of allowing parsing failures to become zero.controller/commission.go-500-505 (1)
500-505: 🩺 Stability & Availability | 🟠 Major | ⚡ Quick win
DATE(created_at)在 PostgreSQL 上不存在,该报表接口在 PG 部署会直接报错。MySQL / SQLite 有
DATE(),PostgreSQL 没有(需created_at::date或DATE_TRUNC('day', created_at))。本仓库要求同时支持 SQLite、MySQL 与 PostgreSQL,需按方言分支。另外这些统计查询的Scan错误全部被忽略,失败时会静默返回 0 值报表,建议至少检查一处并返回错误。🐛 建议修改
+ dateExpr := "DATE(created_at)" + if common.UsingMainDatabase(common.DatabaseTypePostgreSQL) { + dateExpr = "created_at::date" + } model.DB.Model(&model.CommissionLog{}). Where("status = ? AND created_at BETWEEN ? AND ?", "settled", start, end). - Select("DATE(created_at) as date, SUM(commission_quota) as commission, COUNT(*) as transactions"). - Group("DATE(created_at)"). + Select(dateExpr + " as date, SUM(commission_quota) as commission, COUNT(*) as transactions"). + Group(dateExpr). Order("date ASC"). Scan(&dailyStats)请确认
common中 PostgreSQL 方言常量的实际名称。#!/bin/bash rg -nP 'DatabaseType(PostgreSQL|Postgres|MySQL|SQLite)\b' -g '!**/vendor/**' | head -20 rg -nP 'func UsingMainDatabase' -A6🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@controller/commission.go` around lines 500 - 505, Update the daily statistics query in the surrounding commission-report method to use a database-dialect-specific date expression: retain DATE(created_at) for SQLite/MySQL and use the repository’s actual PostgreSQL dialect constant with created_at::date or DATE_TRUNC for PostgreSQL. Also handle the Scan error for this statistics query (and consistently for the related statistics scans if applicable) by returning or propagating the error instead of silently producing zero-value results.controller/commission.go-236-259 (1)
236-259: 🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win创建接口直接绑定整个
model.CommissionRule,缺少更新接口已有的数值校验,并允许客户端注入Id。
AdminUpdateCommissionRule对level*_rate ∈ [0,1]、rule_type枚举、各上限非负都做了校验,创建路径完全没有,管理员误传level1_rate: 10会造成 10 倍返佣直接进入实时结算加钱路径。同时ShouldBindJSON会把请求体里的id、created_at等字段一并写入。建议同样使用白名单 DTO,并抽出共享的校验函数供两个 handler 复用。🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@controller/commission.go` around lines 236 - 259, Update AdminCreateCommissionRule to bind through a whitelist request DTO that excludes Id, created_at, and other server-managed fields, then map only permitted values into model.CommissionRule. Extract and reuse the validation shared with AdminUpdateCommissionRule, enforcing level*_rate values in [0,1], the rule_type enum, and non-negative upper limits in both handlers before persistence.service/commission_guard.go-109-129 (1)
109-129: 🔒 Security & Privacy | 🟠 Major | ⚡ Quick win同 IP 检查在查询失败时放行,与频率检查的 fail-closed 策略相反。
checkInvitationFrequency查询失败时返回错误拒绝返佣,而这里 Line 113 / Line 128 遇到错误直接return nil放行。防刷路径上数据库抖动会变成"防刷全面失效",与 PR 目标里声明的 fail-closed 策略不一致。建议统一为返回错误(由调用方跳过该级返佣)。🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@service/commission_guard.go` around lines 109 - 129, 将 CommissionGuard.checkSameIPDevice 中获取用户和统计同 IP 用户数的数据库错误处理统一改为 fail-closed:记录现有日志后返回错误,而不是 return nil 放行;保持无注册 IP 时继续放行,并确保调用方能据此跳过该级返佣。service/commission_guard.go-79-106 (1)
79-106: 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win每日邀请上限在"返佣时"判定,会连带阻断老邀请人的所有返佣。
统计的是"今天注册的下级数量",但检查发生在消费返佣时。某邀请人今天新增邀请超过上限后,当天所有下级(包括几个月前的老用户)产生的返佣都会被拒绝,且这些返佣记录不会补发。若意图是限制邀请行为本身,应该在注册/绑定邀请码时拦截;若意图是限制返佣,判定口径应改为返佣笔数而非注册人数。
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@service/commission_guard.go` around lines 79 - 106, 调整 CommissionGuard.checkInvitationFrequency,避免在返佣处理阶段依据 User.created_at 统计今日新下级并阻断老用户返佣;将每日邀请上限校验移至注册或邀请码绑定流程,在邀请行为发生时拦截,确保既有下级产生的返佣仍可正常处理。model/log.go-387-392 (1)
387-392: 🚀 Performance & Scalability | 🟠 Major | ⚡ Quick win回调在请求线程内同步执行,会把返佣事务(含
SELECT ... FOR UPDATE)压到每次消费的关键路径上。
RecordConsumeLog在计费收尾时被请求 goroutine 调用,而service.ProcessCommission会做:邀请链上溯多次查询、防刷多次查询、日/月限额聚合,再进入带行锁的事务。同一邀请人下多个被邀请者并发消费时会在users行锁上串行排队,直接放大请求延迟;本函数中另一个副作用LogQuotaData就是显式用gopool.Go异步化的。建议同样异步执行(并在回调内部自带 recover/错误日志),除非注册方已经异步。🐛 建议修改
err := createLog(log) if err != nil { logger.LogError(c, "failed to record log: "+err.Error()) - } else if OnConsumeLogRecorded != nil { - OnConsumeLogRecorded(int64(log.Id), userId, params.ModelName, params.Quota) + } else if hook := OnConsumeLogRecorded; hook != nil { + logId, quota, modelName := int64(log.Id), params.Quota, params.ModelName + gopool.Go(func() { + hook(logId, userId, modelName, quota) + }) }#!/bin/bash # Check whether the registered hook already offloads work to a goroutine fd -t f 'commission_init.go' --exec cat -n rg -nP 'OnConsumeLogRecorded\s*=' -A20🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@model/log.go` around lines 387 - 392, 将 RecordConsumeLog 中 OnConsumeLogRecorded 的同步调用改为异步执行,避免返佣处理阻塞请求线程;保持现有参数传递不变,并在异步回调内部加入 recover 和错误日志,确保回调 panic 不影响进程且可追踪。仅修改 else 分支中的回调调用,参考同函数中 LogQuotaData 的 gopool.Go 异步模式。service/commission.go-352-389 (1)
352-389: 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win上溯时跳过失效邀请人会让上一级"晋升"到更高返佣比例。
当直接邀请人不存在或已禁用时,代码
continue并继续上溯,但被跳过的层级不会占用chain位置——祖父节点被追加为chain[0],在调用方按下标映射时拿到Level1Rate和Level=1。这既改变了结算金额,也让层级字段与真实邀请关系不符。若期望"跳过但保留层级",应向 chain 填充 0 占位(调用方已有inviterID == 0的处理分支)。🐛 建议修改
if err := model.DB.Select("id, status").First(&inviter, user.InviterId).Error; err != nil { - currentID = user.InviterId // 邀请人不存在,但继续上溯 + chain = append(chain, 0) // 占位,保持层级对应关系 + currentID = user.InviterId continue } if inviter.Status != common.UserStatusEnabled { - currentID = user.InviterId // 邀请人已禁用,跳过该级但继续上溯 + chain = append(chain, 0) // 占位,保持层级对应关系 + currentID = user.InviterId continue }注意:调用方
CalculateCommission目前遇到inviterID == 0是break,需要改成continue才能配合占位语义。🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@service/commission.go` around lines 352 - 389, Update getInviterChain to append a 0 placeholder to chain whenever the direct inviter is missing or disabled, while continuing the upward traversal. In CalculateCommission, change the inviterID == 0 handling from break to continue so later valid levels retain their original positions and rates.
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: CHILL
Plan: Pro Plus
Run ID: bce93ad6-3c18-4f1a-93fe-c7abde62ebac
⛔ Files ignored due to path filters (3)
commission_source.tar.gzis excluded by!**/*.gzcommission_system_full.tar.gzis excluded by!**/*.gzweb/pnpm-lock.yamlis excluded by!**/pnpm-lock.yaml
📒 Files selected for processing (56)
.gitignoreCOMMISSION_QUICK_START.mdCOMMISSION_SYSTEM_DESIGN.mdFINAL_REPORT.mdINTEGRATION_GUIDE.mdREVIEW_CHECKLIST.mdcommon/constants.goconstant/channel.gocontroller/commission.gocontroller/misc.gocontroller/oauth.gocontroller/option.gocontroller/user.gocontroller/wechat.gomain.gomodel/commission_log.gomodel/commission_rule.gomodel/hooks.gomodel/log.gomodel/main.gomodel/option.gomodel/option_commission_test.gomodel/user.gonew-api-customrelay/channel/task/happyhorse/adaptor.gorelay/channel/task/happyhorse/constants.gorelay/relay_adaptor.gorouter/commission-router.gorouter/main.gorouter/video-router.goservice/commission.goservice/commission_guard.goservice/commission_init.goservice/commission_limit_test.goservice/task_billing_test.goweb/default/src/components/layout/config/system-settings.config.tsweb/default/src/features/commission/api.tsweb/default/src/features/commission/components/commission-logs-table.tsxweb/default/src/features/commission/components/commission-overview-card.tsxweb/default/src/features/commission/components/commission-stats-panel.tsxweb/default/src/features/commission/components/commission-transfer-dialog.tsxweb/default/src/features/commission/hooks.tsweb/default/src/features/commission/index.tsxweb/default/src/features/commission/types.tsweb/default/src/features/system-settings/commission/commission-settings-section.tsxweb/default/src/features/system-settings/commission/index.tsxweb/default/src/features/system-settings/commission/rule-api.tsweb/default/src/features/system-settings/commission/rules-section.tsxweb/default/src/features/system-settings/commission/section-registry.tsxweb/default/src/features/wallet/components/affiliate-rewards-card.tsxweb/default/src/hooks/use-sidebar-data.tsweb/default/src/hooks/use-system-config.tsweb/default/src/routes/_authenticated/commission/index.tsxweb/default/src/routes/_authenticated/system-settings/commission/$section.tsxweb/default/src/routes/_authenticated/system-settings/commission/index.tsxweb/default/src/stores/system-config-store.ts
| // ProcessCommission 执行返佣 | ||
| func (s *CommissionService) ProcessCommission(req CommissionRequest) error { | ||
| // 计算返佣 | ||
| result, err := s.CalculateCommission(req) | ||
| if err != nil { | ||
| return err | ||
| } | ||
|
|
||
| // 开始事务 | ||
| return s.db.Transaction(func(tx *gorm.DB) error { | ||
| for _, detail := range result.Details { | ||
| // 1. 创建返佣记录 | ||
| log := &model.CommissionLog{ | ||
| UserID: req.UserID, | ||
| InviterID: detail.InviterID, | ||
| Level: detail.Level, | ||
| OrderID: req.OrderID, | ||
| LogID: req.LogID, | ||
| ModelName: req.ModelName, | ||
| ConsumptionQuota: req.QuotaUsed, | ||
| CommissionRate: detail.CommissionRate, | ||
| CommissionQuota: detail.CommissionQuota, | ||
| Status: "settled", | ||
| SettledAt: time.Now(), | ||
| } |
There was a problem hiding this comment.
🗄️ Data Integrity & Integration | 🔴 Critical | 🏗️ Heavy lift
Make commission processing idempotent before crediting balances.
ProcessCommission always inserts a settled log and increments inviter balances. A retry for the same LogID/OrderID will pay the commission again, despite the documented idempotency requirement. Add a unique business key and atomically claim the source event before applying credits.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@COMMISSION_SYSTEM_DESIGN.md` around lines 255 - 279, Update ProcessCommission
to use a unique business key based on LogID/OrderID and atomically claim the
source event within the existing transaction before creating settled commission
logs or crediting inviter balances. On duplicate claims, return without applying
any commission again, while preserving normal processing for new events; enforce
the key with the relevant model/database uniqueness mechanism.
| rule, err := model.GetApplicableRule(req.ModelName, req.QuotaUsed) | ||
| if err != nil { | ||
| return nil, err | ||
| } | ||
|
|
||
| result := &CommissionResult{} | ||
|
|
||
| // 3. 计算各级返佣 | ||
| for level, inviterID := range inviterChain { | ||
| if inviterID == 0 { | ||
| break | ||
| } | ||
|
|
||
| // 防刷检查 | ||
| if err := s.guard.PreCheck(req.UserID, inviterID); err != nil { | ||
| common.SysLog(fmt.Sprintf("返佣防刷检查失败: user=%d, inviter=%d, err=%v", req.UserID, inviterID, err)) | ||
| continue | ||
| } | ||
|
|
||
| var rate float64 | ||
| switch level { | ||
| case 0: | ||
| rate = rule.Level1Rate | ||
| case 1: | ||
| rate = rule.Level2Rate | ||
| case 2: | ||
| rate = rule.Level3Rate | ||
| } |
There was a problem hiding this comment.
🩺 Stability & Availability | 🔴 Critical | ⚡ Quick win
rule 未判空即解引用,GetApplicableRule 若在无匹配规则时返回 (nil, nil) 会直接 panic。
CalculateCommission 在 err == nil 后立即访问 rule.Level1Rate。由于该函数处于每次消费的回调路径上,一次 nil 会打挂请求(或后台 goroutine)。请确认返回约定,并加显式判空。
🛡️ 建议修改
rule, err := model.GetApplicableRule(req.ModelName, req.QuotaUsed)
if err != nil {
return nil, err
}
+ if rule == nil {
+ return &CommissionResult{}, nil // 无适用规则,不返佣
+ }#!/bin/bash
# Confirm whether GetApplicableRule can return (nil, nil)
ast-grep run --pattern 'func GetApplicableRule($$$) ($$$) { $$$ }' --lang go model🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@service/commission.go` around lines 72 - 99, Update CalculateCommission after
GetApplicableRule returns to explicitly handle a nil rule even when err is nil,
returning an appropriate error before the commission loop dereferences
rule.Level1Rate, rule.Level2Rate, or rule.Level3Rate. Preserve the existing
error propagation for non-nil errors and follow the established project
error-construction convention.
| err = model.DB.Transaction(func(tx *gorm.DB) error { | ||
| for i, detail := range result.Details { | ||
| // 1. 行锁串行化同一邀请人(按 InviterID 升序处理避免死锁) | ||
| var inviter model.User | ||
| if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}). | ||
| Select("id").First(&inviter, detail.InviterID).Error; err != nil { | ||
| common.SysLog(fmt.Sprintf("行锁邀请人失败: inviter=%d, err=%v", detail.InviterID, err)) | ||
| continue | ||
| } |
There was a problem hiding this comment.
🩺 Stability & Availability | 🔴 Critical | ⚡ Quick win
三处直接使用 clause.Locking{Strength: "UPDATE"},会在 SQLite 上生成非法 SQL。 本仓库要求同时支持 SQLite、MySQL >= 5.7.8 与 PostgreSQL >= 9.6,而 SQLite 不支持 SELECT ... FOR UPDATE;model 包为此提供了方言感知的 lockForUpdate(tx) 助手。共同根因是绕过了该助手手写锁子句。
service/commission.go#L167-L175:ProcessCommission中对邀请人的行锁改用导出的方言感知助手(若lockForUpdate未导出,请在model包新增导出封装后调用)。service/commission.go#L299-L303:RefundCommission中读取quota/aff_quota/aff_history的行锁同样替换。controller/commission.go#L625-L630:AdminSettleCommission批量结算中的行锁同样替换。
需要注意:SQLite 下没有行锁,去掉 FOR UPDATE 后必须依赖已有的条件更新(WHERE status = 'pending')与 gorm.Expr 原子增量来保证正确性——好消息是这三处逻辑本身已经具备条件更新保护。
#!/bin/bash
# Locate the dialect-aware helper and existing usage patterns
rg -nP 'func lockForUpdate' -A20 model
rg -nP 'clause\.Locking\{' -g '*.go' -C2以上依据编码规范:“For standard SELECT ... FOR UPDATE locks, use lockForUpdate(tx) rather than the legacy GORM v1 query option or duplicated locking clauses” 以及 “Database code must support SQLite, MySQL >= 5.7.8, and PostgreSQL >= 9.6”。
📍 Affects 2 files
service/commission.go#L167-L175(this comment)service/commission.go#L299-L303controller/commission.go#L625-L630
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@service/commission.go` around lines 167 - 175, Replace the direct
clause.Locking{Strength: "UPDATE"} usage in service/commission.go lines 167-175,
service/commission.go lines 299-303, and controller/commission.go lines 625-630
with the model package’s exported dialect-aware lockForUpdate helper, adding an
exported wrapper if necessary. Preserve the existing conditional updates and
gorm.Expr atomic increments; no other locking or transaction behavior should
change.
| err = model.DB.Transaction(func(tx *gorm.DB) error { | ||
| for _, log := range logs { | ||
| if log.Status != "settled" { | ||
| continue // 只扣回已结算的 | ||
| } | ||
|
|
||
| // 3. 先抢状态:条件更新 settled → refunded(并发安全) | ||
| res := tx.Model(&model.CommissionLog{}). | ||
| Where("id = ? AND status = ?", log.Id, "settled"). | ||
| Update("status", "refunded") | ||
| if res.Error != nil { | ||
| return res.Error | ||
| } | ||
| if res.RowsAffected == 0 { | ||
| continue // 已被并发退款处理,跳过扣钱 | ||
| } | ||
|
|
There was a problem hiding this comment.
🗄️ Data Integrity & Integration | 🔴 Critical | ⚡ Quick win
退款只处理 settled 记录,pending 记录会被后续手动结算照常发放。
Line 282 对非 settled 的记录直接 continue。在延迟结算模式(CommissionRealTimeSettle=false)下,返佣记录全是 pending;消费被退款后这些记录仍留在待结算队列,AdminSettleCommission 之后会照常加钱,等于为已退款的消费付佣金。应把 pending 记录条件更新为 cancelled。
🐛 建议修改
for _, log := range logs {
- if log.Status != "settled" {
- continue // 只扣回已结算的
- }
+ if log.Status == "pending" {
+ // 尚未结算:直接作废,避免后续被手动结算发放
+ if err := tx.Model(&model.CommissionLog{}).
+ Where("id = ? AND status = ?", log.Id, "pending").
+ Update("status", "cancelled").Error; err != nil {
+ return err
+ }
+ continue
+ }
+ if log.Status != "settled" {
+ continue
+ }📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| err = model.DB.Transaction(func(tx *gorm.DB) error { | |
| for _, log := range logs { | |
| if log.Status != "settled" { | |
| continue // 只扣回已结算的 | |
| } | |
| // 3. 先抢状态:条件更新 settled → refunded(并发安全) | |
| res := tx.Model(&model.CommissionLog{}). | |
| Where("id = ? AND status = ?", log.Id, "settled"). | |
| Update("status", "refunded") | |
| if res.Error != nil { | |
| return res.Error | |
| } | |
| if res.RowsAffected == 0 { | |
| continue // 已被并发退款处理,跳过扣钱 | |
| } | |
| err = model.DB.Transaction(func(tx *gorm.DB) error { | |
| for _, log := range logs { | |
| if log.Status == "pending" { | |
| // 尚未结算:直接作废,避免后续被手动结算发放 | |
| if err := tx.Model(&model.CommissionLog{}). | |
| Where("id = ? AND status = ?", log.Id, "pending"). | |
| Update("status", "cancelled").Error; err != nil { | |
| return err | |
| } | |
| continue | |
| } | |
| if log.Status != "settled" { | |
| continue | |
| } | |
| // 3. 先抢状态:条件更新 settled → refunded(并发安全) | |
| res := tx.Model(&model.CommissionLog{}). | |
| Where("id = ? AND status = ?", log.Id, "settled"). | |
| Update("status", "refunded") | |
| if res.Error != nil { | |
| return res.Error | |
| } | |
| if res.RowsAffected == 0 { | |
| continue // 已被并发退款处理,跳过扣钱 | |
| } |
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@service/commission.go` around lines 280 - 296, Update the refund transaction
loop around CommissionLog status handling to process both settled and pending
records: atomically change settled to refunded and pending to cancelled using
the existing conditional update pattern. Only skip other statuses, and ensure
cancelled pending records are excluded from later AdminSettleCommission payouts.
| func (s *CommissionService) checkDailyLimitTx(tx *gorm.DB, inviterID int, newCommission int, dailyLimit int) bool { | ||
| if dailyLimit <= 0 { | ||
| return true // 不限制 | ||
| } | ||
|
|
||
| // 计算今日已返佣总额(time.Time 范围,适配 datetime 列) | ||
| now := time.Now() | ||
| dayStart := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location()) // 不要 .Unix() | ||
| dayEnd := dayStart.Add(24 * time.Hour) // 替代 dayStart + 86400 | ||
|
|
||
| var totalToday int64 | ||
| err := tx.Model(&model.CommissionLog{}). | ||
| Where("inviter_id = ? AND status = ? AND created_at >= ? AND created_at < ?", inviterID, "settled", dayStart, dayEnd). | ||
| Select("COALESCE(SUM(commission_quota), 0)"). | ||
| Scan(&totalToday).Error | ||
|
|
||
| if err != nil { | ||
| common.SysLog(fmt.Sprintf("检查每日限额失败: %v", err)) | ||
| return false // 查询失败,拒绝返佣 | ||
| } | ||
|
|
||
| return int(totalToday)+newCommission <= dailyLimit | ||
| } | ||
|
|
||
| // checkMonthlyLimitTx 事务内检查每月限额(使用 tx 查询,time.Time 范围) | ||
| // 注意:commission_logs.created_at 是 time.Time 类型,必须用 time.Time 参数比较 | ||
| func (s *CommissionService) checkMonthlyLimitTx(tx *gorm.DB, inviterID int, newCommission int, monthlyLimit int) bool { | ||
| if monthlyLimit <= 0 { | ||
| return true // 不限制 | ||
| } | ||
|
|
||
| // 计算本月已返佣总额(time.Time 范围,适配 datetime 列) | ||
| now := time.Now() | ||
| monthStart := time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, now.Location()) // 不要 .Unix() | ||
|
|
||
| var totalMonth int64 | ||
| err := tx.Model(&model.CommissionLog{}). | ||
| Where("inviter_id = ? AND status = ? AND created_at >= ?", inviterID, "settled", monthStart). | ||
| Select("COALESCE(SUM(commission_quota), 0)"). | ||
| Scan(&totalMonth).Error | ||
|
|
||
| if err != nil { | ||
| common.SysLog(fmt.Sprintf("检查每月限额失败: %v", err)) | ||
| return false // 查询失败,拒绝返佣 | ||
| } | ||
|
|
||
| return int(totalMonth)+newCommission <= monthlyLimit | ||
| } |
There was a problem hiding this comment.
🗄️ Data Integrity & Integration | 🔴 Critical | 🏗️ Heavy lift
延迟结算模式下日/月返佣限额完全失效。 共同根因:限额统计只累加 status = 'settled' 的记录,而当 CommissionRealTimeSettle=false 时新建记录全部是 pending,聚合恒为 0;随后手动结算路径又没有任何限额复核,于是所有超额 pending 记录被一次性全额发放,rule.DailyLimit / MonthlyLimit 形同不存在。
service/commission.go#L394-L441:checkDailyLimitTx/checkMonthlyLimitTx的status条件应改为同时计入pending与settled(例如status IN ('pending','settled')),使限额在两种结算模式下口径一致。controller/commission.go#L648-L657:AdminSettleCommission在加钱前应对该邀请人复核对应规则的日/月限额,超限记录跳过或标记cancelled,而不是无条件quota + CommissionQuota。
📍 Affects 2 files
service/commission.go#L394-L441(this comment)controller/commission.go#L648-L657
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@service/commission.go` around lines 394 - 441, Update service/commission.go
in checkDailyLimitTx and checkMonthlyLimitTx to aggregate both pending and
settled commission records instead of filtering only settled entries. Update
controller/commission.go in AdminSettleCommission to revalidate the inviter’s
daily and monthly limits before crediting funds, skipping or marking over-limit
records cancelled rather than adding their CommissionQuota unconditionally.
| <TableCell className='text-xs'>{log.model_name}</TableCell> | ||
| <TableCell className='text-right font-mono text-xs tabular-nums'>{formatQuota(log.consumption)}</TableCell> | ||
| <TableCell className='text-right text-xs tabular-nums'>{(log.rate * 100).toFixed(1)}%</TableCell> | ||
| <TableCell className='text-right font-mono text-xs font-medium tabular-nums">{formatQuota(log.commission)}</TableCell> |
There was a problem hiding this comment.
🎯 Functional Correctness | 🔴 Critical | ⚡ Quick win
Fix the malformed JSX attribute quote.
The className starts with ' but ends with ", causing a TSX parse failure.
- <TableCell className='text-right font-mono text-xs font-medium tabular-nums">{formatQuota(log.commission)}</TableCell>
+ <TableCell className='text-right font-mono text-xs font-medium tabular-nums'>{formatQuota(log.commission)}</TableCell>📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| <TableCell className='text-right font-mono text-xs font-medium tabular-nums">{formatQuota(log.commission)}</TableCell> | |
| <TableCell className='text-right font-mono text-xs font-medium tabular-nums'>{formatQuota(log.commission)}</TableCell> |
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@web/default/src/features/commission/components/commission-logs-table.tsx` at
line 108, Fix the className attribute on the commission log TableCell by using
matching quote delimiters around the existing CSS class string, preserving the
current classes and JSX structure.
| </div> | ||
| <div className='bg-background rounded-lg border p-3 text-center'> | ||
| <div className='text-muted-foreground text-[10px] font-medium tracking-wider uppercase'>{t('总消费')}</div> | ||
| <div className='mt-0.5 text-lg font-semibold tabular-nums">{formatQuota(stats?.total_consumption ?? 0)}</div> |
There was a problem hiding this comment.
🎯 Functional Correctness | 🔴 Critical | ⚡ Quick win
Fix the unterminated className attribute.
The double quote is inside a single-quoted JSX attribute value, leaving the attribute unterminated and preventing the TSX file from parsing.
Proposed fix
- <div className='mt-0.5 text-lg font-semibold tabular-nums">{formatQuota(stats?.total_consumption ?? 0)}</div>
+ <div className='mt-0.5 text-lg font-semibold tabular-nums'>{formatQuota(stats?.total_consumption ?? 0)}</div>rg -nF 'tabular-nums">' web/default/src/features/commission/components/commission-stats-panel.tsx🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@web/default/src/features/commission/components/commission-stats-panel.tsx` at
line 67, Fix the JSX syntax in the commission stats panel by properly
terminating the className attribute on the div rendering
formatQuota(stats?.total_consumption ?? 0). Preserve the existing class names
and displayed value.
📝 变更描述 / Description
实现完整的多层级返佣(Commission)系统,包含后端核心逻辑和前端管理面板。
核心变更:
安全修复(P0-P2):
🚀 变更类型 / Type of change
🔗 关联任务 / Related Issue
✅ 提交前检查项 / Checklist
📸 运行证明 / Proof of Work
Summary by CodeRabbit