07409eb602
Closes HIF-4 Stage 2 交付:人工奖领奖工单完整闭环。crypto / physical / manual_other 三类奖品从抽中到 mark-paid 的全流程可用。 - 迁移 02159_lottery_claim:UNIQUE(draw_id) + 3 支持索引,状态机 pending_claim→reviewing→paying→paid,rejected 可复活,超时 expired - 3 个 PrizeHandler:Dispatch→ErrDispatchNotSupported 兜底、ClaimSchema 各自形态、ValidateClaim 表驱动 - BuildCryptoClaimSchema:抽中时按奖品 config.networks 注入 enum,前端下拉直接可用 - Draw service dispatchOrEnqueueClaim:人工奖同 tx 插 pending_claim(回滚双清),nonce 重放回读 ExpiresAt + ClaimFormSchema - POST /claim 实装:ownership 校验 → prize 类型校验 → handler.ValidateClaim → crypto network 白名单二次校验 → tx CAS status IN (pending_claim, rejected) AND expires_at > now - Admin CRUD 5 接口:list(IN 批拉 snap + user,无 N+1)、summary(GROUP BY 一次拿计数 + overdue 单查)、approve/reject/mark-paid 全走 CAS + audit - Scheduler @every 1h 扫过期,级联 lottery_draw.dispatch_state → expired - 新增错误码 100005-100011(already_submitted / invalid_claim_data / draw_not_found / not_your_draw / claim_expired / claim_state_invalid) - Rebase 后 Stage 2 测试主动 reuse PR E 的 unmetReasonsNotEmpty + evaluatedAtNotZero matcher,人工奖分支若绕过守卫会立即挂 - Stage 1 全部 4 处 guardrail 后端 rebase 时自检过:UnmetReasons、EvaluatedAt、GrantLedger.Payload、AdminMetaMiddleware 全保留 CI 全绿;28 files, +2442/-126;覆盖率 handler 78.9% / model.lottery 74.3% / draw 68.7% / queue/lottery 76.9%
472 lines
15 KiB
Go
472 lines
15 KiB
Go
// admin_claims.go 实现 Stage 2 后台工单接口:
|
||
//
|
||
// GET /v1/admin/lottery/claims — 分页列表
|
||
// POST /v1/admin/lottery/claims/approve — reviewing → paying
|
||
// POST /v1/admin/lottery/claims/reject — reviewing|paying → rejected
|
||
// POST /v1/admin/lottery/claims/mark-paid — paying → paid
|
||
// GET /v1/admin/lottery/claims/summary — 工作台状态计数
|
||
//
|
||
// 状态机严格 CAS:所有写路径都用 WHERE status IN (...) 做前置校验,
|
||
// RowsAffected==0 → 100011 claim_state_invalid(并发/竞态兜底)。
|
||
package lottery
|
||
|
||
import (
|
||
"context"
|
||
"encoding/json"
|
||
"strings"
|
||
"time"
|
||
|
||
"github.com/perfect-panel/server/internal/logic/audit"
|
||
modelLottery "github.com/perfect-panel/server/internal/model/lottery"
|
||
"github.com/perfect-panel/server/internal/svc"
|
||
"github.com/perfect-panel/server/internal/types"
|
||
"github.com/perfect-panel/server/pkg/logger"
|
||
"github.com/perfect-panel/server/pkg/xerr"
|
||
"github.com/pkg/errors"
|
||
"gorm.io/gorm"
|
||
)
|
||
|
||
// audit action codes 供 admin_action_log 用(新增 Stage 2 三个)。
|
||
const (
|
||
ActionLotteryClaimApprove = "lottery.claim.approve"
|
||
ActionLotteryClaimReject = "lottery.claim.reject"
|
||
ActionLotteryClaimMarkPaid = "lottery.claim.mark_paid"
|
||
)
|
||
|
||
// ---- ListLotteryClaims ----------------------------------------------------
|
||
|
||
type ListLotteryClaimsLogic struct {
|
||
logger.Logger
|
||
ctx context.Context
|
||
svcCtx *svc.ServiceContext
|
||
}
|
||
|
||
func NewListLotteryClaimsLogic(ctx context.Context, svcCtx *svc.ServiceContext) *ListLotteryClaimsLogic {
|
||
return &ListLotteryClaimsLogic{Logger: logger.WithContext(ctx), ctx: ctx, svcCtx: svcCtx}
|
||
}
|
||
|
||
// ListLotteryClaims 按 type/status/activity_id/user_id/时间窗过滤。
|
||
// user_id / email 是"友好视图"字段,走 IN 查询批量拉一次 users 表拼上。
|
||
func (l *ListLotteryClaimsLogic) ListLotteryClaims(req *types.ListAdminLotteryClaimsRequest) (*types.ListAdminLotteryClaimsResponse, error) {
|
||
if currentAdminId(l.ctx) == 0 {
|
||
return nil, xerr.NewErrCode(xerr.ErrorTokenInvalid)
|
||
}
|
||
page, size := req.Page, req.Size
|
||
if page <= 0 {
|
||
page = 1
|
||
}
|
||
if size <= 0 || size > 200 {
|
||
size = 20
|
||
}
|
||
|
||
db := l.svcCtx.DB.WithContext(l.ctx).Model(&modelLottery.Claim{})
|
||
if t := strings.TrimSpace(req.Type); t != "" {
|
||
db = db.Where("prize_type = ?", t)
|
||
}
|
||
if s := strings.TrimSpace(req.Status); s != "" {
|
||
db = db.Where("status = ?", s)
|
||
}
|
||
if req.ActivityId > 0 {
|
||
db = db.Where("activity_id = ?", req.ActivityId)
|
||
}
|
||
if req.UserId > 0 {
|
||
db = db.Where("user_id = ?", req.UserId)
|
||
}
|
||
if req.From > 0 {
|
||
db = db.Where("created_at >= ?", time.Unix(req.From, 0))
|
||
}
|
||
if req.To > 0 {
|
||
db = db.Where("created_at < ?", time.Unix(req.To, 0))
|
||
}
|
||
|
||
var total int64
|
||
if err := db.Count(&total).Error; err != nil {
|
||
return nil, errors.Wrap(xerr.NewErrCode(xerr.DatabaseQueryError), err.Error())
|
||
}
|
||
var rows []modelLottery.Claim
|
||
if err := db.Order("id DESC").Limit(size).Offset((page - 1) * size).Find(&rows).Error; err != nil {
|
||
return nil, errors.Wrap(xerr.NewErrCode(xerr.DatabaseQueryError), err.Error())
|
||
}
|
||
|
||
// 附加:一次性拉快照 + 用户信息,避免 N+1。
|
||
drawIds := make([]int64, 0, len(rows))
|
||
userIds := make([]int64, 0, len(rows))
|
||
for _, c := range rows {
|
||
drawIds = append(drawIds, c.DrawId)
|
||
userIds = append(userIds, c.UserId)
|
||
}
|
||
snaps, err := l.loadSnapshots(drawIds)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
users, err := l.loadUsers(userIds)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
|
||
resp := &types.ListAdminLotteryClaimsResponse{Total: total, Claims: make([]types.AdminLotteryClaim, 0, len(rows))}
|
||
for _, c := range rows {
|
||
resp.Claims = append(resp.Claims, claimToAdminView(c, snaps[c.DrawId], users[c.UserId]))
|
||
}
|
||
return resp, nil
|
||
}
|
||
func (l *ListLotteryClaimsLogic) loadSnapshots(drawIds []int64) (map[int64]modelLottery.PrizeSnapshot, error) {
|
||
if len(drawIds) == 0 {
|
||
return nil, nil
|
||
}
|
||
var snaps []modelLottery.PrizeSnapshot
|
||
if err := l.svcCtx.DB.WithContext(l.ctx).Where("draw_id IN ?", drawIds).Find(&snaps).Error; err != nil {
|
||
return nil, errors.Wrap(xerr.NewErrCode(xerr.DatabaseQueryError), err.Error())
|
||
}
|
||
out := make(map[int64]modelLottery.PrizeSnapshot, len(snaps))
|
||
for _, s := range snaps {
|
||
out[s.DrawId] = s
|
||
}
|
||
return out, nil
|
||
}
|
||
|
||
func (l *ListLotteryClaimsLogic) loadUsers(ids []int64) (map[int64]string, error) {
|
||
if len(ids) == 0 {
|
||
return nil, nil
|
||
}
|
||
// email 挂在 user_auth_methods 表;一次批量拉 auth_type='email' 的记录,
|
||
// 每人可能有多条 email(历史合并帐号),按 CreatedAt 排序取第一条即可。
|
||
type row struct {
|
||
UserId int64
|
||
Email string
|
||
}
|
||
var rows []row
|
||
if err := l.svcCtx.DB.WithContext(l.ctx).
|
||
Table("user_auth_methods").
|
||
Select("user_id AS user_id, auth_identifier AS email").
|
||
Where("auth_type = ? AND user_id IN ?", "email", ids).
|
||
Order("created_at ASC").
|
||
Scan(&rows).Error; err != nil {
|
||
return nil, errors.Wrap(xerr.NewErrCode(xerr.DatabaseQueryError), err.Error())
|
||
}
|
||
out := make(map[int64]string, len(rows))
|
||
for _, r := range rows {
|
||
if _, exists := out[r.UserId]; exists {
|
||
continue
|
||
}
|
||
out[r.UserId] = r.Email
|
||
}
|
||
return out, nil
|
||
}
|
||
|
||
// ---- ApproveLotteryClaim --------------------------------------------------
|
||
|
||
type ApproveLotteryClaimLogic struct {
|
||
logger.Logger
|
||
ctx context.Context
|
||
svcCtx *svc.ServiceContext
|
||
}
|
||
|
||
func NewApproveLotteryClaimLogic(ctx context.Context, svcCtx *svc.ServiceContext) *ApproveLotteryClaimLogic {
|
||
return &ApproveLotteryClaimLogic{Logger: logger.WithContext(ctx), ctx: ctx, svcCtx: svcCtx}
|
||
}
|
||
|
||
// ApproveLotteryClaim reviewing → paying。CAS:命中 status='reviewing' 才推进。
|
||
func (l *ApproveLotteryClaimLogic) ApproveLotteryClaim(req *types.AdminApproveClaimRequest) error {
|
||
actor := currentAdminId(l.ctx)
|
||
if actor == 0 {
|
||
return xerr.NewErrCode(xerr.ErrorTokenInvalid)
|
||
}
|
||
now := time.Now()
|
||
body, _ := json.Marshal(req)
|
||
return l.svcCtx.DB.WithContext(l.ctx).Transaction(func(tx *gorm.DB) error {
|
||
res := tx.Model(&modelLottery.Claim{}).
|
||
Where("id = ? AND status = ?", req.Id, modelLottery.ClaimStatusReviewing).
|
||
Updates(map[string]any{
|
||
"status": modelLottery.ClaimStatusPaying,
|
||
"reviewed_by": actor,
|
||
"reviewed_at": now,
|
||
"reject_reason": "",
|
||
})
|
||
if res.Error != nil {
|
||
return errors.Wrap(xerr.NewErrCode(xerr.DatabaseUpdateError), res.Error.Error())
|
||
}
|
||
if res.RowsAffected == 0 {
|
||
return xerr.NewErrCode(xerr.LotteryClaimStateInvalid)
|
||
}
|
||
ip, ua := requestMeta(l.ctx)
|
||
return audit.WriteAdminAction(l.ctx, tx, audit.Entry{
|
||
ActorUserId: actor,
|
||
Action: ActionLotteryClaimApprove,
|
||
TargetIds: int64ToStr(req.Id),
|
||
RequestBody: body,
|
||
IP: ip,
|
||
UserAgent: ua,
|
||
})
|
||
})
|
||
}
|
||
|
||
// ---- RejectLotteryClaim ---------------------------------------------------
|
||
|
||
type RejectLotteryClaimLogic struct {
|
||
logger.Logger
|
||
ctx context.Context
|
||
svcCtx *svc.ServiceContext
|
||
}
|
||
|
||
func NewRejectLotteryClaimLogic(ctx context.Context, svcCtx *svc.ServiceContext) *RejectLotteryClaimLogic {
|
||
return &RejectLotteryClaimLogic{Logger: logger.WithContext(ctx), ctx: ctx, svcCtx: svcCtx}
|
||
}
|
||
|
||
// RejectLotteryClaim reviewing|paying → rejected;expires_at 不重置,用户在
|
||
// 剩余窗口内可再次提交。
|
||
func (l *RejectLotteryClaimLogic) RejectLotteryClaim(req *types.AdminRejectClaimRequest) error {
|
||
actor := currentAdminId(l.ctx)
|
||
if actor == 0 {
|
||
return xerr.NewErrCode(xerr.ErrorTokenInvalid)
|
||
}
|
||
reason := strings.TrimSpace(req.Reason)
|
||
if reason == "" {
|
||
return xerr.NewErrCode(xerr.InvalidParams)
|
||
}
|
||
now := time.Now()
|
||
body, _ := json.Marshal(req)
|
||
return l.svcCtx.DB.WithContext(l.ctx).Transaction(func(tx *gorm.DB) error {
|
||
res := tx.Model(&modelLottery.Claim{}).
|
||
Where("id = ? AND status IN ?", req.Id,
|
||
[]string{modelLottery.ClaimStatusReviewing, modelLottery.ClaimStatusPaying}).
|
||
Updates(map[string]any{
|
||
"status": modelLottery.ClaimStatusRejected,
|
||
"reviewed_by": actor,
|
||
"reviewed_at": now,
|
||
"reject_reason": reason,
|
||
})
|
||
if res.Error != nil {
|
||
return errors.Wrap(xerr.NewErrCode(xerr.DatabaseUpdateError), res.Error.Error())
|
||
}
|
||
if res.RowsAffected == 0 {
|
||
return xerr.NewErrCode(xerr.LotteryClaimStateInvalid)
|
||
}
|
||
ip, ua := requestMeta(l.ctx)
|
||
return audit.WriteAdminAction(l.ctx, tx, audit.Entry{
|
||
ActorUserId: actor,
|
||
Action: ActionLotteryClaimReject,
|
||
TargetIds: int64ToStr(req.Id),
|
||
RequestBody: body,
|
||
IP: ip,
|
||
UserAgent: ua,
|
||
})
|
||
})
|
||
}
|
||
|
||
// ---- MarkPaidLotteryClaim -------------------------------------------------
|
||
|
||
type MarkPaidLotteryClaimLogic struct {
|
||
logger.Logger
|
||
ctx context.Context
|
||
svcCtx *svc.ServiceContext
|
||
}
|
||
|
||
func NewMarkPaidLotteryClaimLogic(ctx context.Context, svcCtx *svc.ServiceContext) *MarkPaidLotteryClaimLogic {
|
||
return &MarkPaidLotteryClaimLogic{Logger: logger.WithContext(ctx), ctx: ctx, svcCtx: svcCtx}
|
||
}
|
||
|
||
// MarkPaidLotteryClaim paying → paid。
|
||
// 校验:crypto 必填 tx_hash / physical 必填 delivery_ref / manual_other 至少填一个。
|
||
// paid_at 缺省用服务端 now。同事务把 lottery_draw.dispatch_state 也推 paid。
|
||
func (l *MarkPaidLotteryClaimLogic) MarkPaidLotteryClaim(req *types.AdminMarkPaidClaimRequest) error {
|
||
actor := currentAdminId(l.ctx)
|
||
if actor == 0 {
|
||
return xerr.NewErrCode(xerr.ErrorTokenInvalid)
|
||
}
|
||
txHash := strings.TrimSpace(req.TxHash)
|
||
deliveryRef := strings.TrimSpace(req.DeliveryRef)
|
||
now := time.Now()
|
||
paidAt := now
|
||
if req.PaidAt > 0 {
|
||
paidAt = time.Unix(req.PaidAt, 0)
|
||
}
|
||
body, _ := json.Marshal(req)
|
||
|
||
return l.svcCtx.DB.WithContext(l.ctx).Transaction(func(tx *gorm.DB) error {
|
||
// 先取当前 claim 用于类型强校验
|
||
var claim modelLottery.Claim
|
||
if err := tx.Where("id = ?", req.Id).First(&claim).Error; err != nil {
|
||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||
return xerr.NewErrCode(xerr.LotteryClaimStateInvalid)
|
||
}
|
||
return errors.Wrap(xerr.NewErrCode(xerr.DatabaseQueryError), err.Error())
|
||
}
|
||
if err := validateMarkPaidByType(claim.PrizeType, txHash, deliveryRef); err != nil {
|
||
return err
|
||
}
|
||
|
||
// CAS 推进 status。
|
||
res := tx.Model(&modelLottery.Claim{}).
|
||
Where("id = ? AND status = ?", req.Id, modelLottery.ClaimStatusPaying).
|
||
Updates(map[string]any{
|
||
"status": modelLottery.ClaimStatusPaid,
|
||
"reviewed_by": actor,
|
||
"reviewed_at": now,
|
||
"tx_hash": txHash,
|
||
"delivery_ref": deliveryRef,
|
||
"paid_at": paidAt,
|
||
})
|
||
if res.Error != nil {
|
||
return errors.Wrap(xerr.NewErrCode(xerr.DatabaseUpdateError), res.Error.Error())
|
||
}
|
||
if res.RowsAffected == 0 {
|
||
return xerr.NewErrCode(xerr.LotteryClaimStateInvalid)
|
||
}
|
||
// 同事务把 draw 的 dispatch_state 推到 paid,让 GET /records 与 admin 视图一致。
|
||
if err := tx.Model(&modelLottery.Draw{}).
|
||
Where("id = ? AND dispatch_state = ?", claim.DrawId, modelLottery.DispatchStatePendingClaim).
|
||
Updates(map[string]any{
|
||
"dispatch_state": modelLottery.DispatchStatePaid,
|
||
"dispatched_at": paidAt,
|
||
}).Error; err != nil {
|
||
return errors.Wrap(xerr.NewErrCode(xerr.DatabaseUpdateError), err.Error())
|
||
}
|
||
ip, ua := requestMeta(l.ctx)
|
||
return audit.WriteAdminAction(l.ctx, tx, audit.Entry{
|
||
ActorUserId: actor,
|
||
Action: ActionLotteryClaimMarkPaid,
|
||
TargetIds: int64ToStr(req.Id),
|
||
RequestBody: body,
|
||
IP: ip,
|
||
UserAgent: ua,
|
||
})
|
||
})
|
||
}
|
||
|
||
// validateMarkPaidByType 强制不同奖品类型的最少凭证:
|
||
// - crypto: tx_hash 必填
|
||
// - physical: delivery_ref 必填
|
||
// - manual_other: tx_hash 或 delivery_ref 至少一个
|
||
//
|
||
// 校验失败返回 InvalidParams(带具体原因,前端展示给运营)。
|
||
func validateMarkPaidByType(prizeType, txHash, deliveryRef string) error {
|
||
switch prizeType {
|
||
case modelLottery.PrizeTypeCrypto:
|
||
if txHash == "" {
|
||
return xerr.NewErrCodeMsg(xerr.InvalidParams, "crypto 奖品必须填写 tx_hash")
|
||
}
|
||
case modelLottery.PrizeTypePhysical:
|
||
if deliveryRef == "" {
|
||
return xerr.NewErrCodeMsg(xerr.InvalidParams, "physical 奖品必须填写 delivery_ref")
|
||
}
|
||
case modelLottery.PrizeTypeManualOther:
|
||
if txHash == "" && deliveryRef == "" {
|
||
return xerr.NewErrCodeMsg(xerr.InvalidParams, "manual_other 奖品必须至少填写 tx_hash 或 delivery_ref")
|
||
}
|
||
default:
|
||
return xerr.NewErrCode(xerr.LotteryClaimStateInvalid)
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// ---- ClaimsSummary --------------------------------------------------------
|
||
|
||
type LotteryClaimsSummaryLogic struct {
|
||
logger.Logger
|
||
ctx context.Context
|
||
svcCtx *svc.ServiceContext
|
||
}
|
||
|
||
func NewLotteryClaimsSummaryLogic(ctx context.Context, svcCtx *svc.ServiceContext) *LotteryClaimsSummaryLogic {
|
||
return &LotteryClaimsSummaryLogic{Logger: logger.WithContext(ctx), ctx: ctx, svcCtx: svcCtx}
|
||
}
|
||
|
||
// LotteryClaimsSummary 一次 GROUP BY 拉齐 reviewing/paying 计数 + 单独查 overdue。
|
||
func (l *LotteryClaimsSummaryLogic) LotteryClaimsSummary() (*types.AdminLotteryClaimsSummary, error) {
|
||
if currentAdminId(l.ctx) == 0 {
|
||
return nil, xerr.NewErrCode(xerr.ErrorTokenInvalid)
|
||
}
|
||
type row struct {
|
||
PrizeType string
|
||
Status string
|
||
Cnt int64
|
||
}
|
||
var rows []row
|
||
if err := l.svcCtx.DB.WithContext(l.ctx).
|
||
Model(&modelLottery.Claim{}).
|
||
Select("prize_type, status, COUNT(*) AS cnt").
|
||
Where("status IN ?", []string{modelLottery.ClaimStatusReviewing, modelLottery.ClaimStatusPaying}).
|
||
Group("prize_type, status").
|
||
Scan(&rows).Error; err != nil {
|
||
return nil, errors.Wrap(xerr.NewErrCode(xerr.DatabaseQueryError), err.Error())
|
||
}
|
||
summary := &types.AdminLotteryClaimsSummary{}
|
||
for _, r := range rows {
|
||
bucket := bucketByType(summary, r.PrizeType)
|
||
if bucket == nil {
|
||
continue
|
||
}
|
||
switch r.Status {
|
||
case modelLottery.ClaimStatusReviewing:
|
||
bucket.Reviewing = r.Cnt
|
||
case modelLottery.ClaimStatusPaying:
|
||
bucket.Paying = r.Cnt
|
||
}
|
||
}
|
||
// overdue:pending_claim 且 expires_at 已过(还没转 expired 的边缘时刻)。
|
||
if err := l.svcCtx.DB.WithContext(l.ctx).
|
||
Model(&modelLottery.Claim{}).
|
||
Where("status = ? AND expires_at < ?", modelLottery.ClaimStatusPendingClaim, time.Now()).
|
||
Count(&summary.Overdue).Error; err != nil {
|
||
return nil, errors.Wrap(xerr.NewErrCode(xerr.DatabaseQueryError), err.Error())
|
||
}
|
||
return summary, nil
|
||
}
|
||
|
||
func bucketByType(s *types.AdminLotteryClaimsSummary, prizeType string) *types.AdminLotteryClaimsStatusCount {
|
||
switch prizeType {
|
||
case modelLottery.PrizeTypeCrypto:
|
||
return &s.Crypto
|
||
case modelLottery.PrizeTypePhysical:
|
||
return &s.Physical
|
||
case modelLottery.PrizeTypeManualOther:
|
||
return &s.ManualOther
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// ---- helpers ---------------------------------------------------------------
|
||
|
||
// claimToAdminView 把 model.Claim 组装成后台视图,附带快照 + 用户信息。
|
||
func claimToAdminView(c modelLottery.Claim, snap modelLottery.PrizeSnapshot, email string) types.AdminLotteryClaim {
|
||
view := types.AdminLotteryClaim{
|
||
Id: c.Id,
|
||
DrawId: c.DrawId,
|
||
ActivityId: c.ActivityId,
|
||
Status: c.Status,
|
||
ExpiresAt: c.ExpiresAt.Unix(),
|
||
ReviewedBy: c.ReviewedBy,
|
||
RejectReason: c.RejectReason,
|
||
TxHash: c.TxHash,
|
||
DeliveryRef: c.DeliveryRef,
|
||
CreatedAt: c.CreatedAt.Unix(),
|
||
User: types.AdminLotteryClaimUser{
|
||
Id: c.UserId,
|
||
Email: email,
|
||
},
|
||
Prize: types.AdminLotteryClaimPrize{
|
||
Type: snap.Type,
|
||
Name: snap.Name,
|
||
Config: json.RawMessage(defaultRawIfEmpty(snap.Config, "{}")),
|
||
},
|
||
}
|
||
if snap.Type == "" {
|
||
// snapshot 未命中,用 claim.prize_type 兜底
|
||
view.Prize.Type = c.PrizeType
|
||
}
|
||
if c.ClaimData != "" {
|
||
view.ClaimData = json.RawMessage(c.ClaimData)
|
||
}
|
||
if c.SubmittedAt != nil {
|
||
view.SubmittedAt = c.SubmittedAt.Unix()
|
||
}
|
||
if c.ReviewedAt != nil {
|
||
view.ReviewedAt = c.ReviewedAt.Unix()
|
||
}
|
||
if c.PaidAt != nil {
|
||
view.PaidAt = c.PaidAt.Unix()
|
||
}
|
||
return view
|
||
}
|