mirror of
https://gitee.com/wanwujie/sub2api
synced 2026-05-06 06:00:44 +08:00
fix: correct affiliate audit record sources
This commit is contained in:
@@ -4,7 +4,6 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"crypto/rand"
|
"crypto/rand"
|
||||||
"database/sql"
|
"database/sql"
|
||||||
"encoding/json"
|
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -115,7 +114,7 @@ func (r *affiliateRepository) BindInviter(ctx context.Context, userID, inviterID
|
|||||||
return bound, nil
|
return bound, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *affiliateRepository) AccrueQuota(ctx context.Context, inviterID, inviteeUserID int64, amount float64, freezeHours int) (bool, error) {
|
func (r *affiliateRepository) AccrueQuota(ctx context.Context, inviterID, inviteeUserID int64, amount float64, freezeHours int, sourceOrderID *int64) (bool, error) {
|
||||||
if amount <= 0 {
|
if amount <= 0 {
|
||||||
return false, nil
|
return false, nil
|
||||||
}
|
}
|
||||||
@@ -141,15 +140,15 @@ func (r *affiliateRepository) AccrueQuota(ctx context.Context, inviterID, invite
|
|||||||
|
|
||||||
if freezeHours > 0 {
|
if freezeHours > 0 {
|
||||||
if _, err = txClient.ExecContext(txCtx, `
|
if _, err = txClient.ExecContext(txCtx, `
|
||||||
INSERT INTO user_affiliate_ledger (user_id, action, amount, source_user_id, frozen_until, created_at, updated_at)
|
INSERT INTO user_affiliate_ledger (user_id, action, amount, source_user_id, source_order_id, frozen_until, created_at, updated_at)
|
||||||
VALUES ($1, 'accrue', $2, $3, NOW() + make_interval(hours => $4), NOW(), NOW())`,
|
VALUES ($1, 'accrue', $2, $3, $4, NOW() + make_interval(hours => $5), NOW(), NOW())`,
|
||||||
inviterID, amount, inviteeUserID, freezeHours); err != nil {
|
inviterID, amount, inviteeUserID, nullableInt64Arg(sourceOrderID), freezeHours); err != nil {
|
||||||
return fmt.Errorf("insert affiliate accrue ledger: %w", err)
|
return fmt.Errorf("insert affiliate accrue ledger: %w", err)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
if _, err = txClient.ExecContext(txCtx, `
|
if _, err = txClient.ExecContext(txCtx, `
|
||||||
INSERT INTO user_affiliate_ledger (user_id, action, amount, source_user_id, created_at, updated_at)
|
INSERT INTO user_affiliate_ledger (user_id, action, amount, source_user_id, source_order_id, created_at, updated_at)
|
||||||
VALUES ($1, 'accrue', $2, $3, NOW(), NOW())`, inviterID, amount, inviteeUserID); err != nil {
|
VALUES ($1, 'accrue', $2, $3, $4, NOW(), NOW())`, inviterID, amount, inviteeUserID, nullableInt64Arg(sourceOrderID)); err != nil {
|
||||||
return fmt.Errorf("insert affiliate accrue ledger: %w", err)
|
return fmt.Errorf("insert affiliate accrue ledger: %w", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -304,9 +303,32 @@ FROM cleared`, userID)
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
snapshot, err := queryAffiliateTransferSnapshot(txCtx, txClient, userID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
if _, err = txClient.ExecContext(txCtx, `
|
if _, err = txClient.ExecContext(txCtx, `
|
||||||
INSERT INTO user_affiliate_ledger (user_id, action, amount, source_user_id, created_at, updated_at)
|
INSERT INTO user_affiliate_ledger (
|
||||||
VALUES ($1, 'transfer', $2, NULL, NOW(), NOW())`, userID, transferred); err != nil {
|
user_id,
|
||||||
|
action,
|
||||||
|
amount,
|
||||||
|
source_user_id,
|
||||||
|
balance_after,
|
||||||
|
aff_quota_after,
|
||||||
|
aff_frozen_quota_after,
|
||||||
|
aff_history_quota_after,
|
||||||
|
created_at,
|
||||||
|
updated_at
|
||||||
|
)
|
||||||
|
VALUES ($1, 'transfer', $2, NULL, $3, $4, $5, $6, NOW(), NOW())`,
|
||||||
|
userID,
|
||||||
|
transferred,
|
||||||
|
snapshot.BalanceAfter,
|
||||||
|
snapshot.AvailableQuotaAfter,
|
||||||
|
snapshot.FrozenQuotaAfter,
|
||||||
|
snapshot.HistoryQuotaAfter,
|
||||||
|
); err != nil {
|
||||||
return fmt.Errorf("insert affiliate transfer ledger: %w", err)
|
return fmt.Errorf("insert affiliate transfer ledger: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -440,17 +462,17 @@ LIMIT $`+fmt.Sprint(len(args)-1)+` OFFSET $`+fmt.Sprint(len(args)), args...)
|
|||||||
|
|
||||||
func (r *affiliateRepository) ListAffiliateRebateRecords(ctx context.Context, filter service.AffiliateRecordFilter) ([]service.AffiliateRebateRecord, int64, error) {
|
func (r *affiliateRepository) ListAffiliateRebateRecords(ctx context.Context, filter service.AffiliateRecordFilter) ([]service.AffiliateRebateRecord, int64, error) {
|
||||||
client := clientFromContext(ctx, r.client)
|
client := clientFromContext(ctx, r.client)
|
||||||
where, args := buildAffiliateRecordWhere(filter, "pal.created_at", []string{
|
where, args := buildAffiliateRecordWhere(filter, "ual.created_at", []string{
|
||||||
"inviter.email", "inviter.username", "invitee.email", "invitee.username",
|
"inviter.email", "inviter.username", "invitee.email", "invitee.username",
|
||||||
"po.id::text", "po.out_trade_no", "po.payment_type", "po.status",
|
"po.id::text", "po.out_trade_no", "po.payment_type", "po.status",
|
||||||
})
|
})
|
||||||
baseJoin := `
|
baseJoin := `
|
||||||
FROM payment_audit_logs pal
|
FROM user_affiliate_ledger ual
|
||||||
JOIN payment_orders po ON po.id::text = pal.order_id
|
JOIN payment_orders po ON po.id = ual.source_order_id
|
||||||
JOIN user_affiliates invitee_aff ON invitee_aff.user_id = po.user_id
|
JOIN users invitee ON invitee.id = ual.source_user_id
|
||||||
JOIN users invitee ON invitee.id = po.user_id
|
JOIN users inviter ON inviter.id = ual.user_id
|
||||||
JOIN users inviter ON inviter.id = invitee_aff.inviter_id
|
WHERE ual.action = 'accrue'
|
||||||
WHERE pal.action = 'AFFILIATE_REBATE_APPLIED'`
|
AND ual.source_order_id IS NOT NULL`
|
||||||
if where != "" {
|
if where != "" {
|
||||||
where = strings.Replace(where, "WHERE ", " AND ", 1)
|
where = strings.Replace(where, "WHERE ", " AND ", 1)
|
||||||
}
|
}
|
||||||
@@ -461,31 +483,32 @@ WHERE pal.action = 'AFFILIATE_REBATE_APPLIED'`
|
|||||||
}
|
}
|
||||||
|
|
||||||
orderBy := buildAffiliateRecordOrderBy(filter, map[string]string{
|
orderBy := buildAffiliateRecordOrderBy(filter, map[string]string{
|
||||||
"order": "po.id",
|
"order": "po.id",
|
||||||
"inviter": "inviter.email",
|
"inviter": "inviter.email",
|
||||||
"invitee": "invitee.email",
|
"invitee": "invitee.email",
|
||||||
"order_amount": "po.amount",
|
"order_amount": "po.amount",
|
||||||
"pay_amount": "po.pay_amount",
|
"pay_amount": "po.pay_amount",
|
||||||
"payment_type": "po.payment_type",
|
"rebate_amount": "ual.amount",
|
||||||
"order_status": "po.status",
|
"payment_type": "po.payment_type",
|
||||||
"created_at": "pal.created_at",
|
"order_status": "po.status",
|
||||||
}, "pal.created_at")
|
"created_at": "ual.created_at",
|
||||||
|
}, "ual.created_at")
|
||||||
args = append(args, filter.PageSize, (filter.Page-1)*filter.PageSize)
|
args = append(args, filter.PageSize, (filter.Page-1)*filter.PageSize)
|
||||||
rows, err := client.QueryContext(ctx, `
|
rows, err := client.QueryContext(ctx, `
|
||||||
SELECT po.id,
|
SELECT po.id,
|
||||||
po.out_trade_no,
|
po.out_trade_no,
|
||||||
invitee_aff.inviter_id,
|
ual.user_id,
|
||||||
COALESCE(inviter.email, ''),
|
COALESCE(inviter.email, ''),
|
||||||
COALESCE(inviter.username, ''),
|
COALESCE(inviter.username, ''),
|
||||||
po.user_id,
|
ual.source_user_id,
|
||||||
COALESCE(invitee.email, ''),
|
COALESCE(invitee.email, ''),
|
||||||
COALESCE(invitee.username, ''),
|
COALESCE(invitee.username, ''),
|
||||||
po.amount::double precision,
|
po.amount::double precision,
|
||||||
po.pay_amount::double precision,
|
po.pay_amount::double precision,
|
||||||
COALESCE(pal.detail, ''),
|
ual.amount::double precision,
|
||||||
po.payment_type,
|
po.payment_type,
|
||||||
po.status,
|
po.status,
|
||||||
pal.created_at
|
ual.created_at
|
||||||
`+baseJoin+where+`
|
`+baseJoin+where+`
|
||||||
`+orderBy+`
|
`+orderBy+`
|
||||||
LIMIT $`+fmt.Sprint(len(args)-1)+` OFFSET $`+fmt.Sprint(len(args)), args...)
|
LIMIT $`+fmt.Sprint(len(args)-1)+` OFFSET $`+fmt.Sprint(len(args)), args...)
|
||||||
@@ -497,7 +520,6 @@ LIMIT $`+fmt.Sprint(len(args)-1)+` OFFSET $`+fmt.Sprint(len(args)), args...)
|
|||||||
items := make([]service.AffiliateRebateRecord, 0)
|
items := make([]service.AffiliateRebateRecord, 0)
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var item service.AffiliateRebateRecord
|
var item service.AffiliateRebateRecord
|
||||||
var detail string
|
|
||||||
if err := rows.Scan(
|
if err := rows.Scan(
|
||||||
&item.OrderID,
|
&item.OrderID,
|
||||||
&item.OutTradeNo,
|
&item.OutTradeNo,
|
||||||
@@ -509,14 +531,13 @@ LIMIT $`+fmt.Sprint(len(args)-1)+` OFFSET $`+fmt.Sprint(len(args)), args...)
|
|||||||
&item.InviteeUsername,
|
&item.InviteeUsername,
|
||||||
&item.OrderAmount,
|
&item.OrderAmount,
|
||||||
&item.PayAmount,
|
&item.PayAmount,
|
||||||
&detail,
|
&item.RebateAmount,
|
||||||
&item.PaymentType,
|
&item.PaymentType,
|
||||||
&item.OrderStatus,
|
&item.OrderStatus,
|
||||||
&item.CreatedAt,
|
&item.CreatedAt,
|
||||||
); err != nil {
|
); err != nil {
|
||||||
return nil, 0, err
|
return nil, 0, err
|
||||||
}
|
}
|
||||||
item.RebateAmount = parseAffiliateRebateAmount(detail)
|
|
||||||
items = append(items, item)
|
items = append(items, item)
|
||||||
}
|
}
|
||||||
if err := rows.Err(); err != nil {
|
if err := rows.Err(); err != nil {
|
||||||
@@ -533,7 +554,6 @@ func (r *affiliateRepository) ListAffiliateTransferRecords(ctx context.Context,
|
|||||||
baseJoin := `
|
baseJoin := `
|
||||||
FROM user_affiliate_ledger ual
|
FROM user_affiliate_ledger ual
|
||||||
JOIN users u ON u.id = ual.user_id
|
JOIN users u ON u.id = ual.user_id
|
||||||
JOIN user_affiliates ua ON ua.user_id = ual.user_id
|
|
||||||
WHERE ual.action = 'transfer'`
|
WHERE ual.action = 'transfer'`
|
||||||
if where != "" {
|
if where != "" {
|
||||||
where = strings.Replace(where, "WHERE ", " AND ", 1)
|
where = strings.Replace(where, "WHERE ", " AND ", 1)
|
||||||
@@ -545,13 +565,13 @@ WHERE ual.action = 'transfer'`
|
|||||||
}
|
}
|
||||||
|
|
||||||
orderBy := buildAffiliateRecordOrderBy(filter, map[string]string{
|
orderBy := buildAffiliateRecordOrderBy(filter, map[string]string{
|
||||||
"user": "u.email",
|
"user": "u.email",
|
||||||
"amount": "ual.amount",
|
"amount": "ual.amount",
|
||||||
"current_balance": "u.balance",
|
"balance_after": "ual.balance_after",
|
||||||
"remaining_quota": "ua.aff_quota",
|
"available_quota_after": "ual.aff_quota_after",
|
||||||
"frozen_quota": "ua.aff_frozen_quota",
|
"frozen_quota_after": "ual.aff_frozen_quota_after",
|
||||||
"history_quota": "ua.aff_history_quota",
|
"history_quota_after": "ual.aff_history_quota_after",
|
||||||
"created_at": "ual.created_at",
|
"created_at": "ual.created_at",
|
||||||
}, "ual.created_at")
|
}, "ual.created_at")
|
||||||
args = append(args, filter.PageSize, (filter.Page-1)*filter.PageSize)
|
args = append(args, filter.PageSize, (filter.Page-1)*filter.PageSize)
|
||||||
rows, err := client.QueryContext(ctx, `
|
rows, err := client.QueryContext(ctx, `
|
||||||
@@ -560,10 +580,10 @@ SELECT ual.id,
|
|||||||
COALESCE(u.email, ''),
|
COALESCE(u.email, ''),
|
||||||
COALESCE(u.username, ''),
|
COALESCE(u.username, ''),
|
||||||
ual.amount::double precision,
|
ual.amount::double precision,
|
||||||
u.balance::double precision,
|
ual.balance_after::double precision,
|
||||||
ua.aff_quota::double precision,
|
ual.aff_quota_after::double precision,
|
||||||
ua.aff_frozen_quota::double precision,
|
ual.aff_frozen_quota_after::double precision,
|
||||||
ua.aff_history_quota::double precision,
|
ual.aff_history_quota_after::double precision,
|
||||||
ual.created_at
|
ual.created_at
|
||||||
`+baseJoin+where+`
|
`+baseJoin+where+`
|
||||||
`+orderBy+`
|
`+orderBy+`
|
||||||
@@ -576,20 +596,32 @@ LIMIT $`+fmt.Sprint(len(args)-1)+` OFFSET $`+fmt.Sprint(len(args)), args...)
|
|||||||
items := make([]service.AffiliateTransferRecord, 0)
|
items := make([]service.AffiliateTransferRecord, 0)
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var item service.AffiliateTransferRecord
|
var item service.AffiliateTransferRecord
|
||||||
|
var balanceAfter sql.NullFloat64
|
||||||
|
var availableQuotaAfter sql.NullFloat64
|
||||||
|
var frozenQuotaAfter sql.NullFloat64
|
||||||
|
var historyQuotaAfter sql.NullFloat64
|
||||||
if err := rows.Scan(
|
if err := rows.Scan(
|
||||||
&item.LedgerID,
|
&item.LedgerID,
|
||||||
&item.UserID,
|
&item.UserID,
|
||||||
&item.UserEmail,
|
&item.UserEmail,
|
||||||
&item.Username,
|
&item.Username,
|
||||||
&item.Amount,
|
&item.Amount,
|
||||||
&item.CurrentBalance,
|
&balanceAfter,
|
||||||
&item.RemainingQuota,
|
&availableQuotaAfter,
|
||||||
&item.FrozenQuota,
|
&frozenQuotaAfter,
|
||||||
&item.HistoryQuota,
|
&historyQuotaAfter,
|
||||||
&item.CreatedAt,
|
&item.CreatedAt,
|
||||||
); err != nil {
|
); err != nil {
|
||||||
return nil, 0, err
|
return nil, 0, err
|
||||||
}
|
}
|
||||||
|
item.BalanceAfter = nullableFloat64Ptr(balanceAfter)
|
||||||
|
item.AvailableQuotaAfter = nullableFloat64Ptr(availableQuotaAfter)
|
||||||
|
item.FrozenQuotaAfter = nullableFloat64Ptr(frozenQuotaAfter)
|
||||||
|
item.HistoryQuotaAfter = nullableFloat64Ptr(historyQuotaAfter)
|
||||||
|
item.SnapshotAvailable = balanceAfter.Valid &&
|
||||||
|
availableQuotaAfter.Valid &&
|
||||||
|
frozenQuotaAfter.Valid &&
|
||||||
|
historyQuotaAfter.Valid
|
||||||
items = append(items, item)
|
items = append(items, item)
|
||||||
}
|
}
|
||||||
if err := rows.Err(); err != nil {
|
if err := rows.Err(); err != nil {
|
||||||
@@ -675,7 +707,7 @@ func buildAffiliateRecordOrderBy(filter service.AffiliateRecordFilter, sortColum
|
|||||||
if !filter.SortDesc {
|
if !filter.SortDesc {
|
||||||
direction = "ASC"
|
direction = "ASC"
|
||||||
}
|
}
|
||||||
return "ORDER BY " + column + " " + direction
|
return "ORDER BY " + column + " " + direction + " NULLS LAST"
|
||||||
}
|
}
|
||||||
|
|
||||||
func queryAffiliateRecordCount(ctx context.Context, client affiliateQueryExecer, query string, args ...any) (int64, error) {
|
func queryAffiliateRecordCount(ctx context.Context, client affiliateQueryExecer, query string, args ...any) (int64, error) {
|
||||||
@@ -694,16 +726,6 @@ func queryAffiliateRecordCount(ctx context.Context, client affiliateQueryExecer,
|
|||||||
return total, rows.Err()
|
return total, rows.Err()
|
||||||
}
|
}
|
||||||
|
|
||||||
func parseAffiliateRebateAmount(detail string) float64 {
|
|
||||||
var payload struct {
|
|
||||||
RebateAmount float64 `json:"rebateAmount"`
|
|
||||||
}
|
|
||||||
if err := json.Unmarshal([]byte(detail), &payload); err != nil {
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
return payload.RebateAmount
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *affiliateRepository) withTx(ctx context.Context, fn func(txCtx context.Context, txClient *dbent.Client) error) error {
|
func (r *affiliateRepository) withTx(ctx context.Context, fn func(txCtx context.Context, txClient *dbent.Client) error) error {
|
||||||
if tx := dbent.TxFromContext(ctx); tx != nil {
|
if tx := dbent.TxFromContext(ctx); tx != nil {
|
||||||
return fn(ctx, tx.Client())
|
return fn(ctx, tx.Client())
|
||||||
@@ -888,6 +910,54 @@ func queryUserBalance(ctx context.Context, client affiliateQueryExecer, userID i
|
|||||||
return balance, nil
|
return balance, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type affiliateTransferSnapshot struct {
|
||||||
|
BalanceAfter float64
|
||||||
|
AvailableQuotaAfter float64
|
||||||
|
FrozenQuotaAfter float64
|
||||||
|
HistoryQuotaAfter float64
|
||||||
|
}
|
||||||
|
|
||||||
|
func queryAffiliateTransferSnapshot(ctx context.Context, client affiliateQueryExecer, userID int64) (*affiliateTransferSnapshot, error) {
|
||||||
|
rows, err := client.QueryContext(ctx, `
|
||||||
|
SELECT u.balance::double precision,
|
||||||
|
ua.aff_quota::double precision,
|
||||||
|
ua.aff_frozen_quota::double precision,
|
||||||
|
ua.aff_history_quota::double precision
|
||||||
|
FROM users u
|
||||||
|
JOIN user_affiliates ua ON ua.user_id = u.id
|
||||||
|
WHERE u.id = $1
|
||||||
|
LIMIT 1`, userID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("query affiliate transfer snapshot: %w", err)
|
||||||
|
}
|
||||||
|
defer func() { _ = rows.Close() }()
|
||||||
|
|
||||||
|
if !rows.Next() {
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return nil, service.ErrUserNotFound
|
||||||
|
}
|
||||||
|
|
||||||
|
var snapshot affiliateTransferSnapshot
|
||||||
|
if err := rows.Scan(
|
||||||
|
&snapshot.BalanceAfter,
|
||||||
|
&snapshot.AvailableQuotaAfter,
|
||||||
|
&snapshot.FrozenQuotaAfter,
|
||||||
|
&snapshot.HistoryQuotaAfter,
|
||||||
|
); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &snapshot, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
func nullableFloat64Ptr(v sql.NullFloat64) *float64 {
|
||||||
|
if !v.Valid {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return &v.Float64
|
||||||
|
}
|
||||||
|
|
||||||
func generateAffiliateCode() (string, error) {
|
func generateAffiliateCode() (string, error) {
|
||||||
buf := make([]byte, affiliateCodeLength)
|
buf := make([]byte, affiliateCodeLength)
|
||||||
if _, err := rand.Read(buf); err != nil {
|
if _, err := rand.Read(buf); err != nil {
|
||||||
@@ -1046,6 +1116,13 @@ func nullableArg(v *float64) any {
|
|||||||
return *v
|
return *v
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func nullableInt64Arg(v *int64) any {
|
||||||
|
if v == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return *v
|
||||||
|
}
|
||||||
|
|
||||||
// ListUsersWithCustomSettings 列出有专属配置(自定义码或专属比例)的用户。
|
// ListUsersWithCustomSettings 列出有专属配置(自定义码或专属比例)的用户。
|
||||||
//
|
//
|
||||||
// 单一查询同时处理"无搜索"与"按邮箱/用户名模糊搜索":
|
// 单一查询同时处理"无搜索"与"按邮箱/用户名模糊搜索":
|
||||||
|
|||||||
@@ -78,6 +78,26 @@ VALUES ($1, $2, $3, $3, NOW(), NOW())`, u.ID, affCode, 12.34)
|
|||||||
ledgerCount := querySingleInt(t, txCtx, client,
|
ledgerCount := querySingleInt(t, txCtx, client,
|
||||||
"SELECT COUNT(*) FROM user_affiliate_ledger WHERE user_id = $1 AND action = 'transfer'", u.ID)
|
"SELECT COUNT(*) FROM user_affiliate_ledger WHERE user_id = $1 AND action = 'transfer'", u.ID)
|
||||||
require.Equal(t, 1, ledgerCount)
|
require.Equal(t, 1, ledgerCount)
|
||||||
|
|
||||||
|
rows, err := client.QueryContext(txCtx, `
|
||||||
|
SELECT amount::double precision,
|
||||||
|
balance_after::double precision,
|
||||||
|
aff_quota_after::double precision,
|
||||||
|
aff_frozen_quota_after::double precision,
|
||||||
|
aff_history_quota_after::double precision
|
||||||
|
FROM user_affiliate_ledger
|
||||||
|
WHERE user_id = $1 AND action = 'transfer'
|
||||||
|
LIMIT 1`, u.ID)
|
||||||
|
require.NoError(t, err)
|
||||||
|
defer func() { _ = rows.Close() }()
|
||||||
|
require.True(t, rows.Next(), "expected transfer ledger")
|
||||||
|
var amount, balanceAfter, quotaAfter, frozenAfter, historyAfter float64
|
||||||
|
require.NoError(t, rows.Scan(&amount, &balanceAfter, "aAfter, &frozenAfter, &historyAfter))
|
||||||
|
require.InDelta(t, 12.34, amount, 1e-9)
|
||||||
|
require.InDelta(t, 17.84, balanceAfter, 1e-9)
|
||||||
|
require.InDelta(t, 0.0, quotaAfter, 1e-9)
|
||||||
|
require.InDelta(t, 0.0, frozenAfter, 1e-9)
|
||||||
|
require.InDelta(t, 12.34, historyAfter, 1e-9)
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestAffiliateRepository_AccrueQuota_ReusesOuterTransaction guards the
|
// TestAffiliateRepository_AccrueQuota_ReusesOuterTransaction guards the
|
||||||
@@ -125,7 +145,7 @@ func TestAffiliateRepository_AccrueQuota_ReusesOuterTransaction(t *testing.T) {
|
|||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
require.True(t, bound, "invitee must bind to inviter")
|
require.True(t, bound, "invitee must bind to inviter")
|
||||||
|
|
||||||
applied, err := repo.AccrueQuota(txCtx, inviter.ID, invitee.ID, 3.5, 0)
|
applied, err := repo.AccrueQuota(txCtx, inviter.ID, invitee.ID, 3.5, 0, nil)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
require.True(t, applied, "AccrueQuota must report applied=true")
|
require.True(t, applied, "AccrueQuota must report applied=true")
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package repository
|
package repository
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"os"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
@@ -13,3 +14,15 @@ func TestAffiliateUserOverviewSQLIncludesMaturedFrozenQuota(t *testing.T) {
|
|||||||
require.Contains(t, query, "ua.aff_quota + COALESCE(matured.matured_frozen_quota, 0)")
|
require.Contains(t, query, "ua.aff_quota + COALESCE(matured.matured_frozen_quota, 0)")
|
||||||
require.Contains(t, query, "frozen_until <= NOW()")
|
require.Contains(t, query, "frozen_until <= NOW()")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestAffiliateRecordQueriesUseLedgerAuditFields(t *testing.T) {
|
||||||
|
source, err := os.ReadFile("affiliate_repo.go")
|
||||||
|
require.NoError(t, err)
|
||||||
|
content := string(source)
|
||||||
|
|
||||||
|
require.Contains(t, content, "JOIN payment_orders po ON po.id = ual.source_order_id")
|
||||||
|
require.Contains(t, content, "ual.amount::double precision")
|
||||||
|
require.Contains(t, content, "ual.balance_after::double precision")
|
||||||
|
require.NotContains(t, content, "parseAffiliateRebateAmount")
|
||||||
|
require.NotContains(t, content, `"current_balance": "u.balance"`)
|
||||||
|
}
|
||||||
|
|||||||
@@ -98,7 +98,7 @@ type AffiliateRepository interface {
|
|||||||
EnsureUserAffiliate(ctx context.Context, userID int64) (*AffiliateSummary, error)
|
EnsureUserAffiliate(ctx context.Context, userID int64) (*AffiliateSummary, error)
|
||||||
GetAffiliateByCode(ctx context.Context, code string) (*AffiliateSummary, error)
|
GetAffiliateByCode(ctx context.Context, code string) (*AffiliateSummary, error)
|
||||||
BindInviter(ctx context.Context, userID, inviterID int64) (bool, error)
|
BindInviter(ctx context.Context, userID, inviterID int64) (bool, error)
|
||||||
AccrueQuota(ctx context.Context, inviterID, inviteeUserID int64, amount float64, freezeHours int) (bool, error)
|
AccrueQuota(ctx context.Context, inviterID, inviteeUserID int64, amount float64, freezeHours int, sourceOrderID *int64) (bool, error)
|
||||||
GetAccruedRebateFromInvitee(ctx context.Context, inviterID, inviteeUserID int64) (float64, error)
|
GetAccruedRebateFromInvitee(ctx context.Context, inviterID, inviteeUserID int64) (float64, error)
|
||||||
ThawFrozenQuota(ctx context.Context, userID int64) (float64, error)
|
ThawFrozenQuota(ctx context.Context, userID int64) (float64, error)
|
||||||
TransferQuotaToBalance(ctx context.Context, userID int64) (float64, float64, error)
|
TransferQuotaToBalance(ctx context.Context, userID int64) (float64, float64, error)
|
||||||
@@ -174,16 +174,21 @@ type AffiliateRebateRecord struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type AffiliateTransferRecord struct {
|
type AffiliateTransferRecord struct {
|
||||||
LedgerID int64 `json:"ledger_id"`
|
LedgerID int64 `json:"ledger_id"`
|
||||||
UserID int64 `json:"user_id"`
|
UserID int64 `json:"user_id"`
|
||||||
UserEmail string `json:"user_email"`
|
UserEmail string `json:"user_email"`
|
||||||
Username string `json:"username"`
|
Username string `json:"username"`
|
||||||
Amount float64 `json:"amount"`
|
Amount float64 `json:"amount"`
|
||||||
CurrentBalance float64 `json:"current_balance"`
|
BalanceAfter *float64 `json:"balance_after,omitempty"`
|
||||||
RemainingQuota float64 `json:"remaining_quota"`
|
AvailableQuotaAfter *float64 `json:"available_quota_after,omitempty"`
|
||||||
FrozenQuota float64 `json:"frozen_quota"`
|
FrozenQuotaAfter *float64 `json:"frozen_quota_after,omitempty"`
|
||||||
HistoryQuota float64 `json:"history_quota"`
|
HistoryQuotaAfter *float64 `json:"history_quota_after,omitempty"`
|
||||||
CreatedAt time.Time `json:"created_at"`
|
SnapshotAvailable bool `json:"snapshot_available"`
|
||||||
|
CurrentBalance float64 `json:"-"`
|
||||||
|
RemainingQuota float64 `json:"-"`
|
||||||
|
FrozenQuota float64 `json:"-"`
|
||||||
|
HistoryQuota float64 `json:"-"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type AffiliateUserOverview struct {
|
type AffiliateUserOverview struct {
|
||||||
@@ -307,6 +312,10 @@ func (s *AffiliateService) BindInviterByCode(ctx context.Context, userID int64,
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *AffiliateService) AccrueInviteRebate(ctx context.Context, inviteeUserID int64, baseRechargeAmount float64) (float64, error) {
|
func (s *AffiliateService) AccrueInviteRebate(ctx context.Context, inviteeUserID int64, baseRechargeAmount float64) (float64, error) {
|
||||||
|
return s.AccrueInviteRebateForOrder(ctx, inviteeUserID, baseRechargeAmount, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *AffiliateService) AccrueInviteRebateForOrder(ctx context.Context, inviteeUserID int64, baseRechargeAmount float64, sourceOrderID *int64) (float64, error) {
|
||||||
if s == nil || s.repo == nil {
|
if s == nil || s.repo == nil {
|
||||||
return 0, nil
|
return 0, nil
|
||||||
}
|
}
|
||||||
@@ -367,7 +376,7 @@ func (s *AffiliateService) AccrueInviteRebate(ctx context.Context, inviteeUserID
|
|||||||
freezeHours = s.settingService.GetAffiliateRebateFreezeHours(ctx)
|
freezeHours = s.settingService.GetAffiliateRebateFreezeHours(ctx)
|
||||||
}
|
}
|
||||||
|
|
||||||
applied, err := s.repo.AccrueQuota(ctx, *inviteeSummary.InviterID, inviteeUserID, rebate, freezeHours)
|
applied, err := s.repo.AccrueQuota(ctx, *inviteeSummary.InviterID, inviteeUserID, rebate, freezeHours, sourceOrderID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return 0, err
|
return 0, err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -394,7 +394,8 @@ func (s *PaymentService) applyAffiliateRebateForOrder(ctx context.Context, o *db
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
rebateAmount, err := s.affiliateService.AccrueInviteRebate(txCtx, o.UserID, o.Amount)
|
sourceOrderID := o.ID
|
||||||
|
rebateAmount, err := s.affiliateService.AccrueInviteRebateForOrder(txCtx, o.UserID, o.Amount, &sourceOrderID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.writeAuditLog(ctx, o.ID, "AFFILIATE_REBATE_FAILED", "system", map[string]any{
|
s.writeAuditLog(ctx, o.ID, "AFFILIATE_REBATE_FAILED", "system", map[string]any{
|
||||||
"error": err.Error(),
|
"error": err.Error(),
|
||||||
|
|||||||
85
backend/migrations/134_affiliate_ledger_audit_snapshots.sql
Normal file
85
backend/migrations/134_affiliate_ledger_audit_snapshots.sql
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
-- 邀请返利流水补充订单关联和转余额快照。
|
||||||
|
-- 这些字段只用于审计展示;历史旧流水无法可靠反推的字段保持 NULL,避免把当前状态误展示为历史状态。
|
||||||
|
|
||||||
|
ALTER TABLE user_affiliate_ledger
|
||||||
|
ADD COLUMN IF NOT EXISTS source_order_id BIGINT NULL REFERENCES payment_orders(id) ON DELETE SET NULL;
|
||||||
|
|
||||||
|
ALTER TABLE user_affiliate_ledger
|
||||||
|
ADD COLUMN IF NOT EXISTS balance_after DECIMAL(20,8) NULL;
|
||||||
|
|
||||||
|
ALTER TABLE user_affiliate_ledger
|
||||||
|
ADD COLUMN IF NOT EXISTS aff_quota_after DECIMAL(20,8) NULL;
|
||||||
|
|
||||||
|
ALTER TABLE user_affiliate_ledger
|
||||||
|
ADD COLUMN IF NOT EXISTS aff_frozen_quota_after DECIMAL(20,8) NULL;
|
||||||
|
|
||||||
|
ALTER TABLE user_affiliate_ledger
|
||||||
|
ADD COLUMN IF NOT EXISTS aff_history_quota_after DECIMAL(20,8) NULL;
|
||||||
|
|
||||||
|
COMMENT ON COLUMN user_affiliate_ledger.source_order_id IS '产生该返利流水的充值订单;转余额或无法可靠回填的历史数据为 NULL';
|
||||||
|
COMMENT ON COLUMN user_affiliate_ledger.balance_after IS '邀请返利转余额后的用户余额快照;无法取得时为 NULL';
|
||||||
|
COMMENT ON COLUMN user_affiliate_ledger.aff_quota_after IS '邀请返利转余额后的可用返利额度快照;无法取得时为 NULL';
|
||||||
|
COMMENT ON COLUMN user_affiliate_ledger.aff_frozen_quota_after IS '邀请返利转余额后的冻结返利额度快照;无法取得时为 NULL';
|
||||||
|
COMMENT ON COLUMN user_affiliate_ledger.aff_history_quota_after IS '邀请返利转余额后的历史返利总额快照;无法取得时为 NULL';
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_user_affiliate_ledger_source_order_id
|
||||||
|
ON user_affiliate_ledger(source_order_id)
|
||||||
|
WHERE source_order_id IS NOT NULL;
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_user_affiliate_ledger_rebate_lookup
|
||||||
|
ON user_affiliate_ledger(action, source_order_id, user_id, source_user_id, created_at)
|
||||||
|
WHERE action = 'accrue';
|
||||||
|
|
||||||
|
-- 尽力回填 PR #2169 合并后、该迁移前已经产生的返利流水。
|
||||||
|
-- 只有在同一订单只能匹配到一条返利流水时才回填,避免把多笔同额流水错误绑定到订单。
|
||||||
|
WITH rebate_audits AS (
|
||||||
|
SELECT po.id AS order_id,
|
||||||
|
po.user_id AS invitee_user_id,
|
||||||
|
invitee_aff.inviter_id,
|
||||||
|
rebate_detail.rebate_amount,
|
||||||
|
pal.created_at AS audit_created_at
|
||||||
|
FROM payment_audit_logs pal
|
||||||
|
CROSS JOIN LATERAL (
|
||||||
|
SELECT substring(
|
||||||
|
pal.detail
|
||||||
|
FROM '"rebateAmount"[[:space:]]*:[[:space:]]*(-?[0-9]+(\.[0-9]+)?)'
|
||||||
|
)::numeric AS rebate_amount
|
||||||
|
) rebate_detail
|
||||||
|
JOIN payment_orders po ON po.id::text = pal.order_id
|
||||||
|
JOIN user_affiliates invitee_aff ON invitee_aff.user_id = po.user_id
|
||||||
|
WHERE pal.action = 'AFFILIATE_REBATE_APPLIED'
|
||||||
|
AND rebate_detail.rebate_amount IS NOT NULL
|
||||||
|
),
|
||||||
|
ranked_matches AS (
|
||||||
|
SELECT ual.id AS ledger_id,
|
||||||
|
ra.order_id,
|
||||||
|
COUNT(*) OVER (PARTITION BY ra.order_id) AS order_match_count,
|
||||||
|
COUNT(*) OVER (PARTITION BY ual.id) AS ledger_match_count,
|
||||||
|
ROW_NUMBER() OVER (
|
||||||
|
PARTITION BY ual.id
|
||||||
|
ORDER BY ABS(EXTRACT(EPOCH FROM (ual.created_at - ra.audit_created_at))), ra.order_id
|
||||||
|
) AS ledger_rank
|
||||||
|
FROM rebate_audits ra
|
||||||
|
JOIN user_affiliate_ledger ual
|
||||||
|
ON ual.action = 'accrue'
|
||||||
|
AND ual.source_order_id IS NULL
|
||||||
|
AND ual.user_id = ra.inviter_id
|
||||||
|
AND ual.source_user_id = ra.invitee_user_id
|
||||||
|
AND ABS(ual.amount - ra.rebate_amount) < 0.00000001
|
||||||
|
AND ual.created_at BETWEEN ra.audit_created_at - INTERVAL '10 minutes'
|
||||||
|
AND ra.audit_created_at + INTERVAL '10 minutes'
|
||||||
|
)
|
||||||
|
UPDATE user_affiliate_ledger ual
|
||||||
|
SET source_order_id = ranked_matches.order_id,
|
||||||
|
updated_at = NOW()
|
||||||
|
FROM ranked_matches
|
||||||
|
WHERE ual.id = ranked_matches.ledger_id
|
||||||
|
AND ranked_matches.order_match_count = 1
|
||||||
|
AND ranked_matches.ledger_match_count = 1
|
||||||
|
AND ranked_matches.ledger_rank = 1
|
||||||
|
AND NOT EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM user_affiliate_ledger existing
|
||||||
|
WHERE existing.source_order_id = ranked_matches.order_id
|
||||||
|
AND existing.action = 'accrue'
|
||||||
|
);
|
||||||
@@ -127,3 +127,18 @@ func TestMigration124BackfillsLegacyOIDCSecurityFlagsSafely(t *testing.T) {
|
|||||||
require.Contains(t, sql, "oidc_connect_enabled")
|
require.Contains(t, sql, "oidc_connect_enabled")
|
||||||
require.Contains(t, sql, "'false'")
|
require.Contains(t, sql, "'false'")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestMigration134AddsAffiliateLedgerAuditFieldsWithoutJSONCast(t *testing.T) {
|
||||||
|
content, err := FS.ReadFile("134_affiliate_ledger_audit_snapshots.sql")
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
sql := string(content)
|
||||||
|
require.Contains(t, sql, "ADD COLUMN IF NOT EXISTS source_order_id BIGINT")
|
||||||
|
require.Contains(t, sql, "ADD COLUMN IF NOT EXISTS balance_after DECIMAL(20,8)")
|
||||||
|
require.Contains(t, sql, "ADD COLUMN IF NOT EXISTS aff_quota_after DECIMAL(20,8)")
|
||||||
|
require.Contains(t, sql, "substring(")
|
||||||
|
require.Contains(t, sql, `"rebateAmount"`)
|
||||||
|
require.Contains(t, sql, "COUNT(*) OVER (PARTITION BY ra.order_id) AS order_match_count")
|
||||||
|
require.Contains(t, sql, "COUNT(*) OVER (PARTITION BY ual.id) AS ledger_match_count")
|
||||||
|
require.NotContains(t, sql, "detail::jsonb")
|
||||||
|
}
|
||||||
|
|||||||
@@ -69,10 +69,11 @@ export interface AffiliateTransferRecord {
|
|||||||
user_email: string
|
user_email: string
|
||||||
username: string
|
username: string
|
||||||
amount: number
|
amount: number
|
||||||
current_balance: number
|
balance_after?: number | null
|
||||||
remaining_quota: number
|
available_quota_after?: number | null
|
||||||
frozen_quota: number
|
frozen_quota_after?: number | null
|
||||||
history_quota: number
|
history_quota_after?: number | null
|
||||||
|
snapshot_available: boolean
|
||||||
created_at: string
|
created_at: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1664,10 +1664,10 @@ export default {
|
|||||||
paymentType: 'Payment Method',
|
paymentType: 'Payment Method',
|
||||||
orderStatus: 'Order Status',
|
orderStatus: 'Order Status',
|
||||||
transferAmount: 'Transfer Amount',
|
transferAmount: 'Transfer Amount',
|
||||||
currentBalance: 'Current Balance',
|
balanceAfter: 'Balance After',
|
||||||
remainingQuota: 'Remaining Quota',
|
availableQuotaAfter: 'Available After',
|
||||||
frozenQuota: 'Frozen Rebate',
|
frozenQuotaAfter: 'Frozen After',
|
||||||
historyQuota: 'Historical Rebate',
|
historyQuotaAfter: 'Historical Rebate After',
|
||||||
invitedAt: 'Invited At',
|
invitedAt: 'Invited At',
|
||||||
rebatedAt: 'Rebated At',
|
rebatedAt: 'Rebated At',
|
||||||
transferredAt: 'Transferred At'
|
transferredAt: 'Transferred At'
|
||||||
|
|||||||
@@ -1685,10 +1685,10 @@ export default {
|
|||||||
paymentType: '支付方式',
|
paymentType: '支付方式',
|
||||||
orderStatus: '订单状态',
|
orderStatus: '订单状态',
|
||||||
transferAmount: '提取金额',
|
transferAmount: '提取金额',
|
||||||
currentBalance: '当前余额',
|
balanceAfter: '提取后余额',
|
||||||
remainingQuota: '剩余可提取',
|
availableQuotaAfter: '提取后可提',
|
||||||
frozenQuota: '冻结返利',
|
frozenQuotaAfter: '提取后冻结',
|
||||||
historyQuota: '历史返利',
|
historyQuotaAfter: '提取后历史返利',
|
||||||
invitedAt: '邀请时间',
|
invitedAt: '邀请时间',
|
||||||
rebatedAt: '返利时间',
|
rebatedAt: '返利时间',
|
||||||
transferredAt: '提取时间'
|
transferredAt: '提取时间'
|
||||||
|
|||||||
@@ -83,17 +83,17 @@
|
|||||||
<template #cell-amount="{ row }">
|
<template #cell-amount="{ row }">
|
||||||
<AmountText :value="row.amount" strong />
|
<AmountText :value="row.amount" strong />
|
||||||
</template>
|
</template>
|
||||||
<template #cell-current_balance="{ row }">
|
<template #cell-balance_after="{ row }">
|
||||||
<AmountText :value="row.current_balance" />
|
<NullableAmountText :value="row.balance_after" />
|
||||||
</template>
|
</template>
|
||||||
<template #cell-remaining_quota="{ row }">
|
<template #cell-available_quota_after="{ row }">
|
||||||
<AmountText :value="row.remaining_quota" />
|
<NullableAmountText :value="row.available_quota_after" />
|
||||||
</template>
|
</template>
|
||||||
<template #cell-frozen_quota="{ row }">
|
<template #cell-frozen_quota_after="{ row }">
|
||||||
<AmountText :value="row.frozen_quota" />
|
<NullableAmountText :value="row.frozen_quota_after" />
|
||||||
</template>
|
</template>
|
||||||
<template #cell-history_quota="{ row }">
|
<template #cell-history_quota_after="{ row }">
|
||||||
<AmountText :value="row.history_quota" />
|
<NullableAmountText :value="row.history_quota_after" />
|
||||||
</template>
|
</template>
|
||||||
<template #cell-created_at="{ row }">
|
<template #cell-created_at="{ row }">
|
||||||
<span class="text-sm text-gray-700 dark:text-gray-300">{{ formatDateTime(row.created_at) }}</span>
|
<span class="text-sm text-gray-700 dark:text-gray-300">{{ formatDateTime(row.created_at) }}</span>
|
||||||
@@ -142,7 +142,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, defineComponent, h, onMounted, reactive, ref } from 'vue'
|
import { computed, defineComponent, h, onMounted, reactive, ref, type PropType } from 'vue'
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
import AppLayout from '@/components/layout/AppLayout.vue'
|
import AppLayout from '@/components/layout/AppLayout.vue'
|
||||||
import TablePageLayout from '@/components/layout/TablePageLayout.vue'
|
import TablePageLayout from '@/components/layout/TablePageLayout.vue'
|
||||||
@@ -202,10 +202,10 @@ const columns = computed<Column[]>(() => {
|
|||||||
return [
|
return [
|
||||||
{ key: 'user', label: t('admin.affiliates.records.user'), sortable: true },
|
{ key: 'user', label: t('admin.affiliates.records.user'), sortable: true },
|
||||||
{ key: 'amount', label: t('admin.affiliates.records.transferAmount'), sortable: true },
|
{ key: 'amount', label: t('admin.affiliates.records.transferAmount'), sortable: true },
|
||||||
{ key: 'current_balance', label: t('admin.affiliates.records.currentBalance'), sortable: true },
|
{ key: 'balance_after', label: t('admin.affiliates.records.balanceAfter'), sortable: true },
|
||||||
{ key: 'remaining_quota', label: t('admin.affiliates.records.remainingQuota'), sortable: true },
|
{ key: 'available_quota_after', label: t('admin.affiliates.records.availableQuotaAfter'), sortable: true },
|
||||||
{ key: 'frozen_quota', label: t('admin.affiliates.records.frozenQuota'), sortable: true },
|
{ key: 'frozen_quota_after', label: t('admin.affiliates.records.frozenQuotaAfter'), sortable: true },
|
||||||
{ key: 'history_quota', label: t('admin.affiliates.records.historyQuota'), sortable: true },
|
{ key: 'history_quota_after', label: t('admin.affiliates.records.historyQuotaAfter'), sortable: true },
|
||||||
{ key: 'created_at', label: t('admin.affiliates.records.transferredAt'), sortable: true },
|
{ key: 'created_at', label: t('admin.affiliates.records.transferredAt'), sortable: true },
|
||||||
]
|
]
|
||||||
})
|
})
|
||||||
@@ -368,6 +368,21 @@ const AmountText = defineComponent({
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const NullableAmountText = defineComponent({
|
||||||
|
props: {
|
||||||
|
value: { type: Number as PropType<number | null | undefined>, default: null },
|
||||||
|
},
|
||||||
|
setup(amountProps) {
|
||||||
|
return () => {
|
||||||
|
const value = amountProps.value
|
||||||
|
if (value === null || value === undefined) {
|
||||||
|
return h('span', { class: 'text-sm text-gray-400 dark:text-dark-500' }, '-')
|
||||||
|
}
|
||||||
|
return h(AmountText, { value })
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
const OverviewStat = defineComponent({
|
const OverviewStat = defineComponent({
|
||||||
props: {
|
props: {
|
||||||
label: { type: String, required: true },
|
label: { type: String, required: true },
|
||||||
|
|||||||
Reference in New Issue
Block a user