diff --git a/config.example.yaml b/config.example.yaml index af36a527..696e0ad5 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(优先) @@ -107,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/docs/docs.go b/docs/docs.go index 5adcf3e8..1755f78f 100644 --- a/docs/docs.go +++ b/docs/docs.go @@ -1315,6 +1315,129 @@ 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/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/{id}": { + "get": { + "produces": [ + "application/json" + ], + "tags": [ + "redenvelope" + ], + "parameters": [ + { + "type": "string", + "description": "红包ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/util.ResponseAny" + } + } + } + } + }, "/api/v1/user/pay-key": { "put": { "consumes": [ @@ -1624,6 +1747,17 @@ const docTemplate = `{ "PayLevelPremium" ] }, + "model.RedEnvelopeType": { + "type": "string", + "enum": [ + "fixed", + "random" + ], + "x-enum-varnames": [ + "RedEnvelopeTypeFixed", + "RedEnvelopeTypeRandom" + ] + }, "oauth.CallbackRequest": { "type": "object", "properties": { @@ -1690,7 +1824,10 @@ const docTemplate = `{ "community", "online", "test", - "distribute" + "distribute", + "red_envelope_send", + "red_envelope_receive", + "red_envelope_refund" ] } } @@ -1881,6 +2018,80 @@ const docTemplate = `{ } } }, + "redenvelope.ClaimRequest": { + "type": "object", + "required": [ + "id" + ], + "properties": { + "id": { + "type": "string", + "example": "0" + } + } + }, + "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 f675cf91..cb81c8d1 100644 --- a/docs/swagger.json +++ b/docs/swagger.json @@ -1306,6 +1306,129 @@ } } }, + "/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/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/{id}": { + "get": { + "produces": [ + "application/json" + ], + "tags": [ + "redenvelope" + ], + "parameters": [ + { + "type": "string", + "description": "红包ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/util.ResponseAny" + } + } + } + } + }, "/api/v1/user/pay-key": { "put": { "consumes": [ @@ -1615,6 +1738,17 @@ "PayLevelPremium" ] }, + "model.RedEnvelopeType": { + "type": "string", + "enum": [ + "fixed", + "random" + ], + "x-enum-varnames": [ + "RedEnvelopeTypeFixed", + "RedEnvelopeTypeRandom" + ] + }, "oauth.CallbackRequest": { "type": "object", "properties": { @@ -1681,7 +1815,10 @@ "community", "online", "test", - "distribute" + "distribute", + "red_envelope_send", + "red_envelope_receive", + "red_envelope_refund" ] } } @@ -1872,6 +2009,80 @@ } } }, + "redenvelope.ClaimRequest": { + "type": "object", + "required": [ + "id" + ], + "properties": { + "id": { + "type": "string", + "example": "0" + } + } + }, + "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 07233aa5..875c7460 100644 --- a/docs/swagger.yaml +++ b/docs/swagger.yaml @@ -146,6 +146,14 @@ definitions: - PayLevelBasic - PayLevelStandard - PayLevelPremium + model.RedEnvelopeType: + enum: + - fixed + - random + type: string + x-enum-varnames: + - RedEnvelopeTypeFixed + - RedEnvelopeTypeRandom oauth.CallbackRequest: properties: code: @@ -196,6 +204,9 @@ definitions: - online - test - distribute + - red_envelope_send + - red_envelope_receive + - red_envelope_refund type: string type: object payment.CreateOrderRequest: @@ -330,6 +341,57 @@ definitions: - recipient_id - recipient_username type: object + redenvelope.ClaimRequest: + properties: + id: + example: "0" + type: string + required: + - id + 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: @@ -1260,6 +1322,83 @@ paths: $ref: '#/definitions/util.ResponseAny' tags: - payment + /api/v1/redenvelope/{id}: + get: + parameters: + - description: 红包ID + in: path + name: id + 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/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/app/(pay)/redenvelope/[id]/page.tsx b/frontend/app/(pay)/redenvelope/[id]/page.tsx new file mode 100644 index 00000000..1e677f73 --- /dev/null +++ b/frontend/app/(pay)/redenvelope/[id]/page.tsx @@ -0,0 +1,10 @@ +import { RedEnvelopeClaimPage } from "@/components/common/redenvelope/red-envelope-claim" + +interface Props { + params: Promise<{ id: string }> +} + +export default async function RedEnvelopePage({ params }: Props) { + const { id } = 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/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 ( + + {title} + + {description} + + - - {title} - - {description} - - = { 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' }, distribute: { label: '商户分发', color: 'bg-indigo-100 text-indigo-800 dark:bg-indigo-900 dark:text-indigo-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..99a69e0c --- /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, useCallback } 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 { + id: string +} + +type ClaimState = "loading" | "ready" | "opening" | "opened" | "claimed" | "error" + +export function RedEnvelopeClaimPage({ id }: RedEnvelopeClaimProps) { + const [state, setState] = useState("loading") + const [detail, setDetail] = useState(null) + const [claimedAmount, setClaimedAmount] = useState(null) + const [error, setError] = useState(null) + + const loadDetail = useCallback(async () => { + try { + const data = await services.redEnvelope.getDetail(id) + 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") + } + }, [id]) + + useEffect(() => { + loadDetail() + }, [loadDetail]) + + const handleOpen = async () => { + setState("opening") + try { + const result = await services.redEnvelope.claim({ id }) + setClaimedAmount(result.amount) + + // Reload the full details to get updated claims list + const updatedDetail = await services.redEnvelope.getDetail(id) + 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 + + ))} + + + + + {/* 底部按钮 */} + + window.location.href = "/trade"} + > + 查看我的红包 + + + + + )} + + + + ) +} \ 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..d1eb8555 --- /dev/null +++ b/frontend/components/common/trade/red-envelope.tsx @@ -0,0 +1,655 @@ + +"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, PublicConfigResponse } 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 [config, setConfig] = useState(null) + + /* 加载红包列表 */ + 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 { + toast.error('加载红包列表失败') + } finally { + setListLoading(false) + } + } + + /* 加载配置 */ + const loadConfig = async () => { + try { + const result = await services.config.getPublicConfig() + setConfig(result) + } catch { + toast.error('加载配置失败') + } + } + + /* 初始加载 */ + useEffect(() => { + loadConfig() + 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, sentEnvelopes.length, receivedEnvelopes.length]) + + /* 验证金额格式 */ + 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 (config && parseFloat(config.red_envelope_max_amount) > 0) { + if (amount > parseFloat(config.red_envelope_max_amount)) { + toast.error(`红包金额不能超过 ${config.red_envelope_max_amount} LDC`) + return + } + } + + 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 || "恭喜发财,大吉大利", + pay_key: password, + } + + const result = await services.redEnvelope.create(data) + + // 前端生成链接 + const link = getEnvelopeLink(result.id) + setResultLink(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: string) => { + 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 feeInfo = React.useMemo(() => { + if (!totalAmount || !config) return null + const amount = parseFloat(totalAmount) + if (isNaN(amount)) return null + + const feeRate = parseFloat(config.red_envelope_fee_rate) + const fee = (amount * feeRate).toFixed(2) + const total = (amount + parseFloat(fee)).toFixed(2) + + return { fee, total, rate: feeRate } + }, [totalAmount, config]) + + /* 获取状态标签 */ + 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 = (id: string) => { + if (typeof window !== 'undefined') { + return `${window.location.origin}/redenvelope/${id}` + } + return `/redenvelope/${id}` + } + + /* 渲染红包卡片 */ + const renderEnvelopeCard = (envelope: RedEnvelope, isSent: boolean) => { + const link = getEnvelopeLink(envelope.id) + 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 && ( + + handleCopyLink(link, envelope.id)} + > + {copiedId === envelope.id ? ( + + ) : ( + + )} + {copiedId === envelope.id ? '已复制' : '复制链接'} + + window.open(link, '_blank')} + > + + + + )} + + {!isSent && envelope.status === 'active' && ( + window.open(link, '_blank')} + > + 查看红包 + + )} + + + ) + } + + return ( + + + + + + 发红包 + + + 创建红包分享给好友,支持固定金额和拼手气两种模式。 + + setIsFormOpen(true)} + className="bg-red-500 hover:bg-red-600 font-medium px-6 rounded-md shadow-sm" + > + 发红包 + + + + + {/* 红包列表 */} + + setActiveTab(v as 'sent' | 'received')}> + + + + 我发出的 ({sentTotal}) + + + + 我收到的 ({receivedTotal}) + + + + + {listLoading && sentEnvelopes.length === 0 ? ( + + + + ) : sentEnvelopes.length === 0 ? ( + + + 还没有发送红包记录 + + ) : ( + <> + + {sentEnvelopes.map(envelope => renderEnvelopeCard(envelope, true))} + + {sentEnvelopes.length < sentTotal && ( + { + const nextPage = sentPage + 1 + setSentPage(nextPage) + loadEnvelopes('sent', nextPage) + }} + disabled={listLoading} + className="w-full" + > + {listLoading ? : null} + 加载更多 ({sentEnvelopes.length}/{sentTotal}) + + )} + > + )} + + + + {listLoading && receivedEnvelopes.length === 0 ? ( + + + + ) : receivedEnvelopes.length === 0 ? ( + + + 还没有收到红包记录 + + ) : ( + <> + + {receivedEnvelopes.map(envelope => renderEnvelopeCard(envelope, false))} + + {receivedEnvelopes.length < receivedTotal && ( + { + const nextPage = receivedPage + 1 + setReceivedPage(nextPage) + loadEnvelopes('received', nextPage) + }} + disabled={listLoading} + className="w-full" + > + {listLoading ? : null} + 加载更多 ({receivedEnvelopes.length}/{receivedTotal}) + + )} + > + )} + + + + + {/* 创建红包表单 */} + + + + + + 发红包 + + + 创建红包并分享链接给好友领取 + + + + + + 红包类型 * + setType(v as RedEnvelopeType)}> + + + + + 拼手气红包 + 固定金额红包 + + + + {type === "random" ? "每人领取金额随机" : "每人领取相同金额"} + + + + + + 总金额 * + + LDC + setTotalAmount(e.target.value)} + className="pl-12 font-mono" + disabled={loading} + /> + + {config && parseFloat(config.red_envelope_max_amount) > 0 ? ( + + 最大 {config.red_envelope_max_amount} LDC + + ) : ( + 6565 + )} + + + + 红包个数 * + setTotalCount(e.target.value)} + disabled={loading} + /> + 占位 + + + + {type === "fixed" && perAmount && ( + + 每个红包 {perAmount} LDC + + )} + + {feeInfo && parseFloat(feeInfo.fee) > 0 && ( + + + + 红包金额 + {totalAmount} LDC + + + 手续费 ({(feeInfo.rate * 100).toFixed(1)}%) + +{feeInfo.fee} LDC + + + 总计支付 + {feeInfo.total} LDC + + + + )} + + + 祝福语 + setGreeting(e.target.value)} + maxLength={100} + rows={2} + className="resize-none" + disabled={loading} + /> + 最多100字符 + + + + + setIsFormOpen(false)} disabled={loading} className="h-8 text-xs"> + 取消 + + + 下一步 + + + + + + {/* 密码验证 */} + { + setIsPasswordOpen(open) + if (!open) setIsFormOpen(true) + }} + onConfirm={handleConfirmCreate} + loading={loading} + title="密码验证" + description={ + feeInfo && parseFloat(feeInfo.fee) > 0 + ? `正在创建 ${totalAmount} LDC 的红包(${totalCount}个),手续费 ${feeInfo.fee} LDC,总计支付 ${feeInfo.total} LDC` + : `正在创建 ${totalAmount} LDC 的红包(${totalCount}个)` + } + /> + + {/* 创建成功结果 */} + + + + + + 红包创建成功 + + + 复制链接分享给好友领取红包 + + + + + + + handleCopyLink(resultLink, "result")} + > + {copiedId === "result" ? : } + + + + + + setIsResultOpen(false)} className="h-8 text-xs"> + 完成 + + + + + + ) +} \ 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 5cb0cce3..d853ad09 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 = { @@ -46,6 +49,9 @@ const PAGE_COMPONENTS: Record = { online: Online, distribute: () => null, test: () => null, + red_envelope_send: RedEnvelope, + red_envelope_receive: RedEnvelope, + redenvelope: RedEnvelope, all: AllActivity, } @@ -62,6 +68,15 @@ export function TradeMain() { setMounted(true) }, []) + const [redEnvelopeEnabled, setRedEnvelopeEnabled] = React.useState(0) + + React.useEffect(() => { + // 从公共配置中获取红包功能状态 + services.config.getPublicConfig() + .then(res => setRedEnvelopeEnabled(res.red_envelope_enabled)) + .catch(() => setRedEnvelopeEnabled(0)) + }, []) + /* 获取活动类型 */ const getOrderType = (tab: TabValue): OrderType | undefined => { if (tab === 'all') return undefined @@ -103,6 +118,9 @@ export function TradeMain() { 积分转移 社区划转 在线流转 + {redEnvelopeEnabled === 1 && ( + 红包 + )} 所有活动 @@ -110,10 +128,12 @@ export function TradeMain() { {renderPageContent()} - - 活动记录 - - + {activeTab !== 'redenvelope' && ( + + 活动记录 + + + )} diff --git a/frontend/lib/services/config/types.ts b/frontend/lib/services/config/types.ts index 49286aa4..27d314de 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; + /** 红包功能是否启用(1启用,0禁用) */ + red_envelope_enabled: number; + /** 单个红包的最大积分上限 */ + red_envelope_max_amount: string; + /** 每日发红包的个数限制 */ + red_envelope_daily_limit: number; + /** 红包手续费率(0-1之间的小数) */ + red_envelope_fee_rate: string; } 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..b388c2cc --- /dev/null +++ b/frontend/lib/services/redenvelope/redenvelope.service.ts @@ -0,0 +1,96 @@ +import { BaseService } from '../core/base.service'; +import type { + CreateRedEnvelopeRequest, + CreateRedEnvelopeResponse, + ClaimRedEnvelopeRequest, + ClaimRedEnvelopeResponse, + RedEnvelopeDetailResponse, + RedEnvelopeListParams, + RedEnvelopeListResponse, +} 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({ id: '123456' }); + * console.log('领取金额:', result.amount); + * ``` + */ + static async claim(data: ClaimRedEnvelopeRequest): Promise { + return this.post('/claim', data); + } + + /** + * 获取红包详情 + * @param id - 红包ID + * @returns 红包详情(包含领取记录) + * @throws {NotFoundError} 当红包不存在时 + * + * @example + * ```typescript + * const detail = await RedEnvelopeService.getDetail('123456'); + * console.log('红包状态:', detail.red_envelope.status); + * console.log('已领取人数:', detail.claims.length); + * ``` + */ + static async getDetail(id: string): Promise { + return this.get(`/${id}`); + } + + /** + * 获取红包列表 + * @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); + } +} \ 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..1545ff14 --- /dev/null +++ b/frontend/lib/services/redenvelope/types.ts @@ -0,0 +1,146 @@ +/** + * 红包类型 + * - fixed: 固定金额,每个红包金额相同 + * - random: 拼手气,随机分配金额 + */ +export type RedEnvelopeType = 'fixed' | 'random'; + +/** + * 红包状态 + * - active: 进行中,可领取 + * - finished: 已领完 + * - expired: 已过期 + */ +export type RedEnvelopeStatus = 'active' | 'finished' | 'expired'; + +/** + * 红包信息 + */ +export interface RedEnvelope { + /** 红包 ID */ + id: string; + /** 创建者用户 ID */ + creator_id: string; + /** 创建者用户名 */ + 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 (作为字符串以避免 JS 精度问题) */ + id: string; + /** 红包 ID (作为字符串以避免 JS 精度问题) */ + red_envelope_id: string; + /** 领取者用户 ID (作为字符串以避免 JS 精度问题) */ + user_id: string; + /** 领取者用户名 */ + 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 (作为字符串以避免 JS 精度问题) */ + id: string; +} + +/** + * 领取红包请求参数 + */ +export interface ClaimRedEnvelopeRequest { + /** 红包 ID */ + id: 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[]; +} \ No newline at end of file diff --git a/frontend/lib/services/transaction/types.ts b/frontend/lib/services/transaction/types.ts index aa49fe91..112f10d7 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' | 'distribute'; +export type OrderType = 'receive' | 'payment' | 'transfer' | 'community' | 'online' | 'test' | 'distribute' | 'red_envelope_send' | 'red_envelope_receive'; /** * 订单状态 diff --git a/internal/apps/config/routers.go b/internal/apps/config/routers.go index f4108c68..4c53bad8 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 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 获取公共配置 @@ -43,8 +48,38 @@ func GetPublicConfig(c *gin.Context) { return } + // 获取红包功能启用状态 + redEnvelopeEnabled, err := model.GetIntByKey(c.Request.Context(), model.ConfigKeyRedEnvelopeEnabled) + if err != nil { + c.JSON(http.StatusInternalServerError, util.Err(err.Error())) + return + } + + // 获取红包配置 + 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.GetIntByKey(c.Request.Context(), model.ConfigKeyRedEnvelopeDailyLimit) + if err != nil { + c.JSON(http.StatusInternalServerError, util.Err(err.Error())) + return + } + + 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, } 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/order/routers.go b/internal/apps/order/routers.go index 6063cc90..299bcabc 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 distribute"` + Type string `json:"type" form:"type" binding:"omitempty,oneof=receive payment transfer community online test distribute 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"` @@ -90,9 +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.OrderTypeRedEnvelopeRefund: + // community、red_envelope_refund 类型:查询当前用户作为收款方的订单 baseQuery = baseQuery.Where("orders.type = ? AND orders.payee_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 != "" { @@ -112,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/errs.go b/internal/apps/redenvelope/errs.go new file mode 100644 index 00000000..0aa843e0 --- /dev/null +++ b/internal/apps/redenvelope/errs.go @@ -0,0 +1,31 @@ +/* +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" + RedEnvelopeTooPopular = "太火爆啦,稍后再试试吧~" + InvalidRedEnvelopeID = "红包ID格式错误" +) diff --git a/internal/apps/redenvelope/middlewares.go b/internal/apps/redenvelope/middlewares.go new file mode 100644 index 00000000..49bb317b --- /dev/null +++ b/internal/apps/redenvelope/middlewares.go @@ -0,0 +1,42 @@ +/* +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.AbortWithStatusJSON(http.StatusInternalServerError, util.Err(err.Error())) + return + } + if enabled != 1 { + 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 new file mode 100644 index 00000000..ed281b4f --- /dev/null +++ b/internal/apps/redenvelope/routers.go @@ -0,0 +1,473 @@ +/* +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" + "net/http" + "strconv" + "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/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" + "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,string"` +} + +// ClaimRequest 领取红包请求 +type ClaimRequest struct { + ID uint64 `json:"id,string" 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) { + var req CreateRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, util.Err(err.Error())) + return + } + + if err := util.ValidateAmount(req.TotalAmount); err != nil { + c.JSON(http.StatusBadRequest, util.Err(err.Error())) + return + } + + // 检查单个红包最大金额限制 + maxAmount, err := model.GetDecimalByKey(c.Request.Context(), model.ConfigKeyRedEnvelopeMaxAmount, 2) + 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 + } + + // 固定金额红包检查每个红包金额 + 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) + + // 检查每日红包发送数量限制 + 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) + 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 + } + + // 获取红包手续费率并计算手续费 + 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) + + // 提前检查余额,避免不必要的事务 + 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 { + // 扣减发送者余额并更新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 + } + + // 创建红包 + redEnvelope = model.RedEnvelope{ + ID: idgen.NextUint64ID(), + CreatorID: currentUser.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 + } + + // 创建订单记录(红包支出) + remarkMsg := fmt.Sprintf("创建红包,共%d个", req.TotalCount) + 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: "红包支出", + PayerUserID: 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 + }); 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, + })) +} + +// 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) { + 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("id = ?", req.ID).First(&redEnvelope).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return errors.New(RedEnvelopeNotFound) + } + // 捕获锁等待超时错误,返回友好提示 + return errors.New(RedEnvelopeTooPopular) + } + + // 检查红包状态 + 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{ + ID: idgen.NextUint64ID(), + 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 + + // 增加领取者余额并更新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: "红包收入", + PayerUserID: redEnvelope.CreatorID, + PayeeUserID: currentUser.ID, + Amount: claimedAmount, + Status: model.OrderStatusSuccess, + Type: model.OrderTypeRedEnvelopeReceive, + Remark: fmt.Sprintf("祝福语: %s", redEnvelope.Greeting), + TradeTime: time.Now(), + ExpiresAt: time.Now().Add(24 * time.Hour), + } + + 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 id path string true "红包ID" +// @Success 200 {object} util.ResponseAny +// @Router /api/v1/redenvelope/{id} [get] +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(InvalidRedEnvelopeID)) + 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.id = ?", redEnvelopeID).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, + })) +} diff --git a/internal/apps/redenvelope/tasks.go b/internal/apps/redenvelope/tasks.go new file mode 100644 index 00000000..c302aa2a --- /dev/null +++ b/internal/apps/redenvelope/tasks.go @@ -0,0 +1,133 @@ +/* +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" + "github.com/linux-do/credit/internal/service" + "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) { + 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 { + break + } + + 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() { + // 增加余额并减少total_payment + if err := service.UpdateBalance(tx, service.BalanceUpdateOptions{ + UserID: envelope.CreatorID, + Amount: envelope.RemainingAmount.Neg(), + Operation: service.BalanceDeduct, + TotalField: "total_payment", + }); 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()) + } + + 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, "没有需要退款的过期红包") + } +} 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/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 696ab3f6..27470c48 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,26 @@ func initSystemConfigs() { Value: "30", Description: "新用户保护期天数,期内积分下降不扣分", }, + { + Key: model.ConfigKeyRedEnvelopeEnabled, + 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/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 218b99ba..e8c5f0d7 100644 --- a/internal/model/orders.go +++ b/internal/model/orders.go @@ -32,13 +32,16 @@ import ( type OrderType string const ( - OrderTypeReceive OrderType = "receive" - OrderTypePayment OrderType = "payment" - OrderTypeTransfer OrderType = "transfer" - OrderTypeCommunity OrderType = "community" - OrderTypeOnline OrderType = "online" - OrderTypeTest OrderType = "test" - OrderTypeDistribute OrderType = "distribute" + OrderTypeReceive OrderType = "receive" + OrderTypePayment OrderType = "payment" + OrderTypeTransfer OrderType = "transfer" + OrderTypeCommunity OrderType = "community" + OrderTypeOnline OrderType = "online" + OrderTypeTest OrderType = "test" + OrderTypeDistribute OrderType = "distribute" + OrderTypeRedEnvelopeSend OrderType = "red_envelope_send" + OrderTypeRedEnvelopeReceive OrderType = "red_envelope_receive" + OrderTypeRedEnvelopeRefund OrderType = "red_envelope_refund" ) type OrderStatus string @@ -61,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 new file mode 100644 index 00000000..e0cd3720 --- /dev/null +++ b/internal/model/red_envelopes.go @@ -0,0 +1,67 @@ +/* +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,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"` + 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,string" gorm:"primaryKey"` + 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"` + ClaimedAt time.Time `json:"claimed_at" gorm:"autoCreateTime"` +} diff --git a/internal/model/system_configs.go b/internal/model/system_configs.go index 1fc6c3ce..948a8144 100644 --- a/internal/model/system_configs.go +++ b/internal/model/system_configs.go @@ -36,6 +36,10 @@ const ( ConfigKeyDisputeTimeWindowHours = "dispute_time_window_hours" // 商家争议时间窗口(小时) 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 ( diff --git a/internal/router/router.go b/internal/router/router.go index 42a22e10..8fe50313 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" @@ -163,6 +164,15 @@ func Serve() { paymentRouter.POST("/transfer", payment.Transfer) } + // Red Envelope + redEnvelopeRouter := apiV1Router.Group("/redenvelope") + { + 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) configRouter := apiV1Router.Group("/config") { diff --git a/internal/task/constants.go b/internal/task/constants.go index 99b4ec3b..18de1c9e 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 ( @@ -33,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 任务元数据 @@ -78,6 +80,15 @@ var DispatchableTasks = []TaskMeta{ MaxRetry: 5, Queue: QueueDefault, }, + { + Type: TaskTypeRedEnvelopeRefund, + AsynqTask: RefundExpiredRedEnvelopesTask, + Name: "红包自动退款", + Description: "处理过期红包的自动退款", + SupportsTime: false, + MaxRetry: 5, + Queue: QueueDefault, + }, } // GetTaskMeta 根据任务类型获取元数据 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) }
- {description} -
+ {error} +
{envelope?.creator_username} 的红包
+ {envelope?.greeting || "恭喜发财,大吉大利"} +
{claimedAmount}
LDC
+ 创建红包分享给好友,支持固定金额和拼手气两种模式。 +
还没有发送红包记录
还没有收到红包记录
+ {type === "random" ? "每人领取金额随机" : "每人领取相同金额"} +
+ 最大 {config.red_envelope_max_amount} LDC +
6565
占位
+ 每个红包 {perAmount} LDC +
最多100字符