235 lines
8.0 KiB
Go
235 lines
8.0 KiB
Go
package sv
|
||
|
||
import (
|
||
"context"
|
||
"errors"
|
||
"strconv"
|
||
"strings"
|
||
|
||
tokenmodel "github.com/LoveLosita/smartflow/backend/services/tokenstore/model"
|
||
tokencontracts "github.com/LoveLosita/smartflow/backend/shared/contracts/tokenstore"
|
||
"github.com/spf13/viper"
|
||
)
|
||
|
||
const (
|
||
forumLikeRewardConfigKey = "tokenstore.reward.forumLikeAmount"
|
||
forumImportRewardConfigKey = "tokenstore.reward.forumImportAmount"
|
||
|
||
defaultForumLikeRewardAmount int64 = 1
|
||
defaultForumImportRewardAmount int64 = 5
|
||
)
|
||
|
||
type forumRewardGrantRequest struct {
|
||
EventID string
|
||
ReceiverUserID uint64
|
||
Source string
|
||
SourceRefID uint64
|
||
}
|
||
|
||
type forumRewardDecision struct {
|
||
Amount int64
|
||
Status string
|
||
Description string
|
||
}
|
||
|
||
// RecordForumRewardGrant 负责把论坛点赞/导入奖励写入 token_grants。
|
||
//
|
||
// 职责边界:
|
||
// 1. 只处理 forum_like / forum_import 两类奖励账本写入,不修改 users,也不调用 user/auth;
|
||
// 2. 以 event_id 作为最终幂等边界,重复请求校验一致后返回既有 grant;
|
||
// 3. 奖励金额优先读取 token_reward_rules,配置和代码默认值只作为兜底。
|
||
func (s *Service) RecordForumRewardGrant(ctx context.Context, req tokencontracts.RecordForumRewardGrantRequest) (*tokencontracts.TokenGrantView, error) {
|
||
if err := s.Ready(); err != nil {
|
||
return nil, err
|
||
}
|
||
|
||
normalized, err := normalizeForumRewardGrantRequest(req)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
|
||
// 1. 先按 event_id 回查,命中时直接视为成功,避免 outbox 重试重复写账本。
|
||
// 2. 命中后必须校验用户、来源和来源业务 ID,避免错误复用 event_id 时静默吞掉错账。
|
||
// 3. 校验通过才返回既有 grant,兼容“首次已成功、调用方超时后重试”的常见场景。
|
||
existing, err := s.tokenDAO.FindGrantByEventID(ctx, normalized.EventID)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
if existing != nil {
|
||
if err := validateExistingForumRewardGrant(*existing, normalized); err != nil {
|
||
return nil, err
|
||
}
|
||
view := grantViewFromModel(*existing)
|
||
return &view, nil
|
||
}
|
||
|
||
sourceRefID := normalized.SourceRefID
|
||
decision, err := s.forumRewardDecision(ctx, normalized.Source)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
grant := tokenmodel.TokenGrant{
|
||
EventID: normalized.EventID,
|
||
UserID: normalized.ReceiverUserID,
|
||
Source: normalized.Source,
|
||
SourceLabel: grantSourceLabel(normalized.Source, ""),
|
||
SourceRefID: &sourceRefID,
|
||
Amount: decision.Amount,
|
||
Status: decision.Status,
|
||
QuotaApplied: false,
|
||
Description: decision.Description,
|
||
}
|
||
|
||
// 1. 账本写入只依赖 token_grants.event_id 唯一约束兜底并发幂等。
|
||
// 2. 若并发下插入触发唯一键冲突,立刻回查 event_id,把已有 grant 当作成功结果返回。
|
||
// 3. 只有“冲突后仍查不到旧记录”这种异常态才上抛内部错误,避免吞掉真实一致性问题。
|
||
if err := s.tokenDAO.CreateGrant(ctx, &grant); err != nil {
|
||
if !isDuplicateKeyError(err) {
|
||
return nil, err
|
||
}
|
||
|
||
existing, err := s.tokenDAO.FindGrantByEventID(ctx, normalized.EventID)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
if existing == nil {
|
||
return nil, errors.New("forum reward grant duplicated but not found by event_id")
|
||
}
|
||
if err := validateExistingForumRewardGrant(*existing, normalized); err != nil {
|
||
return nil, err
|
||
}
|
||
view := grantViewFromModel(*existing)
|
||
return &view, nil
|
||
}
|
||
|
||
view := grantViewFromModel(grant)
|
||
return &view, nil
|
||
}
|
||
|
||
func normalizeForumRewardGrantRequest(req tokencontracts.RecordForumRewardGrantRequest) (forumRewardGrantRequest, error) {
|
||
normalized := forumRewardGrantRequest{
|
||
EventID: strings.TrimSpace(req.EventID),
|
||
ReceiverUserID: req.ReceiverUserID,
|
||
Source: strings.ToLower(strings.TrimSpace(req.Source)),
|
||
}
|
||
|
||
switch {
|
||
case normalized.EventID == "":
|
||
return forumRewardGrantRequest{}, tokenStoreBadRequest("event_id 不能为空")
|
||
case normalized.ReceiverUserID == 0:
|
||
return forumRewardGrantRequest{}, tokenStoreBadRequest("receiver_user_id 不能为空")
|
||
}
|
||
|
||
sourceRefID, err := parseForumRewardSourceRefID(req.SourceRefID)
|
||
if err != nil {
|
||
return forumRewardGrantRequest{}, err
|
||
}
|
||
normalized.SourceRefID = sourceRefID
|
||
|
||
switch normalized.Source {
|
||
case tokenmodel.TokenGrantSourceForumLike, tokenmodel.TokenGrantSourceForumImport:
|
||
return normalized, nil
|
||
default:
|
||
return forumRewardGrantRequest{}, tokenStoreBadRequest("source 仅支持 forum_like 或 forum_import")
|
||
}
|
||
}
|
||
|
||
func parseForumRewardSourceRefID(raw string) (uint64, error) {
|
||
trimmed := strings.TrimSpace(raw)
|
||
if trimmed == "" {
|
||
return 0, tokenStoreBadRequest("source_ref_id 不能为空")
|
||
}
|
||
|
||
parsed, err := strconv.ParseUint(trimmed, 10, 64)
|
||
if err != nil || parsed == 0 {
|
||
return 0, tokenStoreBadRequest("source_ref_id 必须是正整数")
|
||
}
|
||
return parsed, nil
|
||
}
|
||
|
||
// validateExistingForumRewardGrant 校验重复 event_id 是否真的是同一条论坛奖励。
|
||
//
|
||
// 职责边界:
|
||
// 1. 只比较幂等所需的最小字段:接收人、来源和来源业务 ID;
|
||
// 2. 不比较金额和状态,避免规则调整后重放旧事件被误判;
|
||
// 3. 不一致时返回业务校验错误,让上游暴露这类错账风险。
|
||
func validateExistingForumRewardGrant(existing tokenmodel.TokenGrant, req forumRewardGrantRequest) error {
|
||
sourceRefID := uint64(0)
|
||
if existing.SourceRefID != nil {
|
||
sourceRefID = *existing.SourceRefID
|
||
}
|
||
if existing.UserID != req.ReceiverUserID || existing.Source != req.Source || sourceRefID != req.SourceRefID {
|
||
return tokenStoreBadRequest("event_id 幂等冲突:已有奖励记录与本次论坛奖励请求不一致")
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// forumRewardDecision 解析论坛奖励发放决策。
|
||
//
|
||
// 职责边界:
|
||
// 1. 优先读取 token_reward_rules,保持“从表里读”的 P0 口径;
|
||
// 2. 规则停用或金额非正时写 skipped 账本,消费 outbox 但不增加 Token;
|
||
// 3. 表规则缺失时再读取配置和代码默认值,兼容旧环境尚未 seed 的情况。
|
||
func (s *Service) forumRewardDecision(ctx context.Context, source string) (forumRewardDecision, error) {
|
||
rule, err := s.tokenDAO.FindRewardRuleBySource(ctx, source)
|
||
if err != nil {
|
||
return forumRewardDecision{}, err
|
||
}
|
||
if rule != nil {
|
||
if strings.TrimSpace(rule.Status) != tokenmodel.TokenRewardRuleStatusActive {
|
||
return skippedForumRewardDecision(source, "奖励规则已停用,未发放 Token"), nil
|
||
}
|
||
if rule.Amount <= 0 {
|
||
return skippedForumRewardDecision(source, "奖励规则金额非正,未发放 Token"), nil
|
||
}
|
||
return recordedForumRewardDecision(source, rule.Amount), nil
|
||
}
|
||
|
||
switch strings.TrimSpace(source) {
|
||
case tokenmodel.TokenGrantSourceForumLike:
|
||
return recordedForumRewardDecision(source, positiveConfigAmountOrDefault(forumLikeRewardConfigKey, defaultForumLikeRewardAmount)), nil
|
||
case tokenmodel.TokenGrantSourceForumImport:
|
||
return recordedForumRewardDecision(source, positiveConfigAmountOrDefault(forumImportRewardConfigKey, defaultForumImportRewardAmount)), nil
|
||
default:
|
||
return skippedForumRewardDecision(source, "未知论坛奖励来源,未发放 Token"), nil
|
||
}
|
||
}
|
||
|
||
func recordedForumRewardDecision(source string, amount int64) forumRewardDecision {
|
||
if amount <= 0 {
|
||
return skippedForumRewardDecision(source, "奖励金额非正,未发放 Token")
|
||
}
|
||
return forumRewardDecision{
|
||
Amount: amount,
|
||
Status: tokenmodel.TokenGrantStatusRecorded,
|
||
Description: forumRewardDescription(source),
|
||
}
|
||
}
|
||
|
||
func skippedForumRewardDecision(source string, description string) forumRewardDecision {
|
||
return forumRewardDecision{
|
||
Amount: 0,
|
||
Status: tokenmodel.TokenGrantStatusSkipped,
|
||
Description: strings.TrimSpace(description),
|
||
}
|
||
}
|
||
|
||
func positiveConfigAmountOrDefault(configKey string, fallback int64) int64 {
|
||
amount := viper.GetInt64(configKey)
|
||
if amount <= 0 {
|
||
return fallback
|
||
}
|
||
return amount
|
||
}
|
||
|
||
func forumRewardDescription(source string) string {
|
||
switch strings.TrimSpace(source) {
|
||
case tokenmodel.TokenGrantSourceForumLike:
|
||
return "计划被点赞奖励"
|
||
case tokenmodel.TokenGrantSourceForumImport:
|
||
return "计划被导入奖励"
|
||
default:
|
||
return "论坛奖励入账"
|
||
}
|
||
}
|