From 5d36e879393153df73dacdc9d3cc8f69b774126a Mon Sep 17 00:00:00 2001
From: cattie <2237829695@qq.com>
Date: Mon, 29 Dec 2025 15:22:17 +0800
Subject: [PATCH 1/6] feat: Red Envelope function
---
config.example.yaml | 1 +
.../app/(pay)/redenvelope/[code]/page.tsx | 10 +
frontend/app/globals.css | 25 +-
.../common/general/table-filter.tsx | 4 +-
.../common/redenvelope/red-envelope-claim.tsx | 347 +++++++++++
.../components/common/trade/red-envelope.tsx | 586 ++++++++++++++++++
.../components/common/trade/trade-main.tsx | 20 +-
frontend/lib/services/index.ts | 18 +
frontend/lib/services/redenvelope/index.ts | 14 +
.../redenvelope/redenvelope.service.ts | 113 ++++
frontend/lib/services/redenvelope/types.ts | 160 +++++
frontend/lib/services/transaction/types.ts | 2 +-
internal/apps/order/routers.go | 63 +-
internal/apps/redenvelope/errs.go | 29 +
internal/apps/redenvelope/routers.go | 506 +++++++++++++++
internal/apps/redenvelope/tasks.go | 109 ++++
internal/config/model.go | 2 +
internal/db/migrator/migrator.go | 7 +
internal/model/orders.go | 2 +
internal/model/red_envelopes.go | 76 +++
internal/model/system_configs.go | 11 +
internal/router/router.go | 11 +
internal/task/constants.go | 1 +
internal/task/scheduler/scheduler.go | 9 +
internal/task/worker/worker.go | 2 +
25 files changed, 2095 insertions(+), 33 deletions(-)
create mode 100644 frontend/app/(pay)/redenvelope/[code]/page.tsx
create mode 100644 frontend/components/common/redenvelope/red-envelope-claim.tsx
create mode 100644 frontend/components/common/trade/red-envelope.tsx
create mode 100644 frontend/lib/services/redenvelope/index.ts
create mode 100644 frontend/lib/services/redenvelope/redenvelope.service.ts
create mode 100644 frontend/lib/services/redenvelope/types.ts
create mode 100644 internal/apps/redenvelope/errs.go
create mode 100644 internal/apps/redenvelope/routers.go
create mode 100644 internal/apps/redenvelope/tasks.go
create mode 100644 internal/model/red_envelopes.go
diff --git a/config.example.yaml b/config.example.yaml
index af36a527..6baeda19 100644
--- a/config.example.yaml
+++ b/config.example.yaml
@@ -15,6 +15,7 @@ app:
session_secure: false
session_http_only: false
api_prefix: "/api"
+ frontend_url: "http://localhost:3000"
frontend_pay_url: "http://localhost:3000/paying"
# OAuth2/OIDC(优先)
diff --git a/frontend/app/(pay)/redenvelope/[code]/page.tsx b/frontend/app/(pay)/redenvelope/[code]/page.tsx
new file mode 100644
index 00000000..3f71451f
--- /dev/null
+++ b/frontend/app/(pay)/redenvelope/[code]/page.tsx
@@ -0,0 +1,10 @@
+import { RedEnvelopeClaimPage } from "@/components/common/redenvelope/red-envelope-claim"
+
+interface Props {
+ params: Promise<{ code: string }>
+}
+
+export default async function RedEnvelopePage({ params }: Props) {
+ const { code } = await params
+ return
+}
\ No newline at end of file
diff --git a/frontend/app/globals.css b/frontend/app/globals.css
index badcf6e4..5b192e7d 100644
--- a/frontend/app/globals.css
+++ b/frontend/app/globals.css
@@ -133,7 +133,7 @@
}
@theme inline {
- /*
+ /*
* Aurora 动画配置
*/
--animate-aurora: aurora 120s linear infinite;
@@ -151,4 +151,27 @@
350% 50%;
}
}
+
+ /*
+ * 红包动画配置
+ */
+ --animate-redenvelope-shake: redenvelope-shake 0.5s ease-in-out;
+ --animate-redenvelope-glow: redenvelope-glow 2s ease-in-out infinite;
+ --animate-gold-particle: gold-particle 1s ease-out forwards;
+
+ @keyframes redenvelope-shake {
+ 0%, 100% { transform: rotate(0deg); }
+ 25% { transform: rotate(-5deg); }
+ 75% { transform: rotate(5deg); }
+ }
+
+ @keyframes redenvelope-glow {
+ 0%, 100% { box-shadow: 0 0 20px rgba(255, 215, 0, 0.3); }
+ 50% { box-shadow: 0 0 40px rgba(255, 215, 0, 0.6); }
+ }
+
+ @keyframes gold-particle {
+ 0% { transform: translateY(0) scale(1); opacity: 1; }
+ 100% { transform: translateY(-100px) scale(0); opacity: 0; }
+ }
}
\ No newline at end of file
diff --git a/frontend/components/common/general/table-filter.tsx b/frontend/components/common/general/table-filter.tsx
index e45f38df..8b3d0de2 100644
--- a/frontend/components/common/general/table-filter.tsx
+++ b/frontend/components/common/general/table-filter.tsx
@@ -24,7 +24,9 @@ export const typeConfig: Record = {
transfer: { label: '积分转移', color: 'bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-300' },
community: { label: '社区划转', color: 'bg-purple-100 text-purple-800 dark:bg-purple-900 dark:text-purple-300' },
online: { label: '在线活动', color: 'bg-teal-100 text-teal-800 dark:bg-teal-900 dark:text-teal-300' },
- test: { label: '测试', color: 'bg-orange-100 text-orange-800 dark:bg-orange-900 dark:text-orange-300 font-bold' }
+ test: { label: '测试', color: 'bg-orange-100 text-orange-800 dark:bg-orange-900 dark:text-orange-300 font-bold' },
+ red_envelope_send: { label: '红包支出', color: 'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-300' },
+ red_envelope_receive: { label: '红包收入', color: 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-300' }
}
/* 状态标签配置 */
diff --git a/frontend/components/common/redenvelope/red-envelope-claim.tsx b/frontend/components/common/redenvelope/red-envelope-claim.tsx
new file mode 100644
index 00000000..32683284
--- /dev/null
+++ b/frontend/components/common/redenvelope/red-envelope-claim.tsx
@@ -0,0 +1,347 @@
+"use client"
+
+import * as React from "react"
+import { useState, useEffect } from "react"
+import { motion, AnimatePresence } from "motion/react"
+import { toast } from "sonner"
+import { Gift } from "lucide-react"
+import { Button } from "@/components/ui/button"
+import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
+import { ScrollArea } from "@/components/ui/scroll-area"
+import services from "@/lib/services"
+import type { RedEnvelopeDetailResponse, RedEnvelopeClaim } from "@/lib/services"
+
+interface RedEnvelopeClaimProps {
+ code: string
+}
+
+type ClaimState = "loading" | "ready" | "opening" | "opened" | "claimed" | "error"
+
+export function RedEnvelopeClaimPage({ code }: RedEnvelopeClaimProps) {
+ const [state, setState] = useState("loading")
+ const [detail, setDetail] = useState(null)
+ const [claimedAmount, setClaimedAmount] = useState(null)
+ const [error, setError] = useState(null)
+
+ useEffect(() => {
+ loadDetail()
+ }, [code])
+
+ const loadDetail = async () => {
+ try {
+ const data = await services.redEnvelope.getDetail(code)
+ console.log('Red envelope data:', data.red_envelope)
+ setDetail(data)
+ if (data.user_claimed) {
+ setClaimedAmount(data.user_claimed.amount)
+ setState("claimed")
+ } else if (data.red_envelope.status !== "active") {
+ setState("opened")
+ } else {
+ setState("ready")
+ }
+ } catch (err) {
+ setError(err instanceof Error ? err.message : "加载失败")
+ setState("error")
+ }
+ }
+
+ const handleOpen = async () => {
+ setState("opening")
+ try {
+ const result = await services.redEnvelope.claim({ code })
+ setClaimedAmount(result.amount)
+
+ // Reload the full details to get updated claims list
+ const updatedDetail = await services.redEnvelope.getDetail(code)
+ setDetail(updatedDetail)
+
+ setTimeout(() => setState("claimed"), 1500)
+ } catch (err) {
+ toast.error(err instanceof Error ? err.message : "领取失败")
+ setState("ready")
+ }
+ }
+
+ if (state === "loading") {
+ return (
+
+ )
+ }
+
+ if (state === "error") {
+ return (
+
+
+
+
+
+
+
+
+ 红包加载失败
+
+
+ {error}
+
+
+
+
+
+ )
+ }
+
+ const envelope = detail?.red_envelope
+
+ return (
+
+
+
+ {(state === "ready" || state === "opening") && (
+
+ {/* 红包封面 - WeChat style with enhanced visuals */}
+
+ {/* 红包主体 */}
+
+ {/* 顶部金色光晕效果 */}
+
+
+ {/* 底部阴影 */}
+
+
+ {/* 装饰性图案 */}
+
+
+ {/* 发送者信息 */}
+
+
+
+
+
+ {envelope?.creator_username?.charAt(0).toUpperCase()}
+
+
+
+
+ {envelope?.creator_username} 的红包
+
+
+
+ {/* 中间圆形按钮 - 只有这个可点击 */}
+
+
+ {/* 内部光晕 */}
+
+
+ {/* 開 字 */}
+
+ 開
+
+
+ {/* 外部光圈动画 */}
+ {state === "ready" && (
+
+ )}
+
+
+
+ {/* 祝福语 */}
+
+
+ {envelope?.greeting || "恭喜发财,大吉大利"}
+
+
+
+
+ {/* 3D 立体效果阴影 */}
+
+
+
+ {state === "ready" && (
+
+ 点击 "開" 字领取红包
+
+ )}
+
+ )}
+
+ {(state === "claimed" || state === "opened") && (
+
+
+ {/* 头部 */}
+
+ {/* 背景装饰 */}
+
+
+
+
+
+
+ {envelope?.creator_username?.charAt(0).toUpperCase()}
+
+
+
+
{envelope?.creator_username} 的红包
+
+ {envelope?.greeting || "恭喜发财,大吉大利"}
+
+
+
+ {/* 领取金额 */}
+ {claimedAmount && (
+
+
+ {claimedAmount}
+ LDC
+
+
+ )}
+
+ {/* 领取记录 */}
+
+
+
+ {detail?.claims.length || 0}/{envelope?.total_count} 个红包已领取
+
+
+ {envelope?.remaining_amount}/{envelope?.total_amount} LDC
+
+
+
+
+
+ {detail?.claims.map((claim: RedEnvelopeClaim) => (
+
+
+
+
+
+ {claim.username.charAt(0).toUpperCase()}
+
+
+
{claim.username}
+
+ {claim.amount} LDC
+
+ ))}
+
+
+
+
+ {/* 底部按钮 */}
+
+
+
+
+
+ )}
+
+
+
+ )
+}
\ No newline at end of file
diff --git a/frontend/components/common/trade/red-envelope.tsx b/frontend/components/common/trade/red-envelope.tsx
new file mode 100644
index 00000000..4f088eee
--- /dev/null
+++ b/frontend/components/common/trade/red-envelope.tsx
@@ -0,0 +1,586 @@
+
+"use client"
+
+import * as React from "react"
+import { useState, useEffect } from "react"
+import { toast } from "sonner"
+import { Gift, Copy, Check, ExternalLink, Users, Coins } from "lucide-react"
+import { Input } from "@/components/ui/input"
+import { Label } from "@/components/ui/label"
+import { Button } from "@/components/ui/button"
+import { Textarea } from "@/components/ui/textarea"
+import { Badge } from "@/components/ui/badge"
+import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs"
+import { Card, CardContent } from "@/components/ui/card"
+import { Spinner } from "@/components/ui/spinner"
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogHeader,
+ DialogTitle,
+} from "@/components/ui/dialog"
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from "@/components/ui/select"
+import { PasswordDialog } from "@/components/common/general/password-dialog"
+import services from "@/lib/services"
+import type { RedEnvelopeType, CreateRedEnvelopeRequest, RedEnvelope, RedEnvelopeListResponse } from "@/lib/services"
+import { formatDateTime } from "@/lib/utils"
+
+/**
+ * 红包组件
+ *
+ * 提供红包发送功能和红包列表查看
+ */
+export function RedEnvelope() {
+ const [isFormOpen, setIsFormOpen] = useState(false)
+ const [isPasswordOpen, setIsPasswordOpen] = useState(false)
+ const [isResultOpen, setIsResultOpen] = useState(false)
+ const [copiedId, setCopiedId] = useState(null)
+ const [activeTab, setActiveTab] = useState<'sent' | 'received'>('sent')
+
+ /* 表单状态 */
+ const [type, setType] = useState("random")
+ const [totalAmount, setTotalAmount] = useState("")
+ const [totalCount, setTotalCount] = useState("")
+ const [greeting, setGreeting] = useState("")
+ const [loading, setLoading] = useState(false)
+
+ /* 结果状态 */
+ const [resultLink, setResultLink] = useState("")
+
+ /* 列表状态 */
+ const [sentEnvelopes, setSentEnvelopes] = useState([])
+ const [receivedEnvelopes, setReceivedEnvelopes] = useState([])
+ const [listLoading, setListLoading] = useState(false)
+ const [sentPage, setSentPage] = useState(1)
+ const [receivedPage, setReceivedPage] = useState(1)
+ const [sentTotal, setSentTotal] = useState(0)
+ const [receivedTotal, setReceivedTotal] = useState(0)
+
+ /* 加载红包列表 */
+ const loadEnvelopes = async (listType: 'sent' | 'received', page: number) => {
+ setListLoading(true)
+ try {
+ const result: RedEnvelopeListResponse = await services.redEnvelope.getList({
+ page,
+ page_size: 10,
+ type: listType,
+ })
+
+ if (listType === 'sent') {
+ setSentEnvelopes(prev => page === 1 ? result.red_envelopes : [...prev, ...result.red_envelopes])
+ setSentTotal(result.total)
+ } else {
+ setReceivedEnvelopes(prev => page === 1 ? result.red_envelopes : [...prev, ...result.red_envelopes])
+ setReceivedTotal(result.total)
+ }
+ } catch (error) {
+ toast.error('加载红包列表失败')
+ } finally {
+ setListLoading(false)
+ }
+ }
+
+ /* 初始加载 */
+ useEffect(() => {
+ loadEnvelopes('sent', 1)
+ loadEnvelopes('received', 1)
+ }, [])
+
+ /* 标签切换时加载数据 */
+ useEffect(() => {
+ if (activeTab === 'sent' && sentEnvelopes.length === 0) {
+ loadEnvelopes('sent', 1)
+ } else if (activeTab === 'received' && receivedEnvelopes.length === 0) {
+ loadEnvelopes('received', 1)
+ }
+ }, [activeTab])
+
+ /* 验证金额格式 */
+ const validateAmount = (value: string): boolean => {
+ const regex = /^\d+(\.\d{1,2})?$/
+ return regex.test(value) && parseFloat(value) > 0
+ }
+
+ /* 处理表单提交(第一步) */
+ const handleFormSubmit = () => {
+ if (!totalAmount.trim()) {
+ toast.error("请输入红包总金额")
+ return
+ }
+
+ if (!validateAmount(totalAmount)) {
+ toast.error("金额格式不正确,必须大于0且最多2位小数")
+ return
+ }
+
+ if (!totalCount.trim()) {
+ toast.error("请输入红包个数")
+ return
+ }
+
+ const count = parseInt(totalCount)
+ if (isNaN(count) || count <= 0) {
+ toast.error("红包个数必须大于0")
+ return
+ }
+
+ const amount = parseFloat(totalAmount)
+ if (type === "fixed" && amount / count < 0.01) {
+ toast.error("每个红包金额不能小于0.01")
+ return
+ }
+
+ if (greeting.length > 100) {
+ toast.error("祝福语最多100字符")
+ return
+ }
+
+ setIsFormOpen(false)
+ setIsPasswordOpen(true)
+ }
+
+ /* 处理最终创建(第二步) */
+ const handleConfirmCreate = async (password: string) => {
+ setLoading(true)
+ try {
+ const data: CreateRedEnvelopeRequest = {
+ type,
+ total_amount: parseFloat(totalAmount),
+ total_count: parseInt(totalCount),
+ greeting: greeting || undefined,
+ pay_key: password,
+ }
+
+ const result = await services.redEnvelope.create(data)
+
+ setResultLink(result.link)
+ setIsPasswordOpen(false)
+ setIsResultOpen(true)
+
+ /* 重置表单 */
+ setType("random")
+ setTotalAmount("")
+ setTotalCount("")
+ setGreeting("")
+
+ /* 刷新发送列表 */
+ loadEnvelopes('sent', 1)
+ setSentPage(1)
+ } catch (error: unknown) {
+ const errorMessage = error instanceof Error ? error.message : '创建失败'
+ toast.error('创建红包失败', { description: errorMessage })
+ } finally {
+ setLoading(false)
+ }
+ }
+
+ /* 复制链接 */
+ const handleCopyLink = async (link: string, envelopeId: number) => {
+ try {
+ await navigator.clipboard.writeText(link)
+ setCopiedId(envelopeId)
+ toast.success("链接已复制")
+ setTimeout(() => setCopiedId(null), 2000)
+ } catch {
+ toast.error("复制失败")
+ }
+ }
+
+ /* 计算每个红包金额 */
+ const perAmount = React.useMemo(() => {
+ if (!totalAmount || !totalCount) return null
+ const amount = parseFloat(totalAmount)
+ const count = parseInt(totalCount)
+ if (isNaN(amount) || isNaN(count) || count <= 0) return null
+ if (type === "fixed") {
+ return (amount / count).toFixed(2)
+ }
+ return null
+ }, [totalAmount, totalCount, type])
+
+ /* 获取状态标签 */
+ const getStatusBadge = (status: string) => {
+ const config = {
+ active: { label: '进行中', color: 'bg-green-500/10 text-green-600 border-green-500/20' },
+ finished: { label: '已领完', color: 'bg-gray-500/10 text-gray-600 border-gray-500/20' },
+ expired: { label: '已过期', color: 'bg-red-500/10 text-red-600 border-red-500/20' },
+ }[status] || { label: status, color: '' }
+
+ return (
+
+ {config.label}
+
+ )
+ }
+
+ /* 生成红包链接 */
+ const getEnvelopeLink = (code: string) => {
+ if (typeof window !== 'undefined') {
+ return `${window.location.origin}/redenvelope/${code}`
+ }
+ return `/redenvelope/${code}`
+ }
+
+ /* 渲染红包卡片 */
+ const renderEnvelopeCard = (envelope: RedEnvelope, isSent: boolean) => {
+ const link = getEnvelopeLink(envelope.code)
+ const claimedCount = envelope.total_count - envelope.remaining_count
+ const claimedAmount = (parseFloat(envelope.total_amount) - parseFloat(envelope.remaining_amount)).toFixed(2)
+
+ return (
+
+
+
+
+
+
+
+
+
+ {isSent ? '发给朋友' : `来自 ${envelope.creator_username}`}
+
+
+ {envelope.greeting || "恭喜发财,大吉大利"}
+
+
+
+ {getStatusBadge(envelope.status)}
+
+
+
+
+
总金额
+
+
+ {envelope.total_amount} LDC
+
+
+
+
已领取
+
+
+ {claimedCount}/{envelope.total_count} 个
+
+
+
+
剩余金额
+
+ {envelope.remaining_amount} LDC
+
+
+
+
已领金额
+
+ {claimedAmount} LDC
+
+
+
+
+
+ 类型: {envelope.type === 'random' ? '拼手气' : '固定金额'}
+ •
+ {formatDateTime(envelope.created_at)}
+
+
+ {isSent && (
+
+
+
+
+ )}
+
+ {!isSent && envelope.status === 'active' && (
+
+ )}
+
+
+ )
+ }
+
+ return (
+
+
+
+
+
+ 发红包
+
+
+ 创建红包分享给好友,支持固定金额和拼手气两种模式。
+
+
+
+
+
+ {/* 红包列表 */}
+
+
setActiveTab(v as 'sent' | 'received')}>
+
+
+
+ 我发出的 ({sentTotal})
+
+
+
+ 我收到的 ({receivedTotal})
+
+
+
+
+ {listLoading && sentEnvelopes.length === 0 ? (
+
+
+
+ ) : sentEnvelopes.length === 0 ? (
+
+ ) : (
+ <>
+
+ {sentEnvelopes.map(envelope => renderEnvelopeCard(envelope, true))}
+
+ {sentEnvelopes.length < sentTotal && (
+
+ )}
+ >
+ )}
+
+
+
+ {listLoading && receivedEnvelopes.length === 0 ? (
+
+
+
+ ) : receivedEnvelopes.length === 0 ? (
+
+ ) : (
+ <>
+
+ {receivedEnvelopes.map(envelope => renderEnvelopeCard(envelope, false))}
+
+ {receivedEnvelopes.length < receivedTotal && (
+
+ )}
+ >
+ )}
+
+
+
+
+ {/* 创建红包表单 */}
+
+
+ {/* 密码验证 */}
+
{
+ setIsPasswordOpen(open)
+ if (!open) setIsFormOpen(true)
+ }}
+ onConfirm={handleConfirmCreate}
+ loading={loading}
+ title="密码验证"
+ description={`正在创建 ${totalAmount} LDC 的红包(${totalCount}个)`}
+ />
+
+ {/* 创建成功结果 */}
+
+
+ )
+}
\ No newline at end of file
diff --git a/frontend/components/common/trade/trade-main.tsx b/frontend/components/common/trade/trade-main.tsx
index e7c37fd9..896baa5e 100644
--- a/frontend/components/common/trade/trade-main.tsx
+++ b/frontend/components/common/trade/trade-main.tsx
@@ -9,7 +9,9 @@ import { Receive } from "@/components/common/trade/receive"
import { TradeTable } from "@/components/common/trade/trade-table"
import { Transfer } from "@/components/common/trade/transfer"
import { Online } from "@/components/common/trade/online"
+import { RedEnvelope } from "@/components/common/trade/red-envelope"
import { TransactionProvider } from "@/contexts/transaction-context"
+import services from "@/lib/services"
import type { OrderType } from "@/lib/services"
/** 标签触发器样式 */
@@ -35,7 +37,8 @@ const TAB_TRIGGER_STYLES =
"transition-colors " +
"flex-none"
-type TabValue = OrderType | 'all'
+/** 标签值类型 */
+type TabValue = OrderType | 'all' | 'redenvelope'
/** 页面组件映射表 */
const PAGE_COMPONENTS: Record = {
@@ -45,6 +48,9 @@ const PAGE_COMPONENTS: Record = {
community: Community,
online: Online,
test: () => null,
+ red_envelope_send: RedEnvelope,
+ red_envelope_receive: RedEnvelope,
+ redenvelope: RedEnvelope,
all: AllActivity,
}
@@ -61,6 +67,15 @@ export function TradeMain() {
setMounted(true)
}, [])
+ const [redEnvelopeEnabled, setRedEnvelopeEnabled] = React.useState(true)
+
+ React.useEffect(() => {
+ // 检查红包功能是否启用
+ services.redEnvelope.isEnabled()
+ .then(res => setRedEnvelopeEnabled(res.enabled))
+ .catch(() => setRedEnvelopeEnabled(false))
+ })
+
/* 获取活动类型 */
const getOrderType = (tab: TabValue): OrderType | undefined => {
if (tab === 'all') return undefined
@@ -102,6 +117,9 @@ export function TradeMain() {
积分转移
社区划转
在线流转
+ {redEnvelopeEnabled && (
+ 红包
+ )}
所有活动
diff --git a/frontend/lib/services/index.ts b/frontend/lib/services/index.ts
index 3d145923..02e9a147 100644
--- a/frontend/lib/services/index.ts
+++ b/frontend/lib/services/index.ts
@@ -28,6 +28,7 @@ import { UserService } from './user';
import { DisputeService } from './dispute';
import { ConfigService } from './config';
import { DashboardService } from './dashboard';
+import { RedEnvelopeService } from './redenvelope';
/**
* 服务对象
@@ -53,6 +54,8 @@ const services = {
config: ConfigService,
/** 仪表板服务 */
dashboard: DashboardService,
+ /** 红包服务 */
+ redEnvelope: RedEnvelopeService,
} as const;
export default services;
@@ -179,4 +182,19 @@ export type {
GetTopCustomersRequest,
} from './dashboard';
+// 红包服务
+export { RedEnvelopeService } from './redenvelope';
+export type {
+ RedEnvelopeType,
+ RedEnvelopeStatus,
+ RedEnvelope,
+ RedEnvelopeClaim,
+ CreateRedEnvelopeRequest,
+ CreateRedEnvelopeResponse,
+ ClaimRedEnvelopeRequest,
+ ClaimRedEnvelopeResponse,
+ RedEnvelopeDetailResponse,
+ RedEnvelopeListParams,
+ RedEnvelopeListResponse,
+} from './redenvelope';
diff --git a/frontend/lib/services/redenvelope/index.ts b/frontend/lib/services/redenvelope/index.ts
new file mode 100644
index 00000000..10ac460d
--- /dev/null
+++ b/frontend/lib/services/redenvelope/index.ts
@@ -0,0 +1,14 @@
+export { RedEnvelopeService } from './redenvelope.service';
+export type {
+ RedEnvelopeType,
+ RedEnvelopeStatus,
+ RedEnvelope,
+ RedEnvelopeClaim,
+ CreateRedEnvelopeRequest,
+ CreateRedEnvelopeResponse,
+ ClaimRedEnvelopeRequest,
+ ClaimRedEnvelopeResponse,
+ RedEnvelopeDetailResponse,
+ RedEnvelopeListParams,
+ RedEnvelopeListResponse,
+} from './types';
\ No newline at end of file
diff --git a/frontend/lib/services/redenvelope/redenvelope.service.ts b/frontend/lib/services/redenvelope/redenvelope.service.ts
new file mode 100644
index 00000000..5bb0c4f0
--- /dev/null
+++ b/frontend/lib/services/redenvelope/redenvelope.service.ts
@@ -0,0 +1,113 @@
+import { BaseService } from '../core/base.service';
+import type {
+ CreateRedEnvelopeRequest,
+ CreateRedEnvelopeResponse,
+ ClaimRedEnvelopeRequest,
+ ClaimRedEnvelopeResponse,
+ RedEnvelopeDetailResponse,
+ RedEnvelopeListParams,
+ RedEnvelopeListResponse,
+ RedEnvelopeEnabledResponse,
+} from './types';
+
+/**
+ * 红包服务
+ * 处理红包创建、领取、查询相关的 API 请求
+ */
+export class RedEnvelopeService extends BaseService {
+ protected static readonly basePath = '/api/v1/redenvelope';
+
+ /**
+ * 创建红包
+ * @param data - 创建红包请求参数
+ * @returns 红包信息(包含分享链接)
+ * @throws {UnauthorizedError} 当未登录时
+ * @throws {ValidationError} 当参数验证失败时
+ * @throws {ApiErrorBase} 当余额不足或支付密码错误时
+ *
+ * @example
+ * ```typescript
+ * const result = await RedEnvelopeService.create({
+ * type: 'random',
+ * total_amount: 100,
+ * total_count: 10,
+ * greeting: '恭喜发财',
+ * pay_key: '123456'
+ * });
+ * console.log('分享链接:', result.link);
+ * ```
+ */
+ static async create(data: CreateRedEnvelopeRequest): Promise {
+ return this.post('/create', data);
+ }
+
+ /**
+ * 领取红包
+ * @param data - 领取红包请求参数
+ * @returns 领取结果(包含领取金额)
+ * @throws {UnauthorizedError} 当未登录时
+ * @throws {NotFoundError} 当红包不存在时
+ * @throws {ApiErrorBase} 当红包已领完、已过期或已领取过时
+ *
+ * @example
+ * ```typescript
+ * const result = await RedEnvelopeService.claim({ code: 'abc123' });
+ * console.log('领取金额:', result.amount);
+ * ```
+ */
+ static async claim(data: ClaimRedEnvelopeRequest): Promise {
+ return this.post('/claim', data);
+ }
+
+ /**
+ * 获取红包详情
+ * @param code - 红包唯一码
+ * @returns 红包详情(包含领取记录)
+ * @throws {NotFoundError} 当红包不存在时
+ *
+ * @example
+ * ```typescript
+ * const detail = await RedEnvelopeService.getDetail('abc123');
+ * console.log('红包状态:', detail.red_envelope.status);
+ * console.log('已领取人数:', detail.claims.length);
+ * ```
+ */
+ static async getDetail(code: string): Promise {
+ return this.get(`/${code}`);
+ }
+
+ /**
+ * 获取红包列表
+ * @param params - 查询参数
+ * @returns 红包列表
+ * @throws {UnauthorizedError} 当未登录时
+ *
+ * @example
+ * ```typescript
+ * const result = await RedEnvelopeService.getList({
+ * page: 1,
+ * page_size: 20,
+ * type: 'sent'
+ * });
+ * ```
+ */
+ static async getList(params: RedEnvelopeListParams): Promise {
+ return this.post('/list', params);
+ }
+
+ /**
+ * 检查红包功能是否启用
+ * @returns 红包功能启用状态
+ *
+ * @example
+ * ```typescript
+ * const result = await RedEnvelopeService.isEnabled();
+ * if (result.enabled) {
+ * console.log('红包功能已启用');
+ * }
+ * ```
+ */
+ static async isEnabled(): Promise {
+ return this.get('/enabled');
+ }
+}
\ No newline at end of file
diff --git a/frontend/lib/services/redenvelope/types.ts b/frontend/lib/services/redenvelope/types.ts
new file mode 100644
index 00000000..1db8744e
--- /dev/null
+++ b/frontend/lib/services/redenvelope/types.ts
@@ -0,0 +1,160 @@
+/**
+ * 红包类型
+ * - fixed: 固定金额,每个红包金额相同
+ * - random: 拼手气,随机分配金额
+ */
+export type RedEnvelopeType = 'fixed' | 'random';
+
+/**
+ * 红包状态
+ * - active: 进行中,可领取
+ * - finished: 已领完
+ * - expired: 已过期
+ */
+export type RedEnvelopeStatus = 'active' | 'finished' | 'expired';
+
+/**
+ * 红包信息
+ */
+export interface RedEnvelope {
+ /** 红包 ID */
+ id: number;
+ /** 红包唯一码(用于分享链接) */
+ code: string;
+ /** 创建者用户 ID */
+ creator_id: number;
+ /** 创建者用户名 */
+ creator_username: string;
+ /** 创建者头像 URL */
+ creator_avatar_url?: string;
+ /** 红包类型 */
+ type: RedEnvelopeType;
+ /** 总金额 */
+ total_amount: string;
+ /** 剩余金额 */
+ remaining_amount: string;
+ /** 红包总个数 */
+ total_count: number;
+ /** 剩余个数 */
+ remaining_count: number;
+ /** 祝福语 */
+ greeting: string;
+ /** 红包状态 */
+ status: RedEnvelopeStatus;
+ /** 过期时间 */
+ expires_at: string;
+ /** 创建时间 */
+ created_at: string;
+}
+
+/**
+ * 红包领取记录
+ */
+export interface RedEnvelopeClaim {
+ /** 记录 ID */
+ id: number;
+ /** 红包 ID */
+ red_envelope_id: number;
+ /** 领取者用户 ID */
+ user_id: number;
+ /** 领取者用户名 */
+ username: string;
+ /** 领取者头像 URL */
+ avatar_url?: string;
+ /** 领取金额 */
+ amount: string;
+ /** 领取时间 */
+ claimed_at: string;
+}
+
+/**
+ * 创建红包请求参数
+ */
+export interface CreateRedEnvelopeRequest {
+ /** 红包类型(fixed: 固定金额, random: 拼手气) */
+ type: RedEnvelopeType;
+ /** 总金额(必须大于0,最多2位小数) */
+ total_amount: number;
+ /** 红包个数(必须大于0) */
+ total_count: number;
+ /** 祝福语(可选,最大100字符) */
+ greeting?: string;
+ /** 支付密码(6-10位) */
+ pay_key: string;
+}
+
+/**
+ * 创建红包响应
+ */
+export interface CreateRedEnvelopeResponse {
+ /** 红包 ID */
+ id: number;
+ /** 红包唯一码 */
+ code: string;
+ /** 分享链接 */
+ link: string;
+}
+
+/**
+ * 领取红包请求参数
+ */
+export interface ClaimRedEnvelopeRequest {
+ /** 红包唯一码 */
+ code: string;
+}
+
+/**
+ * 领取红包响应
+ */
+export interface ClaimRedEnvelopeResponse {
+ /** 领取到的金额 */
+ amount: string;
+ /** 红包信息 */
+ red_envelope: RedEnvelope;
+}
+
+/**
+ * 获取红包详情响应
+ */
+export interface RedEnvelopeDetailResponse {
+ /** 红包信息 */
+ red_envelope: RedEnvelope;
+ /** 领取记录列表 */
+ claims: RedEnvelopeClaim[];
+ /** 当前用户的领取记录(如果已领取) */
+ user_claimed?: RedEnvelopeClaim;
+}
+
+/**
+ * 红包列表查询参数
+ */
+export interface RedEnvelopeListParams {
+ /** 页码,从 1 开始 */
+ page: number;
+ /** 每页数量,1-100 */
+ page_size: number;
+ /** 查询类型(sent: 发出的, received: 收到的) */
+ type?: 'sent' | 'received';
+}
+
+/**
+ * 红包列表响应
+ */
+export interface RedEnvelopeListResponse {
+ /** 总记录数 */
+ total: number;
+ /** 当前页码 */
+ page: number;
+ /** 每页数量 */
+ page_size: number;
+ /** 红包列表 */
+ red_envelopes: RedEnvelope[];
+}
+
+/**
+ * 红包功能是否启用响应
+ */
+export interface RedEnvelopeEnabledResponse {
+ /** 是否启用 */
+ enabled: boolean;
+}
\ No newline at end of file
diff --git a/frontend/lib/services/transaction/types.ts b/frontend/lib/services/transaction/types.ts
index 2d5d4949..2673e7f3 100644
--- a/frontend/lib/services/transaction/types.ts
+++ b/frontend/lib/services/transaction/types.ts
@@ -1,7 +1,7 @@
/**
* 订单类型
*/
-export type OrderType = 'receive' | 'payment' | 'transfer' | 'community' | 'online' | 'test';
+export type OrderType = 'receive' | 'payment' | 'transfer' | 'community' | 'online' | 'test' | 'red_envelope_send' | 'red_envelope_receive';
/**
* 订单状态
diff --git a/internal/apps/order/routers.go b/internal/apps/order/routers.go
index 36d012ef..f3afcf2c 100644
--- a/internal/apps/order/routers.go
+++ b/internal/apps/order/routers.go
@@ -30,7 +30,7 @@ import (
type TransactionListRequest struct {
Page int `json:"page" form:"page" binding:"min=1"`
PageSize int `json:"page_size" form:"page_size" binding:"min=1,max=100"`
- Type string `json:"type" form:"type" binding:"omitempty,oneof=receive payment transfer community online test"`
+ Type string `json:"type" form:"type" binding:"omitempty,oneof=receive payment transfer community online test redenvelope"`
Status string `json:"status" form:"status" binding:"omitempty,oneof=success pending failed expired disputing refund refused"`
ClientID string `json:"client_id" form:"client_id" binding:"omitempty"`
StartTime *time.Time `json:"startTime" form:"startTime" binding:"omitempty"`
@@ -82,37 +82,42 @@ func ListTransactions(c *gin.Context) {
clientIDHandled := false
if req.Type != "" {
- orderType := model.OrderType(req.Type)
-
- switch orderType {
- case model.OrderTypeReceive:
- // receive 类型:查询当前用户作为收款方的 payment 订单
- baseQuery = baseQuery.Where("orders.type = ? AND orders.payee_user_id = ?", model.OrderTypePayment, user.ID)
- case model.OrderTypeCommunity:
- // community 类型:查询当前用户作为收款方的 community 订单
- baseQuery = baseQuery.Where("orders.type = ? AND orders.payee_user_id = ?", orderType, user.ID)
- case model.OrderTypeOnline:
- // online 类型:商家可查看自己 client_id 的所有订单,普通用户只能查看与自己相关的订单
- if req.ClientID != "" {
- clientIDHandled = true
- var count int64
- if err := db.DB(c.Request.Context()).Model(&model.MerchantAPIKey{}).
- Where("client_id = ? AND user_id = ?", req.ClientID, user.ID).
- Count(&count).Error; err != nil {
- c.JSON(http.StatusInternalServerError, util.Err(err.Error()))
- return
- }
- if count > 0 {
- baseQuery = baseQuery.Where("orders.type = ? AND orders.client_id = ?", orderType, req.ClientID)
+ // redenvelope 类型不过滤(红包记录在单独的表中,这里返回空结果)
+ if req.Type == "redenvelope" {
+ baseQuery = baseQuery.Where("1 = 0") // 返回空结果
+ } else {
+ orderType := model.OrderType(req.Type)
+
+ switch orderType {
+ case model.OrderTypeReceive:
+ // receive 类型:查询当前用户作为收款方的 payment 订单
+ baseQuery = baseQuery.Where("orders.type = ? AND orders.payee_user_id = ?", model.OrderTypePayment, user.ID)
+ case model.OrderTypeCommunity:
+ // community 类型:查询当前用户作为收款方的 community 订单
+ baseQuery = baseQuery.Where("orders.type = ? AND orders.payee_user_id = ?", orderType, user.ID)
+ case model.OrderTypeOnline:
+ // online 类型:商家可查看自己 client_id 的所有订单,普通用户只能查看与自己相关的订单
+ if req.ClientID != "" {
+ clientIDHandled = true
+ var count int64
+ if err := db.DB(c.Request.Context()).Model(&model.MerchantAPIKey{}).
+ Where("client_id = ? AND user_id = ?", req.ClientID, user.ID).
+ Count(&count).Error; err != nil {
+ c.JSON(http.StatusInternalServerError, util.Err(err.Error()))
+ return
+ }
+ if count > 0 {
+ baseQuery = baseQuery.Where("orders.type = ? AND orders.client_id = ?", orderType, req.ClientID)
+ } else {
+ baseQuery = baseQuery.Where("orders.type = ? AND orders.client_id = ? AND (orders.payer_user_id = ? OR orders.payee_user_id = ?)", orderType, req.ClientID, user.ID, user.ID)
+ }
} else {
- baseQuery = baseQuery.Where("orders.type = ? AND orders.client_id = ? AND (orders.payer_user_id = ? OR orders.payee_user_id = ?)", orderType, req.ClientID, user.ID, user.ID)
+ baseQuery = baseQuery.Where("orders.type = ? AND (orders.payer_user_id = ? OR orders.payee_user_id = ?)", orderType, user.ID, user.ID)
}
- } else {
- baseQuery = baseQuery.Where("orders.type = ? AND (orders.payer_user_id = ? OR orders.payee_user_id = ?)", orderType, user.ID, user.ID)
+ case model.OrderTypePayment, model.OrderTypeTransfer, model.OrderTypeTest:
+ // payment、transfer 类型:查询当前用户作为付款方的订单
+ baseQuery = baseQuery.Where("orders.type = ? AND orders.payer_user_id = ?", orderType, user.ID)
}
- case model.OrderTypePayment, model.OrderTypeTransfer, model.OrderTypeTest:
- // payment、transfer 类型:查询当前用户作为付款方的订单
- baseQuery = baseQuery.Where("orders.type = ? AND orders.payer_user_id = ?", orderType, user.ID)
}
} else {
baseQuery = baseQuery.Where("orders.payee_user_id = ? OR orders.payer_user_id = ?", user.ID, user.ID)
diff --git a/internal/apps/redenvelope/errs.go b/internal/apps/redenvelope/errs.go
new file mode 100644
index 00000000..6d30879d
--- /dev/null
+++ b/internal/apps/redenvelope/errs.go
@@ -0,0 +1,29 @@
+/*
+Copyright 2025 linux.do
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+package redenvelope
+
+const (
+ RedEnvelopeNotFound = "红包不存在"
+ RedEnvelopeExpired = "红包已过期"
+ RedEnvelopeFinished = "红包已领完"
+ RedEnvelopeAlreadyClaimed = "您已领取过该红包"
+ CannotClaimOwnRedEnvelope = "不能领取自己的红包"
+ InvalidRedEnvelopeType = "无效的红包类型"
+ InvalidRedEnvelopeCount = "红包个数必须大于0"
+ InvalidRedEnvelopeAmount = "红包金额必须大于0"
+ AmountTooSmall = "每个红包金额不能小于0.01"
+)
\ No newline at end of file
diff --git a/internal/apps/redenvelope/routers.go b/internal/apps/redenvelope/routers.go
new file mode 100644
index 00000000..91c769ab
--- /dev/null
+++ b/internal/apps/redenvelope/routers.go
@@ -0,0 +1,506 @@
+/*
+Copyright 2025 linux.do
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+package redenvelope
+
+import (
+ "errors"
+ "fmt"
+ "math/rand"
+ "net/http"
+ "time"
+
+ "github.com/gin-gonic/gin"
+ "github.com/linux-do/credit/internal/apps/oauth"
+ "github.com/linux-do/credit/internal/common"
+ "github.com/linux-do/credit/internal/config"
+ "github.com/linux-do/credit/internal/db"
+ "github.com/linux-do/credit/internal/model"
+ "github.com/linux-do/credit/internal/util"
+ "github.com/shopspring/decimal"
+ "gorm.io/gorm"
+ "gorm.io/gorm/clause"
+)
+
+// CreateRequest 创建红包请求
+type CreateRequest struct {
+ Type model.RedEnvelopeType `json:"type" binding:"required,oneof=fixed random"`
+ TotalAmount decimal.Decimal `json:"total_amount" binding:"required"`
+ TotalCount int `json:"total_count" binding:"required,min=1"`
+ Greeting string `json:"greeting" binding:"max=100"`
+ PayKey string `json:"pay_key" binding:"required,max=10"`
+}
+
+// CreateResponse 创建红包响应
+type CreateResponse struct {
+ ID uint64 `json:"id"`
+ Code string `json:"code"`
+ Link string `json:"link"`
+}
+
+// IsEnabledResponse 红包功能是否启用响应
+type IsEnabledResponse struct {
+ Enabled bool `json:"enabled"`
+}
+
+// ClaimRequest 领取红包请求
+type ClaimRequest struct {
+ Code string `json:"code" binding:"required"`
+}
+
+// ClaimResponse 领取红包响应
+type ClaimResponse struct {
+ Amount decimal.Decimal `json:"amount"`
+ RedEnvelope *model.RedEnvelope `json:"red_envelope"`
+}
+
+// DetailResponse 红包详情响应
+type DetailResponse struct {
+ RedEnvelope *model.RedEnvelope `json:"red_envelope"`
+ Claims []model.RedEnvelopeClaim `json:"claims"`
+ UserClaimed *model.RedEnvelopeClaim `json:"user_claimed,omitempty"`
+}
+
+// ListRequest 红包列表请求
+type ListRequest struct {
+ Page int `json:"page" binding:"required,min=1"`
+ PageSize int `json:"page_size" binding:"required,min=1,max=100"`
+ Type string `json:"type" binding:"omitempty,oneof=sent received"`
+}
+
+// ListResponse 红包列表响应
+type ListResponse struct {
+ Total int64 `json:"total"`
+ Page int `json:"page"`
+ PageSize int `json:"page_size"`
+ RedEnvelopes []model.RedEnvelope `json:"red_envelopes"`
+}
+
+// Create 创建红包
+// @Tags redenvelope
+// @Accept json
+// @Produce json
+// @Param request body CreateRequest true "创建红包请求"
+// @Success 200 {object} util.ResponseAny
+// @Router /api/v1/redenvelope/create [post]
+func Create(c *gin.Context) {
+ // 检查红包功能是否启用
+ if !model.IsRedEnvelopeEnabled(c.Request.Context()) {
+ c.JSON(http.StatusForbidden, util.Err("红包功能未启用"))
+ return
+ }
+
+ var req CreateRequest
+ if err := c.ShouldBindJSON(&req); err != nil {
+ c.JSON(http.StatusBadRequest, util.Err(err.Error()))
+ return
+ }
+
+ if req.TotalAmount.LessThanOrEqual(decimal.Zero) {
+ c.JSON(http.StatusBadRequest, util.Err(InvalidRedEnvelopeAmount))
+ return
+ }
+
+ if req.TotalAmount.Exponent() < -2 {
+ c.JSON(http.StatusBadRequest, util.Err(common.AmountDecimalPlacesExceeded))
+ return
+ }
+
+ // 固定金额红包检查每个红包金额
+ if req.Type == model.RedEnvelopeTypeFixed {
+ perAmount := req.TotalAmount.Div(decimal.NewFromInt(int64(req.TotalCount)))
+ if perAmount.LessThan(decimal.NewFromFloat(0.01)) {
+ c.JSON(http.StatusBadRequest, util.Err(AmountTooSmall))
+ return
+ }
+ }
+
+ currentUser, _ := util.GetFromContext[*model.User](c, oauth.UserObjKey)
+
+ if !currentUser.VerifyPayKey(req.PayKey) {
+ c.JSON(http.StatusBadRequest, util.Err(common.PayKeyIncorrect))
+ return
+ }
+
+ code := util.GenerateUniqueIDSimple()
+ var redEnvelope model.RedEnvelope
+
+ if err := db.DB(c.Request.Context()).Transaction(func(tx *gorm.DB) error {
+ // 锁定用户余额
+ var user model.User
+ if err := tx.Clauses(clause.Locking{Strength: "UPDATE", Options: "NOWAIT"}).
+ Where("id = ?", currentUser.ID).First(&user).Error; err != nil {
+ return err
+ }
+
+ if user.AvailableBalance.LessThan(req.TotalAmount) {
+ return errors.New(common.InsufficientBalance)
+ }
+
+ // 扣减余额
+ if err := tx.Model(&model.User{}).Where("id = ?", user.ID).
+ Update("available_balance", gorm.Expr("available_balance - ?", req.TotalAmount)).Error; err != nil {
+ return err
+ }
+
+ // 创建红包
+ redEnvelope = model.RedEnvelope{
+ Code: code,
+ CreatorID: user.ID,
+ Type: req.Type,
+ TotalAmount: req.TotalAmount,
+ RemainingAmount: req.TotalAmount,
+ TotalCount: req.TotalCount,
+ RemainingCount: req.TotalCount,
+ Greeting: req.Greeting,
+ Status: model.RedEnvelopeStatusActive,
+ ExpiresAt: time.Now().Add(24 * time.Hour),
+ }
+
+ if err := tx.Create(&redEnvelope).Error; err != nil {
+ return err
+ }
+
+ // 创建订单记录(红包支出)
+ order := model.Order{
+ OrderName: fmt.Sprintf("红包支出-%s", req.Greeting),
+ ClientID: "red_envelope",
+ PayerUserID: user.ID,
+ PayeeUserID: user.ID, // 红包支出时,收款人也是自己
+ Amount: req.TotalAmount,
+ Status: model.OrderStatusSuccess,
+ Type: model.OrderTypeRedEnvelopeSend,
+ Remark: fmt.Sprintf("创建红包,共%d个", req.TotalCount),
+ PaymentType: "balance",
+ TradeTime: time.Now(),
+ ExpiresAt: time.Now().Add(24 * time.Hour),
+ }
+ if order.OrderName == "红包支出-" {
+ order.OrderName = "红包支出"
+ }
+
+ return tx.Create(&order).Error
+ }); err != nil {
+ if err.Error() == common.InsufficientBalance {
+ c.JSON(http.StatusBadRequest, util.Err(common.InsufficientBalance))
+ } else {
+ c.JSON(http.StatusInternalServerError, util.Err(err.Error()))
+ }
+ return
+ }
+
+ c.JSON(http.StatusOK, util.OK(CreateResponse{
+ ID: redEnvelope.ID,
+ Code: code,
+ Link: fmt.Sprintf("%s/redenvelope/%s", config.Config.App.FrontendURL, code),
+ }))
+}
+
+// Claim 领取红包
+// @Tags redenvelope
+// @Accept json
+// @Produce json
+// @Param request body ClaimRequest true "领取红包请求"
+// @Success 200 {object} util.ResponseAny
+// @Router /api/v1/redenvelope/claim [post]
+func Claim(c *gin.Context) {
+ // 检查红包功能是否启用
+ if !model.IsRedEnvelopeEnabled(c.Request.Context()) {
+ c.JSON(http.StatusForbidden, util.Err("红包功能未启用"))
+ return
+ }
+
+ var req ClaimRequest
+ if err := c.ShouldBindJSON(&req); err != nil {
+ c.JSON(http.StatusBadRequest, util.Err(err.Error()))
+ return
+ }
+
+ currentUser, _ := util.GetFromContext[*model.User](c, oauth.UserObjKey)
+
+ var claimedAmount decimal.Decimal
+ var redEnvelope model.RedEnvelope
+
+ if err := db.DB(c.Request.Context()).Transaction(func(tx *gorm.DB) error {
+ // 使用 FOR UPDATE 锁定红包记录,防止并发领取
+ if err := tx.Clauses(clause.Locking{Strength: "UPDATE", Options: "NOWAIT"}).
+ Where("code = ?", req.Code).First(&redEnvelope).Error; err != nil {
+ if errors.Is(err, gorm.ErrRecordNotFound) {
+ return errors.New(RedEnvelopeNotFound)
+ }
+ return err
+ }
+
+ // 检查红包状态
+ if redEnvelope.Status == model.RedEnvelopeStatusExpired || redEnvelope.ExpiresAt.Before(time.Now()) {
+ return errors.New(RedEnvelopeExpired)
+ }
+
+ if redEnvelope.Status == model.RedEnvelopeStatusFinished || redEnvelope.RemainingCount <= 0 {
+ return errors.New(RedEnvelopeFinished)
+ }
+
+ // 检查是否已领取
+ var existingClaim model.RedEnvelopeClaim
+ if err := tx.Where("red_envelope_id = ? AND user_id = ?", redEnvelope.ID, currentUser.ID).
+ First(&existingClaim).Error; err == nil {
+ return errors.New(RedEnvelopeAlreadyClaimed)
+ }
+
+ // 计算领取金额
+ if redEnvelope.Type == model.RedEnvelopeTypeFixed {
+ // 固定金额:如果是最后一个,给全部剩余金额(避免舍入误差)
+ if redEnvelope.RemainingCount == 1 {
+ claimedAmount = redEnvelope.RemainingAmount
+ } else {
+ claimedAmount = redEnvelope.TotalAmount.Div(decimal.NewFromInt(int64(redEnvelope.TotalCount))).Round(2)
+ }
+ } else {
+ // 拼手气红包:使用二倍均值算法
+ claimedAmount = calculateRandomAmount(redEnvelope.RemainingAmount, redEnvelope.RemainingCount)
+ }
+
+ // 创建领取记录
+ claim := model.RedEnvelopeClaim{
+ RedEnvelopeID: redEnvelope.ID,
+ UserID: currentUser.ID,
+ Amount: claimedAmount,
+ }
+ if err := tx.Create(&claim).Error; err != nil {
+ return err
+ }
+
+ // 更新红包状态
+ newRemainingCount := redEnvelope.RemainingCount - 1
+ newRemainingAmount := redEnvelope.RemainingAmount.Sub(claimedAmount)
+ newStatus := redEnvelope.Status
+ if newRemainingCount <= 0 {
+ newStatus = model.RedEnvelopeStatusFinished
+ }
+
+ if err := tx.Model(&model.RedEnvelope{}).Where("id = ?", redEnvelope.ID).
+ Updates(map[string]interface{}{
+ "remaining_count": newRemainingCount,
+ "remaining_amount": newRemainingAmount,
+ "status": newStatus,
+ }).Error; err != nil {
+ return err
+ }
+
+ // 更新红包对象用于返回
+ redEnvelope.RemainingCount = newRemainingCount
+ redEnvelope.RemainingAmount = newRemainingAmount
+ redEnvelope.Status = newStatus
+
+ // 增加用户余额
+ if err := tx.Model(&model.User{}).Where("id = ?", currentUser.ID).
+ Update("available_balance", gorm.Expr("available_balance + ?", claimedAmount)).Error; err != nil {
+ return err
+ }
+
+ // 创建订单记录(红包收入)
+ order := model.Order{
+ OrderName: fmt.Sprintf("红包收入-%s", redEnvelope.Greeting),
+ ClientID: "red_envelope",
+ PayerUserID: redEnvelope.CreatorID,
+ PayeeUserID: currentUser.ID,
+ Amount: claimedAmount,
+ Status: model.OrderStatusSuccess,
+ Type: model.OrderTypeRedEnvelopeReceive,
+ Remark: fmt.Sprintf("领取红包,来自创建者ID:%d", redEnvelope.CreatorID),
+ PaymentType: "balance",
+ TradeTime: time.Now(),
+ ExpiresAt: time.Now().Add(24 * time.Hour),
+ }
+ if order.OrderName == "红包收入-" {
+ order.OrderName = "红包收入"
+ }
+
+ return tx.Create(&order).Error
+ }); err != nil {
+ errMsg := err.Error()
+ switch errMsg {
+ case RedEnvelopeNotFound:
+ c.JSON(http.StatusNotFound, util.Err(errMsg))
+ case RedEnvelopeExpired, RedEnvelopeFinished, RedEnvelopeAlreadyClaimed, CannotClaimOwnRedEnvelope:
+ c.JSON(http.StatusBadRequest, util.Err(errMsg))
+ default:
+ c.JSON(http.StatusInternalServerError, util.Err(errMsg))
+ }
+ return
+ }
+
+ c.JSON(http.StatusOK, util.OK(ClaimResponse{
+ Amount: claimedAmount,
+ RedEnvelope: &redEnvelope,
+ }))
+}
+
+// GetDetail 获取红包详情
+// @Tags redenvelope
+// @Produce json
+// @Param code path string true "红包码"
+// @Success 200 {object} util.ResponseAny
+// @Router /api/v1/redenvelope/{code} [get]
+func GetDetail(c *gin.Context) {
+ code := c.Param("code")
+ currentUser, _ := util.GetFromContext[*model.User](c, oauth.UserObjKey)
+
+ var redEnvelope model.RedEnvelope
+ if err := db.DB(c.Request.Context()).
+ Select("red_envelopes.*, users.username as creator_username, users.avatar_url as creator_avatar_url").
+ Joins("LEFT JOIN users ON red_envelopes.creator_id = users.id").
+ Where("red_envelopes.code = ?", code).First(&redEnvelope).Error; err != nil {
+ if errors.Is(err, gorm.ErrRecordNotFound) {
+ c.JSON(http.StatusNotFound, util.Err(RedEnvelopeNotFound))
+ return
+ }
+ c.JSON(http.StatusInternalServerError, util.Err(err.Error()))
+ return
+ }
+
+ var claims []model.RedEnvelopeClaim
+ db.DB(c.Request.Context()).
+ Select("red_envelope_claims.*, users.username, users.avatar_url").
+ Joins("LEFT JOIN users ON red_envelope_claims.user_id = users.id").
+ Where("red_envelope_claims.red_envelope_id = ?", redEnvelope.ID).
+ Order("red_envelope_claims.claimed_at DESC").
+ Find(&claims)
+
+ var userClaimed *model.RedEnvelopeClaim
+ if currentUser != nil {
+ for i := range claims {
+ if claims[i].UserID == currentUser.ID {
+ userClaimed = &claims[i]
+ break
+ }
+ }
+ }
+
+ c.JSON(http.StatusOK, util.OK(DetailResponse{
+ RedEnvelope: &redEnvelope,
+ Claims: claims,
+ UserClaimed: userClaimed,
+ }))
+}
+
+// List 获取红包列表
+// @Tags redenvelope
+// @Accept json
+// @Produce json
+// @Param request body ListRequest true "列表请求"
+// @Success 200 {object} util.ResponseAny
+// @Router /api/v1/redenvelope/list [post]
+func List(c *gin.Context) {
+ var req ListRequest
+ if err := c.ShouldBindJSON(&req); err != nil {
+ c.JSON(http.StatusBadRequest, util.Err(err.Error()))
+ return
+ }
+
+ currentUser, _ := util.GetFromContext[*model.User](c, oauth.UserObjKey)
+
+ query := db.DB(c.Request.Context()).Model(&model.RedEnvelope{}).
+ Select("red_envelopes.*, users.username as creator_username, users.avatar_url as creator_avatar_url").
+ Joins("LEFT JOIN users ON red_envelopes.creator_id = users.id")
+
+ if req.Type == "sent" {
+ query = query.Where("red_envelopes.creator_id = ?", currentUser.ID)
+ } else if req.Type == "received" {
+ query = query.Joins("INNER JOIN red_envelope_claims ON red_envelopes.id = red_envelope_claims.red_envelope_id").
+ Where("red_envelope_claims.user_id = ?", currentUser.ID)
+ } else {
+ query = query.Where("red_envelopes.creator_id = ?", currentUser.ID)
+ }
+
+ var total int64
+ query.Count(&total)
+
+ var redEnvelopes []model.RedEnvelope
+ query.Order("red_envelopes.created_at DESC").
+ Offset((req.Page - 1) * req.PageSize).
+ Limit(req.PageSize).
+ Find(&redEnvelopes)
+
+ c.JSON(http.StatusOK, util.OK(ListResponse{
+ Total: total,
+ Page: req.Page,
+ PageSize: req.PageSize,
+ RedEnvelopes: redEnvelopes,
+ }))
+}
+
+// IsEnabled 检查红包功能是否启用
+// @Tags redenvelope
+// @Produce json
+// @Success 200 {object} util.ResponseAny
+// @Router /api/v1/redenvelope/enabled [get]
+func IsEnabled(c *gin.Context) {
+ enabled := model.IsRedEnvelopeEnabled(c.Request.Context())
+ c.JSON(http.StatusOK, util.OK(IsEnabledResponse{
+ Enabled: enabled,
+ }))
+}
+
+// calculateRandomAmount 二倍均值算法计算随机红包金额
+func calculateRandomAmount(remaining decimal.Decimal, count int) decimal.Decimal {
+ // 如果是最后一个红包,返回所有剩余金额(避免舍入误差)
+ if count == 1 {
+ return remaining
+ }
+
+ minAmount := decimal.NewFromFloat(0.01)
+
+ // 确保剩余金额足够分配给所有人至少0.01
+ minRequired := minAmount.Mul(decimal.NewFromInt(int64(count)))
+ if remaining.LessThan(minRequired) {
+ // 如果剩余金额不足,平均分配
+ return remaining.Div(decimal.NewFromInt(int64(count))).Round(2)
+ }
+
+ // 二倍均值算法:金额范围 [0.01, min(剩余金额/剩余人数*2, 剩余金额-其他人最小金额)]
+ avg := remaining.Div(decimal.NewFromInt(int64(count)))
+ maxAmount := avg.Mul(decimal.NewFromInt(2))
+
+ // 确保给其他人留下足够的金额(每人至少0.01)
+ maxPossible := remaining.Sub(minAmount.Mul(decimal.NewFromInt(int64(count - 1))))
+ if maxAmount.GreaterThan(maxPossible) {
+ maxAmount = maxPossible
+ }
+
+ // 确保maxAmount不小于minAmount
+ if maxAmount.LessThan(minAmount) {
+ maxAmount = minAmount
+ }
+
+ // 生成随机金额 [minAmount, maxAmount]
+ diff := maxAmount.Sub(minAmount)
+ if diff.LessThanOrEqual(decimal.Zero) {
+ return minAmount
+ }
+
+ // 生成随机数:转换为分(cents)来处理,避免精度问题
+ diffCents := diff.Mul(decimal.NewFromInt(100)).IntPart()
+ if diffCents <= 0 {
+ return minAmount
+ }
+
+ randCents := rand.Int63n(diffCents + 1) // [0, diffCents]
+ randAmount := decimal.NewFromInt(randCents).Div(decimal.NewFromInt(100))
+ amount := minAmount.Add(randAmount)
+
+ return amount.Round(2)
+}
\ No newline at end of file
diff --git a/internal/apps/redenvelope/tasks.go b/internal/apps/redenvelope/tasks.go
new file mode 100644
index 00000000..d109ef62
--- /dev/null
+++ b/internal/apps/redenvelope/tasks.go
@@ -0,0 +1,109 @@
+/*
+Copyright 2025 linux.do
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+package redenvelope
+
+import (
+ "context"
+ "fmt"
+ "time"
+
+ "github.com/hibiken/asynq"
+ "github.com/linux-do/credit/internal/db"
+ "github.com/linux-do/credit/internal/logger"
+ "github.com/linux-do/credit/internal/model"
+ "gorm.io/gorm"
+)
+
+// HandleRefundExpiredRedEnvelopes 处理过期红包退款的定时任务
+func HandleRefundExpiredRedEnvelopes(ctx context.Context, t *asynq.Task) error {
+ logger.InfoF(ctx, "开始处理过期红包退款任务")
+ refundExpiredRedEnvelopes(ctx)
+ logger.InfoF(ctx, "过期红包退款任务完成")
+ return nil
+}
+
+// refundExpiredRedEnvelopes 退款过期红包
+func refundExpiredRedEnvelopes(ctx context.Context) {
+ // 查询所有过期且未退款的红包
+ var expiredEnvelopes []model.RedEnvelope
+ if err := db.DB(ctx).
+ Where("status = ? AND expires_at < ? AND remaining_amount > 0", model.RedEnvelopeStatusActive, time.Now()).
+ Find(&expiredEnvelopes).Error; err != nil {
+ logger.ErrorF(ctx, "查询过期红包失败: %v", err)
+ return
+ }
+
+ if len(expiredEnvelopes) == 0 {
+ logger.InfoF(ctx, "没有需要退款的过期红包")
+ return
+ }
+
+ logger.InfoF(ctx, "找到 %d 个需要退款的过期红包", len(expiredEnvelopes))
+
+ // 处理每个过期红包
+ for _, envelope := range expiredEnvelopes {
+ if err := db.DB(ctx).Transaction(func(tx *gorm.DB) error {
+ // 更新红包状态为已过期
+ if err := tx.Model(&model.RedEnvelope{}).
+ Where("id = ? AND status = ?", envelope.ID, model.RedEnvelopeStatusActive).
+ Updates(map[string]interface{}{
+ "status": model.RedEnvelopeStatusExpired,
+ "remaining_amount": 0,
+ "remaining_count": 0,
+ }).Error; err != nil {
+ return err
+ }
+
+ // 退还剩余金额给创建者
+ if envelope.RemainingAmount.IsPositive() {
+ if err := tx.Model(&model.User{}).
+ Where("id = ?", envelope.CreatorID).
+ Update("available_balance", gorm.Expr("available_balance + ?", envelope.RemainingAmount)).Error; err != nil {
+ return err
+ }
+
+ // 创建退款订单记录
+ order := model.Order{
+ OrderName: fmt.Sprintf("红包退款-%s", envelope.Greeting),
+ ClientID: "red_envelope",
+ PayerUserID: envelope.CreatorID,
+ PayeeUserID: envelope.CreatorID,
+ Amount: envelope.RemainingAmount,
+ Status: model.OrderStatusSuccess,
+ Type: "red_envelope_refund",
+ Remark: fmt.Sprintf("红包过期退款,红包ID:%d", envelope.ID),
+ PaymentType: "balance",
+ TradeTime: time.Now(),
+ ExpiresAt: time.Now().Add(24 * time.Hour),
+ }
+ if order.OrderName == "红包退款-" {
+ order.OrderName = "红包退款"
+ }
+
+ if err := tx.Create(&order).Error; err != nil {
+ return err
+ }
+
+ logger.InfoF(ctx, "红包ID:%d 退款成功,金额:%s", envelope.ID, envelope.RemainingAmount.String())
+ }
+
+ return nil
+ }); err != nil {
+ logger.ErrorF(ctx, "红包ID:%d 退款失败: %v", envelope.ID, err)
+ }
+ }
+}
\ No newline at end of file
diff --git a/internal/config/model.go b/internal/config/model.go
index 66ae34f7..a8b03b04 100644
--- a/internal/config/model.go
+++ b/internal/config/model.go
@@ -37,6 +37,7 @@ type appConfig struct {
NodeID int64 `mapstructure:"node_id"`
APIPrefix string `mapstructure:"api_prefix"`
GracefulShutdownTimeout int `mapstructure:"graceful_shutdown_timeout"`
+ FrontendURL string `mapstructure:"frontend_url"`
FrontendPayURL string `mapstructure:"frontend_pay_url"`
SessionCookieName string `mapstructure:"session_cookie_name"`
SessionSecret string `mapstructure:"session_secret"`
@@ -145,6 +146,7 @@ type schedulerConfig struct {
DisputeAutoRefundDispatchIntervalSeconds int `mapstructure:"dispute_auto_refund_dispatch_interval_seconds"`
AutoRefundExpiredDisputesTaskCron string `mapstructure:"auto_refund_expired_disputes_task_cron"`
SyncOrdersToClickHouseTaskCron string `mapstructure:"sync_orders_to_clickhouse_task_cron"`
+ RefundExpiredRedEnvelopesTaskCron string `mapstructure:"refund_expired_red_envelopes_task_cron"`
}
// workerConfig 工作配置
diff --git a/internal/db/migrator/migrator.go b/internal/db/migrator/migrator.go
index 03405f05..1fe43e19 100644
--- a/internal/db/migrator/migrator.go
+++ b/internal/db/migrator/migrator.go
@@ -40,6 +40,8 @@ func Migrate() {
&model.Order{},
&model.SystemConfig{},
&model.Dispute{},
+ &model.RedEnvelope{},
+ &model.RedEnvelopeClaim{},
); err != nil {
log.Fatalf("[PostgreSQL] auto migrate failed: %v\n", err)
}
@@ -92,6 +94,11 @@ func initSystemConfigs() {
Value: "30",
Description: "新用户保护期天数,期内积分下降不扣分",
},
+ {
+ Key: model.ConfigKeyRedEnvelopeEnabled,
+ Value: "0",
+ Description: "红包功能是否启用(1启用,0禁用)",
+ },
}
if err := tx.Create(&defaultConfigs).Error; err != nil {
diff --git a/internal/model/orders.go b/internal/model/orders.go
index 5712f6dd..4ea4501f 100644
--- a/internal/model/orders.go
+++ b/internal/model/orders.go
@@ -38,6 +38,8 @@ const (
OrderTypeCommunity OrderType = "community"
OrderTypeOnline OrderType = "online"
OrderTypeTest OrderType = "test"
+ OrderTypeRedEnvelopeSend OrderType = "red_envelope_send"
+ OrderTypeRedEnvelopeReceive OrderType = "red_envelope_receive"
)
type OrderStatus string
diff --git a/internal/model/red_envelopes.go b/internal/model/red_envelopes.go
new file mode 100644
index 00000000..1256e682
--- /dev/null
+++ b/internal/model/red_envelopes.go
@@ -0,0 +1,76 @@
+/*
+Copyright 2025 linux.do
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+package model
+
+import (
+ "time"
+
+ "github.com/shopspring/decimal"
+)
+
+type RedEnvelopeType string
+
+const (
+ RedEnvelopeTypeFixed RedEnvelopeType = "fixed"
+ RedEnvelopeTypeRandom RedEnvelopeType = "random"
+)
+
+type RedEnvelopeStatus string
+
+const (
+ RedEnvelopeStatusActive RedEnvelopeStatus = "active"
+ RedEnvelopeStatusFinished RedEnvelopeStatus = "finished"
+ RedEnvelopeStatusExpired RedEnvelopeStatus = "expired"
+)
+
+// RedEnvelope 红包
+type RedEnvelope struct {
+ ID uint64 `json:"id" gorm:"primaryKey;autoIncrement"`
+ Code string `json:"code" gorm:"size:64;uniqueIndex;not null"`
+ CreatorID uint64 `json:"creator_id" gorm:"index;not null"`
+ CreatorUsername string `json:"creator_username" gorm:"->"`
+ CreatorAvatarURL string `json:"creator_avatar_url" gorm:"->"`
+ Type RedEnvelopeType `json:"type" gorm:"type:varchar(20);not null"`
+ TotalAmount decimal.Decimal `json:"total_amount" gorm:"type:numeric(20,2);not null"`
+ RemainingAmount decimal.Decimal `json:"remaining_amount" gorm:"type:numeric(20,2);not null"`
+ TotalCount int `json:"total_count" gorm:"not null"`
+ RemainingCount int `json:"remaining_count" gorm:"not null"`
+ Greeting string `json:"greeting" gorm:"size:100"`
+ Status RedEnvelopeStatus `json:"status" gorm:"type:varchar(20);not null;index"`
+ ExpiresAt time.Time `json:"expires_at" gorm:"not null;index"`
+ CreatedAt time.Time `json:"created_at" gorm:"autoCreateTime"`
+ UpdatedAt time.Time `json:"updated_at" gorm:"autoUpdateTime"`
+}
+
+// RedEnvelopeClaim 红包领取记录
+type RedEnvelopeClaim struct {
+ ID uint64 `json:"id" gorm:"primaryKey;autoIncrement"`
+ RedEnvelopeID uint64 `json:"red_envelope_id" gorm:"index;not null"`
+ UserID uint64 `json:"user_id" gorm:"index;not null"`
+ Username string `json:"username" gorm:"->"`
+ AvatarURL string `json:"avatar_url" gorm:"->"`
+ Amount decimal.Decimal `json:"amount" gorm:"type:numeric(20,2);not null"`
+ ClaimedAt time.Time `json:"claimed_at" gorm:"autoCreateTime"`
+}
+
+func (RedEnvelope) TableName() string {
+ return "red_envelopes"
+}
+
+func (RedEnvelopeClaim) TableName() string {
+ return "red_envelope_claims"
+}
\ No newline at end of file
diff --git a/internal/model/system_configs.go b/internal/model/system_configs.go
index 1fc6c3ce..eebdc7da 100644
--- a/internal/model/system_configs.go
+++ b/internal/model/system_configs.go
@@ -36,6 +36,7 @@ const (
ConfigKeyDisputeTimeWindowHours = "dispute_time_window_hours" // 商家争议时间窗口(小时)
ConfigKeyNewUserInitialCredit = "new_user_initial_credit" // 新用户注册初始积分
ConfigKeyNewUserProtectionDays = "new_user_protection_days" // 新用户保护期天数(期内不扣分)
+ ConfigKeyRedEnvelopeEnabled = "red_envelope_enabled" // 红包功能是否启用(1启用,0禁用)
)
const (
@@ -102,3 +103,13 @@ func GetDecimalByKey(ctx context.Context, key string, precision int32) (decimal.
// 裁剪到指定小数位数
return value.Truncate(precision), nil
}
+
+// IsRedEnvelopeEnabled 检查红包功能是否启用
+func IsRedEnvelopeEnabled(ctx context.Context) bool {
+ value, err := GetIntByKey(ctx, ConfigKeyRedEnvelopeEnabled)
+ if err != nil {
+ // 如果配置不存在或出错,默认启用
+ return true
+ }
+ return value == 1
+}
diff --git a/internal/router/router.go b/internal/router/router.go
index 9b6c012c..4cff580b 100644
--- a/internal/router/router.go
+++ b/internal/router/router.go
@@ -35,6 +35,7 @@ import (
"github.com/linux-do/credit/internal/apps/health"
"github.com/linux-do/credit/internal/apps/merchant/api_key"
"github.com/linux-do/credit/internal/apps/merchant/link"
+ "github.com/linux-do/credit/internal/apps/redenvelope"
"github.com/linux-do/credit/internal/listener"
"github.com/linux-do/credit/internal/util"
@@ -161,6 +162,16 @@ func Serve() {
paymentRouter.POST("/transfer", payment.Transfer)
}
+ // Red Envelope
+ redEnvelopeRouter := apiV1Router.Group("/redenvelope")
+ {
+ redEnvelopeRouter.GET("/enabled", redenvelope.IsEnabled)
+ redEnvelopeRouter.GET("/:code", oauth.LoginRequired(), redenvelope.GetDetail)
+ redEnvelopeRouter.POST("/create", oauth.LoginRequired(), redenvelope.Create)
+ redEnvelopeRouter.POST("/claim", oauth.LoginRequired(), redenvelope.Claim)
+ redEnvelopeRouter.POST("/list", oauth.LoginRequired(), redenvelope.List)
+ }
+
// Config (public)
configRouter := apiV1Router.Group("/config")
{
diff --git a/internal/task/constants.go b/internal/task/constants.go
index 99b4ec3b..87031045 100644
--- a/internal/task/constants.go
+++ b/internal/task/constants.go
@@ -23,6 +23,7 @@ const (
AutoRefundSingleDisputeTask = "dispute:auto_refund_single"
MerchantPaymentNotifyTask = "payment:merchant_notify"
SyncOrdersToClickHouseTask = "order:sync_to_clickhouse"
+ RefundExpiredRedEnvelopesTask = "redenvelope:refund_expired"
)
const (
diff --git a/internal/task/scheduler/scheduler.go b/internal/task/scheduler/scheduler.go
index 55c20b76..c86a134e 100644
--- a/internal/task/scheduler/scheduler.go
+++ b/internal/task/scheduler/scheduler.go
@@ -83,6 +83,15 @@ func StartScheduler() error {
return
}
+ // 红包过期退款任务
+ if _, err = scheduler.Register(
+ config.Config.Scheduler.RefundExpiredRedEnvelopesTaskCron,
+ asynq.NewTask(task.RefundExpiredRedEnvelopesTask, nil),
+ asynq.Unique(23*time.Hour),
+ ); err != nil {
+ return
+ }
+
// 启动调度器
err = scheduler.Run()
})
diff --git a/internal/task/worker/worker.go b/internal/task/worker/worker.go
index 79d3ace1..a9d36bc6 100644
--- a/internal/task/worker/worker.go
+++ b/internal/task/worker/worker.go
@@ -26,6 +26,7 @@ import (
"github.com/linux-do/credit/internal/apps/dispute"
"github.com/linux-do/credit/internal/apps/order"
"github.com/linux-do/credit/internal/apps/payment"
+ "github.com/linux-do/credit/internal/apps/redenvelope"
"github.com/linux-do/credit/internal/apps/user"
"github.com/linux-do/credit/internal/config"
"github.com/linux-do/credit/internal/task"
@@ -77,6 +78,7 @@ func StartWorker() error {
mux.HandleFunc(task.AutoRefundSingleDisputeTask, dispute.HandleAutoRefundSingleDispute)
mux.HandleFunc(task.MerchantPaymentNotifyTask, payment.HandleMerchantPaymentNotify)
mux.HandleFunc(task.SyncOrdersToClickHouseTask, order.HandleSyncOrdersToClickHouse)
+ mux.HandleFunc(task.RefundExpiredRedEnvelopesTask, redenvelope.HandleRefundExpiredRedEnvelopes)
// 启动服务器
return asynqServer.Run(mux)
}
From 0c4b919c81e3a90cf20f36bda7187d1c6f51f3cb Mon Sep 17 00:00:00 2001
From: cattie <2237829695@qq.com>
Date: Tue, 30 Dec 2025 15:02:31 +0800
Subject: [PATCH 2/6] fix: ESLint and swagger problems
---
docs/docs.go | 228 +++++++++++++++++-
docs/swagger.json | 228 +++++++++++++++++-
docs/swagger.yaml | 147 +++++++++++
.../common/redenvelope/red-envelope-claim.tsx | 16 +-
.../components/common/trade/red-envelope.tsx | 4 +-
5 files changed, 611 insertions(+), 12 deletions(-)
diff --git a/docs/docs.go b/docs/docs.go
index 16963dff..1c178c93 100644
--- a/docs/docs.go
+++ b/docs/docs.go
@@ -1315,6 +1315,147 @@ const docTemplate = `{
}
}
},
+ "/api/v1/redenvelope/claim": {
+ "post": {
+ "consumes": [
+ "application/json"
+ ],
+ "produces": [
+ "application/json"
+ ],
+ "tags": [
+ "redenvelope"
+ ],
+ "parameters": [
+ {
+ "description": "领取红包请求",
+ "name": "request",
+ "in": "body",
+ "required": true,
+ "schema": {
+ "$ref": "#/definitions/redenvelope.ClaimRequest"
+ }
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "OK",
+ "schema": {
+ "$ref": "#/definitions/util.ResponseAny"
+ }
+ }
+ }
+ }
+ },
+ "/api/v1/redenvelope/create": {
+ "post": {
+ "consumes": [
+ "application/json"
+ ],
+ "produces": [
+ "application/json"
+ ],
+ "tags": [
+ "redenvelope"
+ ],
+ "parameters": [
+ {
+ "description": "创建红包请求",
+ "name": "request",
+ "in": "body",
+ "required": true,
+ "schema": {
+ "$ref": "#/definitions/redenvelope.CreateRequest"
+ }
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "OK",
+ "schema": {
+ "$ref": "#/definitions/util.ResponseAny"
+ }
+ }
+ }
+ }
+ },
+ "/api/v1/redenvelope/enabled": {
+ "get": {
+ "produces": [
+ "application/json"
+ ],
+ "tags": [
+ "redenvelope"
+ ],
+ "responses": {
+ "200": {
+ "description": "OK",
+ "schema": {
+ "$ref": "#/definitions/util.ResponseAny"
+ }
+ }
+ }
+ }
+ },
+ "/api/v1/redenvelope/list": {
+ "post": {
+ "consumes": [
+ "application/json"
+ ],
+ "produces": [
+ "application/json"
+ ],
+ "tags": [
+ "redenvelope"
+ ],
+ "parameters": [
+ {
+ "description": "列表请求",
+ "name": "request",
+ "in": "body",
+ "required": true,
+ "schema": {
+ "$ref": "#/definitions/redenvelope.ListRequest"
+ }
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "OK",
+ "schema": {
+ "$ref": "#/definitions/util.ResponseAny"
+ }
+ }
+ }
+ }
+ },
+ "/api/v1/redenvelope/{code}": {
+ "get": {
+ "produces": [
+ "application/json"
+ ],
+ "tags": [
+ "redenvelope"
+ ],
+ "parameters": [
+ {
+ "type": "string",
+ "description": "红包码",
+ "name": "code",
+ "in": "path",
+ "required": true
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "OK",
+ "schema": {
+ "$ref": "#/definitions/util.ResponseAny"
+ }
+ }
+ }
+ }
+ },
"/api/v1/user/pay-key": {
"put": {
"consumes": [
@@ -1581,6 +1722,17 @@ const docTemplate = `{
"PayLevelPremium"
]
},
+ "model.RedEnvelopeType": {
+ "type": "string",
+ "enum": [
+ "fixed",
+ "random"
+ ],
+ "x-enum-varnames": [
+ "RedEnvelopeTypeFixed",
+ "RedEnvelopeTypeRandom"
+ ]
+ },
"oauth.CallbackRequest": {
"type": "object",
"properties": {
@@ -1645,7 +1797,8 @@ const docTemplate = `{
"transfer",
"community",
"online",
- "test"
+ "test",
+ "redenvelope"
]
}
}
@@ -1808,6 +1961,79 @@ const docTemplate = `{
}
}
},
+ "redenvelope.ClaimRequest": {
+ "type": "object",
+ "required": [
+ "code"
+ ],
+ "properties": {
+ "code": {
+ "type": "string"
+ }
+ }
+ },
+ "redenvelope.CreateRequest": {
+ "type": "object",
+ "required": [
+ "pay_key",
+ "total_amount",
+ "total_count",
+ "type"
+ ],
+ "properties": {
+ "greeting": {
+ "type": "string",
+ "maxLength": 100
+ },
+ "pay_key": {
+ "type": "string",
+ "maxLength": 10
+ },
+ "total_amount": {
+ "type": "number"
+ },
+ "total_count": {
+ "type": "integer",
+ "minimum": 1
+ },
+ "type": {
+ "enum": [
+ "fixed",
+ "random"
+ ],
+ "allOf": [
+ {
+ "$ref": "#/definitions/model.RedEnvelopeType"
+ }
+ ]
+ }
+ }
+ },
+ "redenvelope.ListRequest": {
+ "type": "object",
+ "required": [
+ "page",
+ "page_size"
+ ],
+ "properties": {
+ "page": {
+ "type": "integer",
+ "minimum": 1
+ },
+ "page_size": {
+ "type": "integer",
+ "maximum": 100,
+ "minimum": 1
+ },
+ "type": {
+ "type": "string",
+ "enum": [
+ "sent",
+ "received"
+ ]
+ }
+ }
+ },
"system_config.CreateSystemConfigRequest": {
"type": "object",
"required": [
diff --git a/docs/swagger.json b/docs/swagger.json
index 3e007cce..87de6048 100644
--- a/docs/swagger.json
+++ b/docs/swagger.json
@@ -1306,6 +1306,147 @@
}
}
},
+ "/api/v1/redenvelope/claim": {
+ "post": {
+ "consumes": [
+ "application/json"
+ ],
+ "produces": [
+ "application/json"
+ ],
+ "tags": [
+ "redenvelope"
+ ],
+ "parameters": [
+ {
+ "description": "领取红包请求",
+ "name": "request",
+ "in": "body",
+ "required": true,
+ "schema": {
+ "$ref": "#/definitions/redenvelope.ClaimRequest"
+ }
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "OK",
+ "schema": {
+ "$ref": "#/definitions/util.ResponseAny"
+ }
+ }
+ }
+ }
+ },
+ "/api/v1/redenvelope/create": {
+ "post": {
+ "consumes": [
+ "application/json"
+ ],
+ "produces": [
+ "application/json"
+ ],
+ "tags": [
+ "redenvelope"
+ ],
+ "parameters": [
+ {
+ "description": "创建红包请求",
+ "name": "request",
+ "in": "body",
+ "required": true,
+ "schema": {
+ "$ref": "#/definitions/redenvelope.CreateRequest"
+ }
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "OK",
+ "schema": {
+ "$ref": "#/definitions/util.ResponseAny"
+ }
+ }
+ }
+ }
+ },
+ "/api/v1/redenvelope/enabled": {
+ "get": {
+ "produces": [
+ "application/json"
+ ],
+ "tags": [
+ "redenvelope"
+ ],
+ "responses": {
+ "200": {
+ "description": "OK",
+ "schema": {
+ "$ref": "#/definitions/util.ResponseAny"
+ }
+ }
+ }
+ }
+ },
+ "/api/v1/redenvelope/list": {
+ "post": {
+ "consumes": [
+ "application/json"
+ ],
+ "produces": [
+ "application/json"
+ ],
+ "tags": [
+ "redenvelope"
+ ],
+ "parameters": [
+ {
+ "description": "列表请求",
+ "name": "request",
+ "in": "body",
+ "required": true,
+ "schema": {
+ "$ref": "#/definitions/redenvelope.ListRequest"
+ }
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "OK",
+ "schema": {
+ "$ref": "#/definitions/util.ResponseAny"
+ }
+ }
+ }
+ }
+ },
+ "/api/v1/redenvelope/{code}": {
+ "get": {
+ "produces": [
+ "application/json"
+ ],
+ "tags": [
+ "redenvelope"
+ ],
+ "parameters": [
+ {
+ "type": "string",
+ "description": "红包码",
+ "name": "code",
+ "in": "path",
+ "required": true
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "OK",
+ "schema": {
+ "$ref": "#/definitions/util.ResponseAny"
+ }
+ }
+ }
+ }
+ },
"/api/v1/user/pay-key": {
"put": {
"consumes": [
@@ -1572,6 +1713,17 @@
"PayLevelPremium"
]
},
+ "model.RedEnvelopeType": {
+ "type": "string",
+ "enum": [
+ "fixed",
+ "random"
+ ],
+ "x-enum-varnames": [
+ "RedEnvelopeTypeFixed",
+ "RedEnvelopeTypeRandom"
+ ]
+ },
"oauth.CallbackRequest": {
"type": "object",
"properties": {
@@ -1636,7 +1788,8 @@
"transfer",
"community",
"online",
- "test"
+ "test",
+ "redenvelope"
]
}
}
@@ -1799,6 +1952,79 @@
}
}
},
+ "redenvelope.ClaimRequest": {
+ "type": "object",
+ "required": [
+ "code"
+ ],
+ "properties": {
+ "code": {
+ "type": "string"
+ }
+ }
+ },
+ "redenvelope.CreateRequest": {
+ "type": "object",
+ "required": [
+ "pay_key",
+ "total_amount",
+ "total_count",
+ "type"
+ ],
+ "properties": {
+ "greeting": {
+ "type": "string",
+ "maxLength": 100
+ },
+ "pay_key": {
+ "type": "string",
+ "maxLength": 10
+ },
+ "total_amount": {
+ "type": "number"
+ },
+ "total_count": {
+ "type": "integer",
+ "minimum": 1
+ },
+ "type": {
+ "enum": [
+ "fixed",
+ "random"
+ ],
+ "allOf": [
+ {
+ "$ref": "#/definitions/model.RedEnvelopeType"
+ }
+ ]
+ }
+ }
+ },
+ "redenvelope.ListRequest": {
+ "type": "object",
+ "required": [
+ "page",
+ "page_size"
+ ],
+ "properties": {
+ "page": {
+ "type": "integer",
+ "minimum": 1
+ },
+ "page_size": {
+ "type": "integer",
+ "maximum": 100,
+ "minimum": 1
+ },
+ "type": {
+ "type": "string",
+ "enum": [
+ "sent",
+ "received"
+ ]
+ }
+ }
+ },
"system_config.CreateSystemConfigRequest": {
"type": "object",
"required": [
diff --git a/docs/swagger.yaml b/docs/swagger.yaml
index 54949434..97019850 100644
--- a/docs/swagger.yaml
+++ b/docs/swagger.yaml
@@ -142,6 +142,14 @@ definitions:
- PayLevelBasic
- PayLevelStandard
- PayLevelPremium
+ model.RedEnvelopeType:
+ enum:
+ - fixed
+ - random
+ type: string
+ x-enum-varnames:
+ - RedEnvelopeTypeFixed
+ - RedEnvelopeTypeRandom
oauth.CallbackRequest:
properties:
code:
@@ -190,6 +198,7 @@ definitions:
- community
- online
- test
+ - redenvelope
type: string
type: object
payment.CreateOrderRequest:
@@ -304,6 +313,56 @@ definitions:
- recipient_id
- recipient_username
type: object
+ redenvelope.ClaimRequest:
+ properties:
+ code:
+ type: string
+ required:
+ - code
+ type: object
+ redenvelope.CreateRequest:
+ properties:
+ greeting:
+ maxLength: 100
+ type: string
+ pay_key:
+ maxLength: 10
+ type: string
+ total_amount:
+ type: number
+ total_count:
+ minimum: 1
+ type: integer
+ type:
+ allOf:
+ - $ref: '#/definitions/model.RedEnvelopeType'
+ enum:
+ - fixed
+ - random
+ required:
+ - pay_key
+ - total_amount
+ - total_count
+ - type
+ type: object
+ redenvelope.ListRequest:
+ properties:
+ page:
+ minimum: 1
+ type: integer
+ page_size:
+ maximum: 100
+ minimum: 1
+ type: integer
+ type:
+ enum:
+ - sent
+ - received
+ type: string
+ required:
+ - page
+ - page_size
+ type: object
system_config.CreateSystemConfigRequest:
properties:
description:
@@ -1228,6 +1287,94 @@ paths:
$ref: '#/definitions/util.ResponseAny'
tags:
- payment
+ /api/v1/redenvelope/{code}:
+ get:
+ parameters:
+ - description: 红包码
+ in: path
+ name: code
+ required: true
+ type: string
+ produces:
+ - application/json
+ responses:
+ "200":
+ description: OK
+ schema:
+ $ref: '#/definitions/util.ResponseAny'
+ tags:
+ - redenvelope
+ /api/v1/redenvelope/claim:
+ post:
+ consumes:
+ - application/json
+ parameters:
+ - description: 领取红包请求
+ in: body
+ name: request
+ required: true
+ schema:
+ $ref: '#/definitions/redenvelope.ClaimRequest'
+ produces:
+ - application/json
+ responses:
+ "200":
+ description: OK
+ schema:
+ $ref: '#/definitions/util.ResponseAny'
+ tags:
+ - redenvelope
+ /api/v1/redenvelope/create:
+ post:
+ consumes:
+ - application/json
+ parameters:
+ - description: 创建红包请求
+ in: body
+ name: request
+ required: true
+ schema:
+ $ref: '#/definitions/redenvelope.CreateRequest'
+ produces:
+ - application/json
+ responses:
+ "200":
+ description: OK
+ schema:
+ $ref: '#/definitions/util.ResponseAny'
+ tags:
+ - redenvelope
+ /api/v1/redenvelope/enabled:
+ get:
+ produces:
+ - application/json
+ responses:
+ "200":
+ description: OK
+ schema:
+ $ref: '#/definitions/util.ResponseAny'
+ tags:
+ - redenvelope
+ /api/v1/redenvelope/list:
+ post:
+ consumes:
+ - application/json
+ parameters:
+ - description: 列表请求
+ in: body
+ name: request
+ required: true
+ schema:
+ $ref: '#/definitions/redenvelope.ListRequest'
+ produces:
+ - application/json
+ responses:
+ "200":
+ description: OK
+ schema:
+ $ref: '#/definitions/util.ResponseAny'
+ tags:
+ - redenvelope
/api/v1/user/pay-key:
put:
consumes:
diff --git a/frontend/components/common/redenvelope/red-envelope-claim.tsx b/frontend/components/common/redenvelope/red-envelope-claim.tsx
index 32683284..d1a93044 100644
--- a/frontend/components/common/redenvelope/red-envelope-claim.tsx
+++ b/frontend/components/common/redenvelope/red-envelope-claim.tsx
@@ -1,7 +1,7 @@
"use client"
import * as React from "react"
-import { useState, useEffect } from "react"
+import { useState, useEffect, useCallback } from "react"
import { motion, AnimatePresence } from "motion/react"
import { toast } from "sonner"
import { Gift } from "lucide-react"
@@ -23,11 +23,7 @@ export function RedEnvelopeClaimPage({ code }: RedEnvelopeClaimProps) {
const [claimedAmount, setClaimedAmount] = useState(null)
const [error, setError] = useState(null)
- useEffect(() => {
- loadDetail()
- }, [code])
-
- const loadDetail = async () => {
+ const loadDetail = useCallback(async () => {
try {
const data = await services.redEnvelope.getDetail(code)
console.log('Red envelope data:', data.red_envelope)
@@ -44,7 +40,11 @@ export function RedEnvelopeClaimPage({ code }: RedEnvelopeClaimProps) {
setError(err instanceof Error ? err.message : "加载失败")
setState("error")
}
- }
+ }, [code])
+
+ useEffect(() => {
+ loadDetail()
+ }, [loadDetail])
const handleOpen = async () => {
setState("opening")
@@ -233,7 +233,7 @@ export function RedEnvelopeClaimPage({ code }: RedEnvelopeClaimProps) {
transition={{ delay: 0.5 }}
className="text-muted-foreground text-sm mt-8"
>
- 点击 "開" 字领取红包
+ 点击 “開” 字领取红包
)}
diff --git a/frontend/components/common/trade/red-envelope.tsx b/frontend/components/common/trade/red-envelope.tsx
index 4f088eee..64b4fec5 100644
--- a/frontend/components/common/trade/red-envelope.tsx
+++ b/frontend/components/common/trade/red-envelope.tsx
@@ -80,7 +80,7 @@ export function RedEnvelope() {
setReceivedEnvelopes(prev => page === 1 ? result.red_envelopes : [...prev, ...result.red_envelopes])
setReceivedTotal(result.total)
}
- } catch (error) {
+ } catch {
toast.error('加载红包列表失败')
} finally {
setListLoading(false)
@@ -100,7 +100,7 @@ export function RedEnvelope() {
} else if (activeTab === 'received' && receivedEnvelopes.length === 0) {
loadEnvelopes('received', 1)
}
- }, [activeTab])
+ }, [activeTab, sentEnvelopes.length, receivedEnvelopes.length])
/* 验证金额格式 */
const validateAmount = (value: string): boolean => {
From 0e3fd069becc10ab665883c3af55669e5abc9593 Mon Sep 17 00:00:00 2001
From: cattie <2237829695@qq.com>
Date: Wed, 31 Dec 2025 12:40:27 +0800
Subject: [PATCH 3/6] Mod: New version
---
docs/docs.go | 32 +--
docs/swagger.json | 32 +--
docs/swagger.yaml | 25 +-
.../redenvelope/{[code] => [id]}/page.tsx | 6 +-
.../common/general/password-dialog.tsx | 15 +-
.../common/redenvelope/red-envelope-claim.tsx | 12 +-
.../components/common/trade/red-envelope.tsx | 79 +++++-
.../components/common/trade/trade-main.tsx | 20 +-
frontend/lib/services/config/types.ts | 8 +
.../redenvelope/redenvelope.service.ts | 27 +-
frontend/lib/services/redenvelope/types.ts | 14 +-
internal/apps/config/routers.go | 39 ++-
internal/apps/order/routers.go | 42 +--
internal/apps/redenvelope/routers.go | 258 +++++++++---------
internal/apps/redenvelope/tasks.go | 84 +++---
internal/apps/redenvelope/utils.go | 73 +++++
internal/common/errs.go | 27 +-
internal/db/migrator/migrator.go | 15 +
internal/model/orders.go | 15 +-
internal/model/red_envelopes.go | 31 +--
internal/model/system_configs.go | 37 ++-
internal/router/router.go | 3 +-
22 files changed, 537 insertions(+), 357 deletions(-)
rename frontend/app/(pay)/redenvelope/{[code] => [id]}/page.tsx (61%)
create mode 100644 internal/apps/redenvelope/utils.go
diff --git a/docs/docs.go b/docs/docs.go
index 1c178c93..995d6102 100644
--- a/docs/docs.go
+++ b/docs/docs.go
@@ -1379,24 +1379,6 @@ const docTemplate = `{
}
}
},
- "/api/v1/redenvelope/enabled": {
- "get": {
- "produces": [
- "application/json"
- ],
- "tags": [
- "redenvelope"
- ],
- "responses": {
- "200": {
- "description": "OK",
- "schema": {
- "$ref": "#/definitions/util.ResponseAny"
- }
- }
- }
- }
- },
"/api/v1/redenvelope/list": {
"post": {
"consumes": [
@@ -1429,7 +1411,7 @@ const docTemplate = `{
}
}
},
- "/api/v1/redenvelope/{code}": {
+ "/api/v1/redenvelope/{id}": {
"get": {
"produces": [
"application/json"
@@ -1440,8 +1422,8 @@ const docTemplate = `{
"parameters": [
{
"type": "string",
- "description": "红包码",
- "name": "code",
+ "description": "红包ID",
+ "name": "id",
"in": "path",
"required": true
}
@@ -1798,7 +1780,9 @@ const docTemplate = `{
"community",
"online",
"test",
- "redenvelope"
+ "red_envelope_send",
+ "red_envelope_receive",
+ "red_envelope_refund"
]
}
}
@@ -1964,10 +1948,10 @@ const docTemplate = `{
"redenvelope.ClaimRequest": {
"type": "object",
"required": [
- "code"
+ "id"
],
"properties": {
- "code": {
+ "id": {
"type": "string"
}
}
diff --git a/docs/swagger.json b/docs/swagger.json
index 87de6048..3407e3dc 100644
--- a/docs/swagger.json
+++ b/docs/swagger.json
@@ -1370,24 +1370,6 @@
}
}
},
- "/api/v1/redenvelope/enabled": {
- "get": {
- "produces": [
- "application/json"
- ],
- "tags": [
- "redenvelope"
- ],
- "responses": {
- "200": {
- "description": "OK",
- "schema": {
- "$ref": "#/definitions/util.ResponseAny"
- }
- }
- }
- }
- },
"/api/v1/redenvelope/list": {
"post": {
"consumes": [
@@ -1420,7 +1402,7 @@
}
}
},
- "/api/v1/redenvelope/{code}": {
+ "/api/v1/redenvelope/{id}": {
"get": {
"produces": [
"application/json"
@@ -1431,8 +1413,8 @@
"parameters": [
{
"type": "string",
- "description": "红包码",
- "name": "code",
+ "description": "红包ID",
+ "name": "id",
"in": "path",
"required": true
}
@@ -1789,7 +1771,9 @@
"community",
"online",
"test",
- "redenvelope"
+ "red_envelope_send",
+ "red_envelope_receive",
+ "red_envelope_refund"
]
}
}
@@ -1955,10 +1939,10 @@
"redenvelope.ClaimRequest": {
"type": "object",
"required": [
- "code"
+ "id"
],
"properties": {
- "code": {
+ "id": {
"type": "string"
}
}
diff --git a/docs/swagger.yaml b/docs/swagger.yaml
index 97019850..8374d622 100644
--- a/docs/swagger.yaml
+++ b/docs/swagger.yaml
@@ -198,7 +198,9 @@ definitions:
- community
- online
- test
- - redenvelope
+ - red_envelope_send
+ - red_envelope_receive
+ - red_envelope_refund
type: string
type: object
payment.CreateOrderRequest:
@@ -315,10 +317,10 @@ definitions:
type: object
redenvelope.ClaimRequest:
properties:
- code:
+ id:
type: string
required:
- - code
+ - id
type: object
redenvelope.CreateRequest:
properties:
@@ -1287,12 +1289,12 @@ paths:
$ref: '#/definitions/util.ResponseAny'
tags:
- payment
- /api/v1/redenvelope/{code}:
+ /api/v1/redenvelope/{id}:
get:
parameters:
- - description: 红包码
+ - description: 红包ID
in: path
- name: code
+ name: id
required: true
type: string
produces:
@@ -1344,17 +1346,6 @@ paths:
$ref: '#/definitions/util.ResponseAny'
tags:
- redenvelope
- /api/v1/redenvelope/enabled:
- get:
- produces:
- - application/json
- responses:
- "200":
- description: OK
- schema:
- $ref: '#/definitions/util.ResponseAny'
- tags:
- - redenvelope
/api/v1/redenvelope/list:
post:
consumes:
diff --git a/frontend/app/(pay)/redenvelope/[code]/page.tsx b/frontend/app/(pay)/redenvelope/[id]/page.tsx
similarity index 61%
rename from frontend/app/(pay)/redenvelope/[code]/page.tsx
rename to frontend/app/(pay)/redenvelope/[id]/page.tsx
index 3f71451f..1e677f73 100644
--- a/frontend/app/(pay)/redenvelope/[code]/page.tsx
+++ b/frontend/app/(pay)/redenvelope/[id]/page.tsx
@@ -1,10 +1,10 @@
import { RedEnvelopeClaimPage } from "@/components/common/redenvelope/red-envelope-claim"
interface Props {
- params: Promise<{ code: string }>
+ params: Promise<{ id: string }>
}
export default async function RedEnvelopePage({ params }: Props) {
- const { code } = await params
- return
+ const { id } = await params
+ return
}
\ No newline at end of file
diff --git a/frontend/components/common/general/password-dialog.tsx b/frontend/components/common/general/password-dialog.tsx
index 3867deed..f193487b 100644
--- a/frontend/components/common/general/password-dialog.tsx
+++ b/frontend/components/common/general/password-dialog.tsx
@@ -5,6 +5,9 @@ import { useState } from "react"
import {
Dialog,
DialogContent,
+ DialogDescription,
+ DialogHeader,
+ DialogTitle,
} from "@/components/ui/dialog"
import {
InputOTP,
@@ -54,13 +57,13 @@ export function PasswordDialog({
return (
)}
+ {feeInfo && parseFloat(feeInfo.fee) > 0 && (
+
+
+
+ 红包金额
+ {totalAmount} LDC
+
+
+ 手续费 ({(feeInfo.rate * 100).toFixed(1)}%)
+ +{feeInfo.fee} LDC
+
+
+ 总计支付
+ {feeInfo.total} LDC
+
+
+
+ )}
+
diff --git a/frontend/lib/services/config/types.ts b/frontend/lib/services/config/types.ts
index 49286aa4..2f2aa8fe 100644
--- a/frontend/lib/services/config/types.ts
+++ b/frontend/lib/services/config/types.ts
@@ -4,4 +4,12 @@
export interface PublicConfigResponse {
/** 争议时间窗口(小时) */
dispute_time_window_hours: number;
+ /** 红包功能是否启用 */
+ red_envelope_enabled: boolean;
+ /** 单个红包的最大积分上限 */
+ red_envelope_max_amount: string;
+ /** 每日发红包的个数限制 */
+ red_envelope_daily_limit: number;
+ /** 红包手续费率(0-1之间的小数) */
+ red_envelope_fee_rate: string;
}
diff --git a/frontend/lib/services/redenvelope/redenvelope.service.ts b/frontend/lib/services/redenvelope/redenvelope.service.ts
index 5bb0c4f0..b388c2cc 100644
--- a/frontend/lib/services/redenvelope/redenvelope.service.ts
+++ b/frontend/lib/services/redenvelope/redenvelope.service.ts
@@ -7,7 +7,6 @@ import type {
RedEnvelopeDetailResponse,
RedEnvelopeListParams,
RedEnvelopeListResponse,
- RedEnvelopeEnabledResponse,
} from './types';
/**
@@ -51,7 +50,7 @@ export class RedEnvelopeService extends BaseService {
*
* @example
* ```typescript
- * const result = await RedEnvelopeService.claim({ code: 'abc123' });
+ * const result = await RedEnvelopeService.claim({ id: '123456' });
* console.log('领取金额:', result.amount);
* ```
*/
@@ -61,19 +60,19 @@ export class RedEnvelopeService extends BaseService {
/**
* 获取红包详情
- * @param code - 红包唯一码
+ * @param id - 红包ID
* @returns 红包详情(包含领取记录)
* @throws {NotFoundError} 当红包不存在时
*
* @example
* ```typescript
- * const detail = await RedEnvelopeService.getDetail('abc123');
+ * const detail = await RedEnvelopeService.getDetail('123456');
* console.log('红包状态:', detail.red_envelope.status);
* console.log('已领取人数:', detail.claims.length);
* ```
*/
- static async getDetail(code: string): Promise {
- return this.get(`/${code}`);
+ static async getDetail(id: string): Promise {
+ return this.get(`/${id}`);
}
/**
@@ -94,20 +93,4 @@ export class RedEnvelopeService extends BaseService {
static async getList(params: RedEnvelopeListParams): Promise {
return this.post('/list', params);
}
-
- /**
- * 检查红包功能是否启用
- * @returns 红包功能启用状态
- *
- * @example
- * ```typescript
- * const result = await RedEnvelopeService.isEnabled();
- * if (result.enabled) {
- * console.log('红包功能已启用');
- * }
- * ```
- */
- static async isEnabled(): Promise {
- return this.get('/enabled');
- }
}
\ No newline at end of file
diff --git a/frontend/lib/services/redenvelope/types.ts b/frontend/lib/services/redenvelope/types.ts
index 1db8744e..54e1bbd5 100644
--- a/frontend/lib/services/redenvelope/types.ts
+++ b/frontend/lib/services/redenvelope/types.ts
@@ -19,8 +19,6 @@ export type RedEnvelopeStatus = 'active' | 'finished' | 'expired';
export interface RedEnvelope {
/** 红包 ID */
id: number;
- /** 红包唯一码(用于分享链接) */
- code: string;
/** 创建者用户 ID */
creator_id: number;
/** 创建者用户名 */
@@ -99,8 +97,8 @@ export interface CreateRedEnvelopeResponse {
* 领取红包请求参数
*/
export interface ClaimRedEnvelopeRequest {
- /** 红包唯一码 */
- code: string;
+ /** 红包 ID */
+ id: string;
}
/**
@@ -149,12 +147,4 @@ export interface RedEnvelopeListResponse {
page_size: number;
/** 红包列表 */
red_envelopes: RedEnvelope[];
-}
-
-/**
- * 红包功能是否启用响应
- */
-export interface RedEnvelopeEnabledResponse {
- /** 是否启用 */
- enabled: boolean;
}
\ No newline at end of file
diff --git a/internal/apps/config/routers.go b/internal/apps/config/routers.go
index f4108c68..244e5b07 100644
--- a/internal/apps/config/routers.go
+++ b/internal/apps/config/routers.go
@@ -22,11 +22,16 @@ import (
"github.com/gin-gonic/gin"
"github.com/linux-do/credit/internal/model"
"github.com/linux-do/credit/internal/util"
+ "github.com/shopspring/decimal"
)
// PublicConfigResponse 公共配置响应
type PublicConfigResponse struct {
- DisputeTimeWindowHours int `json:"dispute_time_window_hours"` // 争议时间窗口(小时)
+ DisputeTimeWindowHours int `json:"dispute_time_window_hours"` // 争议时间窗口(小时)
+ RedEnvelopeEnabled bool `json:"red_envelope_enabled"` // 红包功能是否启用
+ RedEnvelopeMaxAmount decimal.Decimal `json:"red_envelope_max_amount"` // 单个红包的最大积分上限
+ RedEnvelopeDailyLimit int `json:"red_envelope_daily_limit"` // 每日发红包的个数限制
+ RedEnvelopeFeeRate decimal.Decimal `json:"red_envelope_fee_rate"` // 红包手续费率
}
// GetPublicConfig 获取公共配置
@@ -43,8 +48,38 @@ func GetPublicConfig(c *gin.Context) {
return
}
+ // 获取红包功能启用状态
+ redEnvelopeEnabled, err := model.IsRedEnvelopeEnabled(c.Request.Context())
+ if err != nil {
+ c.JSON(http.StatusInternalServerError, util.Err(err.Error()))
+ return
+ }
+
+ // 获取红包配置
+ redEnvelopeMaxAmount, err := model.GetRedEnvelopeMaxAmount(c.Request.Context())
+ if err != nil {
+ c.JSON(http.StatusInternalServerError, util.Err(err.Error()))
+ return
+ }
+
+ redEnvelopeDailyLimit, err := model.GetRedEnvelopeDailyLimit(c.Request.Context())
+ if err != nil {
+ c.JSON(http.StatusInternalServerError, util.Err(err.Error()))
+ return
+ }
+
+ redEnvelopeFeeRate, err := model.GetRedEnvelopeFeeRate(c.Request.Context())
+ if err != nil {
+ c.JSON(http.StatusInternalServerError, util.Err(err.Error()))
+ return
+ }
+
response := PublicConfigResponse{
- DisputeTimeWindowHours: disputeTimeHours,
+ DisputeTimeWindowHours: disputeTimeHours,
+ RedEnvelopeEnabled: redEnvelopeEnabled,
+ RedEnvelopeMaxAmount: redEnvelopeMaxAmount,
+ RedEnvelopeDailyLimit: redEnvelopeDailyLimit,
+ RedEnvelopeFeeRate: redEnvelopeFeeRate,
}
c.JSON(http.StatusOK, util.OK(response))
diff --git a/internal/apps/order/routers.go b/internal/apps/order/routers.go
index f3afcf2c..27f92053 100644
--- a/internal/apps/order/routers.go
+++ b/internal/apps/order/routers.go
@@ -30,7 +30,7 @@ import (
type TransactionListRequest struct {
Page int `json:"page" form:"page" binding:"min=1"`
PageSize int `json:"page_size" form:"page_size" binding:"min=1,max=100"`
- Type string `json:"type" form:"type" binding:"omitempty,oneof=receive payment transfer community online test redenvelope"`
+ Type string `json:"type" form:"type" binding:"omitempty,oneof=receive payment transfer community online test red_envelope_send red_envelope_receive red_envelope_refund"`
Status string `json:"status" form:"status" binding:"omitempty,oneof=success pending failed expired disputing refund refused"`
ClientID string `json:"client_id" form:"client_id" binding:"omitempty"`
StartTime *time.Time `json:"startTime" form:"startTime" binding:"omitempty"`
@@ -82,20 +82,25 @@ func ListTransactions(c *gin.Context) {
clientIDHandled := false
if req.Type != "" {
- // redenvelope 类型不过滤(红包记录在单独的表中,这里返回空结果)
- if req.Type == "redenvelope" {
- baseQuery = baseQuery.Where("1 = 0") // 返回空结果
- } else {
- orderType := model.OrderType(req.Type)
-
- switch orderType {
- case model.OrderTypeReceive:
- // receive 类型:查询当前用户作为收款方的 payment 订单
- baseQuery = baseQuery.Where("orders.type = ? AND orders.payee_user_id = ?", model.OrderTypePayment, user.ID)
- case model.OrderTypeCommunity:
- // community 类型:查询当前用户作为收款方的 community 订单
- baseQuery = baseQuery.Where("orders.type = ? AND orders.payee_user_id = ?", orderType, user.ID)
- case model.OrderTypeOnline:
+ orderType := model.OrderType(req.Type)
+
+ switch orderType {
+ case model.OrderTypeReceive:
+ // receive 类型:查询当前用户作为收款方的 payment 订单
+ baseQuery = baseQuery.Where("orders.type = ? AND orders.payee_user_id = ?", model.OrderTypePayment, user.ID)
+ case model.OrderTypeCommunity:
+ // community 类型:查询当前用户作为收款方的 community 订单
+ baseQuery = baseQuery.Where("orders.type = ? AND orders.payee_user_id = ?", orderType, user.ID)
+ case model.OrderTypeRedEnvelopeSend:
+ // red_envelope_send 类型:查询当前用户作为发送方的红包订单
+ baseQuery = baseQuery.Where("orders.type = ? AND orders.payer_user_id = ?", orderType, user.ID)
+ case model.OrderTypeRedEnvelopeReceive:
+ // red_envelope_receive 类型:查询当前用户作为接收方的红包订单
+ baseQuery = baseQuery.Where("orders.type = ? AND orders.payee_user_id = ?", orderType, user.ID)
+ case model.OrderTypeRedEnvelopeRefund:
+ // red_envelope_refund 类型:查询当前用户的红包退款订单
+ baseQuery = baseQuery.Where("orders.type = ? AND orders.payee_user_id = ?", orderType, user.ID)
+ case model.OrderTypeOnline:
// online 类型:商家可查看自己 client_id 的所有订单,普通用户只能查看与自己相关的订单
if req.ClientID != "" {
clientIDHandled = true
@@ -114,10 +119,9 @@ func ListTransactions(c *gin.Context) {
} else {
baseQuery = baseQuery.Where("orders.type = ? AND (orders.payer_user_id = ? OR orders.payee_user_id = ?)", orderType, user.ID, user.ID)
}
- case model.OrderTypePayment, model.OrderTypeTransfer, model.OrderTypeTest:
- // payment、transfer 类型:查询当前用户作为付款方的订单
- baseQuery = baseQuery.Where("orders.type = ? AND orders.payer_user_id = ?", orderType, user.ID)
- }
+ case model.OrderTypePayment, model.OrderTypeTransfer, model.OrderTypeTest:
+ // payment、transfer 类型:查询当前用户作为付款方的订单
+ baseQuery = baseQuery.Where("orders.type = ? AND orders.payer_user_id = ?", orderType, user.ID)
}
} else {
baseQuery = baseQuery.Where("orders.payee_user_id = ? OR orders.payer_user_id = ?", user.ID, user.ID)
diff --git a/internal/apps/redenvelope/routers.go b/internal/apps/redenvelope/routers.go
index 91c769ab..85d7fdd7 100644
--- a/internal/apps/redenvelope/routers.go
+++ b/internal/apps/redenvelope/routers.go
@@ -19,8 +19,8 @@ package redenvelope
import (
"errors"
"fmt"
- "math/rand"
"net/http"
+ "strconv"
"time"
"github.com/gin-gonic/gin"
@@ -28,6 +28,7 @@ import (
"github.com/linux-do/credit/internal/common"
"github.com/linux-do/credit/internal/config"
"github.com/linux-do/credit/internal/db"
+ "github.com/linux-do/credit/internal/db/idgen"
"github.com/linux-do/credit/internal/model"
"github.com/linux-do/credit/internal/util"
"github.com/shopspring/decimal"
@@ -51,14 +52,9 @@ type CreateResponse struct {
Link string `json:"link"`
}
-// IsEnabledResponse 红包功能是否启用响应
-type IsEnabledResponse struct {
- Enabled bool `json:"enabled"`
-}
-
// ClaimRequest 领取红包请求
type ClaimRequest struct {
- Code string `json:"code" binding:"required"`
+ ID string `json:"id" binding:"required"`
}
// ClaimResponse 领取红包响应
@@ -69,7 +65,7 @@ type ClaimResponse struct {
// DetailResponse 红包详情响应
type DetailResponse struct {
- RedEnvelope *model.RedEnvelope `json:"red_envelope"`
+ RedEnvelope *model.RedEnvelope `json:"red_envelope"`
Claims []model.RedEnvelopeClaim `json:"claims"`
UserClaimed *model.RedEnvelopeClaim `json:"user_claimed,omitempty"`
}
@@ -86,7 +82,7 @@ type ListResponse struct {
Total int64 `json:"total"`
Page int `json:"page"`
PageSize int `json:"page_size"`
- RedEnvelopes []model.RedEnvelope `json:"red_envelopes"`
+ RedEnvelopes []model.RedEnvelope `json:"red_envelopes"`
}
// Create 创建红包
@@ -98,8 +94,13 @@ type ListResponse struct {
// @Router /api/v1/redenvelope/create [post]
func Create(c *gin.Context) {
// 检查红包功能是否启用
- if !model.IsRedEnvelopeEnabled(c.Request.Context()) {
- c.JSON(http.StatusForbidden, util.Err("红包功能未启用"))
+ enabled, err := model.IsRedEnvelopeEnabled(c.Request.Context())
+ if err != nil {
+ c.JSON(http.StatusInternalServerError, util.Err(err.Error()))
+ return
+ }
+ if !enabled {
+ c.JSON(http.StatusForbidden, util.Err(common.RedEnvelopeDisabled))
return
}
@@ -109,13 +110,19 @@ func Create(c *gin.Context) {
return
}
- if req.TotalAmount.LessThanOrEqual(decimal.Zero) {
- c.JSON(http.StatusBadRequest, util.Err(InvalidRedEnvelopeAmount))
+ if err := util.ValidateAmount(req.TotalAmount); err != nil {
+ c.JSON(http.StatusBadRequest, util.Err(err.Error()))
return
}
- if req.TotalAmount.Exponent() < -2 {
- c.JSON(http.StatusBadRequest, util.Err(common.AmountDecimalPlacesExceeded))
+ // 检查单个红包最大金额限制
+ maxAmount, err := model.GetRedEnvelopeMaxAmount(c.Request.Context())
+ if err != nil {
+ c.JSON(http.StatusInternalServerError, util.Err(err.Error()))
+ return
+ }
+ if req.TotalAmount.GreaterThan(maxAmount) {
+ c.JSON(http.StatusBadRequest, util.Err(common.RedEnvelopeAmountExceeded))
return
}
@@ -130,36 +137,72 @@ func Create(c *gin.Context) {
currentUser, _ := util.GetFromContext[*model.User](c, oauth.UserObjKey)
+ // 检查每日红包发送数量限制
+ dailyLimit, err := model.GetRedEnvelopeDailyLimit(c.Request.Context())
+ if err != nil {
+ c.JSON(http.StatusInternalServerError, util.Err(err.Error()))
+ return
+ }
+
+ // 查询今日已发送的红包数量
+ var todayCount int64
+ today := time.Now().Truncate(24 * time.Hour)
+ if err := db.DB(c.Request.Context()).Model(&model.RedEnvelope{}).
+ Where("creator_id = ? AND created_at >= ?", currentUser.ID, today).
+ Count(&todayCount).Error; err != nil {
+ c.JSON(http.StatusInternalServerError, util.Err(err.Error()))
+ return
+ }
+
+ if todayCount >= int64(dailyLimit) {
+ c.JSON(http.StatusBadRequest, util.Err(common.RedEnvelopeDailyLimitExceeded))
+ return
+ }
+
if !currentUser.VerifyPayKey(req.PayKey) {
c.JSON(http.StatusBadRequest, util.Err(common.PayKeyIncorrect))
return
}
- code := util.GenerateUniqueIDSimple()
+ // 获取红包手续费率并计算手续费
+ feeRate, err := model.GetRedEnvelopeFeeRate(c.Request.Context())
+ if err != nil {
+ c.JSON(http.StatusInternalServerError, util.Err(err.Error()))
+ return
+ }
+
+ // 计算手续费(红包金额 * 费率)
+ feeAmount := req.TotalAmount.Mul(feeRate).Round(2)
+
+ // 总扣款金额 = 红包金额 + 手续费
+ totalDeduction := req.TotalAmount.Add(feeAmount)
+
+ // 提前检查余额,避免不必要的事务
+ if currentUser.AvailableBalance.LessThan(totalDeduction) {
+ c.JSON(http.StatusBadRequest, util.Err(common.InsufficientBalance))
+ return
+ }
+
var redEnvelope model.RedEnvelope
if err := db.DB(c.Request.Context()).Transaction(func(tx *gorm.DB) error {
- // 锁定用户余额
- var user model.User
- if err := tx.Clauses(clause.Locking{Strength: "UPDATE", Options: "NOWAIT"}).
- Where("id = ?", currentUser.ID).First(&user).Error; err != nil {
- return err
+ // 使用乐观锁扣减余额,扣除红包金额+手续费
+ result := tx.Model(&model.User{}).
+ Where("id = ? AND available_balance >= ?", currentUser.ID, totalDeduction).
+ Update("available_balance", gorm.Expr("available_balance - ?", totalDeduction))
+
+ if result.Error != nil {
+ return result.Error
}
-
- if user.AvailableBalance.LessThan(req.TotalAmount) {
+
+ if result.RowsAffected == 0 {
return errors.New(common.InsufficientBalance)
}
- // 扣减余额
- if err := tx.Model(&model.User{}).Where("id = ?", user.ID).
- Update("available_balance", gorm.Expr("available_balance - ?", req.TotalAmount)).Error; err != nil {
- return err
- }
-
// 创建红包
redEnvelope = model.RedEnvelope{
- Code: code,
- CreatorID: user.ID,
+ ID: idgen.NextUint64ID(),
+ CreatorID: currentUser.ID,
Type: req.Type,
TotalAmount: req.TotalAmount,
RemainingAmount: req.TotalAmount,
@@ -175,21 +218,21 @@ func Create(c *gin.Context) {
}
// 创建订单记录(红包支出)
- order := model.Order{
- OrderName: fmt.Sprintf("红包支出-%s", req.Greeting),
- ClientID: "red_envelope",
- PayerUserID: user.ID,
- PayeeUserID: user.ID, // 红包支出时,收款人也是自己
- Amount: req.TotalAmount,
- Status: model.OrderStatusSuccess,
- Type: model.OrderTypeRedEnvelopeSend,
- Remark: fmt.Sprintf("创建红包,共%d个", req.TotalCount),
- PaymentType: "balance",
- TradeTime: time.Now(),
- ExpiresAt: time.Now().Add(24 * time.Hour),
+ remarkMsg := fmt.Sprintf("创建红包,共%d个", req.TotalCount)
+ if feeAmount.GreaterThan(decimal.Zero) {
+ remarkMsg = fmt.Sprintf("%s,手续费: %s", remarkMsg, feeAmount.String())
}
- if order.OrderName == "红包支出-" {
- order.OrderName = "红包支出"
+
+ order := model.Order{
+ OrderName: fmt.Sprintf("红包支出-%s", req.Greeting),
+ PayerUserID: currentUser.ID,
+ PayeeUserID: currentUser.ID, // 红包支出时,收款人也是自己
+ Amount: totalDeduction,
+ Status: model.OrderStatusSuccess,
+ Type: model.OrderTypeRedEnvelopeSend,
+ Remark: remarkMsg,
+ TradeTime: time.Now(),
+ ExpiresAt: time.Now().Add(24 * time.Hour),
}
return tx.Create(&order).Error
@@ -204,8 +247,8 @@ func Create(c *gin.Context) {
c.JSON(http.StatusOK, util.OK(CreateResponse{
ID: redEnvelope.ID,
- Code: code,
- Link: fmt.Sprintf("%s/redenvelope/%s", config.Config.App.FrontendURL, code),
+ Code: strconv.FormatUint(redEnvelope.ID, 10),
+ Link: fmt.Sprintf("%s/redenvelope/%d", config.Config.App.FrontendURL, redEnvelope.ID),
}))
}
@@ -218,8 +261,13 @@ func Create(c *gin.Context) {
// @Router /api/v1/redenvelope/claim [post]
func Claim(c *gin.Context) {
// 检查红包功能是否启用
- if !model.IsRedEnvelopeEnabled(c.Request.Context()) {
- c.JSON(http.StatusForbidden, util.Err("红包功能未启用"))
+ enabled, err := model.IsRedEnvelopeEnabled(c.Request.Context())
+ if err != nil {
+ c.JSON(http.StatusInternalServerError, util.Err(err.Error()))
+ return
+ }
+ if !enabled {
+ c.JSON(http.StatusForbidden, util.Err(common.RedEnvelopeDisabled))
return
}
@@ -235,12 +283,23 @@ func Claim(c *gin.Context) {
var redEnvelope model.RedEnvelope
if err := db.DB(c.Request.Context()).Transaction(func(tx *gorm.DB) error {
+ // 解析红包ID
+ redEnvelopeID, err := strconv.ParseUint(req.ID, 10, 64)
+ if err != nil {
+ return errors.New("红包ID格式错误")
+ }
+
// 使用 FOR UPDATE 锁定红包记录,防止并发领取
if err := tx.Clauses(clause.Locking{Strength: "UPDATE", Options: "NOWAIT"}).
- Where("code = ?", req.Code).First(&redEnvelope).Error; err != nil {
+ Where("id = ?", redEnvelopeID).First(&redEnvelope).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return errors.New(RedEnvelopeNotFound)
}
+ // 捕获锁等待超时错误,返回友好提示
+ errMsg := err.Error()
+ if errMsg != "" {
+ return errors.New("太火爆啦,稍后再试试吧~")
+ }
return err
}
@@ -275,6 +334,7 @@ func Claim(c *gin.Context) {
// 创建领取记录
claim := model.RedEnvelopeClaim{
+ ID: idgen.NextUint64ID(),
RedEnvelopeID: redEnvelope.ID,
UserID: currentUser.ID,
Amount: claimedAmount,
@@ -313,20 +373,15 @@ func Claim(c *gin.Context) {
// 创建订单记录(红包收入)
order := model.Order{
- OrderName: fmt.Sprintf("红包收入-%s", redEnvelope.Greeting),
- ClientID: "red_envelope",
- PayerUserID: redEnvelope.CreatorID,
- PayeeUserID: currentUser.ID,
- Amount: claimedAmount,
- Status: model.OrderStatusSuccess,
- Type: model.OrderTypeRedEnvelopeReceive,
- Remark: fmt.Sprintf("领取红包,来自创建者ID:%d", redEnvelope.CreatorID),
- PaymentType: "balance",
- TradeTime: time.Now(),
- ExpiresAt: time.Now().Add(24 * time.Hour),
- }
- if order.OrderName == "红包收入-" {
- order.OrderName = "红包收入"
+ OrderName: fmt.Sprintf("红包收入-%s", redEnvelope.Greeting),
+ PayerUserID: redEnvelope.CreatorID,
+ PayeeUserID: currentUser.ID,
+ Amount: claimedAmount,
+ Status: model.OrderStatusSuccess,
+ Type: model.OrderTypeRedEnvelopeReceive,
+ Remark: fmt.Sprintf("领取红包,来自创建者ID:%d", redEnvelope.CreatorID),
+ TradeTime: time.Now(),
+ ExpiresAt: time.Now().Add(24 * time.Hour),
}
return tx.Create(&order).Error
@@ -352,18 +407,24 @@ func Claim(c *gin.Context) {
// GetDetail 获取红包详情
// @Tags redenvelope
// @Produce json
-// @Param code path string true "红包码"
+// @Param id path string true "红包ID"
// @Success 200 {object} util.ResponseAny
-// @Router /api/v1/redenvelope/{code} [get]
+// @Router /api/v1/redenvelope/{id} [get]
func GetDetail(c *gin.Context) {
- code := c.Param("code")
+ idStr := c.Param("id")
+ redEnvelopeID, err := strconv.ParseUint(idStr, 10, 64)
+ if err != nil {
+ c.JSON(http.StatusBadRequest, util.Err("红包ID格式错误"))
+ return
+ }
+
currentUser, _ := util.GetFromContext[*model.User](c, oauth.UserObjKey)
var redEnvelope model.RedEnvelope
if err := db.DB(c.Request.Context()).
Select("red_envelopes.*, users.username as creator_username, users.avatar_url as creator_avatar_url").
Joins("LEFT JOIN users ON red_envelopes.creator_id = users.id").
- Where("red_envelopes.code = ?", code).First(&redEnvelope).Error; err != nil {
+ Where("red_envelopes.id = ?", redEnvelopeID).First(&redEnvelope).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
c.JSON(http.StatusNotFound, util.Err(RedEnvelopeNotFound))
return
@@ -443,64 +504,3 @@ func List(c *gin.Context) {
}))
}
-// IsEnabled 检查红包功能是否启用
-// @Tags redenvelope
-// @Produce json
-// @Success 200 {object} util.ResponseAny
-// @Router /api/v1/redenvelope/enabled [get]
-func IsEnabled(c *gin.Context) {
- enabled := model.IsRedEnvelopeEnabled(c.Request.Context())
- c.JSON(http.StatusOK, util.OK(IsEnabledResponse{
- Enabled: enabled,
- }))
-}
-
-// calculateRandomAmount 二倍均值算法计算随机红包金额
-func calculateRandomAmount(remaining decimal.Decimal, count int) decimal.Decimal {
- // 如果是最后一个红包,返回所有剩余金额(避免舍入误差)
- if count == 1 {
- return remaining
- }
-
- minAmount := decimal.NewFromFloat(0.01)
-
- // 确保剩余金额足够分配给所有人至少0.01
- minRequired := minAmount.Mul(decimal.NewFromInt(int64(count)))
- if remaining.LessThan(minRequired) {
- // 如果剩余金额不足,平均分配
- return remaining.Div(decimal.NewFromInt(int64(count))).Round(2)
- }
-
- // 二倍均值算法:金额范围 [0.01, min(剩余金额/剩余人数*2, 剩余金额-其他人最小金额)]
- avg := remaining.Div(decimal.NewFromInt(int64(count)))
- maxAmount := avg.Mul(decimal.NewFromInt(2))
-
- // 确保给其他人留下足够的金额(每人至少0.01)
- maxPossible := remaining.Sub(minAmount.Mul(decimal.NewFromInt(int64(count - 1))))
- if maxAmount.GreaterThan(maxPossible) {
- maxAmount = maxPossible
- }
-
- // 确保maxAmount不小于minAmount
- if maxAmount.LessThan(minAmount) {
- maxAmount = minAmount
- }
-
- // 生成随机金额 [minAmount, maxAmount]
- diff := maxAmount.Sub(minAmount)
- if diff.LessThanOrEqual(decimal.Zero) {
- return minAmount
- }
-
- // 生成随机数:转换为分(cents)来处理,避免精度问题
- diffCents := diff.Mul(decimal.NewFromInt(100)).IntPart()
- if diffCents <= 0 {
- return minAmount
- }
-
- randCents := rand.Int63n(diffCents + 1) // [0, diffCents]
- randAmount := decimal.NewFromInt(randCents).Div(decimal.NewFromInt(100))
- amount := minAmount.Add(randAmount)
-
- return amount.Round(2)
-}
\ No newline at end of file
diff --git a/internal/apps/redenvelope/tasks.go b/internal/apps/redenvelope/tasks.go
index d109ef62..2f4b0ae8 100644
--- a/internal/apps/redenvelope/tasks.go
+++ b/internal/apps/redenvelope/tasks.go
@@ -38,25 +38,32 @@ func HandleRefundExpiredRedEnvelopes(ctx context.Context, t *asynq.Task) error {
// refundExpiredRedEnvelopes 退款过期红包
func refundExpiredRedEnvelopes(ctx context.Context) {
- // 查询所有过期且未退款的红包
- var expiredEnvelopes []model.RedEnvelope
- if err := db.DB(ctx).
- Where("status = ? AND expires_at < ? AND remaining_amount > 0", model.RedEnvelopeStatusActive, time.Now()).
- Find(&expiredEnvelopes).Error; err != nil {
- logger.ErrorF(ctx, "查询过期红包失败: %v", err)
- return
- }
+ const batchSize = 100 // 每批处理100个红包
+ var lastID uint64 = 0
+ var totalProcessed int = 0
+
+ for {
+ // 使用游标分页查询过期红包
+ var expiredEnvelopes []model.RedEnvelope
+ if err := db.DB(ctx).
+ Where("id > ? AND status = ? AND expires_at < ? AND remaining_amount > 0", lastID, model.RedEnvelopeStatusActive, time.Now()).
+ Order("id ASC").
+ Limit(batchSize).
+ Find(&expiredEnvelopes).Error; err != nil {
+ logger.ErrorF(ctx, "查询过期红包失败: %v", err)
+ return
+ }
- if len(expiredEnvelopes) == 0 {
- logger.InfoF(ctx, "没有需要退款的过期红包")
- return
- }
+ // 没有更多数据,退出循环
+ if len(expiredEnvelopes) == 0 {
+ break
+ }
- logger.InfoF(ctx, "找到 %d 个需要退款的过期红包", len(expiredEnvelopes))
+ logger.InfoF(ctx, "本批次找到 %d 个需要退款的过期红包", len(expiredEnvelopes))
- // 处理每个过期红包
- for _, envelope := range expiredEnvelopes {
- if err := db.DB(ctx).Transaction(func(tx *gorm.DB) error {
+ // 处理每个过期红包
+ for _, envelope := range expiredEnvelopes {
+ if err := db.DB(ctx).Transaction(func(tx *gorm.DB) error {
// 更新红包状态为已过期
if err := tx.Model(&model.RedEnvelope{}).
Where("id = ? AND status = ?", envelope.ID, model.RedEnvelopeStatusActive).
@@ -77,21 +84,20 @@ func refundExpiredRedEnvelopes(ctx context.Context) {
}
// 创建退款订单记录
- order := model.Order{
- OrderName: fmt.Sprintf("红包退款-%s", envelope.Greeting),
- ClientID: "red_envelope",
- PayerUserID: envelope.CreatorID,
- PayeeUserID: envelope.CreatorID,
- Amount: envelope.RemainingAmount,
- Status: model.OrderStatusSuccess,
- Type: "red_envelope_refund",
- Remark: fmt.Sprintf("红包过期退款,红包ID:%d", envelope.ID),
- PaymentType: "balance",
- TradeTime: time.Now(),
- ExpiresAt: time.Now().Add(24 * time.Hour),
+ orderName := "红包退款"
+ if envelope.Greeting != "" {
+ orderName = fmt.Sprintf("红包退款-%s", envelope.Greeting)
}
- if order.OrderName == "红包退款-" {
- order.OrderName = "红包退款"
+ order := model.Order{
+ OrderName: orderName,
+ PayerUserID: envelope.CreatorID,
+ PayeeUserID: envelope.CreatorID,
+ Amount: envelope.RemainingAmount,
+ Status: model.OrderStatusSuccess,
+ Type: model.OrderTypeRedEnvelopeRefund,
+ Remark: fmt.Sprintf("红包过期退款,红包ID:%d", envelope.ID),
+ TradeTime: time.Now(),
+ ExpiresAt: time.Now().Add(24 * time.Hour),
}
if err := tx.Create(&order).Error; err != nil {
@@ -101,9 +107,21 @@ func refundExpiredRedEnvelopes(ctx context.Context) {
logger.InfoF(ctx, "红包ID:%d 退款成功,金额:%s", envelope.ID, envelope.RemainingAmount.String())
}
- return nil
- }); err != nil {
- logger.ErrorF(ctx, "红包ID:%d 退款失败: %v", envelope.ID, err)
+ return nil
+ }); err != nil {
+ logger.ErrorF(ctx, "红包ID:%d 退款失败: %v", envelope.ID, err)
+ } else {
+ totalProcessed++
+ }
+
+ // 更新游标
+ lastID = envelope.ID
}
}
+
+ if totalProcessed > 0 {
+ logger.InfoF(ctx, "退款任务完成,共处理 %d 个过期红包", totalProcessed)
+ } else {
+ logger.InfoF(ctx, "没有需要退款的过期红包")
+ }
}
\ No newline at end of file
diff --git a/internal/apps/redenvelope/utils.go b/internal/apps/redenvelope/utils.go
new file mode 100644
index 00000000..649ef891
--- /dev/null
+++ b/internal/apps/redenvelope/utils.go
@@ -0,0 +1,73 @@
+/*
+Copyright 2025 linux.do
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+package redenvelope
+
+import (
+ "math/rand"
+
+ "github.com/shopspring/decimal"
+)
+
+// calculateRandomAmount 二倍均值算法计算随机红包金额
+func calculateRandomAmount(remaining decimal.Decimal, count int) decimal.Decimal {
+ // 如果是最后一个红包,返回所有剩余金额(避免舍入误差)
+ if count == 1 {
+ return remaining
+ }
+
+ minAmount := decimal.NewFromFloat(0.01)
+
+ // 确保剩余金额足够分配给所有人至少0.01
+ minRequired := minAmount.Mul(decimal.NewFromInt(int64(count)))
+ if remaining.LessThan(minRequired) {
+ // 如果剩余金额不足,平均分配
+ return remaining.Div(decimal.NewFromInt(int64(count))).Round(2)
+ }
+
+ // 二倍均值算法:金额范围 [0.01, min(剩余金额/剩余人数*2, 剩余金额-其他人最小金额)]
+ avg := remaining.Div(decimal.NewFromInt(int64(count)))
+ maxAmount := avg.Mul(decimal.NewFromInt(2))
+
+ // 确保给其他人留下足够的金额(每人至少0.01)
+ maxPossible := remaining.Sub(minAmount.Mul(decimal.NewFromInt(int64(count - 1))))
+ if maxAmount.GreaterThan(maxPossible) {
+ maxAmount = maxPossible
+ }
+
+ // 确保maxAmount不小于minAmount
+ if maxAmount.LessThan(minAmount) {
+ maxAmount = minAmount
+ }
+
+ // 生成随机金额 [minAmount, maxAmount]
+ diff := maxAmount.Sub(minAmount)
+ if diff.LessThanOrEqual(decimal.Zero) {
+ return minAmount
+ }
+
+ // 生成随机数:转换为分(cents)来处理,避免精度问题
+ diffCents := diff.Mul(decimal.NewFromInt(100)).IntPart()
+ if diffCents <= 0 {
+ return minAmount
+ }
+
+ randCents := rand.Int63n(diffCents + 1) // [0, diffCents]
+ randAmount := decimal.NewFromInt(randCents).Div(decimal.NewFromInt(100))
+ amount := minAmount.Add(randAmount)
+
+ return amount.Round(2)
+}
\ No newline at end of file
diff --git a/internal/common/errs.go b/internal/common/errs.go
index ca5c7485..1fdbb107 100644
--- a/internal/common/errs.go
+++ b/internal/common/errs.go
@@ -17,18 +17,21 @@ limitations under the License.
package common
const (
- BannedAccount = "账号已被封禁"
- AmountMustBeGreaterThanZero = "金额必须大于0"
- AmountDecimalPlacesExceeded = "金额小数位数不能超过2位"
- RateMustBeBetweenZeroAndOne = "比率必须在 0 到 1 之间"
- RateDecimalPlacesExceeded = "比率小数位数不能超过2位"
- InsufficientBalance = "余额不足"
- DailyLimitExceeded = "已超过每日限额"
- PayKeyIncorrect = "支付密钥错误"
- CannotPaySelf = "不能给自己付款"
- TestModeCannotProcessOrder = "测试模式下无法处理订单"
- TestModeOrderRemark = "[测试模式] 此订单为测试订单,未实际扣款"
- UnAuthorized = "未登录"
+ BannedAccount = "账号已被封禁"
+ AmountMustBeGreaterThanZero = "金额必须大于0"
+ AmountDecimalPlacesExceeded = "金额小数位数不能超过2位"
+ RateMustBeBetweenZeroAndOne = "比率必须在 0 到 1 之间"
+ RateDecimalPlacesExceeded = "比率小数位数不能超过2位"
+ InsufficientBalance = "余额不足"
+ DailyLimitExceeded = "已超过每日限额"
+ PayKeyIncorrect = "支付密钥错误"
+ CannotPaySelf = "不能给自己付款"
+ TestModeCannotProcessOrder = "测试模式下无法处理订单"
+ TestModeOrderRemark = "[测试模式] 此订单为测试订单,未实际扣款"
+ UnAuthorized = "未登录"
+ RedEnvelopeDisabled = "红包功能未启用"
+ RedEnvelopeAmountExceeded = "红包金额超过单个红包最大限额"
+ RedEnvelopeDailyLimitExceeded = "今日发红包数量已达上限"
)
const (
diff --git a/internal/db/migrator/migrator.go b/internal/db/migrator/migrator.go
index 1fe43e19..59f7b7c5 100644
--- a/internal/db/migrator/migrator.go
+++ b/internal/db/migrator/migrator.go
@@ -99,6 +99,21 @@ func initSystemConfigs() {
Value: "0",
Description: "红包功能是否启用(1启用,0禁用)",
},
+ {
+ Key: model.ConfigKeyRedEnvelopeMaxAmount,
+ Value: "1000",
+ Description: "单个红包的最大积分上限",
+ },
+ {
+ Key: model.ConfigKeyRedEnvelopeDailyLimit,
+ Value: "10",
+ Description: "每日发红包的个数限制",
+ },
+ {
+ Key: model.ConfigKeyRedEnvelopeFeeRate,
+ Value: "0",
+ Description: "红包手续费率(0-1之间的小数,0表示不收费)",
+ },
}
if err := tx.Create(&defaultConfigs).Error; err != nil {
diff --git a/internal/model/orders.go b/internal/model/orders.go
index 4ea4501f..2491121c 100644
--- a/internal/model/orders.go
+++ b/internal/model/orders.go
@@ -32,14 +32,15 @@ import (
type OrderType string
const (
- OrderTypeReceive OrderType = "receive"
- OrderTypePayment OrderType = "payment"
- OrderTypeTransfer OrderType = "transfer"
- OrderTypeCommunity OrderType = "community"
- OrderTypeOnline OrderType = "online"
- OrderTypeTest OrderType = "test"
- OrderTypeRedEnvelopeSend OrderType = "red_envelope_send"
+ OrderTypeReceive OrderType = "receive"
+ OrderTypePayment OrderType = "payment"
+ OrderTypeTransfer OrderType = "transfer"
+ OrderTypeCommunity OrderType = "community"
+ OrderTypeOnline OrderType = "online"
+ OrderTypeTest OrderType = "test"
+ OrderTypeRedEnvelopeSend OrderType = "red_envelope_send"
OrderTypeRedEnvelopeReceive OrderType = "red_envelope_receive"
+ OrderTypeRedEnvelopeRefund OrderType = "red_envelope_refund"
)
type OrderStatus string
diff --git a/internal/model/red_envelopes.go b/internal/model/red_envelopes.go
index 1256e682..8462e211 100644
--- a/internal/model/red_envelopes.go
+++ b/internal/model/red_envelopes.go
@@ -39,38 +39,29 @@ const (
// RedEnvelope 红包
type RedEnvelope struct {
- ID uint64 `json:"id" gorm:"primaryKey;autoIncrement"`
- Code string `json:"code" gorm:"size:64;uniqueIndex;not null"`
+ ID uint64 `json:"id" gorm:"primaryKey"`
CreatorID uint64 `json:"creator_id" gorm:"index;not null"`
CreatorUsername string `json:"creator_username" gorm:"->"`
CreatorAvatarURL string `json:"creator_avatar_url" gorm:"->"`
Type RedEnvelopeType `json:"type" gorm:"type:varchar(20);not null"`
- TotalAmount decimal.Decimal `json:"total_amount" gorm:"type:numeric(20,2);not null"`
- RemainingAmount decimal.Decimal `json:"remaining_amount" gorm:"type:numeric(20,2);not null"`
- TotalCount int `json:"total_count" gorm:"not null"`
- RemainingCount int `json:"remaining_count" gorm:"not null"`
- Greeting string `json:"greeting" gorm:"size:100"`
- Status RedEnvelopeStatus `json:"status" gorm:"type:varchar(20);not null;index"`
- ExpiresAt time.Time `json:"expires_at" gorm:"not null;index"`
- CreatedAt time.Time `json:"created_at" gorm:"autoCreateTime"`
- UpdatedAt time.Time `json:"updated_at" gorm:"autoUpdateTime"`
+ TotalAmount decimal.Decimal `json:"total_amount" gorm:"type:numeric(20,2);not null"`
+ RemainingAmount decimal.Decimal `json:"remaining_amount" gorm:"type:numeric(20,2);not null"`
+ TotalCount int `json:"total_count" gorm:"not null"`
+ RemainingCount int `json:"remaining_count" gorm:"not null"`
+ Greeting string `json:"greeting" gorm:"size:100"`
+ Status RedEnvelopeStatus `json:"status" gorm:"type:varchar(20);not null;index"`
+ ExpiresAt time.Time `json:"expires_at" gorm:"not null;index"`
+ CreatedAt time.Time `json:"created_at" gorm:"autoCreateTime"`
+ UpdatedAt time.Time `json:"updated_at" gorm:"autoUpdateTime"`
}
// RedEnvelopeClaim 红包领取记录
type RedEnvelopeClaim struct {
- ID uint64 `json:"id" gorm:"primaryKey;autoIncrement"`
+ ID uint64 `json:"id" gorm:"primaryKey"`
RedEnvelopeID uint64 `json:"red_envelope_id" gorm:"index;not null"`
UserID uint64 `json:"user_id" gorm:"index;not null"`
Username string `json:"username" gorm:"->"`
AvatarURL string `json:"avatar_url" gorm:"->"`
Amount decimal.Decimal `json:"amount" gorm:"type:numeric(20,2);not null"`
ClaimedAt time.Time `json:"claimed_at" gorm:"autoCreateTime"`
-}
-
-func (RedEnvelope) TableName() string {
- return "red_envelopes"
-}
-
-func (RedEnvelopeClaim) TableName() string {
- return "red_envelope_claims"
}
\ No newline at end of file
diff --git a/internal/model/system_configs.go b/internal/model/system_configs.go
index eebdc7da..d1143621 100644
--- a/internal/model/system_configs.go
+++ b/internal/model/system_configs.go
@@ -37,6 +37,9 @@ const (
ConfigKeyNewUserInitialCredit = "new_user_initial_credit" // 新用户注册初始积分
ConfigKeyNewUserProtectionDays = "new_user_protection_days" // 新用户保护期天数(期内不扣分)
ConfigKeyRedEnvelopeEnabled = "red_envelope_enabled" // 红包功能是否启用(1启用,0禁用)
+ ConfigKeyRedEnvelopeMaxAmount = "red_envelope_max_amount" // 单个红包的最大积分上限
+ ConfigKeyRedEnvelopeDailyLimit = "red_envelope_daily_limit" // 每日发红包的个数限制
+ ConfigKeyRedEnvelopeFeeRate = "red_envelope_fee_rate" // 红包手续费率(0-1之间的小数,0表示不收费)
)
const (
@@ -105,11 +108,37 @@ func GetDecimalByKey(ctx context.Context, key string, precision int32) (decimal.
}
// IsRedEnvelopeEnabled 检查红包功能是否启用
-func IsRedEnvelopeEnabled(ctx context.Context) bool {
+func IsRedEnvelopeEnabled(ctx context.Context) (bool, error) {
value, err := GetIntByKey(ctx, ConfigKeyRedEnvelopeEnabled)
if err != nil {
- // 如果配置不存在或出错,默认启用
- return true
+ return false, fmt.Errorf("获取红包功能配置失败: %w", err)
}
- return value == 1
+ return value == 1, nil
+}
+
+// GetRedEnvelopeMaxAmount 获取单个红包的最大积分上限
+func GetRedEnvelopeMaxAmount(ctx context.Context) (decimal.Decimal, error) {
+ value, err := GetDecimalByKey(ctx, ConfigKeyRedEnvelopeMaxAmount, 2)
+ if err != nil {
+ return decimal.Zero, fmt.Errorf("获取红包最大金额配置失败: %w", err)
+ }
+ return value, nil
+}
+
+// GetRedEnvelopeDailyLimit 获取每日发红包的个数限制
+func GetRedEnvelopeDailyLimit(ctx context.Context) (int, error) {
+ value, err := GetIntByKey(ctx, ConfigKeyRedEnvelopeDailyLimit)
+ if err != nil {
+ return 0, fmt.Errorf("获取红包每日限额配置失败: %w", err)
+ }
+ return value, nil
+}
+
+// GetRedEnvelopeFeeRate 获取红包手续费率
+func GetRedEnvelopeFeeRate(ctx context.Context) (decimal.Decimal, error) {
+ value, err := GetDecimalByKey(ctx, ConfigKeyRedEnvelopeFeeRate, 2)
+ if err != nil {
+ return decimal.Zero, fmt.Errorf("获取红包手续费率配置失败: %w", err)
+ }
+ return value, nil
}
diff --git a/internal/router/router.go b/internal/router/router.go
index 4cff580b..27be92c7 100644
--- a/internal/router/router.go
+++ b/internal/router/router.go
@@ -165,8 +165,7 @@ func Serve() {
// Red Envelope
redEnvelopeRouter := apiV1Router.Group("/redenvelope")
{
- redEnvelopeRouter.GET("/enabled", redenvelope.IsEnabled)
- redEnvelopeRouter.GET("/:code", oauth.LoginRequired(), redenvelope.GetDetail)
+ redEnvelopeRouter.GET("/:id", oauth.LoginRequired(), redenvelope.GetDetail)
redEnvelopeRouter.POST("/create", oauth.LoginRequired(), redenvelope.Create)
redEnvelopeRouter.POST("/claim", oauth.LoginRequired(), redenvelope.Claim)
redEnvelopeRouter.POST("/list", oauth.LoginRequired(), redenvelope.List)
From be6ea6890519da0f0e4150010590dcec88d8640c Mon Sep 17 00:00:00 2001
From: cattie <2237829695@qq.com>
Date: Thu, 1 Jan 2026 19:11:20 +0800
Subject: [PATCH 4/6] Mod: bug fix and code refactor
---
.../components/common/trade/red-envelope.tsx | 16 +--
.../components/common/trade/trade-main.tsx | 6 +-
frontend/lib/services/config/types.ts | 4 +-
frontend/lib/services/redenvelope/types.ts | 24 ++--
internal/apps/config/routers.go | 28 ++---
internal/apps/dashboard/logic.go | 34 ++++--
internal/apps/redenvelope/errs.go | 18 +--
internal/apps/redenvelope/middlewares.go | 44 +++++++
internal/apps/redenvelope/routers.go | 111 +++++++-----------
internal/apps/redenvelope/tasks.go | 84 +++++++------
internal/model/red_envelopes.go | 22 ++--
internal/model/system_configs.go | 36 ------
internal/router/router.go | 8 +-
13 files changed, 216 insertions(+), 219 deletions(-)
create mode 100644 internal/apps/redenvelope/middlewares.go
diff --git a/frontend/components/common/trade/red-envelope.tsx b/frontend/components/common/trade/red-envelope.tsx
index 1be722e7..d1eb8555 100644
--- a/frontend/components/common/trade/red-envelope.tsx
+++ b/frontend/components/common/trade/red-envelope.tsx
@@ -41,7 +41,7 @@ export function RedEnvelope() {
const [isFormOpen, setIsFormOpen] = useState(false)
const [isPasswordOpen, setIsPasswordOpen] = useState(false)
const [isResultOpen, setIsResultOpen] = useState(false)
- const [copiedId, setCopiedId] = useState(null)
+ const [copiedId, setCopiedId] = useState(null)
const [activeTab, setActiveTab] = useState<'sent' | 'received'>('sent')
/* 表单状态 */
@@ -177,13 +177,15 @@ export function RedEnvelope() {
type,
total_amount: parseFloat(totalAmount),
total_count: parseInt(totalCount),
- greeting: greeting || undefined,
+ greeting: greeting || "恭喜发财,大吉大利",
pay_key: password,
}
const result = await services.redEnvelope.create(data)
- setResultLink(result.link)
+ // 前端生成链接
+ const link = getEnvelopeLink(result.id)
+ setResultLink(link)
setIsPasswordOpen(false)
setIsResultOpen(true)
@@ -205,7 +207,7 @@ export function RedEnvelope() {
}
/* 复制链接 */
- const handleCopyLink = async (link: string, envelopeId: number) => {
+ const handleCopyLink = async (link: string, envelopeId: string) => {
try {
await navigator.clipboard.writeText(link)
setCopiedId(envelopeId)
@@ -257,7 +259,7 @@ export function RedEnvelope() {
}
/* 生成红包链接 */
- const getEnvelopeLink = (id: number) => {
+ const getEnvelopeLink = (id: string) => {
if (typeof window !== 'undefined') {
return `${window.location.origin}/redenvelope/${id}`
}
@@ -634,9 +636,9 @@ export function RedEnvelope() {
diff --git a/frontend/components/common/trade/trade-main.tsx b/frontend/components/common/trade/trade-main.tsx
index 10de93aa..d853ad09 100644
--- a/frontend/components/common/trade/trade-main.tsx
+++ b/frontend/components/common/trade/trade-main.tsx
@@ -68,13 +68,13 @@ export function TradeMain() {
setMounted(true)
}, [])
- const [redEnvelopeEnabled, setRedEnvelopeEnabled] = React.useState(false)
+ const [redEnvelopeEnabled, setRedEnvelopeEnabled] = React.useState(0)
React.useEffect(() => {
// 从公共配置中获取红包功能状态
services.config.getPublicConfig()
.then(res => setRedEnvelopeEnabled(res.red_envelope_enabled))
- .catch(() => setRedEnvelopeEnabled(false))
+ .catch(() => setRedEnvelopeEnabled(0))
}, [])
/* 获取活动类型 */
@@ -118,7 +118,7 @@ export function TradeMain() {
积分转移
社区划转
在线流转
- {redEnvelopeEnabled && (
+ {redEnvelopeEnabled === 1 && (
红包
)}
所有活动
diff --git a/frontend/lib/services/config/types.ts b/frontend/lib/services/config/types.ts
index 2f2aa8fe..27d314de 100644
--- a/frontend/lib/services/config/types.ts
+++ b/frontend/lib/services/config/types.ts
@@ -4,8 +4,8 @@
export interface PublicConfigResponse {
/** 争议时间窗口(小时) */
dispute_time_window_hours: number;
- /** 红包功能是否启用 */
- red_envelope_enabled: boolean;
+ /** 红包功能是否启用(1启用,0禁用) */
+ red_envelope_enabled: number;
/** 单个红包的最大积分上限 */
red_envelope_max_amount: string;
/** 每日发红包的个数限制 */
diff --git a/frontend/lib/services/redenvelope/types.ts b/frontend/lib/services/redenvelope/types.ts
index 54e1bbd5..1545ff14 100644
--- a/frontend/lib/services/redenvelope/types.ts
+++ b/frontend/lib/services/redenvelope/types.ts
@@ -18,9 +18,9 @@ export type RedEnvelopeStatus = 'active' | 'finished' | 'expired';
*/
export interface RedEnvelope {
/** 红包 ID */
- id: number;
+ id: string;
/** 创建者用户 ID */
- creator_id: number;
+ creator_id: string;
/** 创建者用户名 */
creator_username: string;
/** 创建者头像 URL */
@@ -49,12 +49,12 @@ export interface RedEnvelope {
* 红包领取记录
*/
export interface RedEnvelopeClaim {
- /** 记录 ID */
- id: number;
- /** 红包 ID */
- red_envelope_id: number;
- /** 领取者用户 ID */
- user_id: number;
+ /** 记录 ID (作为字符串以避免 JS 精度问题) */
+ id: string;
+ /** 红包 ID (作为字符串以避免 JS 精度问题) */
+ red_envelope_id: string;
+ /** 领取者用户 ID (作为字符串以避免 JS 精度问题) */
+ user_id: string;
/** 领取者用户名 */
username: string;
/** 领取者头像 URL */
@@ -85,12 +85,8 @@ export interface CreateRedEnvelopeRequest {
* 创建红包响应
*/
export interface CreateRedEnvelopeResponse {
- /** 红包 ID */
- id: number;
- /** 红包唯一码 */
- code: string;
- /** 分享链接 */
- link: string;
+ /** 红包 ID (作为字符串以避免 JS 精度问题) */
+ id: string;
}
/**
diff --git a/internal/apps/config/routers.go b/internal/apps/config/routers.go
index 244e5b07..4c53bad8 100644
--- a/internal/apps/config/routers.go
+++ b/internal/apps/config/routers.go
@@ -27,11 +27,11 @@ import (
// PublicConfigResponse 公共配置响应
type PublicConfigResponse struct {
- DisputeTimeWindowHours int `json:"dispute_time_window_hours"` // 争议时间窗口(小时)
- RedEnvelopeEnabled bool `json:"red_envelope_enabled"` // 红包功能是否启用
- RedEnvelopeMaxAmount decimal.Decimal `json:"red_envelope_max_amount"` // 单个红包的最大积分上限
- RedEnvelopeDailyLimit int `json:"red_envelope_daily_limit"` // 每日发红包的个数限制
- RedEnvelopeFeeRate decimal.Decimal `json:"red_envelope_fee_rate"` // 红包手续费率
+ DisputeTimeWindowHours int `json:"dispute_time_window_hours"` // 争议时间窗口(小时)
+ RedEnvelopeEnabled int `json:"red_envelope_enabled"` // 红包功能是否启用(1启用,0禁用)
+ RedEnvelopeMaxAmount decimal.Decimal `json:"red_envelope_max_amount"` // 单个红包的最大积分上限
+ RedEnvelopeDailyLimit int `json:"red_envelope_daily_limit"` // 每日发红包的个数限制
+ RedEnvelopeFeeRate decimal.Decimal `json:"red_envelope_fee_rate"` // 红包手续费率
}
// GetPublicConfig 获取公共配置
@@ -49,37 +49,37 @@ func GetPublicConfig(c *gin.Context) {
}
// 获取红包功能启用状态
- redEnvelopeEnabled, err := model.IsRedEnvelopeEnabled(c.Request.Context())
+ redEnvelopeEnabled, err := model.GetIntByKey(c.Request.Context(), model.ConfigKeyRedEnvelopeEnabled)
if err != nil {
c.JSON(http.StatusInternalServerError, util.Err(err.Error()))
return
}
// 获取红包配置
- redEnvelopeMaxAmount, err := model.GetRedEnvelopeMaxAmount(c.Request.Context())
+ redEnvelopeMaxAmount, err := model.GetDecimalByKey(c.Request.Context(), model.ConfigKeyRedEnvelopeMaxAmount, 2)
if err != nil {
c.JSON(http.StatusInternalServerError, util.Err(err.Error()))
return
}
- redEnvelopeDailyLimit, err := model.GetRedEnvelopeDailyLimit(c.Request.Context())
+ redEnvelopeDailyLimit, err := model.GetIntByKey(c.Request.Context(), model.ConfigKeyRedEnvelopeDailyLimit)
if err != nil {
c.JSON(http.StatusInternalServerError, util.Err(err.Error()))
return
}
- redEnvelopeFeeRate, err := model.GetRedEnvelopeFeeRate(c.Request.Context())
+ redEnvelopeFeeRate, err := model.GetDecimalByKey(c.Request.Context(), model.ConfigKeyRedEnvelopeFeeRate, 2)
if err != nil {
c.JSON(http.StatusInternalServerError, util.Err(err.Error()))
return
}
response := PublicConfigResponse{
- DisputeTimeWindowHours: disputeTimeHours,
- RedEnvelopeEnabled: redEnvelopeEnabled,
- RedEnvelopeMaxAmount: redEnvelopeMaxAmount,
- RedEnvelopeDailyLimit: redEnvelopeDailyLimit,
- RedEnvelopeFeeRate: redEnvelopeFeeRate,
+ DisputeTimeWindowHours: disputeTimeHours,
+ RedEnvelopeEnabled: redEnvelopeEnabled,
+ RedEnvelopeMaxAmount: redEnvelopeMaxAmount,
+ RedEnvelopeDailyLimit: redEnvelopeDailyLimit,
+ RedEnvelopeFeeRate: redEnvelopeFeeRate,
}
c.JSON(http.StatusOK, util.OK(response))
diff --git a/internal/apps/dashboard/logic.go b/internal/apps/dashboard/logic.go
index f58720de..206f3be0 100644
--- a/internal/apps/dashboard/logic.go
+++ b/internal/apps/dashboard/logic.go
@@ -41,22 +41,32 @@ type dailyAmountResult struct {
// queryDailyAmounts 查询每日金额
// isIncome: true=收入(payee), false=支出(payer)
func queryDailyAmounts(ctx context.Context, userID uint64, isIncome bool, startDate, endDate time.Time) (map[string]decimal.Decimal, error) {
- var userIDField string
+ var results []dailyAmountResult
+ var err error
+
if isIncome {
- userIDField = "payee_user_id"
+ // 收入查询:payee_user_id = user
+ // 包括:普通收款、红包领取(red_envelope_receive)、红包退款(red_envelope_refund)
+ err = db.DB(ctx).Model(&model.Order{}).
+ Select("DATE_TRUNC('day', created_at) as date, SUM(amount) as amount").
+ Where("payee_user_id = ?", userID).
+ Where("status = ?", model.OrderStatusSuccess).
+ Where("created_at >= ? AND created_at < ?", startDate, endDate).
+ Group("DATE_TRUNC('day', created_at)").
+ Scan(&results).Error
} else {
- userIDField = "payer_user_id"
+ // 支出查询:payer_user_id = user,但排除 red_envelope_receive
+ // red_envelope_receive 的 payer_user_id 是红包创建者,但创建者的支出已在 red_envelope_send 时计算
+ err = db.DB(ctx).Model(&model.Order{}).
+ Select("DATE_TRUNC('day', created_at) as date, SUM(amount) as amount").
+ Where("payer_user_id = ?", userID).
+ Where("status = ?", model.OrderStatusSuccess).
+ Where("type != ?", model.OrderTypeRedEnvelopeReceive).
+ Where("created_at >= ? AND created_at < ?", startDate, endDate).
+ Group("DATE_TRUNC('day', created_at)").
+ Scan(&results).Error
}
- var results []dailyAmountResult
- err := db.DB(ctx).Model(&model.Order{}).
- Select("DATE_TRUNC('day', created_at) as date, SUM(amount) as amount").
- Where(userIDField+" = ?", userID).
- Where("status = ?", model.OrderStatusSuccess).
- Where("created_at >= ? AND created_at < ?", startDate, endDate).
- Group("DATE_TRUNC('day', created_at)").
- Scan(&results).Error
-
if err != nil {
return nil, err
}
diff --git a/internal/apps/redenvelope/errs.go b/internal/apps/redenvelope/errs.go
index 6d30879d..0aa843e0 100644
--- a/internal/apps/redenvelope/errs.go
+++ b/internal/apps/redenvelope/errs.go
@@ -17,13 +17,15 @@ limitations under the License.
package redenvelope
const (
- RedEnvelopeNotFound = "红包不存在"
- RedEnvelopeExpired = "红包已过期"
- RedEnvelopeFinished = "红包已领完"
+ RedEnvelopeNotFound = "红包不存在"
+ RedEnvelopeExpired = "红包已过期"
+ RedEnvelopeFinished = "红包已领完"
RedEnvelopeAlreadyClaimed = "您已领取过该红包"
CannotClaimOwnRedEnvelope = "不能领取自己的红包"
- InvalidRedEnvelopeType = "无效的红包类型"
- InvalidRedEnvelopeCount = "红包个数必须大于0"
- InvalidRedEnvelopeAmount = "红包金额必须大于0"
- AmountTooSmall = "每个红包金额不能小于0.01"
-)
\ No newline at end of file
+ InvalidRedEnvelopeType = "无效的红包类型"
+ InvalidRedEnvelopeCount = "红包个数必须大于0"
+ InvalidRedEnvelopeAmount = "红包金额必须大于0"
+ AmountTooSmall = "每个红包金额不能小于0.01"
+ RedEnvelopeTooPopular = "太火爆啦,稍后再试试吧~"
+ InvalidRedEnvelopeID = "红包ID格式错误"
+)
diff --git a/internal/apps/redenvelope/middlewares.go b/internal/apps/redenvelope/middlewares.go
new file mode 100644
index 00000000..d8077896
--- /dev/null
+++ b/internal/apps/redenvelope/middlewares.go
@@ -0,0 +1,44 @@
+/*
+Copyright 2025 linux.do
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+package redenvelope
+
+import (
+ "net/http"
+
+ "github.com/gin-gonic/gin"
+ "github.com/linux-do/credit/internal/common"
+ "github.com/linux-do/credit/internal/model"
+ "github.com/linux-do/credit/internal/util"
+)
+
+// CheckRedEnvelopeEnabled 检查红包功能是否启用的中间件
+func CheckRedEnvelopeEnabled() gin.HandlerFunc {
+ return func(c *gin.Context) {
+ enabled, err := model.GetIntByKey(c.Request.Context(), model.ConfigKeyRedEnvelopeEnabled)
+ if err != nil {
+ c.JSON(http.StatusInternalServerError, util.Err(err.Error()))
+ c.Abort()
+ return
+ }
+ if enabled != 1 {
+ c.JSON(http.StatusForbidden, util.Err(common.RedEnvelopeDisabled))
+ c.Abort()
+ return
+ }
+ c.Next()
+ }
+}
diff --git a/internal/apps/redenvelope/routers.go b/internal/apps/redenvelope/routers.go
index 85d7fdd7..05c17a3b 100644
--- a/internal/apps/redenvelope/routers.go
+++ b/internal/apps/redenvelope/routers.go
@@ -26,10 +26,10 @@ import (
"github.com/gin-gonic/gin"
"github.com/linux-do/credit/internal/apps/oauth"
"github.com/linux-do/credit/internal/common"
- "github.com/linux-do/credit/internal/config"
"github.com/linux-do/credit/internal/db"
"github.com/linux-do/credit/internal/db/idgen"
"github.com/linux-do/credit/internal/model"
+ "github.com/linux-do/credit/internal/service"
"github.com/linux-do/credit/internal/util"
"github.com/shopspring/decimal"
"gorm.io/gorm"
@@ -47,9 +47,7 @@ type CreateRequest struct {
// CreateResponse 创建红包响应
type CreateResponse struct {
- ID uint64 `json:"id"`
- Code string `json:"code"`
- Link string `json:"link"`
+ ID uint64 `json:"id,string"`
}
// ClaimRequest 领取红包请求
@@ -79,10 +77,10 @@ type ListRequest struct {
// ListResponse 红包列表响应
type ListResponse struct {
- Total int64 `json:"total"`
- Page int `json:"page"`
- PageSize int `json:"page_size"`
- RedEnvelopes []model.RedEnvelope `json:"red_envelopes"`
+ Total int64 `json:"total"`
+ Page int `json:"page"`
+ PageSize int `json:"page_size"`
+ RedEnvelopes []model.RedEnvelope `json:"red_envelopes"`
}
// Create 创建红包
@@ -93,17 +91,6 @@ type ListResponse struct {
// @Success 200 {object} util.ResponseAny
// @Router /api/v1/redenvelope/create [post]
func Create(c *gin.Context) {
- // 检查红包功能是否启用
- enabled, err := model.IsRedEnvelopeEnabled(c.Request.Context())
- if err != nil {
- c.JSON(http.StatusInternalServerError, util.Err(err.Error()))
- return
- }
- if !enabled {
- c.JSON(http.StatusForbidden, util.Err(common.RedEnvelopeDisabled))
- return
- }
-
var req CreateRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, util.Err(err.Error()))
@@ -116,7 +103,7 @@ func Create(c *gin.Context) {
}
// 检查单个红包最大金额限制
- maxAmount, err := model.GetRedEnvelopeMaxAmount(c.Request.Context())
+ maxAmount, err := model.GetDecimalByKey(c.Request.Context(), model.ConfigKeyRedEnvelopeMaxAmount, 2)
if err != nil {
c.JSON(http.StatusInternalServerError, util.Err(err.Error()))
return
@@ -138,12 +125,12 @@ func Create(c *gin.Context) {
currentUser, _ := util.GetFromContext[*model.User](c, oauth.UserObjKey)
// 检查每日红包发送数量限制
- dailyLimit, err := model.GetRedEnvelopeDailyLimit(c.Request.Context())
+ dailyLimit, err := model.GetIntByKey(c.Request.Context(), model.ConfigKeyRedEnvelopeDailyLimit)
if err != nil {
c.JSON(http.StatusInternalServerError, util.Err(err.Error()))
return
}
-
+
// 查询今日已发送的红包数量
var todayCount int64
today := time.Now().Truncate(24 * time.Hour)
@@ -153,7 +140,7 @@ func Create(c *gin.Context) {
c.JSON(http.StatusInternalServerError, util.Err(err.Error()))
return
}
-
+
if todayCount >= int64(dailyLimit) {
c.JSON(http.StatusBadRequest, util.Err(common.RedEnvelopeDailyLimitExceeded))
return
@@ -165,15 +152,15 @@ func Create(c *gin.Context) {
}
// 获取红包手续费率并计算手续费
- feeRate, err := model.GetRedEnvelopeFeeRate(c.Request.Context())
+ feeRate, err := model.GetDecimalByKey(c.Request.Context(), model.ConfigKeyRedEnvelopeFeeRate, 2)
if err != nil {
c.JSON(http.StatusInternalServerError, util.Err(err.Error()))
return
}
-
+
// 计算手续费(红包金额 * 费率)
feeAmount := req.TotalAmount.Mul(feeRate).Round(2)
-
+
// 总扣款金额 = 红包金额 + 手续费
totalDeduction := req.TotalAmount.Add(feeAmount)
@@ -186,17 +173,15 @@ func Create(c *gin.Context) {
var redEnvelope model.RedEnvelope
if err := db.DB(c.Request.Context()).Transaction(func(tx *gorm.DB) error {
- // 使用乐观锁扣减余额,扣除红包金额+手续费
- result := tx.Model(&model.User{}).
- Where("id = ? AND available_balance >= ?", currentUser.ID, totalDeduction).
- Update("available_balance", gorm.Expr("available_balance - ?", totalDeduction))
-
- if result.Error != nil {
- return result.Error
- }
-
- if result.RowsAffected == 0 {
- return errors.New(common.InsufficientBalance)
+ // 扣减发送者余额并更新total_payment
+ if err := service.UpdateBalance(tx, service.BalanceUpdateOptions{
+ UserID: currentUser.ID,
+ Amount: totalDeduction,
+ Operation: service.BalanceDeduct,
+ TotalField: "total_payment",
+ CheckBalance: true,
+ }); err != nil {
+ return err
}
// 创建红包
@@ -222,11 +207,13 @@ func Create(c *gin.Context) {
if feeAmount.GreaterThan(decimal.Zero) {
remarkMsg = fmt.Sprintf("%s,手续费: %s", remarkMsg, feeAmount.String())
}
-
+ if req.Greeting != "" {
+ remarkMsg = fmt.Sprintf("%s,祝福语: %s", remarkMsg, req.Greeting)
+ }
+
order := model.Order{
- OrderName: fmt.Sprintf("红包支出-%s", req.Greeting),
+ OrderName: "红包支出",
PayerUserID: currentUser.ID,
- PayeeUserID: currentUser.ID, // 红包支出时,收款人也是自己
Amount: totalDeduction,
Status: model.OrderStatusSuccess,
Type: model.OrderTypeRedEnvelopeSend,
@@ -246,9 +233,7 @@ func Create(c *gin.Context) {
}
c.JSON(http.StatusOK, util.OK(CreateResponse{
- ID: redEnvelope.ID,
- Code: strconv.FormatUint(redEnvelope.ID, 10),
- Link: fmt.Sprintf("%s/redenvelope/%d", config.Config.App.FrontendURL, redEnvelope.ID),
+ ID: redEnvelope.ID,
}))
}
@@ -260,17 +245,6 @@ func Create(c *gin.Context) {
// @Success 200 {object} util.ResponseAny
// @Router /api/v1/redenvelope/claim [post]
func Claim(c *gin.Context) {
- // 检查红包功能是否启用
- enabled, err := model.IsRedEnvelopeEnabled(c.Request.Context())
- if err != nil {
- c.JSON(http.StatusInternalServerError, util.Err(err.Error()))
- return
- }
- if !enabled {
- c.JSON(http.StatusForbidden, util.Err(common.RedEnvelopeDisabled))
- return
- }
-
var req ClaimRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, util.Err(err.Error()))
@@ -286,9 +260,9 @@ func Claim(c *gin.Context) {
// 解析红包ID
redEnvelopeID, err := strconv.ParseUint(req.ID, 10, 64)
if err != nil {
- return errors.New("红包ID格式错误")
+ return errors.New(InvalidRedEnvelopeID)
}
-
+
// 使用 FOR UPDATE 锁定红包记录,防止并发领取
if err := tx.Clauses(clause.Locking{Strength: "UPDATE", Options: "NOWAIT"}).
Where("id = ?", redEnvelopeID).First(&redEnvelope).Error; err != nil {
@@ -296,11 +270,7 @@ func Claim(c *gin.Context) {
return errors.New(RedEnvelopeNotFound)
}
// 捕获锁等待超时错误,返回友好提示
- errMsg := err.Error()
- if errMsg != "" {
- return errors.New("太火爆啦,稍后再试试吧~")
- }
- return err
+ return errors.New(RedEnvelopeTooPopular)
}
// 检查红包状态
@@ -365,21 +335,25 @@ func Claim(c *gin.Context) {
redEnvelope.RemainingAmount = newRemainingAmount
redEnvelope.Status = newStatus
- // 增加用户余额
- if err := tx.Model(&model.User{}).Where("id = ?", currentUser.ID).
- Update("available_balance", gorm.Expr("available_balance + ?", claimedAmount)).Error; err != nil {
+ // 增加领取者余额并更新total_receive
+ if err := service.UpdateBalance(tx, service.BalanceUpdateOptions{
+ UserID: currentUser.ID,
+ Amount: claimedAmount,
+ Operation: service.BalanceAdd,
+ TotalField: "total_receive",
+ }); err != nil {
return err
}
// 创建订单记录(红包收入)
order := model.Order{
- OrderName: fmt.Sprintf("红包收入-%s", redEnvelope.Greeting),
+ OrderName: "红包收入",
PayerUserID: redEnvelope.CreatorID,
PayeeUserID: currentUser.ID,
Amount: claimedAmount,
Status: model.OrderStatusSuccess,
Type: model.OrderTypeRedEnvelopeReceive,
- Remark: fmt.Sprintf("领取红包,来自创建者ID:%d", redEnvelope.CreatorID),
+ Remark: fmt.Sprintf("祝福语: %s", redEnvelope.Greeting),
TradeTime: time.Now(),
ExpiresAt: time.Now().Add(24 * time.Hour),
}
@@ -414,10 +388,10 @@ func GetDetail(c *gin.Context) {
idStr := c.Param("id")
redEnvelopeID, err := strconv.ParseUint(idStr, 10, 64)
if err != nil {
- c.JSON(http.StatusBadRequest, util.Err("红包ID格式错误"))
+ c.JSON(http.StatusBadRequest, util.Err(InvalidRedEnvelopeID))
return
}
-
+
currentUser, _ := util.GetFromContext[*model.User](c, oauth.UserObjKey)
var redEnvelope model.RedEnvelope
@@ -503,4 +477,3 @@ func List(c *gin.Context) {
RedEnvelopes: redEnvelopes,
}))
}
-
diff --git a/internal/apps/redenvelope/tasks.go b/internal/apps/redenvelope/tasks.go
index 2f4b0ae8..4cbe2093 100644
--- a/internal/apps/redenvelope/tasks.go
+++ b/internal/apps/redenvelope/tasks.go
@@ -25,6 +25,7 @@ import (
"github.com/linux-do/credit/internal/db"
"github.com/linux-do/credit/internal/logger"
"github.com/linux-do/credit/internal/model"
+ "github.com/linux-do/credit/internal/service"
"gorm.io/gorm"
)
@@ -64,49 +65,54 @@ func refundExpiredRedEnvelopes(ctx context.Context) {
// 处理每个过期红包
for _, envelope := range expiredEnvelopes {
if err := db.DB(ctx).Transaction(func(tx *gorm.DB) error {
- // 更新红包状态为已过期
- if err := tx.Model(&model.RedEnvelope{}).
- Where("id = ? AND status = ?", envelope.ID, model.RedEnvelopeStatusActive).
- Updates(map[string]interface{}{
- "status": model.RedEnvelopeStatusExpired,
- "remaining_amount": 0,
- "remaining_count": 0,
- }).Error; err != nil {
- return err
- }
-
- // 退还剩余金额给创建者
- if envelope.RemainingAmount.IsPositive() {
- if err := tx.Model(&model.User{}).
- Where("id = ?", envelope.CreatorID).
- Update("available_balance", gorm.Expr("available_balance + ?", envelope.RemainingAmount)).Error; err != nil {
+ // 更新红包状态为已过期
+ if err := tx.Model(&model.RedEnvelope{}).
+ Where("id = ? AND status = ?", envelope.ID, model.RedEnvelopeStatusActive).
+ Updates(map[string]interface{}{
+ "status": model.RedEnvelopeStatusExpired,
+ "remaining_amount": 0,
+ "remaining_count": 0,
+ }).Error; err != nil {
return err
}
- // 创建退款订单记录
- orderName := "红包退款"
- if envelope.Greeting != "" {
- orderName = fmt.Sprintf("红包退款-%s", envelope.Greeting)
- }
- order := model.Order{
- OrderName: orderName,
- PayerUserID: envelope.CreatorID,
- PayeeUserID: envelope.CreatorID,
- Amount: envelope.RemainingAmount,
- Status: model.OrderStatusSuccess,
- Type: model.OrderTypeRedEnvelopeRefund,
- Remark: fmt.Sprintf("红包过期退款,红包ID:%d", envelope.ID),
- TradeTime: time.Now(),
- ExpiresAt: time.Now().Add(24 * time.Hour),
+ // 退还剩余金额给创建者
+ if envelope.RemainingAmount.IsPositive() {
+ // 增加余额并更新total_receive
+ if err := service.UpdateBalance(tx, service.BalanceUpdateOptions{
+ UserID: envelope.CreatorID,
+ Amount: envelope.RemainingAmount,
+ Operation: service.BalanceAdd,
+ TotalField: "total_receive",
+ }); err != nil {
+ return err
+ }
+
+ // 创建退款订单记录
+ remarkMsg := fmt.Sprintf("红包过期退款,红包ID:%d", envelope.ID)
+ if envelope.Greeting != "" {
+ remarkMsg = fmt.Sprintf("%s,祝福语: %s", remarkMsg, envelope.Greeting)
+ }
+
+ order := model.Order{
+ OrderName: "红包退款",
+ PayerUserID: 0,
+ PayeeUserID: envelope.CreatorID,
+ Amount: envelope.RemainingAmount,
+ Status: model.OrderStatusSuccess,
+ Type: model.OrderTypeRedEnvelopeRefund,
+ Remark: remarkMsg,
+ TradeTime: time.Now(),
+ ExpiresAt: time.Now().Add(24 * time.Hour),
+ }
+
+ if err := tx.Create(&order).Error; err != nil {
+ return err
+ }
+
+ logger.InfoF(ctx, "红包ID:%d 退款成功,金额:%s", envelope.ID, envelope.RemainingAmount.String())
}
- if err := tx.Create(&order).Error; err != nil {
- return err
- }
-
- logger.InfoF(ctx, "红包ID:%d 退款成功,金额:%s", envelope.ID, envelope.RemainingAmount.String())
- }
-
return nil
}); err != nil {
logger.ErrorF(ctx, "红包ID:%d 退款失败: %v", envelope.ID, err)
@@ -124,4 +130,4 @@ func refundExpiredRedEnvelopes(ctx context.Context) {
} else {
logger.InfoF(ctx, "没有需要退款的过期红包")
}
-}
\ No newline at end of file
+}
diff --git a/internal/model/red_envelopes.go b/internal/model/red_envelopes.go
index 8462e211..297b2b5d 100644
--- a/internal/model/red_envelopes.go
+++ b/internal/model/red_envelopes.go
@@ -39,17 +39,17 @@ const (
// RedEnvelope 红包
type RedEnvelope struct {
- ID uint64 `json:"id" gorm:"primaryKey"`
- CreatorID uint64 `json:"creator_id" gorm:"index;not null"`
- CreatorUsername string `json:"creator_username" gorm:"->"`
- CreatorAvatarURL string `json:"creator_avatar_url" gorm:"->"`
+ ID uint64 `json:"id,string" gorm:"primaryKey"`
+ CreatorID uint64 `json:"creator_id,string" gorm:"index;not null"`
+ CreatorUsername string `json:"creator_username" gorm:"-:migration;->"`
+ CreatorAvatarURL string `json:"creator_avatar_url" gorm:"-:migration;->"`
Type RedEnvelopeType `json:"type" gorm:"type:varchar(20);not null"`
TotalAmount decimal.Decimal `json:"total_amount" gorm:"type:numeric(20,2);not null"`
RemainingAmount decimal.Decimal `json:"remaining_amount" gorm:"type:numeric(20,2);not null"`
TotalCount int `json:"total_count" gorm:"not null"`
RemainingCount int `json:"remaining_count" gorm:"not null"`
Greeting string `json:"greeting" gorm:"size:100"`
- Status RedEnvelopeStatus `json:"status" gorm:"type:varchar(20);not null;index"`
+ Status RedEnvelopeStatus `json:"status" gorm:"type:varchar(20);not null"`
ExpiresAt time.Time `json:"expires_at" gorm:"not null;index"`
CreatedAt time.Time `json:"created_at" gorm:"autoCreateTime"`
UpdatedAt time.Time `json:"updated_at" gorm:"autoUpdateTime"`
@@ -57,11 +57,11 @@ type RedEnvelope struct {
// RedEnvelopeClaim 红包领取记录
type RedEnvelopeClaim struct {
- ID uint64 `json:"id" gorm:"primaryKey"`
- RedEnvelopeID uint64 `json:"red_envelope_id" gorm:"index;not null"`
- UserID uint64 `json:"user_id" gorm:"index;not null"`
- Username string `json:"username" gorm:"->"`
- AvatarURL string `json:"avatar_url" gorm:"->"`
+ ID uint64 `json:"id,string" gorm:"primaryKey"`
+ RedEnvelopeID uint64 `json:"red_envelope_id,string" gorm:"uniqueIndex:idx_red_envelope_user,priority:1;not null"`
+ UserID uint64 `json:"user_id,string" gorm:"uniqueIndex:idx_red_envelope_user,priority:2;not null"`
+ Username string `json:"username" gorm:"-:migration;->"`
+ AvatarURL string `json:"avatar_url" gorm:"-:migration;->"`
Amount decimal.Decimal `json:"amount" gorm:"type:numeric(20,2);not null"`
ClaimedAt time.Time `json:"claimed_at" gorm:"autoCreateTime"`
-}
\ No newline at end of file
+}
diff --git a/internal/model/system_configs.go b/internal/model/system_configs.go
index d1143621..948a8144 100644
--- a/internal/model/system_configs.go
+++ b/internal/model/system_configs.go
@@ -106,39 +106,3 @@ func GetDecimalByKey(ctx context.Context, key string, precision int32) (decimal.
// 裁剪到指定小数位数
return value.Truncate(precision), nil
}
-
-// IsRedEnvelopeEnabled 检查红包功能是否启用
-func IsRedEnvelopeEnabled(ctx context.Context) (bool, error) {
- value, err := GetIntByKey(ctx, ConfigKeyRedEnvelopeEnabled)
- if err != nil {
- return false, fmt.Errorf("获取红包功能配置失败: %w", err)
- }
- return value == 1, nil
-}
-
-// GetRedEnvelopeMaxAmount 获取单个红包的最大积分上限
-func GetRedEnvelopeMaxAmount(ctx context.Context) (decimal.Decimal, error) {
- value, err := GetDecimalByKey(ctx, ConfigKeyRedEnvelopeMaxAmount, 2)
- if err != nil {
- return decimal.Zero, fmt.Errorf("获取红包最大金额配置失败: %w", err)
- }
- return value, nil
-}
-
-// GetRedEnvelopeDailyLimit 获取每日发红包的个数限制
-func GetRedEnvelopeDailyLimit(ctx context.Context) (int, error) {
- value, err := GetIntByKey(ctx, ConfigKeyRedEnvelopeDailyLimit)
- if err != nil {
- return 0, fmt.Errorf("获取红包每日限额配置失败: %w", err)
- }
- return value, nil
-}
-
-// GetRedEnvelopeFeeRate 获取红包手续费率
-func GetRedEnvelopeFeeRate(ctx context.Context) (decimal.Decimal, error) {
- value, err := GetDecimalByKey(ctx, ConfigKeyRedEnvelopeFeeRate, 2)
- if err != nil {
- return decimal.Zero, fmt.Errorf("获取红包手续费率配置失败: %w", err)
- }
- return value, nil
-}
diff --git a/internal/router/router.go b/internal/router/router.go
index 72bb3771..8fe50313 100644
--- a/internal/router/router.go
+++ b/internal/router/router.go
@@ -167,10 +167,10 @@ func Serve() {
// Red Envelope
redEnvelopeRouter := apiV1Router.Group("/redenvelope")
{
- redEnvelopeRouter.GET("/:id", oauth.LoginRequired(), redenvelope.GetDetail)
- redEnvelopeRouter.POST("/create", oauth.LoginRequired(), redenvelope.Create)
- redEnvelopeRouter.POST("/claim", oauth.LoginRequired(), redenvelope.Claim)
- redEnvelopeRouter.POST("/list", oauth.LoginRequired(), redenvelope.List)
+ redEnvelopeRouter.GET("/:id", oauth.LoginRequired(), redenvelope.CheckRedEnvelopeEnabled(), redenvelope.GetDetail)
+ redEnvelopeRouter.POST("/create", oauth.LoginRequired(), redenvelope.CheckRedEnvelopeEnabled(), redenvelope.Create)
+ redEnvelopeRouter.POST("/claim", oauth.LoginRequired(), redenvelope.CheckRedEnvelopeEnabled(), redenvelope.Claim)
+ redEnvelopeRouter.POST("/list", oauth.LoginRequired(), redenvelope.CheckRedEnvelopeEnabled(), redenvelope.List)
}
// Config (public)
From 3975cb1f776f6f87bce087c22fabcef5b9e7997d Mon Sep 17 00:00:00 2001
From: cattie <2237829695@qq.com>
Date: Fri, 2 Jan 2026 14:42:42 +0800
Subject: [PATCH 5/6] Mod: partial code refactor
---
docs/docs.go | 3 ++-
docs/swagger.json | 3 ++-
docs/swagger.yaml | 1 +
internal/apps/order/routers.go | 10 ++--------
internal/apps/redenvelope/middlewares.go | 6 ++----
internal/apps/redenvelope/routers.go | 10 ++--------
internal/model/disputes.go | 4 ++--
internal/model/orders.go | 4 ++--
internal/model/red_envelopes.go | 4 ++--
internal/task/constants.go | 16 +++++++++++++---
10 files changed, 30 insertions(+), 31 deletions(-)
diff --git a/docs/docs.go b/docs/docs.go
index 627ca670..1755f78f 100644
--- a/docs/docs.go
+++ b/docs/docs.go
@@ -2025,7 +2025,8 @@ const docTemplate = `{
],
"properties": {
"id": {
- "type": "string"
+ "type": "string",
+ "example": "0"
}
}
},
diff --git a/docs/swagger.json b/docs/swagger.json
index 81e152f9..cb81c8d1 100644
--- a/docs/swagger.json
+++ b/docs/swagger.json
@@ -2016,7 +2016,8 @@
],
"properties": {
"id": {
- "type": "string"
+ "type": "string",
+ "example": "0"
}
}
},
diff --git a/docs/swagger.yaml b/docs/swagger.yaml
index c687a983..875c7460 100644
--- a/docs/swagger.yaml
+++ b/docs/swagger.yaml
@@ -344,6 +344,7 @@ definitions:
redenvelope.ClaimRequest:
properties:
id:
+ example: "0"
type: string
required:
- id
diff --git a/internal/apps/order/routers.go b/internal/apps/order/routers.go
index 07354e69..86a777db 100644
--- a/internal/apps/order/routers.go
+++ b/internal/apps/order/routers.go
@@ -90,18 +90,12 @@ func ListTransactions(c *gin.Context) {
case model.OrderTypeReceive:
// receive 类型:查询当前用户作为收款方的 payment 订单
baseQuery = baseQuery.Where("orders.type = ? AND orders.payee_user_id = ?", model.OrderTypePayment, user.ID)
- case model.OrderTypeCommunity:
- // community 类型:查询当前用户作为收款方的 community 订单
+ case model.OrderTypeCommunity, model.OrderTypeRedEnvelopeReceive, model.OrderTypeRedEnvelopeRefund:
+ // community、red_envelope_receive、red_envelope_refund 类型:查询当前用户作为收款方的订单
baseQuery = baseQuery.Where("orders.type = ? AND orders.payee_user_id = ?", orderType, user.ID)
case model.OrderTypeRedEnvelopeSend:
// red_envelope_send 类型:查询当前用户作为发送方的红包订单
baseQuery = baseQuery.Where("orders.type = ? AND orders.payer_user_id = ?", orderType, user.ID)
- case model.OrderTypeRedEnvelopeReceive:
- // red_envelope_receive 类型:查询当前用户作为接收方的红包订单
- baseQuery = baseQuery.Where("orders.type = ? AND orders.payee_user_id = ?", orderType, user.ID)
- case model.OrderTypeRedEnvelopeRefund:
- // red_envelope_refund 类型:查询当前用户的红包退款订单
- baseQuery = baseQuery.Where("orders.type = ? AND orders.payee_user_id = ?", orderType, user.ID)
case model.OrderTypeOnline:
// online 类型:商家可查看自己 client_id 的所有订单,普通用户只能查看与自己相关的订单
if req.ClientID != "" {
diff --git a/internal/apps/redenvelope/middlewares.go b/internal/apps/redenvelope/middlewares.go
index d8077896..49bb317b 100644
--- a/internal/apps/redenvelope/middlewares.go
+++ b/internal/apps/redenvelope/middlewares.go
@@ -30,13 +30,11 @@ func CheckRedEnvelopeEnabled() gin.HandlerFunc {
return func(c *gin.Context) {
enabled, err := model.GetIntByKey(c.Request.Context(), model.ConfigKeyRedEnvelopeEnabled)
if err != nil {
- c.JSON(http.StatusInternalServerError, util.Err(err.Error()))
- c.Abort()
+ c.AbortWithStatusJSON(http.StatusInternalServerError, util.Err(err.Error()))
return
}
if enabled != 1 {
- c.JSON(http.StatusForbidden, util.Err(common.RedEnvelopeDisabled))
- c.Abort()
+ c.AbortWithStatusJSON(http.StatusForbidden, util.Err(common.RedEnvelopeDisabled))
return
}
c.Next()
diff --git a/internal/apps/redenvelope/routers.go b/internal/apps/redenvelope/routers.go
index 05c17a3b..ed281b4f 100644
--- a/internal/apps/redenvelope/routers.go
+++ b/internal/apps/redenvelope/routers.go
@@ -52,7 +52,7 @@ type CreateResponse struct {
// ClaimRequest 领取红包请求
type ClaimRequest struct {
- ID string `json:"id" binding:"required"`
+ ID uint64 `json:"id,string" binding:"required"`
}
// ClaimResponse 领取红包响应
@@ -257,15 +257,9 @@ func Claim(c *gin.Context) {
var redEnvelope model.RedEnvelope
if err := db.DB(c.Request.Context()).Transaction(func(tx *gorm.DB) error {
- // 解析红包ID
- redEnvelopeID, err := strconv.ParseUint(req.ID, 10, 64)
- if err != nil {
- return errors.New(InvalidRedEnvelopeID)
- }
-
// 使用 FOR UPDATE 锁定红包记录,防止并发领取
if err := tx.Clauses(clause.Locking{Strength: "UPDATE", Options: "NOWAIT"}).
- Where("id = ?", redEnvelopeID).First(&redEnvelope).Error; err != nil {
+ Where("id = ?", req.ID).First(&redEnvelope).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return errors.New(RedEnvelopeNotFound)
}
diff --git a/internal/model/disputes.go b/internal/model/disputes.go
index f6d89f2d..0da32ed8 100644
--- a/internal/model/disputes.go
+++ b/internal/model/disputes.go
@@ -38,8 +38,8 @@ type Dispute struct {
Reason string `json:"reason" gorm:"size:500;not null"`
Status DisputeStatus `json:"status" gorm:"type:varchar(20);index;index:idx_dispute_order_status,priority:2;index:idx_initiator_status_created,priority:2;not null;default:'disputing'"`
HandlerUserID *uint64 `json:"handler_user_id" gorm:"index"`
- InitiatorUsername string `json:"initiator_username" gorm:"->"`
- HandlerUsername string `json:"handler_username" gorm:"->"`
+ InitiatorUsername string `json:"initiator_username" gorm:"-:migration;->"`
+ HandlerUsername string `json:"handler_username" gorm:"-:migration;->"`
CreatedAt time.Time `json:"created_at" gorm:"autoCreateTime;index:idx_initiator_status_created,priority:3"`
UpdatedAt time.Time `json:"updated_at" gorm:"autoUpdateTime"`
}
diff --git a/internal/model/orders.go b/internal/model/orders.go
index c4490007..e8c5f0d7 100644
--- a/internal/model/orders.go
+++ b/internal/model/orders.go
@@ -64,8 +64,8 @@ type Order struct {
ClientID string `json:"client_id" gorm:"size:64;index:idx_orders_client_status_created,priority:1;index:idx_orders_client_payee,priority:1;index:idx_orders_client_payer,priority:1"`
PayerUserID uint64 `json:"payer_user_id" gorm:"index:idx_orders_payer_status_type_created,priority:1;index:idx_orders_payer_status_type_trade,priority:1;index:idx_orders_client_payer,priority:2"`
PayeeUserID uint64 `json:"payee_user_id" gorm:"index:idx_orders_payee_status_type_created,priority:1;index:idx_orders_client_payee,priority:2"`
- PayerUsername string `json:"payer_username" gorm:"->"`
- PayeeUsername string `json:"payee_username" gorm:"->"`
+ PayerUsername string `json:"payer_username" gorm:"-:migration;->"`
+ PayeeUsername string `json:"payee_username" gorm:"-:migration;->"`
Amount decimal.Decimal `json:"amount" gorm:"type:numeric(20,2);not null;index"`
Status OrderStatus `json:"status" gorm:"type:varchar(20);not null;index:idx_orders_payee_status_type_created,priority:2;index:idx_orders_payer_status_type_created,priority:2;index:idx_orders_client_status_created,priority:2;index:idx_orders_payer_status_type_trade,priority:2;index:idx_orders_payment_link_status,priority:2"`
Type OrderType `json:"type" gorm:"type:varchar(20);not null;index:idx_orders_payee_status_type_created,priority:3;index:idx_orders_payer_status_type_created,priority:3;index:idx_orders_payer_status_type_trade,priority:3"`
diff --git a/internal/model/red_envelopes.go b/internal/model/red_envelopes.go
index 297b2b5d..e0cd3720 100644
--- a/internal/model/red_envelopes.go
+++ b/internal/model/red_envelopes.go
@@ -58,8 +58,8 @@ type RedEnvelope struct {
// RedEnvelopeClaim 红包领取记录
type RedEnvelopeClaim struct {
ID uint64 `json:"id,string" gorm:"primaryKey"`
- RedEnvelopeID uint64 `json:"red_envelope_id,string" gorm:"uniqueIndex:idx_red_envelope_user,priority:1;not null"`
- UserID uint64 `json:"user_id,string" gorm:"uniqueIndex:idx_red_envelope_user,priority:2;not null"`
+ RedEnvelopeID uint64 `json:"red_envelope_id,string" gorm:"uniqueIndex:idx_red_envelope_user,priority:2;not null"`
+ UserID uint64 `json:"user_id,string" gorm:"uniqueIndex:idx_red_envelope_user,priority:1;not null"`
Username string `json:"username" gorm:"-:migration;->"`
AvatarURL string `json:"avatar_url" gorm:"-:migration;->"`
Amount decimal.Decimal `json:"amount" gorm:"type:numeric(20,2);not null"`
diff --git a/internal/task/constants.go b/internal/task/constants.go
index 87031045..18de1c9e 100644
--- a/internal/task/constants.go
+++ b/internal/task/constants.go
@@ -34,9 +34,10 @@ const (
// 管理员可下发的任务类型标识
const (
- TaskTypeOrderSync = "order_sync"
- TaskTypeUserGamification = "user_gamification"
- TaskTypeDisputeRefund = "dispute_auto_refund"
+ TaskTypeOrderSync = "order_sync"
+ TaskTypeUserGamification = "user_gamification"
+ TaskTypeDisputeRefund = "dispute_auto_refund"
+ TaskTypeRedEnvelopeRefund = "redenvelope_auto_refund"
)
// TaskMeta 任务元数据
@@ -79,6 +80,15 @@ var DispatchableTasks = []TaskMeta{
MaxRetry: 5,
Queue: QueueDefault,
},
+ {
+ Type: TaskTypeRedEnvelopeRefund,
+ AsynqTask: RefundExpiredRedEnvelopesTask,
+ Name: "红包自动退款",
+ Description: "处理过期红包的自动退款",
+ SupportsTime: false,
+ MaxRetry: 5,
+ Queue: QueueDefault,
+ },
}
// GetTaskMeta 根据任务类型获取元数据
From 32203d08aef72fdb0f9b9d0bd5132068b819f497 Mon Sep 17 00:00:00 2001
From: cattie <2237829695@qq.com>
Date: Sun, 4 Jan 2026 09:55:32 +0800
Subject: [PATCH 6/6] Mod: bug fix, code refactor and config add
---
config.example.yaml | 1 +
internal/apps/order/routers.go | 14 +++++++-------
internal/apps/redenvelope/tasks.go | 8 ++++----
3 files changed, 12 insertions(+), 11 deletions(-)
diff --git a/config.example.yaml b/config.example.yaml
index 6baeda19..696e0ad5 100644
--- a/config.example.yaml
+++ b/config.example.yaml
@@ -108,6 +108,7 @@ scheduler:
dispute_auto_refund_dispatch_interval_seconds: 3
auto_refund_expired_disputes_task_cron: "0 0 * * *"
sync_orders_to_clickhouse_task_cron: "10 0 * * *"
+ refund_expired_red_envelopes_task_cron: "0 1 * * *"
# Worker
worker:
diff --git a/internal/apps/order/routers.go b/internal/apps/order/routers.go
index 86a777db..299bcabc 100644
--- a/internal/apps/order/routers.go
+++ b/internal/apps/order/routers.go
@@ -90,12 +90,12 @@ func ListTransactions(c *gin.Context) {
case model.OrderTypeReceive:
// receive 类型:查询当前用户作为收款方的 payment 订单
baseQuery = baseQuery.Where("orders.type = ? AND orders.payee_user_id = ?", model.OrderTypePayment, user.ID)
- case model.OrderTypeCommunity, model.OrderTypeRedEnvelopeReceive, model.OrderTypeRedEnvelopeRefund:
- // community、red_envelope_receive、red_envelope_refund 类型:查询当前用户作为收款方的订单
+ case model.OrderTypeCommunity, model.OrderTypeRedEnvelopeRefund:
+ // community、red_envelope_refund 类型:查询当前用户作为收款方的订单
baseQuery = baseQuery.Where("orders.type = ? AND orders.payee_user_id = ?", orderType, user.ID)
- case model.OrderTypeRedEnvelopeSend:
- // red_envelope_send 类型:查询当前用户作为发送方的红包订单
- baseQuery = baseQuery.Where("orders.type = ? AND orders.payer_user_id = ?", orderType, user.ID)
+ case model.OrderTypeRedEnvelopeReceive:
+ // red_envelope_receive 类型:查询当前用户作为付款方或收款方的订单
+ baseQuery = baseQuery.Where("orders.type = ? AND (orders.payer_user_id = ? OR orders.payee_user_id = ?)", orderType, user.ID, user.ID)
case model.OrderTypeOnline:
// online 类型:商家可查看自己 client_id 的所有订单,普通用户只能查看与自己相关的订单
if req.ClientID != "" {
@@ -115,8 +115,8 @@ func ListTransactions(c *gin.Context) {
} else {
baseQuery = baseQuery.Where("orders.type = ? AND (orders.payer_user_id = ? OR orders.payee_user_id = ?)", orderType, user.ID, user.ID)
}
- case model.OrderTypePayment, model.OrderTypeTransfer, model.OrderTypeTest, model.OrderTypeDistribute:
- // payment、transfer、test、distribute 类型:查询当前用户作为付款方的订单
+ case model.OrderTypePayment, model.OrderTypeTransfer, model.OrderTypeTest, model.OrderTypeDistribute, model.OrderTypeRedEnvelopeSend:
+ // payment、transfer、test、distribute、red_envelope_send 类型:查询当前用户作为付款方的订单
baseQuery = baseQuery.Where("orders.type = ? AND orders.payer_user_id = ?", orderType, user.ID)
}
} else {
diff --git a/internal/apps/redenvelope/tasks.go b/internal/apps/redenvelope/tasks.go
index 4cbe2093..c302aa2a 100644
--- a/internal/apps/redenvelope/tasks.go
+++ b/internal/apps/redenvelope/tasks.go
@@ -78,12 +78,12 @@ func refundExpiredRedEnvelopes(ctx context.Context) {
// 退还剩余金额给创建者
if envelope.RemainingAmount.IsPositive() {
- // 增加余额并更新total_receive
+ // 增加余额并减少total_payment
if err := service.UpdateBalance(tx, service.BalanceUpdateOptions{
UserID: envelope.CreatorID,
- Amount: envelope.RemainingAmount,
- Operation: service.BalanceAdd,
- TotalField: "total_receive",
+ Amount: envelope.RemainingAmount.Neg(),
+ Operation: service.BalanceDeduct,
+ TotalField: "total_payment",
}); err != nil {
return err
}