feat: 接入论坛奖励 outbox 链路

This commit is contained in:
Losita
2026-05-05 10:44:33 +08:00
parent 4fc6c0cac3
commit c42f0c5b8c
31 changed files with 1381 additions and 101 deletions

View File

@@ -3,6 +3,7 @@ package dao
import (
"fmt"
outboxinfra "github.com/LoveLosita/smartflow/backend/infra/outbox"
tokenmodel "github.com/LoveLosita/smartflow/backend/services/tokenstore/model"
"github.com/spf13/viper"
"gorm.io/driver/mysql"
@@ -13,7 +14,7 @@ import (
// OpenDBFromConfig 创建 token-store 服务自己的数据库句柄,并迁移本服务私有表。
//
// 职责边界:
// 1. 只迁移 token_* 表,不迁移 users避免和 user/auth 服务边界冲突;
// 1. 只迁移 token_* 表和 token-store outbox 表,不迁移 users避免和 user/auth 服务边界冲突;
// 2. 自动迁移后执行 P0 seed确保前端商品页有可展示商品
// 3. 返回 *gorm.DB 供本服务 DAO 复用,调用方负责进程生命周期。
func OpenDBFromConfig() (*gorm.DB, error) {
@@ -45,8 +46,9 @@ func OpenDBFromConfig() (*gorm.DB, error) {
//
// 步骤说明:
// 1. 先创建商品、订单、获取账本和奖励规则表;
// 2. 通过唯一约束保证 order_no、event_id 和幂等键不会重复写入
// 3. 失败时直接返回错误,避免服务在 schema 不完整时继续启动。
// 2. 再按 service catalog 创建 token-store outbox 表,保证论坛奖励事件有稳定落表目录
// 3. 通过唯一约束保证 order_no、event_id 和幂等键不会重复写入;
// 4. 失败时直接返回错误,避免服务在 schema 不完整时继续启动。
func AutoMigrate(db *gorm.DB) error {
if db == nil {
return fmt.Errorf("tokenstore auto migrate failed: db is nil")
@@ -59,6 +61,9 @@ func AutoMigrate(db *gorm.DB) error {
); err != nil {
return fmt.Errorf("auto migrate tokenstore tables failed: %w", err)
}
if err := outboxinfra.AutoMigrateServiceTable(db, outboxinfra.ServiceTokenStore); err != nil {
return err
}
return nil
}
@@ -172,7 +177,7 @@ func defaultTokenRewardRules() []tokenmodel.TokenRewardRule {
{
Source: tokenmodel.TokenGrantSourceForumImport,
Name: "计划被导入奖励",
Amount: 2,
Amount: 5,
Status: tokenmodel.TokenRewardRuleStatusActive,
},
}

View File

@@ -64,6 +64,31 @@ func (dao *TokenStoreDAO) ListActiveProducts(ctx context.Context) ([]tokenmodel.
return products, err
}
// FindRewardRuleBySource 按来源读取社区奖励规则。
//
// 职责边界:
// 1. 只读取 token_reward_rules不计算最终发放金额也不判断停用语义
// 2. 未找到规则时返回 nil由服务层决定配置或默认值兜底
// 3. source 在 DAO 层做一次规范化,避免大小写和空格造成规则漏命中。
func (dao *TokenStoreDAO) FindRewardRuleBySource(ctx context.Context, source string) (*tokenmodel.TokenRewardRule, error) {
source = strings.ToLower(strings.TrimSpace(source))
if source == "" {
return nil, nil
}
var rule tokenmodel.TokenRewardRule
err := dao.db.WithContext(ctx).
Where("source = ?", source).
First(&rule).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, nil
}
if err != nil {
return nil, err
}
return &rule, nil
}
func (dao *TokenStoreDAO) FindActiveProductByID(ctx context.Context, productID uint64) (*tokenmodel.TokenProduct, error) {
var product tokenmodel.TokenProduct
err := dao.db.WithContext(ctx).

View File

@@ -171,6 +171,28 @@ func (h *Handler) ListGrants(ctx context.Context, req *pb.ListTokenGrantsRequest
}, nil
}
// RecordForumRewardGrant 负责把论坛 outbox 奖励事件转成 token-store 内部账本写入调用。
func (h *Handler) RecordForumRewardGrant(ctx context.Context, req *pb.RecordForumRewardGrantRequest) (*pb.RecordForumRewardGrantResponse, error) {
svc, err := h.service()
if err != nil {
return nil, grpcErrorFromServiceError(err)
}
if req == nil {
return nil, grpcErrorFromServiceError(respond.MissingParam)
}
grant, err := svc.RecordForumRewardGrant(ctx, tokencontracts.RecordForumRewardGrantRequest{
EventID: req.EventId,
ReceiverUserID: req.ReceiverUserId,
Source: req.Source,
SourceRefID: req.SourceRefId,
})
if err != nil {
return nil, grpcErrorFromServiceError(err)
}
return &pb.RecordForumRewardGrantResponse{Grant: tokenGrantToPB(grant)}, nil
}
func tokenPageToPB(page tokencontracts.PageResult) *pb.PageResponse {
return &pb.PageResponse{
Page: int32(page.Page),

View File

@@ -210,3 +210,22 @@ type ListTokenGrantsResponse struct {
func (m *ListTokenGrantsResponse) Reset() { *m = ListTokenGrantsResponse{} }
func (m *ListTokenGrantsResponse) String() string { return proto.CompactTextString(m) }
func (*ListTokenGrantsResponse) ProtoMessage() {}
type RecordForumRewardGrantRequest struct {
EventId string `protobuf:"bytes,1,opt,name=event_id,json=eventId,proto3" json:"event_id,omitempty"`
ReceiverUserId uint64 `protobuf:"varint,2,opt,name=receiver_user_id,json=receiverUserId,proto3" json:"receiver_user_id,omitempty"`
Source string `protobuf:"bytes,3,opt,name=source,proto3" json:"source,omitempty"`
SourceRefId string `protobuf:"bytes,4,opt,name=source_ref_id,json=sourceRefId,proto3" json:"source_ref_id,omitempty"`
}
func (m *RecordForumRewardGrantRequest) Reset() { *m = RecordForumRewardGrantRequest{} }
func (m *RecordForumRewardGrantRequest) String() string { return proto.CompactTextString(m) }
func (*RecordForumRewardGrantRequest) ProtoMessage() {}
type RecordForumRewardGrantResponse struct {
Grant *TokenGrantView `protobuf:"bytes,1,opt,name=grant,proto3" json:"grant,omitempty"`
}
func (m *RecordForumRewardGrantResponse) Reset() { *m = RecordForumRewardGrantResponse{} }
func (m *RecordForumRewardGrantResponse) String() string { return proto.CompactTextString(m) }
func (*RecordForumRewardGrantResponse) ProtoMessage() {}

View File

@@ -9,13 +9,14 @@ import (
)
const (
TokenStoreService_GetSummary_FullMethodName = "/smartflow.tokenstore.TokenStoreService/GetSummary"
TokenStoreService_ListProducts_FullMethodName = "/smartflow.tokenstore.TokenStoreService/ListProducts"
TokenStoreService_CreateOrder_FullMethodName = "/smartflow.tokenstore.TokenStoreService/CreateOrder"
TokenStoreService_ListOrders_FullMethodName = "/smartflow.tokenstore.TokenStoreService/ListOrders"
TokenStoreService_GetOrder_FullMethodName = "/smartflow.tokenstore.TokenStoreService/GetOrder"
TokenStoreService_MockPaidOrder_FullMethodName = "/smartflow.tokenstore.TokenStoreService/MockPaidOrder"
TokenStoreService_ListGrants_FullMethodName = "/smartflow.tokenstore.TokenStoreService/ListGrants"
TokenStoreService_GetSummary_FullMethodName = "/smartflow.tokenstore.TokenStoreService/GetSummary"
TokenStoreService_ListProducts_FullMethodName = "/smartflow.tokenstore.TokenStoreService/ListProducts"
TokenStoreService_CreateOrder_FullMethodName = "/smartflow.tokenstore.TokenStoreService/CreateOrder"
TokenStoreService_ListOrders_FullMethodName = "/smartflow.tokenstore.TokenStoreService/ListOrders"
TokenStoreService_GetOrder_FullMethodName = "/smartflow.tokenstore.TokenStoreService/GetOrder"
TokenStoreService_MockPaidOrder_FullMethodName = "/smartflow.tokenstore.TokenStoreService/MockPaidOrder"
TokenStoreService_ListGrants_FullMethodName = "/smartflow.tokenstore.TokenStoreService/ListGrants"
TokenStoreService_RecordForumRewardGrant_FullMethodName = "/smartflow.tokenstore.TokenStoreService/RecordForumRewardGrant"
)
type TokenStoreServiceClient interface {
@@ -26,6 +27,7 @@ type TokenStoreServiceClient interface {
GetOrder(ctx context.Context, in *GetTokenOrderRequest, opts ...grpc.CallOption) (*GetTokenOrderResponse, error)
MockPaidOrder(ctx context.Context, in *MockPaidOrderRequest, opts ...grpc.CallOption) (*MockPaidOrderResponse, error)
ListGrants(ctx context.Context, in *ListTokenGrantsRequest, opts ...grpc.CallOption) (*ListTokenGrantsResponse, error)
RecordForumRewardGrant(ctx context.Context, in *RecordForumRewardGrantRequest, opts ...grpc.CallOption) (*RecordForumRewardGrantResponse, error)
}
type tokenStoreServiceClient struct {
@@ -64,6 +66,10 @@ func (c *tokenStoreServiceClient) ListGrants(ctx context.Context, in *ListTokenG
return invokeTokenStore[ListTokenGrantsResponse](ctx, c.cc, TokenStoreService_ListGrants_FullMethodName, in, opts...)
}
func (c *tokenStoreServiceClient) RecordForumRewardGrant(ctx context.Context, in *RecordForumRewardGrantRequest, opts ...grpc.CallOption) (*RecordForumRewardGrantResponse, error) {
return invokeTokenStore[RecordForumRewardGrantResponse](ctx, c.cc, TokenStoreService_RecordForumRewardGrant_FullMethodName, in, opts...)
}
func invokeTokenStore[Resp any](ctx context.Context, cc grpc.ClientConnInterface, fullMethod string, in interface{}, opts ...grpc.CallOption) (*Resp, error) {
out := new(Resp)
err := cc.Invoke(ctx, fullMethod, in, out, opts...)
@@ -81,6 +87,7 @@ type TokenStoreServiceServer interface {
GetOrder(context.Context, *GetTokenOrderRequest) (*GetTokenOrderResponse, error)
MockPaidOrder(context.Context, *MockPaidOrderRequest) (*MockPaidOrderResponse, error)
ListGrants(context.Context, *ListTokenGrantsRequest) (*ListTokenGrantsResponse, error)
RecordForumRewardGrant(context.Context, *RecordForumRewardGrantRequest) (*RecordForumRewardGrantResponse, error)
}
type UnimplementedTokenStoreServiceServer struct{}
@@ -113,6 +120,10 @@ func (UnimplementedTokenStoreServiceServer) ListGrants(context.Context, *ListTok
return nil, status.Errorf(codes.Unimplemented, "method ListGrants not implemented")
}
func (UnimplementedTokenStoreServiceServer) RecordForumRewardGrant(context.Context, *RecordForumRewardGrantRequest) (*RecordForumRewardGrantResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method RecordForumRewardGrant not implemented")
}
func RegisterTokenStoreServiceServer(s grpc.ServiceRegistrar, srv TokenStoreServiceServer) {
s.RegisterService(&TokenStoreService_ServiceDesc, srv)
}
@@ -165,6 +176,9 @@ var TokenStoreService_ServiceDesc = grpc.ServiceDesc{
tokenStoreUnaryHandler[ListTokenGrantsRequest]("ListGrants", TokenStoreService_ListGrants_FullMethodName, func(s TokenStoreServiceServer, ctx context.Context, req *ListTokenGrantsRequest) (interface{}, error) {
return s.ListGrants(ctx, req)
}),
tokenStoreUnaryHandler[RecordForumRewardGrantRequest]("RecordForumRewardGrant", TokenStoreService_RecordForumRewardGrant_FullMethodName, func(s TokenStoreServiceServer, ctx context.Context, req *RecordForumRewardGrantRequest) (interface{}, error) {
return s.RecordForumRewardGrant(ctx, req)
}),
},
Streams: []grpc.StreamDesc{},
Metadata: "tokenstore.proto",

View File

@@ -12,6 +12,7 @@ service TokenStoreService {
rpc GetOrder(GetTokenOrderRequest) returns (GetTokenOrderResponse);
rpc MockPaidOrder(MockPaidOrderRequest) returns (MockPaidOrderResponse);
rpc ListGrants(ListTokenGrantsRequest) returns (ListTokenGrantsResponse);
rpc RecordForumRewardGrant(RecordForumRewardGrantRequest) returns (RecordForumRewardGrantResponse);
}
message PageResponse {
@@ -142,3 +143,14 @@ message ListTokenGrantsResponse {
repeated TokenGrantView items = 1;
PageResponse page = 2;
}
message RecordForumRewardGrantRequest {
string event_id = 1;
uint64 receiver_user_id = 2;
string source = 3;
string source_ref_id = 4;
}
message RecordForumRewardGrantResponse {
TokenGrantView grant = 1;
}

View File

@@ -0,0 +1,234 @@
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 "论坛奖励入账"
}
}