From db381c91dd3db8a1c86da6f0eb6b1c693566771c Mon Sep 17 00:00:00 2001 From: cattie <2237829695@qq.com> Date: Tue, 27 Jan 2026 11:12:39 +0800 Subject: [PATCH 01/16] feat: red envelope cover --- config.example.yaml | 1 + docs/docs.go | 45 ++ docs/swagger.json | 45 ++ docs/swagger.yaml | 30 ++ .../common/redenvelope/image-cropper.tsx | 300 ++++++++++++ .../common/redenvelope/red-envelope-claim.tsx | 120 ++++- .../components/common/trade/red-envelope.tsx | 432 ++++++++++++++---- frontend/components/ui/slider.tsx | 28 ++ frontend/lib/services/index.ts | 7 + frontend/lib/services/redenvelope/types.ts | 8 + frontend/lib/services/upload/index.ts | 2 + frontend/lib/services/upload/types.ts | 15 + .../lib/services/upload/upload.service.ts | 78 ++++ frontend/next.config.ts | 5 + frontend/package.json | 2 + frontend/pnpm-lock.yaml | 24 + internal/apps/redenvelope/errs.go | 6 + internal/apps/redenvelope/routers.go | 99 +++- internal/apps/upload/errs.go | 31 ++ internal/apps/upload/routers.go | 254 ++++++++++ internal/apps/upload/tasks.go | 99 ++++ internal/config/model.go | 1 + internal/model/red_envelopes.go | 2 + internal/model/uploads.go | 44 ++ internal/router/router.go | 11 + internal/task/constants.go | 1 + internal/task/scheduler/scheduler.go | 10 + internal/task/worker/worker.go | 2 + 28 files changed, 1581 insertions(+), 121 deletions(-) create mode 100644 frontend/components/common/redenvelope/image-cropper.tsx create mode 100644 frontend/components/ui/slider.tsx create mode 100644 frontend/lib/services/upload/index.ts create mode 100644 frontend/lib/services/upload/types.ts create mode 100644 frontend/lib/services/upload/upload.service.ts create mode 100644 internal/apps/upload/errs.go create mode 100644 internal/apps/upload/routers.go create mode 100644 internal/apps/upload/tasks.go create mode 100644 internal/model/uploads.go diff --git a/config.example.yaml b/config.example.yaml index 696e0ad5..2cfd59db 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -109,6 +109,7 @@ scheduler: auto_refund_expired_disputes_task_cron: "0 0 * * *" sync_orders_to_clickhouse_task_cron: "10 0 * * *" refund_expired_red_envelopes_task_cron: "0 1 * * *" + cleanup_unused_uploads_task_cron: "0 */2 * * *" # Worker worker: diff --git a/docs/docs.go b/docs/docs.go index 042652d0..ba478495 100644 --- a/docs/docs.go +++ b/docs/docs.go @@ -1561,6 +1561,43 @@ const docTemplate = `{ } } }, + "/api/v1/upload/redenvelope/cover": { + "post": { + "consumes": [ + "multipart/form-data" + ], + "produces": [ + "application/json" + ], + "tags": [ + "upload" + ], + "parameters": [ + { + "type": "file", + "description": "图片文件", + "name": "file", + "in": "formData", + "required": true + }, + { + "type": "string", + "description": "封面类型 (cover/heterotypic)", + "name": "type", + "in": "formData", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/util.ResponseAny" + } + } + } + } + }, "/api/v1/user/pay-key": { "put": { "consumes": [ @@ -2221,10 +2258,18 @@ const docTemplate = `{ "type" ], "properties": { + "cover_image": { + "type": "string", + "maxLength": 500 + }, "greeting": { "type": "string", "maxLength": 100 }, + "heterotypic_image": { + "type": "string", + "maxLength": 500 + }, "pay_key": { "type": "string", "maxLength": 10 diff --git a/docs/swagger.json b/docs/swagger.json index 871e27d7..73d65587 100644 --- a/docs/swagger.json +++ b/docs/swagger.json @@ -1552,6 +1552,43 @@ } } }, + "/api/v1/upload/redenvelope/cover": { + "post": { + "consumes": [ + "multipart/form-data" + ], + "produces": [ + "application/json" + ], + "tags": [ + "upload" + ], + "parameters": [ + { + "type": "file", + "description": "图片文件", + "name": "file", + "in": "formData", + "required": true + }, + { + "type": "string", + "description": "封面类型 (cover/heterotypic)", + "name": "type", + "in": "formData", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/util.ResponseAny" + } + } + } + } + }, "/api/v1/user/pay-key": { "put": { "consumes": [ @@ -2212,10 +2249,18 @@ "type" ], "properties": { + "cover_image": { + "type": "string", + "maxLength": 500 + }, "greeting": { "type": "string", "maxLength": 100 }, + "heterotypic_image": { + "type": "string", + "maxLength": 500 + }, "pay_key": { "type": "string", "maxLength": 10 diff --git a/docs/swagger.yaml b/docs/swagger.yaml index b9c055b2..24cef466 100644 --- a/docs/swagger.yaml +++ b/docs/swagger.yaml @@ -371,9 +371,15 @@ definitions: type: object redenvelope.CreateRequest: properties: + cover_image: + maxLength: 500 + type: string greeting: maxLength: 100 type: string + heterotypic_image: + maxLength: 500 + type: string pay_key: maxLength: 10 type: string @@ -1497,6 +1503,30 @@ paths: $ref: '#/definitions/util.ResponseAny' tags: - redenvelope + /api/v1/upload/redenvelope/cover: + post: + consumes: + - multipart/form-data + parameters: + - description: 图片文件 + in: formData + name: file + required: true + type: file + - description: 封面类型 (cover/heterotypic) + in: formData + name: type + required: true + type: string + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/util.ResponseAny' + tags: + - upload /api/v1/user/pay-key: put: consumes: diff --git a/frontend/components/common/redenvelope/image-cropper.tsx b/frontend/components/common/redenvelope/image-cropper.tsx new file mode 100644 index 00000000..45848f2a --- /dev/null +++ b/frontend/components/common/redenvelope/image-cropper.tsx @@ -0,0 +1,300 @@ +"use client" + +import * as React from "react" +import { useState, useCallback } from "react" +import Cropper from "react-easy-crop" +import { Button } from "@/components/ui/button" +import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog" +import { Label } from "@/components/ui/label" +import { Slider } from "@/components/ui/slider" +import { Crop, RotateCw, X } from "lucide-react" +import type { Area, Point } from "react-easy-crop" + +interface ImageCropperProps { + /** 是否打开对话框 */ + isOpen: boolean + /** 关闭对话框回调 */ + onOpenChange: (open: boolean) => void + /** 裁剪完成回调 */ + onCropComplete: (croppedImage: string, coverType: 'cover' | 'heterotypic') => void + /** 封面类型 */ + coverType: 'cover' | 'heterotypic' +} + +/** + * 创建裁剪后的图片 + */ +async function getCroppedImg( + imageSrc: string, + pixelCrop: Area, + rotation = 0 +): Promise { + const image = await createImage(imageSrc) + const canvas = document.createElement('canvas') + const ctx = canvas.getContext('2d') + + if (!ctx) { + throw new Error('无法创建 canvas context') + } + + const maxSize = Math.max(image.width, image.height) + const safeArea = 2 * ((maxSize / 2) * Math.sqrt(2)) + + canvas.width = safeArea + canvas.height = safeArea + + ctx.translate(safeArea / 2, safeArea / 2) + ctx.rotate((rotation * Math.PI) / 180) + ctx.translate(-safeArea / 2, -safeArea / 2) + + ctx.drawImage( + image, + safeArea / 2 - image.width * 0.5, + safeArea / 2 - image.height * 0.5 + ) + + const data = ctx.getImageData(0, 0, safeArea, safeArea) + + canvas.width = pixelCrop.width + canvas.height = pixelCrop.height + + ctx.putImageData( + data, + Math.round(0 - safeArea / 2 + image.width * 0.5 - pixelCrop.x), + Math.round(0 - safeArea / 2 + image.height * 0.5 - pixelCrop.y) + ) + + return canvas.toDataURL('image/png') +} + +/** + * 创建图片对象 + */ +function createImage(url: string): Promise { + return new Promise((resolve, reject) => { + const image = new Image() + image.addEventListener('load', () => resolve(image)) + image.addEventListener('error', (error) => reject(error)) + image.setAttribute('crossOrigin', 'anonymous') + image.src = url + }) +} + +/** + * 图片裁剪组件 + * 使用 react-easy-crop 实现专业的图片裁剪功能 + */ +export function ImageCropper({ isOpen, onOpenChange, onCropComplete, coverType }: ImageCropperProps) { + const [image, setImage] = useState(null) + const [crop, setCrop] = useState({ x: 0, y: 0 }) + const [zoom, setZoom] = useState(1) + const [rotation, setRotation] = useState(0) + const [croppedAreaPixels, setCroppedAreaPixels] = useState(null) + const fileInputRef = React.useRef(null) + + // 根据封面类型设置裁剪比例 + const aspect = coverType === 'cover' ? 2 / 3 : 2 / 3 + const cropShape = coverType === 'cover' ? 'rect' : 'rect' + + const recommendedSize = coverType === 'cover' + ? { width: 360, height: 540, label: "背景封面 (2:3 比例)" } + : { width: 480, height: 720, label: "装饰图片 (2:3 比例,放置在红包后方)" } + + // 处理文件选择 + const handleFileChange = (e: React.ChangeEvent) => { + const file = e.target.files?.[0] + if (!file) return + + // 验证文件类型 + if (!file.type.startsWith('image/')) { + alert('请选择图片文件') + return + } + + // 验证文件大小 (最大 2MB) + if (file.size > 2 * 1024 * 1024) { + alert('图片大小不能超过 2MB') + return + } + + const reader = new FileReader() + reader.onload = (e) => { + setImage(e.target?.result as string) + // 重置裁剪参数 + setCrop({ x: 0, y: 0 }) + setZoom(1) + setRotation(0) + } + reader.readAsDataURL(file) + } + + // 裁剪完成回调 + const onCropCompleteHandler = useCallback((_croppedArea: Area, croppedAreaPixels: Area) => { + setCroppedAreaPixels(croppedAreaPixels) + }, []) + + // 执行裁剪 + const handleCrop = async () => { + if (!image || !croppedAreaPixels) return + + try { + const croppedImage = await getCroppedImg(image, croppedAreaPixels, rotation) + onCropComplete(croppedImage, coverType) + handleClose() + } catch (error) { + console.error('裁剪失败:', error) + alert('裁剪失败,请重试') + } + } + + // 关闭对话框 + const handleClose = () => { + setImage(null) + setCrop({ x: 0, y: 0 }) + setZoom(1) + setRotation(0) + setCroppedAreaPixels(null) + if (fileInputRef.current) { + fileInputRef.current.value = '' + } + onOpenChange(false) + } + + return ( + + + + + + 裁剪{coverType === 'cover' ? '封面' : '装饰'}图片 + + + {recommendedSize.label} - 推荐尺寸: {recommendedSize.width}×{recommendedSize.height}px + + + +
+ {!image ? ( +
+ + +
+ ) : ( + <> + {/* 裁剪区域 */} +
+ +
+ + {/* 控制面板 */} +
+ {/* 缩放控制 */} +
+
+ + {Math.round(zoom * 100)}% +
+ setZoom(value[0])} + min={1} + max={3} + step={0.1} + className="w-full" + /> +
+ + {/* 旋转控制 */} +
+
+ + {rotation}° +
+ setRotation(value[0])} + min={0} + max={360} + step={1} + className="w-full" + /> +
+ + {/* 快捷按钮 */} +
+ + + +
+
+ + )} +
+ + + + + +
+
+ ) +} \ No newline at end of file diff --git a/frontend/components/common/redenvelope/red-envelope-claim.tsx b/frontend/components/common/redenvelope/red-envelope-claim.tsx index 3d29091e..e49881db 100644 --- a/frontend/components/common/redenvelope/red-envelope-claim.tsx +++ b/frontend/components/common/redenvelope/red-envelope-claim.tsx @@ -42,6 +42,29 @@ export function RedEnvelopeClaimPage({ id }: RedEnvelopeClaimProps) { } }, [id]) + // 安全验证图片URL (防止XSS) + const sanitizeImageUrl = (url: string | undefined): string | undefined => { + if (!url) return undefined + + // 只允许相对路径且以 /uploads/redenvelope/ 开头 + if (!url.startsWith('/uploads/redenvelope/')) { + console.warn('Invalid image URL detected:', url) + return undefined + } + + // 防止路径遍历 + if (url.includes('..') || url.includes('//')) { + console.warn('Path traversal detected in URL:', url) + return undefined + } + + return url + } + + // 从后端获取封面图片URL并进行安全验证 + const coverImage = sanitizeImageUrl(detail?.red_envelope?.cover_image) + const heterotypicImage = sanitizeImageUrl(detail?.red_envelope?.heterotypic_image) + useEffect(() => { loadDetail() }, [loadDetail]) @@ -106,13 +129,35 @@ export function RedEnvelopeClaimPage({ id }: RedEnvelopeClaimProps) { return (
- {/* 统一卡片容器 - 响应式尺寸 */} - + {/* 装饰图片容器 - 放置在红包后方 */} +
+ {/* 异形装饰 - 背景装饰层,放置在信封后方 */} + {heterotypicImage && ( + + 红包装饰 + + )} + + {/* 统一卡片容器 - 响应式尺寸 */} + {(state === "ready" || state === "opening") && ( -
- -
- -
-
-
-
-
+ {/* 自定义背景封面或默认渐变背景 */} + {coverImage ? ( +
+ 红包封面 + {/* 半透明遮罩以确保内容可读 */} +
+
+ ) : ( +
+
+
+
+
+
+
+
+
+ )}
-
-
-
-
-
+ {/* 头部区域 - 使用自定义背景或默认渐变 */} +
+ {/* 背景层 */} + {coverImage ? ( +
+ 红包封面 +
+
+ ) : ( +
+
+
+
+
+
+ )} )} - + +
) diff --git a/frontend/components/common/trade/red-envelope.tsx b/frontend/components/common/trade/red-envelope.tsx index aab8a158..6d36051a 100644 --- a/frontend/components/common/trade/red-envelope.tsx +++ b/frontend/components/common/trade/red-envelope.tsx @@ -3,7 +3,7 @@ import * as React from "react" import { useState, useEffect } from "react" import { toast } from "sonner" -import { Gift, Copy, Check, ExternalLink } from "lucide-react" +import { Gift, Copy, Check, ExternalLink, Pencil, X, Upload } from "lucide-react" import { Input } from "@/components/ui/input" import { Label } from "@/components/ui/label" import { Button } from "@/components/ui/button" @@ -12,19 +12,32 @@ import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } f import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select" import { PasswordDialog } from "@/components/common/general/password-dialog" import { ListData } from "@/components/common/general/list-data" +import { ImageCropper } from "@/components/common/redenvelope/image-cropper" +import { motion } from "motion/react" +import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar" +import { useUser } from "@/contexts/user-context" import services from "@/lib/services" import type { RedEnvelopeType, CreateRedEnvelopeRequest, PublicConfigResponse, RedEnvelope, RedEnvelopeListResponse } from "@/lib/services" +interface RedEnvelopeCover { + coverImage: string | null + heterotypicImage: string | null +} + /** * 红包组件 * * 提供红包发送功能,包括 Banner 和创建对话框 */ export function RedEnvelope({ onSuccess }: { onSuccess?: () => void }) { + const { user } = useUser() const [isFormOpen, setIsFormOpen] = useState(false) const [isPasswordOpen, setIsPasswordOpen] = useState(false) const [isResultOpen, setIsResultOpen] = useState(false) const [copiedId, setCopiedId] = useState(null) + const [isCropperOpen, setIsCropperOpen] = useState(false) + const [cropperType, setCropperType] = useState<'cover' | 'heterotypic'>('cover') + const [showCustomization, setShowCustomization] = useState(false) /* 表单状态 */ const [type, setType] = useState("random") @@ -32,6 +45,12 @@ export function RedEnvelope({ onSuccess }: { onSuccess?: () => void }) { const [totalCount, setTotalCount] = useState("") const [greeting, setGreeting] = useState("") const [loading, setLoading] = useState(false) + + /* 封面状态 */ + const [cover, setCover] = useState({ + coverImage: null, + heterotypicImage: null + }) /* 结果状态 */ const [resultLink, setResultLink] = useState("") @@ -85,13 +104,11 @@ export function RedEnvelope({ onSuccess }: { onSuccess?: () => void }) { const amount = parseFloat(totalAmount) - // 检查红包最低金额限制(1 LDC) if (amount < 1) { toast.error("红包总金额不能低于1 LDC") return } - // 检查红包最大金额限制 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`) @@ -104,11 +121,6 @@ export function RedEnvelope({ onSuccess }: { onSuccess?: () => void }) { return } - if (amount < 1) { - toast.error("红包的积分总数不能小于 1 LDC") - return - } - if (amount / count < 0.01) { toast.error("单个红包不可低于 0.01 LDC") return @@ -132,6 +144,8 @@ export function RedEnvelope({ onSuccess }: { onSuccess?: () => void }) { total_count: parseInt(totalCount), greeting: greeting || "恭喜发财,大吉大利", pay_key: password, + cover_image: cover.coverImage || undefined, + heterotypic_image: cover.heterotypicImage || undefined, } const result = await services.redEnvelope.create(data) @@ -148,6 +162,8 @@ export function RedEnvelope({ onSuccess }: { onSuccess?: () => void }) { setTotalAmount("") setTotalCount("") setGreeting("") + setCover({ coverImage: null, heterotypicImage: null }) + setShowCustomization(false) } catch (error: unknown) { const errorMessage = error instanceof Error ? error.message : '创建失败' @@ -190,6 +206,47 @@ export function RedEnvelope({ onSuccess }: { onSuccess?: () => void }) { return `/redenvelope/${ id }` } + /* 处理封面裁剪完成 */ + const handleCropComplete = async (croppedImage: string, coverType: 'cover' | 'heterotypic') => { + try { + const uploadingToast = toast.loading(`正在上传${coverType === 'cover' ? '背景封面' : '装饰图片'}...`) + + const result = await services.upload.uploadBase64Image( + croppedImage, + coverType, + `${coverType}-${Date.now()}.png` + ) + + setCover(prev => ({ + ...prev, + [coverType === 'cover' ? 'coverImage' : 'heterotypicImage']: result.url + })) + + toast.dismiss(uploadingToast) + toast.success(`${coverType === 'cover' ? '背景封面' : '装饰图片'}已设置`) + } catch (error) { + const errorMessage = error instanceof Error ? error.message : '上传失败' + toast.error(`上传${coverType === 'cover' ? '背景封面' : '装饰图片'}失败`, { + description: errorMessage + }) + } + } + + /* 打开裁剪对话框 */ + const handleOpenCropper = (type: 'cover' | 'heterotypic') => { + setCropperType(type) + setIsCropperOpen(true) + } + + /* 移除封面 */ + const handleRemoveCover = (type: 'cover' | 'heterotypic') => { + setCover(prev => ({ + ...prev, + [type === 'cover' ? 'coverImage' : 'heterotypicImage']: null + })) + toast.success(`${type === 'cover' ? '背景封面' : '装饰图片'}已移除`) + } + return (
@@ -210,98 +267,300 @@ export function RedEnvelope({ onSuccess }: { onSuccess?: () => void }) {
- + - - - 发红包 + +
+ + 发红包 +
+
- 创建积分红包,支持固定金额和拼手气两种模式。 + 创建积分红包,支持固定金额和拼手气两种模式{showCustomization && "。可为红包添加个性化背景和装饰"}。
-
-
- - -
- -
+
+ {/* 左侧:表单 */} +
- - 0 ? config.red_envelope_max_amount : undefined} - placeholder={config && parseFloat(config.red_envelope_max_amount) > 0 ? `最大 ${ config.red_envelope_max_amount } LDC` : "0.00"} - value={totalAmount} - onChange={(e) => setTotalAmount(e.target.value)} - className="font-mono shadow-none" - disabled={loading} - /> + + +
+ +
+
+ + 0 ? config.red_envelope_max_amount : undefined} + placeholder={config && parseFloat(config.red_envelope_max_amount) > 0 ? `最大 ${ config.red_envelope_max_amount } LDC` : "0.00"} + value={totalAmount} + onChange={(e) => setTotalAmount(e.target.value)} + className="font-mono shadow-none" + disabled={loading} + /> +
+ +
+ + setTotalCount(e.target.value)} + className="font-mono shadow-none" + disabled={loading} + /> +
- - setTotalCount(e.target.value)} - className="font-mono shadow-none" + +