diff --git a/.gitignore b/.gitignore index 0c019aef..c75014de 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ # IDE .idea/* +.idea !.idea/icon.png .vscode diff --git a/config.example.yaml b/config.example.yaml index 6b7ddd47..96b83e07 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -99,6 +99,7 @@ schedule: user_badge_score_dispatch_interval_seconds: 1 update_user_badges_scores_task_cron: "0 2 * * *" update_all_badges_task_cron: "0 1 * * *" + expire_stale_payment_orders_cron: "*/1 * * * *" # 扫描超时未付款订单的频率 # Worker worker: @@ -111,3 +112,11 @@ linuxDo: # OpenTelemetry 配置 otel: sampling_rate: 0.1 # 采样率 0.0-1.0 + +# Payment (LDC Credit EasyPay-compatible) +payment: + enabled: false + api_url: "https://credit.linux.do/epay" # 易支付网关地址 + notify_base_url: "https://your-domain.com" # 本项目公网基址,用于拼接回调 URL + config_encryption_key: "<32-char-secret-key!!>" # AES-256 密钥,恰好 32 字节,首次部署后不可更改 + order_expire_minutes: 10 # 订单未付款超时时间(分钟) diff --git a/docs/docs.go b/docs/docs.go index ab244010..5f08baf6 100644 --- a/docs/docs.go +++ b/docs/docs.go @@ -589,36 +589,6 @@ const docTemplate = `{ } } }, - "/api/v1/projects/{id}/receive": { - "post": { - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "tags": [ - "project" - ], - "parameters": [ - { - "type": "string", - "description": "project id", - "name": "id", - "in": "path", - "required": true - } - ], - "responses": { - "200": { - "description": "OK", - "schema": { - "$ref": "#/definitions/project.ProjectResponse" - } - } - } - } - }, "/api/v1/projects/{id}/receivers": { "get": { "consumes": [ @@ -1032,6 +1002,9 @@ const docTemplate = `{ "maxLength": 32, "minLength": 1 }, + "price": { + "type": "number" + }, "project_items": { "type": "array", "minItems": 1, @@ -1120,6 +1093,9 @@ const docTemplate = `{ "name": { "type": "string" }, + "price": { + "type": "number" + }, "received_content": { "type": "string" }, @@ -1204,6 +1180,9 @@ const docTemplate = `{ "name": { "type": "string" }, + "price": { + "type": "number" + }, "risk_level": { "type": "integer" }, @@ -1390,6 +1369,9 @@ const docTemplate = `{ "maxLength": 32, "minLength": 1 }, + "price": { + "type": "number" + }, "project_items": { "type": "array", "items": { diff --git a/docs/swagger.json b/docs/swagger.json index 88d92b93..aad87d54 100644 --- a/docs/swagger.json +++ b/docs/swagger.json @@ -580,36 +580,6 @@ } } }, - "/api/v1/projects/{id}/receive": { - "post": { - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "tags": [ - "project" - ], - "parameters": [ - { - "type": "string", - "description": "project id", - "name": "id", - "in": "path", - "required": true - } - ], - "responses": { - "200": { - "description": "OK", - "schema": { - "$ref": "#/definitions/project.ProjectResponse" - } - } - } - } - }, "/api/v1/projects/{id}/receivers": { "get": { "consumes": [ @@ -1023,6 +993,9 @@ "maxLength": 32, "minLength": 1 }, + "price": { + "type": "number" + }, "project_items": { "type": "array", "minItems": 1, @@ -1111,6 +1084,9 @@ "name": { "type": "string" }, + "price": { + "type": "number" + }, "received_content": { "type": "string" }, @@ -1195,6 +1171,9 @@ "name": { "type": "string" }, + "price": { + "type": "number" + }, "risk_level": { "type": "integer" }, @@ -1381,6 +1360,9 @@ "maxLength": 32, "minLength": 1 }, + "price": { + "type": "number" + }, "project_items": { "type": "array", "items": { diff --git a/docs/swagger.yaml b/docs/swagger.yaml index fe066ee2..2f7bc96d 100644 --- a/docs/swagger.yaml +++ b/docs/swagger.yaml @@ -194,6 +194,8 @@ definitions: maxLength: 32 minLength: 1 type: string + price: + type: number project_items: items: type: string @@ -260,6 +262,8 @@ definitions: $ref: '#/definitions/oauth.TrustLevel' name: type: string + price: + type: number received_content: type: string report_count: @@ -315,6 +319,8 @@ definitions: $ref: '#/definitions/oauth.TrustLevel' name: type: string + price: + type: number risk_level: type: integer start_time: @@ -436,6 +442,8 @@ definitions: maxLength: 32 minLength: 1 type: string + price: + type: number project_items: items: type: string @@ -758,25 +766,6 @@ paths: $ref: '#/definitions/project.ProjectResponse' tags: - project - /api/v1/projects/{id}/receive: - post: - consumes: - - application/json - parameters: - - description: project id - in: path - name: id - required: true - type: string - produces: - - application/json - responses: - "200": - description: OK - schema: - $ref: '#/definitions/project.ProjectResponse' - tags: - - project /api/v1/projects/{id}/receivers: get: consumes: diff --git a/frontend/app/(main)/settings/payment/page.tsx b/frontend/app/(main)/settings/payment/page.tsx new file mode 100644 index 00000000..3bf29e95 --- /dev/null +++ b/frontend/app/(main)/settings/payment/page.tsx @@ -0,0 +1,9 @@ +import {PaymentSettingsPage} from '@/components/common/payment'; + +export default function Page() { + return ( +
+ +
+ ); +} diff --git a/frontend/components/common/dashboard/DataCards.tsx b/frontend/components/common/dashboard/DataCards.tsx index a328d49c..793164a7 100644 --- a/frontend/components/common/dashboard/DataCards.tsx +++ b/frontend/components/common/dashboard/DataCards.tsx @@ -49,7 +49,7 @@ export function CardList({title, icon, list, type}: Omit - {item.name.charAt(0)} + {item.name?.charAt(0)} ); diff --git a/frontend/components/common/layout/ManagementBar.tsx b/frontend/components/common/layout/ManagementBar.tsx index 7899c5f5..61446b1b 100644 --- a/frontend/components/common/layout/ManagementBar.tsx +++ b/frontend/components/common/layout/ManagementBar.tsx @@ -11,6 +11,7 @@ import { ExternalLinkIcon, User, LogOutIcon, + Wallet, } from 'lucide-react'; import {useThemeUtils} from '@/hooks/use-theme-utils'; import {useAuth} from '@/hooks/use-auth'; @@ -27,6 +28,7 @@ import { } from '@/components/animate-ui/radix/dialog'; import {Avatar, AvatarFallback, AvatarImage} from '@/components/ui/avatar'; import {TrustLevel} from '@/lib/services/core'; +import {DialogClose} from '@/components/ui/dialog'; const IconOptions = { className: 'h-4 w-4', @@ -188,6 +190,27 @@ export function ManagementBar() { )} + {/* 账户设置 */} +
+

账户设置

+
+ + +
+ +
+
+ 支付设置 + 配置你作为收款商户的 clientID / clientSecret +
+ +
+
+
+ {/* 快速链接区域 */}

快速链接

diff --git a/frontend/components/common/payment/CallbackURLHint.tsx b/frontend/components/common/payment/CallbackURLHint.tsx new file mode 100644 index 00000000..1c516c4b --- /dev/null +++ b/frontend/components/common/payment/CallbackURLHint.tsx @@ -0,0 +1,60 @@ +'use client'; + +import {Button} from '@/components/ui/button'; +import {Input} from '@/components/ui/input'; +import {Label} from '@/components/ui/label'; +import {copyToClipboard} from '@/lib/utils'; +import {Copy, ExternalLink, Info} from 'lucide-react'; +import {toast} from 'sonner'; + +interface CallbackURLHintProps { + notifyUrl: string; + returnUrl: string; +} + +function URLRow({label, value}: {label: string; value: string}) { + return ( +
+ +
+ + +
+
+ ); +} + +/** + * 醒目的 Callback URL 提示块,指引用户到 LDC 商户后台配置地址 + */ +export function CallbackURLHint({notifyUrl, returnUrl}: CallbackURLHintProps) { + return ( +
+
+ +
+

请先在 LDC 商户后台配置回调地址

+

+ 登录 credit.linux.do 进入你的应用设置,把 notify_urlreturn_url(或等价字段)填成下方地址 +

+
+
+ + + +
+ ); +} diff --git a/frontend/components/common/payment/PaymentSettingsPage.tsx b/frontend/components/common/payment/PaymentSettingsPage.tsx new file mode 100644 index 00000000..82327011 --- /dev/null +++ b/frontend/components/common/payment/PaymentSettingsPage.tsx @@ -0,0 +1,160 @@ +'use client'; + +import {useEffect, useState} from 'react'; +import {useRouter} from 'next/navigation'; +import {Button} from '@/components/ui/button'; +import {Input} from '@/components/ui/input'; +import {Label} from '@/components/ui/label'; +import {Skeleton} from '@/components/ui/skeleton'; +import {toast} from 'sonner'; +import {ArrowLeftIcon, Save, Trash2} from 'lucide-react'; +import services from '@/lib/services'; +import {PaymentConfigData} from '@/lib/services/payment'; +import {CallbackURLHint} from '@/components/common/payment/CallbackURLHint'; + +/** + * 支付设置页面: + * - CallbackURLHint 展示并一键复制 notify_url / return_url + * - 表单:clientID + clientSecret(可部分更新:填空 secret 会被 API 拒绝保存) + * - 危险区:删除配置(后端若存在未结束付费项目会拒绝) + */ +export function PaymentSettingsPage() { + const router = useRouter(); + const [loading, setLoading] = useState(true); + const [saving, setSaving] = useState(false); + const [deleting, setDeleting] = useState(false); + const [config, setConfig] = useState(null); + const [clientId, setClientId] = useState(''); + const [clientSecret, setClientSecret] = useState(''); + + const load = async () => { + setLoading(true); + const res = await services.payment.getConfigSafe(); + if (res.success && res.data) { + setConfig(res.data); + setClientId(res.data.client_id || ''); + } else { + toast.error(res.error || '加载失败'); + } + setLoading(false); + }; + + useEffect(() => { + load(); + }, []); + + const handleSave = async () => { + if (!clientId.trim() || !clientSecret.trim()) { + toast.error('请填写 clientID 与 clientSecret'); + return; + } + setSaving(true); + const res = await services.payment.updateConfigSafe({ + client_id: clientId.trim(), + client_secret: clientSecret.trim(), + }); + setSaving(false); + if (res.success) { + toast.success('已保存'); + setClientSecret(''); + load(); + } else { + toast.error(res.error || '保存失败'); + } + }; + + const handleDelete = async () => { + if (!confirm('确认删除支付配置?')) return; + setDeleting(true); + const res = await services.payment.deleteConfigSafe(); + setDeleting(false); + if (res.success) { + toast.success('已删除'); + setConfig(null); + setClientId(''); + setClientSecret(''); + load(); + } else { + toast.error(res.error || '删除失败'); + } + }; + + if (loading) { + return ( +
+ + + +
+ ); + } + + return ( +
+
+
+
支付设置
+
+ 配置你在 LDC 积分系统的商户凭据,用于接收他人领取付费项目的付款 +
+
+ +
+ + {!config?.payment_enabled && ( +
+ 平台的付费功能当前处于关闭状态,保存配置不影响未来启用,但现在无法创建付费项目 +
+ )} + + + +
+
+ + setClientId(e.target.value)} + maxLength={64} + /> +
+ +
+ + setClientSecret(e.target.value)} + maxLength={256} + /> +

+ 服务器以 AES-256-GCM 加密存储,不会回显明文 +

+
+ +
+ + {config?.has_config && ( + + )} +
+
+
+ ); +} diff --git a/frontend/components/common/payment/index.ts b/frontend/components/common/payment/index.ts new file mode 100644 index 00000000..a3aef86d --- /dev/null +++ b/frontend/components/common/payment/index.ts @@ -0,0 +1,2 @@ +export {CallbackURLHint} from './CallbackURLHint'; +export {PaymentSettingsPage} from './PaymentSettingsPage'; diff --git a/frontend/components/common/project/CreateDialog.tsx b/frontend/components/common/project/CreateDialog.tsx index ca917b7c..12df0a80 100644 --- a/frontend/components/common/project/CreateDialog.tsx +++ b/frontend/components/common/project/CreateDialog.tsx @@ -13,7 +13,7 @@ import {Button} from '@/components/ui/button'; import {Tabs, TabsList, TabsTrigger, TabsContent, TabsContents} from '@/components/animate-ui/radix/tabs'; import {Dialog, DialogTrigger, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter} from '@/components/animate-ui/radix/dialog'; import {Tooltip, TooltipContent, TooltipProvider, TooltipTrigger} from '@/components/ui/tooltip'; -import {validateProjectForm} from '@/components/common/project'; +import {validateProjectForm, validatePriceString, CURRENCY_LABEL} from '@/components/common/project'; import {ProjectBasicForm} from '@/components/common/project/ProjectBasicForm'; import {BulkImportSection} from '@/components/common/project/BulkImportSection'; import {DistributionModeSelect} from '@/components/common/project/DistributionModeSelect'; @@ -131,6 +131,20 @@ export function CreateDialog({ return false; } + // 价格校验 + const priceError = validatePriceString(formData.price); + if (priceError) { + toast.error(priceError); + setActiveTab('basic'); + return false; + } + const priceNum = Number(formData.price || '0'); + if (priceNum > 0 && formData.distributionType !== DistributionType.ONE_FOR_EACH) { + toast.error(`仅"一码一用"分发支持设置 ${CURRENCY_LABEL} 金额`); + setActiveTab('basic'); + return false; + } + return true; }; @@ -140,6 +154,24 @@ export function CreateDialog({ const handleSubmit = async () => { if (!validateForm()) return; + // 若设置了付费,先确认已配置支付凭据,避免后端强制校验失败 + const priceNum = Number(formData.price || '0'); + if (priceNum > 0) { + const cfgResult = await services.payment.getConfigSafe(); + if (!cfgResult.success) { + toast.error(cfgResult.error || '无法获取支付配置'); + return; + } + if (!cfgResult.data?.payment_enabled) { + toast.error('平台支付功能当前未启用'); + return; + } + if (!cfgResult.data?.has_config) { + toast.error('请先在"支付设置"中配置 clientID 与 clientSecret'); + return; + } + } + setLoading(true); try { const projectData = { @@ -153,6 +185,7 @@ export function CreateDialog({ risk_level: formData.riskLevel, distribution_type: formData.distributionType, topic_id: formData.topicId, + price: formData.price || '0', project_items: formData.distributionType === DistributionType.ONE_FOR_EACH ? items : diff --git a/frontend/components/common/project/EditDialog.tsx b/frontend/components/common/project/EditDialog.tsx index 8eca23a5..7bc0d908 100644 --- a/frontend/components/common/project/EditDialog.tsx +++ b/frontend/components/common/project/EditDialog.tsx @@ -10,7 +10,7 @@ import {toast} from 'sonner'; import {Button} from '@/components/ui/button'; import {Tabs, TabsList, TabsTrigger, TabsContent, TabsContents} from '@/components/animate-ui/radix/tabs'; import {Dialog, DialogTrigger, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter} from '@/components/animate-ui/radix/dialog'; -import {validateProjectForm} from '@/components/common/project'; +import {validateProjectForm, validatePriceString, CURRENCY_LABEL} from '@/components/common/project'; import {ProjectBasicForm} from '@/components/common/project/ProjectBasicForm'; import {BulkImportSection} from '@/components/common/project/BulkImportSection'; import {Pencil, CheckCircle} from 'lucide-react'; @@ -108,6 +108,17 @@ export function EditDialog({ return false; } + const priceError = validatePriceString(formData.price); + if (priceError) { + toast.error(priceError); + return false; + } + const priceNum = Number(formData.price || '0'); + if (priceNum > 0 && project.distribution_type !== DistributionType.ONE_FOR_EACH) { + toast.error(`仅"一码一用"分发支持设置 ${CURRENCY_LABEL} 金额`); + return false; + } + return true; }; @@ -119,6 +130,26 @@ export function EditDialog({ setLoading(true); try { + const priceNum = Number(formData.price || '0'); + if (priceNum > 0) { + const cfgResult = await services.payment.getConfigSafe(); + if (!cfgResult.success) { + toast.error(cfgResult.error || '无法获取支付配置'); + setLoading(false); + return; + } + if (!cfgResult.data?.payment_enabled) { + toast.error('平台支付功能当前未启用'); + setLoading(false); + return; + } + if (!cfgResult.data?.has_config) { + toast.error('请先在"支付设置"中配置 clientID 与 clientSecret'); + setLoading(false); + return; + } + } + const updateData: UpdateProjectRequest = { name: formData.name.trim(), description: formData.description.trim() || undefined, @@ -128,6 +159,7 @@ export function EditDialog({ minimum_trust_level: formData.minimumTrustLevel, allow_same_ip: formData.allowSameIP, risk_level: formData.riskLevel, + price: formData.price || '0', // 只有非抽奖项目才允许更新项目内容 ...(project.distribution_type !== DistributionType.LOTTERY && { project_items: newItems.length > 0 ? newItems : undefined, diff --git a/frontend/components/common/project/ProjectBasicForm.tsx b/frontend/components/common/project/ProjectBasicForm.tsx index 99485b6e..3654a26b 100644 --- a/frontend/components/common/project/ProjectBasicForm.tsx +++ b/frontend/components/common/project/ProjectBasicForm.tsx @@ -10,8 +10,9 @@ import {DateTimePicker} from '@/components/ui/DateTimePicker'; import {Checkbox} from '@/components/animate-ui/radix/checkbox'; import MarkdownEditor from '@/components/common/markdown/Editor'; import {HelpCircle} from 'lucide-react'; -import {FORM_LIMITS, TRUST_LEVEL_OPTIONS} from '@/components/common/project'; +import {CURRENCY_LABEL, FORM_LIMITS, TRUST_LEVEL_OPTIONS} from '@/components/common/project'; import {TrustLevel} from '@/lib/services/core/types'; +import {DistributionType} from '@/lib/services/project/types'; import {ProjectFormData} from '@/hooks/use-project-form'; interface ProjectBasicFormProps { @@ -171,6 +172,24 @@ export function ProjectBasicForm({

+ + {formData.distributionType === DistributionType.ONE_FOR_EACH && ( +
+ + updateField('price', e.target.value)} + /> +

+ 仅“一码一用”分发支持设置金额,最多保留 2 位小数。领取者付款后自动发放,发放失败会自动退款 +

+
+ )}
diff --git a/frontend/components/common/project/ProjectCard.tsx b/frontend/components/common/project/ProjectCard.tsx index 96f77d77..7cf37e0c 100644 --- a/frontend/components/common/project/ProjectCard.tsx +++ b/frontend/components/common/project/ProjectCard.tsx @@ -4,8 +4,8 @@ import {Badge} from '@/components/ui/badge'; import {Button} from '@/components/ui/button'; import {Tooltip, TooltipContent, TooltipProvider, TooltipTrigger} from '@/components/ui/tooltip'; import {MotionEffect} from '@/components/animate-ui/effects/motion-effect'; -import {DISTRIBUTION_MODE_NAMES, TRUST_LEVEL_CONFIG, getTrustLevelGradient} from '@/components/common/project'; -import {Shield, Clock, Package, Trash2, Calendar, Pencil} from 'lucide-react'; +import {CURRENCY_LABEL, DISTRIBUTION_MODE_NAMES, TRUST_LEVEL_CONFIG, getTrustLevelGradient} from '@/components/common/project'; +import {Shield, Clock, Package, Trash2, Calendar, Pencil, Coins} from 'lucide-react'; import {formatDate, formatDateTimeWithSeconds} from '@/lib/utils'; import {ProjectListItem} from '@/lib/services/project/types'; @@ -29,6 +29,8 @@ export function ProjectCard({ editButton, }: ProjectCardProps) { const gradientTheme = getTrustLevelGradient(project.minimum_trust_level); + const priceNum = Number(project.price || '0'); + const isPaid = priceNum > 0; const now = new Date(); const startTime = new Date(project.start_time); @@ -138,6 +140,15 @@ export function ProjectCard({ {project.name} + {isPaid && ( +
+ + + {priceNum} {CURRENCY_LABEL} + +
+ )} +
diff --git a/frontend/components/common/project/constants.ts b/frontend/components/common/project/constants.ts index a9012175..bddf5303 100644 --- a/frontend/components/common/project/constants.ts +++ b/frontend/components/common/project/constants.ts @@ -9,8 +9,32 @@ export const FORM_LIMITS = { MAX_TAGS: 10, DESCRIPTION_MAX_LENGTH: 1024, CONTENT_ITEM_MAX_LENGTH: 1024, + PRICE_MAX: 99999999.99, } as const; +/** + * 平台货币单位 + */ +export const CURRENCY_LABEL = 'LDC' as const; + +/** + * 校验价格字符串,返回 null 或错误消息 + */ +export const validatePriceString = (value: string): string | null => { + if (value === '' || value === undefined || value === null) return null; + if (!/^\d+(\.\d{1,2})?$/.test(value)) { + return '价格最多保留 2 位小数'; + } + const num = Number(value); + if (Number.isNaN(num) || num < 0) { + return '价格必须大于等于 0'; + } + if (num > FORM_LIMITS.PRICE_MAX) { + return '价格超出允许范围'; + } + return null; +}; + /** * 默认表单值 */ diff --git a/frontend/components/common/receive/ReceiveContent.tsx b/frontend/components/common/receive/ReceiveContent.tsx index 32982c73..efb317b7 100644 --- a/frontend/components/common/receive/ReceiveContent.tsx +++ b/frontend/components/common/receive/ReceiveContent.tsx @@ -2,12 +2,12 @@ import React, {useState, useEffect, useRef} from 'react'; import Link from 'next/link'; -import {useRouter} from 'next/navigation'; +import {useRouter, useSearchParams} from 'next/navigation'; import {toast} from 'sonner'; import {Button} from '@/components/ui/button'; import {Badge} from '@/components/ui/badge'; -import {TRUST_LEVEL_OPTIONS} from '@/components/common/project'; -import {ArrowLeftIcon, Copy, Tag, Gift, Clock, AlertCircle, Package} from 'lucide-react'; +import {CURRENCY_LABEL, TRUST_LEVEL_OPTIONS} from '@/components/common/project'; +import {ArrowLeftIcon, Copy, Tag, Gift, Clock, AlertCircle, Package, Coins, Loader2} from 'lucide-react'; import ContentRender from '@/components/common/markdown/ContentRender'; import {ReportButton} from '@/components/common/receive/ReportButton'; import {ReceiveVerify, ReceiveVerifyRef} from '@/components/common/receive/ReceiveVerify'; @@ -133,6 +133,9 @@ const ReceiveButton = ({ ); } + const priceNum = Number(project.price || '0'); + const isPaid = priceNum > 0; + return ( + {isAwaitingPayment && ( + + + 付款处理中,CDK 将在回调成功后自动发放。如长时间未到账,可刷新页面重试。 + + )} +
{currentProject.name}
diff --git a/frontend/hooks/use-project-form.ts b/frontend/hooks/use-project-form.ts index 3602f25e..073afb3e 100644 --- a/frontend/hooks/use-project-form.ts +++ b/frontend/hooks/use-project-form.ts @@ -15,6 +15,8 @@ export interface ProjectFormData { riskLevel: number; distributionType: DistributionType; topicId?: number; + /** 付费领取单价,字符串,两位小数,"0" 表示免费 */ + price: string; } export interface UseProjectFormOptions { @@ -37,6 +39,7 @@ export function useProjectForm(options: UseProjectFormOptions) { allowSameIP: project.allow_same_ip, riskLevel: project.risk_level, distributionType: project.distribution_type, + price: project.price ?? '0', }; } @@ -49,6 +52,7 @@ export function useProjectForm(options: UseProjectFormOptions) { allowSameIP: false, riskLevel: DEFAULT_FORM_VALUES.RISK_LEVEL, distributionType: DistributionType.ONE_FOR_EACH, + price: '0', }; }, [initialData, mode, project]); diff --git a/frontend/lib/services/index.ts b/frontend/lib/services/index.ts index 8a575166..badc4ae8 100644 --- a/frontend/lib/services/index.ts +++ b/frontend/lib/services/index.ts @@ -1,6 +1,7 @@ import {AuthService} from './auth/index'; import {ProjectService} from './project/index'; import {DashboardService} from './dashboard/index'; +import {PaymentService} from './payment/index'; /** * 服务层架构说明: @@ -85,6 +86,11 @@ const services = { * 仪表盘服务 */ dashboard: DashboardService, + + /** + * 支付服务 + */ + payment: PaymentService, }; export default services; diff --git a/frontend/lib/services/payment/index.ts b/frontend/lib/services/payment/index.ts new file mode 100644 index 00000000..d4b6cd2f --- /dev/null +++ b/frontend/lib/services/payment/index.ts @@ -0,0 +1,7 @@ +export {PaymentService} from './payment.service'; +export type { + PaymentConfigData, + GetPaymentConfigResponse, + UpsertPaymentConfigRequest, + UpsertPaymentConfigResponse, +} from './types'; diff --git a/frontend/lib/services/payment/payment.service.ts b/frontend/lib/services/payment/payment.service.ts new file mode 100644 index 00000000..52548c06 --- /dev/null +++ b/frontend/lib/services/payment/payment.service.ts @@ -0,0 +1,63 @@ +import apiClient from '../core/api-client'; +import { + GetPaymentConfigResponse, + PaymentConfigData, + UpsertPaymentConfigRequest, + UpsertPaymentConfigResponse, +} from './types'; + +/** + * 支付相关 API 服务 + */ +export class PaymentService { + private static readonly base = '/api/v1/users/payment-config'; + + static async getConfig(): Promise { + const response = await apiClient.get(this.base); + if (response.data.error_msg) { + throw new Error(response.data.error_msg); + } + return response.data.data; + } + + static async getConfigSafe(): Promise<{success: boolean; data?: PaymentConfigData; error?: string}> { + try { + const data = await this.getConfig(); + return {success: true, data}; + } catch (err) { + return {success: false, error: err instanceof Error ? err.message : '获取支付配置失败'}; + } + } + + static async updateConfig(payload: UpsertPaymentConfigRequest): Promise { + const response = await apiClient.put(this.base, payload); + if (response.data.error_msg) { + throw new Error(response.data.error_msg); + } + } + + static async updateConfigSafe(payload: UpsertPaymentConfigRequest): Promise<{success: boolean; error?: string}> { + try { + await this.updateConfig(payload); + return {success: true}; + } catch (err) { + return {success: false, error: err instanceof Error ? err.message : '保存支付配置失败'}; + } + } + + static async deleteConfig(): Promise { + const response = await apiClient.delete(this.base); + if (response.data.error_msg) { + throw new Error(response.data.error_msg); + } + } + + static async deleteConfigSafe(): Promise<{success: boolean; error?: string}> { + try { + await this.deleteConfig(); + return {success: true}; + } catch (err) { + return {success: false, error: err instanceof Error ? err.message : '删除支付配置失败'}; + } + } +} diff --git a/frontend/lib/services/payment/types.ts b/frontend/lib/services/payment/types.ts new file mode 100644 index 00000000..8ca3203d --- /dev/null +++ b/frontend/lib/services/payment/types.ts @@ -0,0 +1,31 @@ +import {BackendResponse} from '../project/types'; + +/** + * 用户的支付配置视图(后端不返回明文 secret) + */ +export interface PaymentConfigData { + /** 是否已配置 */ + has_config: boolean; + /** 商户 pid */ + client_id: string; + /** clientSecret 末 4 位,展示用 */ + secret_last4: string; + /** 应该填写在 LDC 商户后台的异步通知地址 */ + callback_notify_url: string; + /** 应该填写在 LDC 商户后台的同步回跳地址 */ + callback_return_url: string; + /** 平台是否启用付费功能 */ + payment_enabled: boolean; +} + +export type GetPaymentConfigResponse = BackendResponse; + +/** + * 写入/更新支付配置请求 + */ +export interface UpsertPaymentConfigRequest { + client_id: string; + client_secret: string; +} + +export type UpsertPaymentConfigResponse = BackendResponse; diff --git a/frontend/lib/services/project/types.ts b/frontend/lib/services/project/types.ts index b9c17919..667d7a48 100644 --- a/frontend/lib/services/project/types.ts +++ b/frontend/lib/services/project/types.ts @@ -37,6 +37,8 @@ export interface Project { allow_same_ip: boolean; /** 风险等级 */ risk_level: number; + /** 领取单价,字符串形式的两位小数。"0" 或空表示免费 */ + price?: string; /** 创建者ID */ creator_id: number; /** 创建时间 */ @@ -71,6 +73,8 @@ export interface CreateProjectRequest { project_items: string[]; /** L站话题ID(用于抽奖项目) */ topic_id?: number; + /** 领取单价,字符串形式,两位小数,"0" 表示免费。仅 ONE_FOR_EACH 支持 >0 */ + price?: string; } /** @@ -97,6 +101,8 @@ export interface UpdateProjectRequest { project_items?: string[]; /** 是否启用过滤(去重) */ enable_filter?: boolean; + /** 领取单价,字符串形式,两位小数 */ + price?: string; } /** @@ -183,11 +189,21 @@ export interface ReceiveHistoryRequest { export type ReceiveHistoryResponse = BackendResponse; /** - * 领取项目成功时的响应数据类型 + * 领取项目响应数据:免费直接返回 itemContent;付费返回跳转信息 */ export interface ReceiveProjectData { /** 领取的内容 */ - itemContent: string; + itemContent?: string; + /** 付费项目是否需要付款 */ + require_payment?: boolean; + /** 付款跳转地址(付费项目返回) */ + pay_url?: string; + /** 本地订单号 */ + out_trade_no?: string; + /** 支付金额(字符串,两位小数) */ + amount?: string; + /** 订单过期时间 */ + expire_at?: string; } /** @@ -266,6 +282,8 @@ export interface ProjectListItem { allow_same_ip: boolean; /** 风险等级 */ risk_level: number; + /** 领取单价,字符串,两位小数 */ + price?: string; /** 项目标签列表 */ tags: string[] | null; /** 创建时间 */ diff --git a/go.mod b/go.mod index be87e927..78177bb1 100644 --- a/go.mod +++ b/go.mod @@ -10,6 +10,7 @@ require ( github.com/hibiken/asynq v0.25.1 github.com/redis/go-redis/extra/redisotel/v9 v9.9.0 github.com/redis/go-redis/v9 v9.10.0 + github.com/shopspring/decimal v1.4.0 github.com/spf13/cobra v1.9.1 github.com/spf13/viper v1.20.1 github.com/swaggo/files v1.0.1 @@ -89,7 +90,6 @@ require ( github.com/robfig/cron/v3 v3.0.1 // indirect github.com/sagikazarmark/locafero v0.7.0 // indirect github.com/segmentio/asm v1.2.0 // indirect - github.com/shopspring/decimal v1.4.0 // indirect github.com/sourcegraph/conc v0.3.0 // indirect github.com/spf13/afero v1.12.0 // indirect github.com/spf13/cast v1.9.2 // indirect diff --git a/internal/apps/payment/crypto.go b/internal/apps/payment/crypto.go new file mode 100644 index 00000000..14d28770 --- /dev/null +++ b/internal/apps/payment/crypto.go @@ -0,0 +1,151 @@ +/* + * MIT License + * + * Copyright (c) 2025 linux.do + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +package payment + +import ( + "crypto/aes" + "crypto/cipher" + "crypto/md5" + "crypto/rand" + "crypto/subtle" + "encoding/base64" + "encoding/hex" + "errors" + "fmt" + "io" + "sort" + "strings" +) + +// deriveAESKey 从配置的 key 派生出 32 字节 AES-256 密钥。 +// 优先尝试 base64 解码,失败则取原字节;不足 32 字节以 MD5 填充至稳定 32 字节。 +func deriveAESKey(raw string) ([]byte, error) { + if raw == "" { + return nil, errors.New(ErrEncryptionKeyMissing) + } + if b, err := base64.StdEncoding.DecodeString(raw); err == nil && len(b) == 32 { + return b, nil + } + if len(raw) == 32 { + return []byte(raw), nil + } + sum := md5.Sum([]byte(raw)) + key := make([]byte, 32) + copy(key[:16], sum[:]) + sum2 := md5.Sum(sum[:]) + copy(key[16:], sum2[:]) + return key, nil +} + +// EncryptSecret 使用 AES-256-GCM 加密明文,输出 base64(nonce|ciphertext|tag)。 +func EncryptSecret(plaintext, key string) (string, error) { + k, err := deriveAESKey(key) + if err != nil { + return "", err + } + block, err := aes.NewCipher(k) + if err != nil { + return "", err + } + gcm, err := cipher.NewGCM(block) + if err != nil { + return "", err + } + nonce := make([]byte, gcm.NonceSize()) + if _, err := io.ReadFull(rand.Reader, nonce); err != nil { + return "", err + } + ct := gcm.Seal(nil, nonce, []byte(plaintext), nil) + out := make([]byte, 0, len(nonce)+len(ct)) + out = append(out, nonce...) + out = append(out, ct...) + return base64.StdEncoding.EncodeToString(out), nil +} + +// DecryptSecret 解密 EncryptSecret 的输出。 +func DecryptSecret(encoded, key string) (string, error) { + k, err := deriveAESKey(key) + if err != nil { + return "", err + } + raw, err := base64.StdEncoding.DecodeString(encoded) + if err != nil { + return "", fmt.Errorf("decode secret: %w", err) + } + block, err := aes.NewCipher(k) + if err != nil { + return "", err + } + gcm, err := cipher.NewGCM(block) + if err != nil { + return "", err + } + ns := gcm.NonceSize() + if len(raw) < ns+gcm.Overhead() { + return "", errors.New("ciphertext too short") + } + nonce, ct := raw[:ns], raw[ns:] + pt, err := gcm.Open(nil, nonce, ct, nil) + if err != nil { + return "", err + } + return string(pt), nil +} + +// BuildSign 按易支付/CodePay/VPay 兼容协议生成 MD5 签名(小写十六进制)。 +// 规则:取非空参数,排除 sign 与 sign_type,按 key ASCII 升序,用 k1=v1&k2=v2 拼接, +// 末尾追加 secret,整体 MD5。 +func BuildSign(params map[string]string, secret string) string { + keys := make([]string, 0, len(params)) + for k, v := range params { + if v == "" || k == "sign" || k == "sign_type" { + continue + } + keys = append(keys, k) + } + sort.Strings(keys) + var b strings.Builder + for i, k := range keys { + if i > 0 { + b.WriteByte('&') + } + b.WriteString(k) + b.WriteByte('=') + b.WriteString(params[k]) + } + b.WriteString(secret) + sum := md5.Sum([]byte(b.String())) + return hex.EncodeToString(sum[:]) +} + +// VerifySign 校验签名。采用常量时间比较防止时序攻击。 +func VerifySign(params map[string]string, secret string) bool { + got := params["sign"] + if got == "" { + return false + } + want := BuildSign(params, secret) + return subtle.ConstantTimeCompare([]byte(got), []byte(want)) == 1 +} diff --git a/internal/apps/payment/crypto_test.go b/internal/apps/payment/crypto_test.go new file mode 100644 index 00000000..e3bd0708 --- /dev/null +++ b/internal/apps/payment/crypto_test.go @@ -0,0 +1,126 @@ +/* + * MIT License + * + * Copyright (c) 2025 linux.do + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +package payment + +import ( + "strings" + "testing" +) + +func TestBuildSignOrdering(t *testing.T) { + // 示例取自 payment.txt: + // payload="money=10&name=Test&out_trade_no=M20250101&pid=001&type=epay" + params := map[string]string{ + "pid": "001", + "type": "epay", + "name": "Test", + "money": "10", + "out_trade_no": "M20250101", + } + got := BuildSign(params, "SECRET") + if len(got) != 32 { + t.Fatalf("want 32-char md5 hex, got %d chars: %s", len(got), got) + } + if got != strings.ToLower(got) { + t.Fatalf("signature must be lowercase hex, got %s", got) + } +} + +func TestBuildSignIgnoresSignAndEmpty(t *testing.T) { + withSign := map[string]string{ + "pid": "001", + "type": "epay", + "name": "Test", + "money": "10", + "out_trade_no": "M20250101", + "sign": "ignoreme", + "sign_type": "MD5", + "device": "", + } + withoutSign := map[string]string{ + "pid": "001", + "type": "epay", + "name": "Test", + "money": "10", + "out_trade_no": "M20250101", + } + a := BuildSign(withSign, "S") + b := BuildSign(withoutSign, "S") + if a != b { + t.Fatalf("sign/sign_type/empty should be excluded:\n a=%s\n b=%s", a, b) + } +} + +func TestVerifySignRoundTrip(t *testing.T) { + params := map[string]string{ + "pid": "001", + "type": "epay", + "name": "Test", + "money": "10.00", + "out_trade_no": "CDK20260420abcd1234", + } + secret := "HELLO_WORLD" + params["sign"] = BuildSign(params, secret) + if !VerifySign(params, secret) { + t.Fatal("VerifySign should accept a freshly-built signature") + } + params["money"] = "20.00" + if VerifySign(params, secret) { + t.Fatal("VerifySign should reject a tampered param") + } +} + +func TestEncryptDecryptRoundTrip(t *testing.T) { + key := "0123456789abcdef0123456789abcdef" // 32 字节 + plain := "some-super-secret-client-key" + enc, err := EncryptSecret(plain, key) + if err != nil { + t.Fatalf("encrypt err: %v", err) + } + if enc == "" || enc == plain { + t.Fatal("ciphertext looks invalid") + } + got, err := DecryptSecret(enc, key) + if err != nil { + t.Fatalf("decrypt err: %v", err) + } + if got != plain { + t.Fatalf("round-trip mismatch: %q != %q", got, plain) + } + + // 不同密钥应该解不出 + if _, err := DecryptSecret(enc, "another-key-of-len-32-xxxxxxxxxxxxxxx"); err == nil { + t.Fatal("wrong key should fail to decrypt") + } +} + +func TestEncryptNonceRandomness(t *testing.T) { + key := "0123456789abcdef0123456789abcdef" + a, _ := EncryptSecret("abc", key) + b, _ := EncryptSecret("abc", key) + if a == b { + t.Fatal("two encryptions of the same plaintext must differ (random nonce)") + } +} diff --git a/internal/apps/payment/epay.go b/internal/apps/payment/epay.go new file mode 100644 index 00000000..418c3bb7 --- /dev/null +++ b/internal/apps/payment/epay.go @@ -0,0 +1,134 @@ +/* + * MIT License + * + * Copyright (c) 2025 linux.do + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +package payment + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/url" + "strings" + + "github.com/linux-do/cdk/internal/config" + "github.com/linux-do/cdk/internal/utils" +) + +// submitURL 构造 /epay/pay/submit.php 的完整 GET 跳转 URL,由浏览器直接访问。 +// 不在后端跟随 302,以确保 credit.linux.do/paying 的付款会话对用户浏览器可见。 +func submitURL(clientID, secret, name, money, outTradeNo, notifyURL, returnURL string) string { + params := map[string]string{ + "pid": clientID, + "type": "epay", + "name": name, + "money": money, + "out_trade_no": outTradeNo, + "notify_url": notifyURL, + "return_url": returnURL, + "sign_type": "MD5", + } + params["sign"] = BuildSign(params, secret) + values := url.Values{} + for k, v := range params { + values.Set(k, v) + } + return strings.TrimRight(config.Config.Payment.ApiUrl, "/") + "/pay/submit.php?" + values.Encode() +} + +// callbackNotifyURL 返回异步通知地址,用于下单参数与前端展示。 +func callbackNotifyURL() string { + return strings.TrimRight(config.Config.Payment.NotifyBaseURL, "/") + "/api/v1/payment/notify" +} + +// callbackReturnURL 返回同步回跳地址。 +func callbackReturnURL() string { + return strings.TrimRight(config.Config.Payment.NotifyBaseURL, "/") + "/received" +} + +// refundResponse 易支付退款接口响应 +type refundResponse struct { + Code int `json:"code"` + Msg string `json:"msg"` +} + +// doEpayRefund 调用易支付兼容 /api.php 完成全额退款。 +// 参考文档:pid+key+trade_no+money 作为 form 参数,返回 {"code":1,"msg":"退款成功"}。 +// 不需要 sign,凭 key 直接鉴权。 +func doEpayRefund(ctx context.Context, clientID, clientSecret, tradeNo, money string) error { + form := url.Values{} + form.Set("act", "refund") + form.Set("pid", clientID) + form.Set("key", clientSecret) + form.Set("trade_no", tradeNo) + form.Set("money", money) + + endpoint := strings.TrimRight(config.Config.Payment.ApiUrl, "/") + "/api.php" + + resp, err := utils.Request( + ctx, + "POST", + endpoint, + strings.NewReader(form.Encode()), + map[string]string{"Content-Type": "application/x-www-form-urlencoded"}, + nil, + ) + if err != nil { + return err + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return err + } + var rr refundResponse + if err := json.Unmarshal(body, &rr); err != nil { + return fmt.Errorf("parse refund response: %w (raw=%s)", err, string(body)) + } + if rr.Code != 1 { + return fmt.Errorf("refund rejected: %s", rr.Msg) + } + return nil +} + +// extractQueryMap 把 gin 的 query 拉平成 map[string]string,便于签名校验 +func extractQueryMap(values url.Values) map[string]string { + m := make(map[string]string, len(values)) + for k, v := range values { + if len(v) > 0 { + m[k] = v[0] + } + } + return m +} + +// validateEncryptionKeyConfigured 检查加密密钥是否配置 +func validateEncryptionKeyConfigured() error { + if config.Config.Payment.ConfigEncryptionKey == "" { + return errors.New(ErrEncryptionKeyMissing) + } + return nil +} diff --git a/internal/apps/payment/err.go b/internal/apps/payment/err.go new file mode 100644 index 00000000..c5ac96eb --- /dev/null +++ b/internal/apps/payment/err.go @@ -0,0 +1,40 @@ +/* + * MIT License + * + * Copyright (c) 2025 linux.do + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +package payment + +const ( + ErrPaymentDisabled = "支付功能未启用" + ErrInvalidAmount = "金额必须大于 0 且最多 2 位小数" + ErrPriceRequiresOneForEach = "仅一码一用分发支持设置金额" + ErrCreatorNotConfigured = "项目创建者尚未配置支付凭据,无法发起支付" + ErrPaymentConfigNotFound = "尚未配置支付凭据" + ErrEncryptionKeyMissing = "服务端未配置支付密钥加密密钥" + ErrInvalidClientCredentials = "clientID 与 clientSecret 不能为空" + ErrOrderNotFound = "订单不存在" + ErrOrderExpired = "订单已过期" + ErrCannotDeleteHasActive = "存在未结束的付费项目,无法删除支付配置" + ErrInvalidPriceDecimals = "金额最多保留 2 位小数" + ErrPriceTooLarge = "金额超出允许范围" +) diff --git a/internal/apps/payment/models.go b/internal/apps/payment/models.go new file mode 100644 index 00000000..44dc90a5 --- /dev/null +++ b/internal/apps/payment/models.go @@ -0,0 +1,88 @@ +/* + * MIT License + * + * Copyright (c) 2025 linux.do + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +package payment + +import ( + "time" + + "github.com/shopspring/decimal" +) + +// OrderStatus 订单状态机 +// +// PENDING(0) -> PAID(1) -> COMPLETED(2) // 正常路径 +// -> REFUNDING(3) -> REFUNDED(4) // 发放失败 +// PENDING -> FAILED(5) // 未付款超时 / 创建失败 +type OrderStatus int8 + +const ( + OrderStatusPending OrderStatus = 0 + OrderStatusPaid OrderStatus = 1 + OrderStatusCompleted OrderStatus = 2 + OrderStatusRefunding OrderStatus = 3 + OrderStatusRefunded OrderStatus = 4 + OrderStatusFailed OrderStatus = 5 +) + +// UserPaymentConfig 用户的商户凭据(一对一绑定 User) +type UserPaymentConfig struct { + UserID uint64 `gorm:"primaryKey" json:"user_id"` + ClientID string `gorm:"size:64;not null" json:"client_id"` + ClientSecretEnc string `gorm:"size:512;not null" json:"-"` + SecretLast4 string `gorm:"size:8" json:"secret_last4"` + CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"` + UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at"` +} + +// PaymentOrder 支付订单(一次付费领取 = 一个订单) +// +// 联合索引: +// - idx_project_payer_status (project_id, payer_id, status):查询某用户在某项目的待支付订单 +// - idx_status_expire (status, expire_at):清理任务扫描超时 PENDING 订单 +type PaymentOrder struct { + ID uint64 `gorm:"primaryKey;autoIncrement" json:"id"` + OutTradeNo string `gorm:"size:64;uniqueIndex;not null" json:"out_trade_no"` + TradeNo string `gorm:"size:64;index" json:"trade_no"` + ProjectID string `gorm:"size:64;not null;index:idx_project_payer_status,priority:1" json:"project_id"` + ItemID uint64 `gorm:"index;not null" json:"item_id"` + PayerID uint64 `gorm:"not null;index:idx_project_payer_status,priority:2" json:"payer_id"` + PayeeID uint64 `gorm:"index;not null" json:"payee_id"` + PayeeClientID string `gorm:"size:64" json:"payee_client_id"` + Amount decimal.Decimal `gorm:"type:decimal(10,2);not null" json:"amount"` + Status OrderStatus `gorm:"default:0;index:idx_project_payer_status,priority:3;index:idx_status_expire,priority:1" json:"status"` + PaidAt *time.Time `json:"paid_at"` + RefundedAt *time.Time `json:"refunded_at"` + FailReason string `gorm:"size:255" json:"fail_reason"` + ExpireAt time.Time `gorm:"index:idx_status_expire,priority:2" json:"expire_at"` + ClientIP string `gorm:"size:64" json:"client_ip"` + CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"` + UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at"` +} + +// TableName 自定义表名 +func (PaymentOrder) TableName() string { return "payment_orders" } + +// TableName 自定义表名 +func (UserPaymentConfig) TableName() string { return "user_payment_configs" } diff --git a/internal/apps/payment/router.go b/internal/apps/payment/router.go new file mode 100644 index 00000000..acd26156 --- /dev/null +++ b/internal/apps/payment/router.go @@ -0,0 +1,194 @@ +/* + * MIT License + * + * Copyright (c) 2025 linux.do + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +package payment + +import ( + "errors" + "net/http" + + "github.com/gin-gonic/gin" + "github.com/linux-do/cdk/internal/apps/oauth" + "github.com/linux-do/cdk/internal/apps/project" + "github.com/linux-do/cdk/internal/config" + "github.com/linux-do/cdk/internal/db" + "gorm.io/gorm" +) + +// Response 统一的 payment 响应壳,保持与其他 app 一致 +type Response struct { + ErrorMsg string `json:"error_msg"` + Data interface{} `json:"data"` +} + +// GetPaymentConfigResponseData 当前用户支付配置的安全视图 +type GetPaymentConfigResponseData struct { + HasConfig bool `json:"has_config"` + ClientID string `json:"client_id"` + SecretLast4 string `json:"secret_last4"` + CallbackNotifyURL string `json:"callback_notify_url"` + CallbackReturnURL string `json:"callback_return_url"` + PaymentEnabled bool `json:"payment_enabled"` +} + +// GetPaymentConfig GET /api/v1/users/payment-config +// @Summary 获取当前用户的支付配置(不返回明文 secret) +func GetPaymentConfig(c *gin.Context) { + userID := oauth.GetUserIDFromContext(c) + cfg, err := GetUserPaymentConfig(c.Request.Context(), userID) + if err != nil { + c.JSON(http.StatusInternalServerError, Response{ErrorMsg: err.Error()}) + return + } + notifyURL, returnURL := CallbackURLs() + resp := GetPaymentConfigResponseData{ + CallbackNotifyURL: notifyURL, + CallbackReturnURL: returnURL, + PaymentEnabled: config.Config.Payment.Enabled, + } + if cfg != nil { + resp.HasConfig = true + resp.ClientID = cfg.ClientID + resp.SecretLast4 = cfg.SecretLast4 + } + c.JSON(http.StatusOK, Response{Data: resp}) +} + +// UpsertPaymentConfigRequest PUT 请求体 +type UpsertPaymentConfigRequest struct { + ClientID string `json:"client_id" binding:"required,min=1,max=64"` + ClientSecret string `json:"client_secret" binding:"required,min=1,max=256"` +} + +// UpsertPaymentConfig PUT /api/v1/users/payment-config +func UpsertPaymentConfig(c *gin.Context) { + if !config.Config.Payment.Enabled { + c.JSON(http.StatusForbidden, Response{ErrorMsg: ErrPaymentDisabled}) + return + } + var req UpsertPaymentConfigRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, Response{ErrorMsg: err.Error()}) + return + } + userID := oauth.GetUserIDFromContext(c) + if err := SaveUserPaymentConfig(c.Request.Context(), userID, req.ClientID, req.ClientSecret); err != nil { + c.JSON(http.StatusInternalServerError, Response{ErrorMsg: err.Error()}) + return + } + c.JSON(http.StatusOK, Response{}) +} + +// DeletePaymentConfig DELETE /api/v1/users/payment-config +func DeletePaymentConfig(c *gin.Context) { + userID := oauth.GetUserIDFromContext(c) + if err := DeleteUserPaymentConfig(c.Request.Context(), userID); err != nil { + c.JSON(http.StatusBadRequest, Response{ErrorMsg: err.Error()}) + return + } + c.JSON(http.StatusOK, Response{}) +} + +// ReceiveResponse 领取接口的统一响应:免费返回 itemContent;付费返回支付跳转信息。 +type ReceiveResponse struct { + ItemContent string `json:"itemContent,omitempty"` + RequirePayment bool `json:"require_payment,omitempty"` + PayURL string `json:"pay_url,omitempty"` + OutTradeNo string `json:"out_trade_no,omitempty"` + Amount string `json:"amount,omitempty"` + ExpireAt string `json:"expire_at,omitempty"` +} + +// DispatchReceive POST /api/v1/projects/:id/receive +// 运行在 project.ReceiveProjectMiddleware() 之后,已通过资格校验并在 context 注入 project。 +// 付费项目:返回 {require_payment:true, pay_url, ...};前端直接跳转 pay_url。 +// 免费项目:执行原领取事务,返回 {itemContent}。 +func DispatchReceive(c *gin.Context) { + ctx := c.Request.Context() + currentUser, _ := oauth.GetUserFromContext(c) + p, ok := project.GetProjectFromContext(c) + if !ok || p == nil { + // 兜底:若 middleware 未注入(例如后续重构),按主键再查一次 + p = &project.Project{} + if err := p.Exact(db.DB(ctx), c.Param("id"), true); err != nil { + c.JSON(http.StatusNotFound, project.ProjectResponse{ErrorMsg: err.Error()}) + return + } + } + + // 付费分叉 + if p.IsPaid() { + init, err := InitiatePayment(ctx, p, currentUser, c.ClientIP()) + if err != nil { + c.JSON(http.StatusInternalServerError, Response{ErrorMsg: err.Error()}) + return + } + c.JSON(http.StatusOK, project.ProjectResponse{Data: ReceiveResponse{ + RequirePayment: true, + PayURL: init.PayURL, + OutTradeNo: init.OutTradeNo, + Amount: init.Amount, + ExpireAt: init.ExpireAt.Format("2006-01-02 15:04:05"), + }}) + return + } + + // 免费分支:复用 project 的预占 + 发放事务 + itemID, err := p.PrepareReceive(ctx, currentUser.Username) + if err != nil { + c.JSON(http.StatusInternalServerError, project.ProjectResponse{ErrorMsg: err.Error()}) + return + } + var item project.ProjectItem + if err := item.Exact(db.DB(ctx), itemID); err != nil { + // 预占成功但 item 记录找不到,回滚 + db.Redis.RPush(ctx, p.ItemsKey(), itemID) + c.JSON(http.StatusNotFound, project.ProjectResponse{ErrorMsg: err.Error()}) + return + } + if err := db.DB(ctx).Transaction(func(tx *gorm.DB) error { + return p.FulfillForReceiver(ctx, tx, &item, currentUser.ID, c.ClientIP()) + }); err != nil { + if p.DistributionType == project.DistributionTypeOneForEach { + db.Redis.RPush(ctx, p.ItemsKey(), itemID) + } + c.JSON(http.StatusInternalServerError, project.ProjectResponse{ErrorMsg: err.Error()}) + return + } + c.JSON(http.StatusOK, project.ProjectResponse{Data: ReceiveResponse{ItemContent: item.Content}}) +} + +// HandleNotifyHTTP GET /api/v1/payment/notify +// 易支付兼容回调,返回纯文本 "success" / "fail"。 +func HandleNotifyHTTP(c *gin.Context) { + ok, _ := HandleNotify(c.Request.Context(), extractQueryMap(c.Request.URL.Query())) + if ok { + c.String(http.StatusOK, "success") + } else { + c.String(http.StatusOK, "fail") + } +} + +// 保留 GORM ErrRecordNotFound 的判断以防将来需要细分 +var _ = errors.Is diff --git a/internal/apps/payment/service.go b/internal/apps/payment/service.go new file mode 100644 index 00000000..740fed44 --- /dev/null +++ b/internal/apps/payment/service.go @@ -0,0 +1,348 @@ +/* + * MIT License + * + * Copyright (c) 2025 linux.do + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +package payment + +import ( + "context" + "crypto/rand" + "encoding/hex" + "errors" + "fmt" + "time" + + "github.com/linux-do/cdk/internal/apps/oauth" + "github.com/linux-do/cdk/internal/apps/project" + "github.com/linux-do/cdk/internal/config" + "github.com/linux-do/cdk/internal/db" + "github.com/shopspring/decimal" + "gorm.io/gorm" +) + +// GetUserPaymentConfig 读取指定用户的支付配置,不存在返回 (nil, nil)。 +func GetUserPaymentConfig(ctx context.Context, userID uint64) (*UserPaymentConfig, error) { + var cfg UserPaymentConfig + err := db.DB(ctx).Where("user_id = ?", userID).First(&cfg).Error + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, nil + } + if err != nil { + return nil, err + } + return &cfg, nil +} + +// SaveUserPaymentConfig 保存/更新用户的支付凭据,clientSecret 明文进入后会被加密。 +func SaveUserPaymentConfig(ctx context.Context, userID uint64, clientID, clientSecret string) error { + if clientID == "" || clientSecret == "" { + return errors.New(ErrInvalidClientCredentials) + } + if err := validateEncryptionKeyConfigured(); err != nil { + return err + } + enc, err := EncryptSecret(clientSecret, config.Config.Payment.ConfigEncryptionKey) + if err != nil { + return err + } + last4 := clientSecret + if len(last4) > 4 { + last4 = last4[len(last4)-4:] + } + cfg := &UserPaymentConfig{} + queryErr := db.DB(ctx).Where("user_id = ?", userID).First(cfg).Error + if errors.Is(queryErr, gorm.ErrRecordNotFound) { + cfg.UserID = userID + cfg.ClientID = clientID + cfg.ClientSecretEnc = enc + cfg.SecretLast4 = last4 + return db.DB(ctx).Create(cfg).Error + } else if queryErr != nil { + return queryErr + } + cfg.ClientID = clientID + cfg.ClientSecretEnc = enc + cfg.SecretLast4 = last4 + return db.DB(ctx).Save(&cfg).Error +} + +// DeleteUserPaymentConfig 删除用户支付配置。 +// 若该用户存在 Price>0 且未结束的自有项目则拒绝删除,避免后续领取者无法付款。 +func DeleteUserPaymentConfig(ctx context.Context, userID uint64) error { + var cnt int64 + err := db.DB(ctx). + Model(&project.Project{}). + Where("creator_id = ? AND price > 0 AND end_time > ? AND is_completed = 0 AND status = ?", + userID, time.Now(), project.ProjectStatusNormal). + Count(&cnt).Error + if err != nil { + return err + } + if cnt > 0 { + return errors.New(ErrCannotDeleteHasActive) + } + return db.DB(ctx).Where("user_id = ?", userID).Delete(&UserPaymentConfig{}).Error +} + +// decryptUserClientSecret 解密指定配置的 clientSecret。 +func decryptUserClientSecret(cfg *UserPaymentConfig) (string, error) { + if err := validateEncryptionKeyConfigured(); err != nil { + return "", err + } + return DecryptSecret(cfg.ClientSecretEnc, config.Config.Payment.ConfigEncryptionKey) +} + +// genOutTradeNo 生成本地订单号:CDK + yyyyMMddHHmmss + 8 位随机十六进制。 +func genOutTradeNo() string { + var b [4]byte + _, _ = rand.Read(b[:]) + return fmt.Sprintf("CDK%s%s", time.Now().Format("20060102150405"), hex.EncodeToString(b[:])) +} + +// moneyString 将 decimal 金额格式化为"两位小数"字符串,与 epay 的金额要求保持一致。 +func moneyString(d decimal.Decimal) string { + return d.StringFixed(2) +} + +// PaymentInitiation 返回给前端的发起支付信息 +type PaymentInitiation struct { + OutTradeNo string `json:"out_trade_no"` + PayURL string `json:"pay_url"` + Amount string `json:"amount"` + ExpireAt time.Time `json:"expire_at"` +} + +// InitiatePayment 为付费项目的一次领取行为创建支付订单,并返回前端可直接跳转的支付 URL。 +// 调用方已通过 ReceiveProjectMiddleware 的前置校验。 +// 流程:载入商户凭据 → Redis LPop 预占 item → 持久化订单 PENDING → 构造 submit URL 返回。 +// 若创建订单失败或拼接失败,需立即把 itemID RPush 回 Redis 以恢复库存。 +func InitiatePayment(ctx context.Context, p *project.Project, payer *oauth.User, clientIP string) (*PaymentInitiation, error) { + if !config.Config.Payment.Enabled { + return nil, errors.New(ErrPaymentDisabled) + } + if !p.IsPaid() { + return nil, errors.New(ErrInvalidAmount) + } + + cfg, err := GetUserPaymentConfig(ctx, p.CreatorID) + if err != nil { + return nil, err + } + if cfg == nil { + return nil, errors.New(ErrCreatorNotConfigured) + } + secret, err := decryptUserClientSecret(cfg) + if err != nil { + return nil, err + } + + // 预占 item(Redis LPOP 原子) + itemID, err := p.PrepareReceive(ctx, payer.Username) + if err != nil { + return nil, err + } + + // 订单过期时间 + expireMin := config.Config.Payment.OrderExpireMinutes + if expireMin <= 0 { + expireMin = 10 + } + expireAt := time.Now().Add(time.Duration(expireMin) * time.Minute) + + outTradeNo := genOutTradeNo() + order := PaymentOrder{ + OutTradeNo: outTradeNo, + ProjectID: p.ID, + ItemID: itemID, + PayerID: payer.ID, + PayeeID: p.CreatorID, + PayeeClientID: cfg.ClientID, + Amount: p.Price, + Status: OrderStatusPending, + ExpireAt: expireAt, + ClientIP: clientIP, + } + if err := db.DB(ctx).Create(&order).Error; err != nil { + // 回滚预占 + db.Redis.RPush(ctx, p.ItemsKey(), itemID) + return nil, err + } + + // 构造支付跳转 URL(名称最长 64) + name := truncateRuneLen("CDK-"+p.Name, 60) + payURL := submitURL(cfg.ClientID, secret, name, moneyString(p.Price), outTradeNo, callbackNotifyURL(), callbackReturnURL()) + + return &PaymentInitiation{ + OutTradeNo: outTradeNo, + PayURL: payURL, + Amount: moneyString(p.Price), + ExpireAt: expireAt, + }, nil +} + +// truncateRuneLen 按 rune 长度截断字符串,避免多字节字符被拦腰截断 +func truncateRuneLen(s string, max int) string { + rs := []rune(s) + if len(rs) <= max { + return s + } + return string(rs[:max]) +} + +// HandleNotifyParams 易支付回调需要的全部字段 +type HandleNotifyParams struct { + Query map[string]string +} + +// HandleNotify 处理异步支付回调。 +// 返回 (success bool, reason string):success=true 表示应返回文本 "success"; +// 否则返回 "fail",epay 最多重试 5 次,每次间隔由对方决定。 +// 实现关键点: +// 1. 拉起订单 → 验签(使用订单记录的 PayeeID 对应凭据) → 校验 trade_status/pid/money +// 2. 幂等:若订单已是 COMPLETED/REFUNDED 直接 success +// 3. CAS 推进 PENDING → PAID,成功者执行 fulfill 事务 +// 4. fulfill 失败 → 调 refund,成功后 RPush item 回 Redis,置 REFUNDED;本次返回 fail 让对方重试, +// 再次进入时因状态非 PENDING 直接 success +func HandleNotify(ctx context.Context, q map[string]string) (bool, string) { + outTradeNo := q["out_trade_no"] + if outTradeNo == "" { + return false, "missing out_trade_no" + } + var order PaymentOrder + if err := db.DB(ctx).Where("out_trade_no = ?", outTradeNo).First(&order).Error; err != nil { + return false, fmt.Sprintf("order not found: %v", err) + } + cfg, err := GetUserPaymentConfig(ctx, order.PayeeID) + if err != nil || cfg == nil { + return false, "payee config missing" + } + secret, err := decryptUserClientSecret(cfg) + if err != nil { + return false, "decrypt secret failed" + } + if !VerifySign(q, secret) { + return false, "sign mismatch" + } + if q["trade_status"] != "TRADE_SUCCESS" { + return false, "trade_status not success" + } + if q["pid"] != cfg.ClientID { + return false, "pid mismatch" + } + if q["money"] != moneyString(order.Amount) { + return false, "money mismatch" + } + + // 幂等分支 + if order.Status == OrderStatusCompleted || order.Status == OrderStatusRefunded { + return true, "idempotent" + } + + // 增加对 Refunding 状态的处理 + if order.Status == OrderStatusRefunding { + refundErr := doEpayRefund(ctx, cfg.ClientID, secret, order.TradeNo, moneyString(order.Amount)) + if refundErr == nil { + tNow := time.Now() + if updateErr := db.DB(ctx). + Model(&PaymentOrder{}). + Where("out_trade_no = ?", outTradeNo). + Updates(map[string]any{"status": OrderStatusRefunded, "refunded_at": &tNow}). + Error; updateErr != nil { + return false, "update order status failed" + } + db.Redis.RPush(ctx, project.ProjectItemsKey(order.ProjectID), order.ItemID) + return true, "refund retry ok" + } + return false, "refund retry failed" + } + + // CAS: PENDING -> PAID + now := time.Now() + rows := db.DB(ctx).Model(&PaymentOrder{}). + Where("out_trade_no = ? AND status = ?", outTradeNo, OrderStatusPending). + Updates(map[string]any{ + "status": OrderStatusPaid, + "trade_no": q["trade_no"], + "paid_at": &now, + }).RowsAffected + if rows == 0 { + // 并发下另一个回调在处理,或订单已过期被扫描任务置 FAILED(itemID 已回滚),此时不再处理 + // 仍返回 success 让对方停止重试,结果以订单最终状态为准 + return true, "concurrent or non-pending" + } + + // 重新读一次订单 + if err := db.DB(ctx).Where("out_trade_no = ?", outTradeNo).First(&order).Error; err != nil { + return false, err.Error() + } + + // 发放 + if err := fulfillPaidOrder(ctx, &order); err != nil { + // 退款 + RPush + refundErr := doEpayRefund(ctx, cfg.ClientID, secret, order.TradeNo, moneyString(order.Amount)) + updates := map[string]any{ + "fail_reason": truncateRuneLen(err.Error(), 200), + } + if refundErr == nil { + tNow := time.Now() + updates["status"] = OrderStatusRefunded + updates["refunded_at"] = &tNow + // 把 item 推回 Redis 队列恢复库存 + db.Redis.RPush(ctx, project.ProjectItemsKey(order.ProjectID), order.ItemID) + } else { + updates["status"] = OrderStatusRefunding + } + db.DB(ctx).Model(&PaymentOrder{}). + Where("out_trade_no = ?", outTradeNo).Updates(updates) + // 让 epay 重试,再次进入时因状态非 PENDING 会回到 idempotent 分支 + return false, fmt.Sprintf("fulfill failed: %v", err) + } + + // 成功 + db.DB(ctx).Model(&PaymentOrder{}). + Where("out_trade_no = ?", outTradeNo).Update("status", OrderStatusCompleted) + return true, "ok" +} + +// fulfillPaidOrder 在已确认付款的前提下执行发放事务,复用 project.FulfillForReceiver。 +func fulfillPaidOrder(ctx context.Context, order *PaymentOrder) error { + return db.DB(ctx).Transaction(func(tx *gorm.DB) error { + var p project.Project + if err := p.Exact(tx, order.ProjectID, true); err != nil { + return err + } + var item project.ProjectItem + if err := item.Exact(tx, order.ItemID); err != nil { + return err + } + return p.FulfillForReceiver(ctx, tx, &item, order.PayerID, order.ClientIP) + }) +} + +// CallbackURLs 返回当前平台配置的回调地址,用于前端展示给用户。 +func CallbackURLs() (notifyURL, returnURL string) { + return callbackNotifyURL(), callbackReturnURL() +} + +// ErrOrderNotFoundSentinel 外部判断 +var ErrOrderNotFoundSentinel = errors.New(ErrOrderNotFound) diff --git a/internal/apps/payment/tasks.go b/internal/apps/payment/tasks.go new file mode 100644 index 00000000..e75811a0 --- /dev/null +++ b/internal/apps/payment/tasks.go @@ -0,0 +1,82 @@ +/* + * MIT License + * + * Copyright (c) 2025 linux.do + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +package payment + +import ( + "context" + "time" + + "github.com/hibiken/asynq" + "github.com/linux-do/cdk/internal/apps/project" + "github.com/linux-do/cdk/internal/db" + "go.uber.org/zap" +) + +// HandleExpireStaleOrders 清理长时间未付款的 PENDING 订单。 +// 查询条件:status=PENDING 且 expire_at 超时超过 5 分钟。 +// 额外 5 分钟宽限期确保 epay 的异步 notify 回调在此之前已到达, +// 避免"cleanup 先置 FAILED + RPush,notify 随后到达发现已无 PENDING 订单"的竞态。 +func HandleExpireStaleOrders(_ context.Context, _ *asynq.Task) error { + ctx := context.Background() + + // expire_at 已超过 5 分钟才认为真正超时 + deadline := time.Now().Add(-5 * time.Minute) + + var orders []PaymentOrder + if err := db.DB(ctx). + Where("status = ? AND expire_at < ?", OrderStatusPending, deadline). + Limit(200). + Find(&orders).Error; err != nil { + zap.L().Error("payment cleanup: query failed", zap.Error(err)) + return err + } + + for _, order := range orders { + expireOrder(ctx, &order) + } + return nil +} + +// expireOrder 通过 CAS 将单笔 PENDING 订单置为 FAILED,并把预占的 item 归还 Redis。 +// 使用 CAS (WHERE status=PENDING) 保证幂等: +// - 若 notify 回调恰好在此同时到达并推进了状态,CAS 得 0 行,安全跳过。 +func expireOrder(ctx context.Context, order *PaymentOrder) { + rows := db.DB(ctx).Model(&PaymentOrder{}). + Where("out_trade_no = ? AND status = ?", order.OutTradeNo, OrderStatusPending). + Update("status", OrderStatusFailed). + RowsAffected + if rows == 0 { + // 另一协程(notify 回调)已处理,跳过 + return + } + + // 归还预占的 item,恢复项目库存 + db.Redis.RPush(ctx, project.ProjectItemsKey(order.ProjectID), order.ItemID) + + zap.L().Info("payment cleanup: order expired", + zap.String("out_trade_no", order.OutTradeNo), + zap.Uint64("item_id", order.ItemID), + ) +} diff --git a/internal/apps/project/err.go b/internal/apps/project/err.go index 2385be1a..d9ef0f41 100644 --- a/internal/apps/project/err.go +++ b/internal/apps/project/err.go @@ -38,4 +38,11 @@ const ( AlreadyReported = "已举报过当前项目" RequirementsFailed = "未达到项目发起者设置的条件" TooManyRequests = "创建项目太频繁,请稍后再试" + // Payment 相关 + InvalidPrice = "金额必须大于等于 0" + InvalidPriceDecimals = "金额最多保留 2 位小数" + PriceTooLarge = "金额超出允许范围" + PriceOnlyOneForEach = "仅一码一用分发支持设置金额" + PaymentDisabled = "平台支付功能未启用" + CreatorNotConfigured = "请先在账户设置中配置支付凭据" ) diff --git a/internal/apps/project/middlewares.go b/internal/apps/project/middlewares.go index e4f71b51..7591299a 100644 --- a/internal/apps/project/middlewares.go +++ b/internal/apps/project/middlewares.go @@ -115,6 +115,8 @@ func ReceiveProjectMiddleware() gin.HandlerFunc { c.AbortWithStatusJSON(http.StatusForbidden, ProjectResponse{ErrorMsg: err.Error()}) return } + // 将 project 注入 context 供 handler 复用,避免重复加载 + SetProjectToContext(c, project) // do next c.Next() } diff --git a/internal/apps/project/models.go b/internal/apps/project/models.go index 227cd079..7b513d79 100644 --- a/internal/apps/project/models.go +++ b/internal/apps/project/models.go @@ -43,6 +43,7 @@ import ( "github.com/linux-do/cdk/internal/apps/oauth" "github.com/linux-do/cdk/internal/db" "github.com/redis/go-redis/v9" + "github.com/shopspring/decimal" "gorm.io/gorm" ) @@ -62,11 +63,17 @@ type Project struct { Status ProjectStatus `json:"status" gorm:"default:0;index;index:idx_projects_end_completed_trust_risk,priority:3"` ReportCount uint8 `json:"report_count" gorm:"default:0"` HideFromExplore bool `json:"hide_from_explore" gorm:"default:false"` + Price decimal.Decimal `json:"price" gorm:"type:decimal(10,2);default:0;not null"` Creator oauth.User `json:"-" gorm:"foreignKey:CreatorID"` CreatedAt time.Time `json:"created_at" gorm:"autoCreateTime"` UpdatedAt time.Time `json:"updated_at" gorm:"autoUpdateTime"` } +// IsPaid 是否为付费项目 +func (p *Project) IsPaid() bool { + return p.Price.GreaterThan(decimal.Zero) +} + func (p *Project) Exact(tx *gorm.DB, id string, isNormal bool) error { query := tx.Preload("Creator"). Where("id = ?", id) @@ -79,7 +86,12 @@ func (p *Project) Exact(tx *gorm.DB, id string, isNormal bool) error { } func (p *Project) ItemsKey() string { - return fmt.Sprintf("project:%s:items", p.ID) + return ProjectItemsKey(p.ID) +} + +// ProjectItemsKey 包级别的 Redis key 构造函数,便于不持有 Project 实例时拼接 key。 +func ProjectItemsKey(projectID string) string { + return fmt.Sprintf("project:%s:items", projectID) } func (p *Project) RefreshTags(tx *gorm.DB, tags []string) error { @@ -459,6 +471,46 @@ func (p *ProjectItem) Exact(tx *gorm.DB, id uint64) error { return nil } +// FulfillForReceiver 执行领取结算事务:将 item 标记为已领取、库存耗尽则标记项目完成、 +// 若不允许同 IP 领取则写 Redis SetNX 锁、抽奖模式从 Redis HDel 用户。 +// 由免费领取与付费回调两条路径共用;失败时上游需决定是否回退 itemID。 +func (p *Project) FulfillForReceiver(ctx context.Context, tx *gorm.DB, item *ProjectItem, receiverID uint64, clientIP string) error { + now := time.Now() + item.ReceiverID = &receiverID + item.ReceivedAt = &now + if err := tx.Save(item).Error; err != nil { + return err + } + + if hasStock, err := p.HasStock(ctx); err != nil { + return err + } else if !hasStock { + p.IsCompleted = true + if err := tx.Save(p).Error; err != nil { + return err + } + } + + if !p.AllowSameIP && clientIP != "" { + if err := db.Redis.SetNX(ctx, p.SameIPCacheKey(clientIP), clientIP, p.EndTime.Sub(now)).Err(); err != nil { + return err + } + } + + if p.DistributionType == DistributionTypeLottery { + // 付费领取限定 OneForEach,此处保留仅为免费 Lottery 路径的兼容 + var user oauth.User + if err := user.Exact(tx, receiverID); err != nil { + return err + } + if err := db.Redis.HDel(ctx, p.ItemsKey(), user.Username).Err(); err != nil { + return err + } + } + + return nil +} + func (p *Project) GetReceivedItem(ctx context.Context, userID uint64) (*ProjectItem, error) { item := &ProjectItem{} err := db.DB(ctx).Where("project_id = ? AND receiver_id = ?", p.ID, userID).First(item).Error diff --git a/internal/apps/project/routers.go b/internal/apps/project/routers.go index 6ae7a4de..650d3e53 100644 --- a/internal/apps/project/routers.go +++ b/internal/apps/project/routers.go @@ -36,6 +36,7 @@ import ( "github.com/linux-do/cdk/internal/config" "github.com/linux-do/cdk/internal/db" "github.com/linux-do/cdk/internal/utils" + "github.com/shopspring/decimal" "gorm.io/gorm" ) @@ -54,6 +55,7 @@ type ProjectRequest struct { AllowSameIP bool `json:"allow_same_ip"` RiskLevel int8 `json:"risk_level" binding:"min=0,max=100"` HideFromExplore bool `json:"hide_from_explore"` + Price decimal.Decimal `json:"price"` } type GetProjectResponseData struct { Project `json:",inline"` // 内嵌所有 Project 字段 @@ -163,6 +165,12 @@ func CreateProject(c *gin.Context) { // init session currentUser, _ := oauth.GetUserFromContext(c) + // validate price + if err := validateProjectPrice(c.Request.Context(), req.Price, req.DistributionType, currentUser.ID); err != nil { + c.JSON(http.StatusBadRequest, ProjectResponse{ErrorMsg: err.Error()}) + return + } + // init project project := Project{ ID: uuid.NewString(), @@ -178,6 +186,7 @@ func CreateProject(c *gin.Context) { CreatorID: currentUser.ID, IsCompleted: false, HideFromExplore: req.HideFromExplore, + Price: req.Price, } // create project @@ -233,6 +242,12 @@ func UpdateProject(c *gin.Context) { // load project project, _ := GetProjectFromContext(c) + // validate price (复用创建者 ID + 原分发类型) + if err := validateProjectPrice(c.Request.Context(), req.Price, project.DistributionType, project.CreatorID); err != nil { + c.JSON(http.StatusBadRequest, ProjectResponse{ErrorMsg: err.Error()}) + return + } + // init project project.Name = req.Name project.Description = req.Description @@ -242,6 +257,7 @@ func UpdateProject(c *gin.Context) { project.AllowSameIP = req.AllowSameIP project.RiskLevel = req.RiskLevel project.HideFromExplore = req.HideFromExplore + project.Price = req.Price if project.DistributionType == DistributionTypeLottery { // save project @@ -409,96 +425,6 @@ func ListProjectReceivers(c *gin.Context) { c.JSON(http.StatusOK, ProjectResponse{Data: receivers}) } -// ReceiveProject -// @Tags project -// @Accept json -// @Produce json -// @Param id path string true "project id" -// @Success 200 {object} ProjectResponse -// @Router /api/v1/projects/{id}/receive [post] -func ReceiveProject(c *gin.Context) { - // init - currentUser, _ := oauth.GetUserFromContext(c) - - // load project - project := &Project{} - if err := project.Exact(db.DB(c.Request.Context()), c.Param("id"), true); err != nil { - c.JSON(http.StatusNotFound, ProjectResponse{ErrorMsg: err.Error()}) - return - } - - // prepare item - itemID, err := project.PrepareReceive(c.Request.Context(), currentUser.Username) - if err != nil { - c.JSON(http.StatusInternalServerError, ProjectResponse{ErrorMsg: err.Error()}) - return - } - - // load item - item := &ProjectItem{} - if err := item.Exact(db.DB(c.Request.Context()), itemID); err != nil { - c.JSON(http.StatusNotFound, ProjectResponse{ErrorMsg: err.Error()}) - return - } - - // do receive - if err := db.DB(c.Request.Context()).Transaction( - func(tx *gorm.DB) error { - now := time.Now() - // save to db - item.ReceiverID = ¤tUser.ID - item.ReceivedAt = &now - if err := tx.Save(item).Error; err != nil { - return err - } - - // query remaining items - if hasStock, err := project.HasStock(c.Request.Context()); err != nil { - return err - } else if !hasStock { - // if remaining is 0, mark project as completed - project.IsCompleted = true - if err := tx.Save(project).Error; err != nil { - return err - } - } - - // check for ip - if !project.AllowSameIP { - if err := db.Redis.SetNX( - c.Request.Context(), - project.SameIPCacheKey(c.ClientIP()), - c.ClientIP(), - project.EndTime.Sub(time.Now()), - ).Err(); err != nil { - return err - } - } - - // if lottery, remove user from redis set - if project.DistributionType == DistributionTypeLottery { - if err := db.Redis.HDel(c.Request.Context(), project.ItemsKey(), currentUser.Username).Err(); err != nil { - return err - } - } - - return nil - }, - ); err != nil { - if project.DistributionType == DistributionTypeOneForEach { - // push items to redis - db.Redis.RPush(c.Request.Context(), project.ItemsKey(), itemID) - } - // response - c.JSON(http.StatusInternalServerError, ProjectResponse{ErrorMsg: err.Error()}) - return - } - - c.JSON(http.StatusOK, ProjectResponse{ - Data: map[string]interface{}{"itemContent": item.Content}, - }) -} - type ReportProjectRequestBody struct { Reason string `json:"reason" binding:"required,min=1,max=255"` } @@ -711,6 +637,7 @@ type ListProjectsResponseDataResult struct { AllowSameIP bool `json:"allow_same_ip"` RiskLevel int8 `json:"risk_level"` HideFromExplore bool `json:"hide_from_explore"` + Price decimal.Decimal `json:"price"` Tags utils.StringArray `json:"tags"` CreatedAt time.Time `json:"created_at"` } diff --git a/internal/apps/project/utils.go b/internal/apps/project/utils.go index ca4b1438..319cb15d 100644 --- a/internal/apps/project/utils.go +++ b/internal/apps/project/utils.go @@ -26,9 +26,14 @@ package project import ( "context" + "errors" + "github.com/gin-gonic/gin" "github.com/linux-do/cdk/internal/apps/oauth" + "github.com/linux-do/cdk/internal/config" "github.com/linux-do/cdk/internal/db" + "github.com/shopspring/decimal" + "time" ) @@ -70,7 +75,7 @@ func ListProjectsWithTags(ctx context.Context, offset, limit int, tags []string, getProjectWithTagsSql := `SELECT p.id,p.name,p.description,p.distribution_type,p.total_items, - p.start_time,p.end_time,p.minimum_trust_level,p.allow_same_ip,p.risk_level,p.created_at, + p.start_time,p.end_time,p.minimum_trust_level,p.allow_same_ip,p.risk_level,p.price,p.created_at, IF(COUNT(pt.tag) = 0, NULL, JSON_ARRAYAGG(pt.tag)) AS tags FROM projects p LEFT JOIN project_tags pt ON p.id = pt.project_id @@ -121,7 +126,7 @@ func ListMyProjectsWithTags(ctx context.Context, creatorID uint64, offset, limit getMyProjectWithTagsSql := `SELECT p.id,p.name,p.description,p.distribution_type,p.total_items, - p.start_time,p.end_time,p.minimum_trust_level,p.allow_same_ip,p.risk_level,p.hide_from_explore,p.created_at, + p.start_time,p.end_time,p.minimum_trust_level,p.allow_same_ip,p.risk_level,p.hide_from_explore,p.price,p.created_at, IF(COUNT(pt.tag) = 0, NULL, JSON_ARRAYAGG(pt.tag)) AS tags FROM projects p LEFT JOIN project_tags pt ON p.id = pt.project_id @@ -163,3 +168,40 @@ func ListMyProjectsWithTags(ctx context.Context, creatorID uint64, offset, limit Results: &listProjectsResponseDataResult, }, nil } + +// maxProjectPrice 付费项目的最大单价上限 +var maxProjectPrice = decimal.RequireFromString("99999999.99") + +// validateProjectPrice 校验 Price 字段合法性。 +// 规则: +// - Price 必须非负,最多 2 位小数,不超过上限 +// - Price > 0 仅允许 DistributionTypeOneForEach +// - Price > 0 时必须确认全局支付功能已启用且创建者已配置 clientID/clientSecret +func validateProjectPrice(ctx context.Context, price decimal.Decimal, dt DistributionType, creatorID uint64) error { + if price.IsNegative() { + return errors.New(InvalidPrice) + } + if price.Exponent() < -2 { + return errors.New(InvalidPriceDecimals) + } + if price.GreaterThan(maxProjectPrice) { + return errors.New(PriceTooLarge) + } + if price.IsZero() { + return nil + } + if dt != DistributionTypeOneForEach { + return errors.New(PriceOnlyOneForEach) + } + if !config.Config.Payment.Enabled { + return errors.New(PaymentDisabled) + } + var cnt int64 + if err := db.DB(ctx).Table("user_payment_configs").Where("user_id = ?", creatorID).Count(&cnt).Error; err != nil { + return err + } + if cnt == 0 { + return errors.New(CreatorNotConfigured) + } + return nil +} diff --git a/internal/config/model.go b/internal/config/model.go index 0c3f15a0..bada9772 100644 --- a/internal/config/model.go +++ b/internal/config/model.go @@ -38,6 +38,7 @@ type configModel struct { ClickHouse clickHouseConfig `mapstructure:"clickhouse"` LinuxDo linuxDoConfig `mapstructure:"linuxdo"` Otel otelConfig `mapstructure:"otel"` + Payment PaymentConfig `mapstructure:"payment"` } // appConfig 应用基本配置 @@ -135,6 +136,7 @@ type scheduleConfig struct { UserBadgeScoreDispatchIntervalSeconds int `mapstructure:"user_badge_score_dispatch_interval_seconds"` UpdateUserBadgeScoresTaskCron string `mapstructure:"update_user_badges_scores_task_cron"` UpdateAllBadgesTaskCron string `mapstructure:"update_all_badges_task_cron"` + ExpireStalePaymentOrdersCron string `mapstructure:"expire_stale_payment_orders_cron"` } // workerConfig 工作配置 @@ -151,3 +153,19 @@ type linuxDoConfig struct { type otelConfig struct { SamplingRate float64 `mapstructure:"sampling_rate"` } + +// PaymentConfig 支付相关全局配置 +type PaymentConfig struct { + // Enabled 是否启用付费功能。关闭时创建/领取付费项目会被拒绝 + Enabled bool `mapstructure:"enabled"` + // ApiUrl LDC 易支付兼容接口基址,例如 https://credit.linux.do/epay + ApiUrl string `mapstructure:"api_url"` + // NotifyBaseURL 本项目对外可访问的基址(不含路径),例如 https://cdk.linux.do + // 用于拼接 notify_url / return_url 并提示用户在 LDC 商户后台填写 + NotifyBaseURL string `mapstructure:"notify_base_url"` + // ConfigEncryptionKey 用于加密用户 clientSecret 的密钥,必须是 32 字节长度 + // 建议直接填 32 字符 ASCII 字符串或 base64 解码得 32 字节 + ConfigEncryptionKey string `mapstructure:"config_encryption_key"` + // OrderExpireMinutes 订单 PENDING 状态的最长保留时间(分钟),默认 10 + OrderExpireMinutes int `mapstructure:"order_expire_minutes"` +} diff --git a/internal/db/migrator/migrator.go b/internal/db/migrator/migrator.go index b3f566d1..e74f19c9 100644 --- a/internal/db/migrator/migrator.go +++ b/internal/db/migrator/migrator.go @@ -32,6 +32,7 @@ import ( "strings" "github.com/linux-do/cdk/internal/apps/oauth" + "github.com/linux-do/cdk/internal/apps/payment" "github.com/linux-do/cdk/internal/apps/project" "github.com/linux-do/cdk/internal/db" ) @@ -47,6 +48,8 @@ func Migrate() { &project.ProjectItem{}, &project.ProjectTag{}, &project.ProjectReport{}, + &payment.UserPaymentConfig{}, + &payment.PaymentOrder{}, ); err != nil { log.Fatalf("[MySQL] auto migrate failed: %v\n", err) } diff --git a/internal/router/router.go b/internal/router/router.go index 30847451..e6ebf2bf 100644 --- a/internal/router/router.go +++ b/internal/router/router.go @@ -38,6 +38,7 @@ import ( "github.com/linux-do/cdk/internal/apps/dashboard" "github.com/linux-do/cdk/internal/apps/health" "github.com/linux-do/cdk/internal/apps/oauth" + "github.com/linux-do/cdk/internal/apps/payment" "github.com/linux-do/cdk/internal/apps/project" "github.com/linux-do/cdk/internal/config" "github.com/linux-do/cdk/internal/otel_trace" @@ -114,13 +115,28 @@ func Serve() { projectRouter.PUT("/:id", project.ProjectCreatorPermMiddleware(), project.UpdateProject) projectRouter.DELETE("/:id", project.ProjectCreatorPermMiddleware(), project.DeleteProject) projectRouter.GET("/:id/receivers", project.ProjectCreatorPermMiddleware(), project.ListProjectReceivers) - projectRouter.POST("/:id/receive", project.ReceiveProjectMiddleware(), project.ReceiveProject) + projectRouter.POST("/:id/receive", project.ReceiveProjectMiddleware(), payment.DispatchReceive) projectRouter.POST("/:id/report", project.ReportProject) projectRouter.GET("/received/chart", project.ListReceiveHistoryChart) projectRouter.GET("/received", project.ListReceiveHistory) projectRouter.GET("/:id", project.GetProject) } + // User (支付配置等用户级设置) + userRouter := apiV1Router.Group("/users") + userRouter.Use(oauth.LoginRequired()) + { + userRouter.GET("/payment-config", payment.GetPaymentConfig) + userRouter.PUT("/payment-config", payment.UpsertPaymentConfig) + userRouter.DELETE("/payment-config", payment.DeletePaymentConfig) + } + + // Payment 回调(易支付 GET 请求,无 session) + paymentRouter := apiV1Router.Group("/payment") + { + paymentRouter.GET("/notify", payment.HandleNotifyHTTP) + } + // Tag tagRouter := apiV1Router.Group("/tags") tagRouter.Use(oauth.LoginRequired()) diff --git a/internal/task/constants.go b/internal/task/constants.go index 373f5bd7..68b73384 100644 --- a/internal/task/constants.go +++ b/internal/task/constants.go @@ -28,4 +28,6 @@ const ( UpdateAllBadgesTask = "user:badge:update_badges_task" UpdateUserBadgeScoresTask = "user:badge:update_scores_task" UpdateSingleUserBadgeScoreTask = "user:badge:update_single_score_task" + + ExpireStalePaymentOrdersTask = "payment:expire_stale_orders" ) diff --git a/internal/task/schedule/schedule.go b/internal/task/schedule/schedule.go index 67d76aa5..02c818cc 100644 --- a/internal/task/schedule/schedule.go +++ b/internal/task/schedule/schedule.go @@ -26,11 +26,12 @@ package schedule import ( "fmt" - "github.com/linux-do/cdk/internal/config" - "github.com/linux-do/cdk/internal/task" "sync" "time" + "github.com/linux-do/cdk/internal/config" + "github.com/linux-do/cdk/internal/task" + "github.com/hibiken/asynq" ) @@ -68,6 +69,11 @@ func StartScheduler() error { return } + // 每分钟扫描一次未付款超时订单 + if _, err = scheduler.Register(config.Config.Schedule.ExpireStalePaymentOrdersCron, asynq.NewTask(task.ExpireStalePaymentOrdersTask, nil)); err != nil { + return + } + // 启动调度器 err = scheduler.Run() }) diff --git a/internal/task/worker/worker.go b/internal/task/worker/worker.go index 9501fade..42fa22a1 100644 --- a/internal/task/worker/worker.go +++ b/internal/task/worker/worker.go @@ -28,6 +28,7 @@ import ( "context" "github.com/hibiken/asynq" "github.com/linux-do/cdk/internal/apps/oauth" + "github.com/linux-do/cdk/internal/apps/payment" "github.com/linux-do/cdk/internal/config" "github.com/linux-do/cdk/internal/db" "github.com/linux-do/cdk/internal/task" @@ -69,6 +70,7 @@ func StartWorker() error { mux.HandleFunc(task.UpdateAllBadgesTask, oauth.HandleUpdateAllBadges) mux.HandleFunc(task.UpdateUserBadgeScoresTask, oauth.HandleUpdateUserBadgeScores) mux.HandleFunc(task.UpdateSingleUserBadgeScoreTask, oauth.HandleUpdateSingleUserBadgeScore) + mux.HandleFunc(task.ExpireStalePaymentOrdersTask, payment.HandleExpireStaleOrders) // 启动服务器 return asynqServer.Run(mux) }