From 1596f887075a1594955ca91c1f8cc2935ff54300 Mon Sep 17 00:00:00 2001
From: orenzhang <41963680+OrenZhang@users.noreply.github.com>
Date: Mon, 20 Apr 2026 19:43:49 +0800
Subject: [PATCH 01/12] chore(gitignore): update .gitignore to include .idea
directory
---
.gitignore | 1 +
1 file changed, 1 insertion(+)
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
From 8a231b156304826c772a6ded5cc57929ea6d23fb Mon Sep 17 00:00:00 2001
From: orenzhang <41963680+OrenZhang@users.noreply.github.com>
Date: Mon, 20 Apr 2026 21:56:57 +0800
Subject: [PATCH 02/12] feat(payment): implement payment configuration and
validation
---
config.example.yaml | 9 +
docs/docs.go | 12 +
docs/swagger.json | 12 +
docs/swagger.yaml | 8 +
frontend/app/(main)/settings/payment/page.tsx | 9 +
.../components/common/dashboard/DataCards.tsx | 2 +-
.../common/layout/ManagementBar.tsx | 20 ++
.../common/payment/CallbackURLHint.tsx | 60 ++++
.../common/payment/PaymentSettingsPage.tsx | 160 +++++++++
frontend/components/common/payment/index.ts | 2 +
.../common/project/CreateDialog.tsx | 35 +-
.../components/common/project/EditDialog.tsx | 34 +-
.../common/project/ProjectBasicForm.tsx | 21 +-
.../components/common/project/ProjectCard.tsx | 15 +-
.../components/common/project/constants.ts | 24 ++
.../common/receive/ReceiveContent.tsx | 94 ++++-
frontend/hooks/use-project-form.ts | 4 +
frontend/lib/services/index.ts | 6 +
frontend/lib/services/payment/index.ts | 7 +
.../lib/services/payment/payment.service.ts | 63 ++++
frontend/lib/services/payment/types.ts | 33 ++
frontend/lib/services/project/types.ts | 22 +-
go.mod | 2 +-
internal/apps/payment/crypto.go | 151 ++++++++
internal/apps/payment/crypto_test.go | 126 +++++++
internal/apps/payment/epay.go | 142 ++++++++
internal/apps/payment/err.go | 40 +++
internal/apps/payment/models.go | 86 +++++
internal/apps/payment/router.go | 205 +++++++++++
internal/apps/payment/service.go | 338 ++++++++++++++++++
internal/apps/project/err.go | 7 +
internal/apps/project/middlewares.go | 2 +
internal/apps/project/models.go | 54 ++-
internal/apps/project/routers.go | 55 +++
internal/apps/project/utils.go | 4 +-
internal/config/model.go | 19 +
internal/db/migrator/migrator.go | 3 +
internal/router/router.go | 19 +-
38 files changed, 1875 insertions(+), 30 deletions(-)
create mode 100644 frontend/app/(main)/settings/payment/page.tsx
create mode 100644 frontend/components/common/payment/CallbackURLHint.tsx
create mode 100644 frontend/components/common/payment/PaymentSettingsPage.tsx
create mode 100644 frontend/components/common/payment/index.ts
create mode 100644 frontend/lib/services/payment/index.ts
create mode 100644 frontend/lib/services/payment/payment.service.ts
create mode 100644 frontend/lib/services/payment/types.ts
create mode 100644 internal/apps/payment/crypto.go
create mode 100644 internal/apps/payment/crypto_test.go
create mode 100644 internal/apps/payment/epay.go
create mode 100644 internal/apps/payment/err.go
create mode 100644 internal/apps/payment/models.go
create mode 100644 internal/apps/payment/router.go
create mode 100644 internal/apps/payment/service.go
diff --git a/config.example.yaml b/config.example.yaml
index 6b7ddd47..26877b19 100644
--- a/config.example.yaml
+++ b/config.example.yaml
@@ -111,3 +111,12 @@ 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 # 订单未付款超时时间(分钟)
+ http_timeout_seconds: 10 # 调用易支付 API 的 HTTP 超时(秒)
diff --git a/docs/docs.go b/docs/docs.go
index ab244010..3ba7f762 100644
--- a/docs/docs.go
+++ b/docs/docs.go
@@ -1032,6 +1032,9 @@ const docTemplate = `{
"maxLength": 32,
"minLength": 1
},
+ "price": {
+ "type": "number"
+ },
"project_items": {
"type": "array",
"minItems": 1,
@@ -1120,6 +1123,9 @@ const docTemplate = `{
"name": {
"type": "string"
},
+ "price": {
+ "type": "number"
+ },
"received_content": {
"type": "string"
},
@@ -1204,6 +1210,9 @@ const docTemplate = `{
"name": {
"type": "string"
},
+ "price": {
+ "type": "number"
+ },
"risk_level": {
"type": "integer"
},
@@ -1390,6 +1399,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..37f79757 100644
--- a/docs/swagger.json
+++ b/docs/swagger.json
@@ -1023,6 +1023,9 @@
"maxLength": 32,
"minLength": 1
},
+ "price": {
+ "type": "number"
+ },
"project_items": {
"type": "array",
"minItems": 1,
@@ -1111,6 +1114,9 @@
"name": {
"type": "string"
},
+ "price": {
+ "type": "number"
+ },
"received_content": {
"type": "string"
},
@@ -1195,6 +1201,9 @@
"name": {
"type": "string"
},
+ "price": {
+ "type": "number"
+ },
"risk_level": {
"type": "integer"
},
@@ -1381,6 +1390,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..6e7a1afa 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
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..aeb77b24 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';
@@ -188,6 +189,25 @@ 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_url 与 return_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 (
验证中...
>
+ ) : isPaid ? (
+ <>
+
+ 支付 {priceNum} {CURRENCY_LABEL} 并领取
+ >
) : (
<>
@@ -171,11 +179,14 @@ interface ReceiveContentProps {
export function ReceiveContent({data}: ReceiveContentProps) {
const {project, user, projectId} = data;
const router = useRouter();
+ const searchParams = useSearchParams();
+ const incomingTradeNo = searchParams.get('trade_no') || '';
const [currentTime, setCurrentTime] = useState(new Date());
const [hasReceived, setHasReceived] = useState(project.is_received);
const [receivedContent, setReceivedContent] = useState(project.received_content || null);
const [currentProject, setCurrentProject] = useState(project);
const [isVerifying, setIsVerifying] = useState(false);
+ const [isAwaitingPayment, setIsAwaitingPayment] = useState(Boolean(incomingTradeNo) && !project.is_received);
const verifyRef = useRef(null);
/**
@@ -217,23 +228,30 @@ export function ReceiveContent({data}: ReceiveContentProps) {
// 调用领取接口
const result = await services.project.receiveProjectSafe(projectId, token);
- if (result.success) {
- const content = result.data?.itemContent || '领取成功,但未获取到兑换内容';
-
- setCurrentProject((prev) => ({
- ...prev,
- available_items_count: prev.available_items_count - 1,
- is_received: true,
- received_content: content,
- }));
-
- setHasReceived(true);
- setReceivedContent(content);
- toast.success('领取成功!');
- } else {
+ if (!result.success) {
toast.error(result.error || '领取失败');
throw new Error(result.error || '领取失败');
}
+
+ // 付费项目:后端返回支付跳转 URL,浏览器直接跳转
+ if (result.data?.require_payment && result.data.pay_url) {
+ toast.info('正在跳转支付页面...');
+ window.location.href = result.data.pay_url;
+ return;
+ }
+
+ const content = result.data?.itemContent || '领取成功,但未获取到兑换内容';
+
+ setCurrentProject((prev) => ({
+ ...prev,
+ available_items_count: prev.available_items_count - 1,
+ is_received: true,
+ received_content: content,
+ }));
+
+ setHasReceived(true);
+ setReceivedContent(content);
+ toast.success('领取成功!');
};
/**
@@ -263,6 +281,38 @@ export function ReceiveContent({data}: ReceiveContentProps) {
return () => clearInterval(timer);
}, []);
+ // 从支付系统回跳后,轮询项目详情直到发放到账或超时
+ useEffect(() => {
+ if (!isAwaitingPayment || !projectId) return;
+ let cancelled = false;
+ let attempts = 0;
+ const maxAttempts = 15; // ~30 秒
+ const tick = async () => {
+ if (cancelled) return;
+ attempts += 1;
+ const res = await services.project.getProjectSafe(projectId);
+ if (cancelled) return;
+ if (res.success && res.data?.is_received) {
+ setCurrentProject(res.data);
+ setHasReceived(true);
+ setReceivedContent(res.data.received_content || null);
+ setIsAwaitingPayment(false);
+ toast.success('付款完成,CDK 已发放');
+ return;
+ }
+ if (attempts >= maxAttempts) {
+ setIsAwaitingPayment(false);
+ toast.info('付款处理中,稍后请手动刷新查看');
+ return;
+ }
+ setTimeout(tick, 2000);
+ };
+ tick();
+ return () => {
+ cancelled = true;
+ };
+ }, [isAwaitingPayment, projectId]);
+
const trustLevelConfig = TRUST_LEVEL_OPTIONS.find((option) => option.value === currentProject.minimum_trust_level);
const containerVariants = {
@@ -320,6 +370,16 @@ export function ReceiveContent({data}: ReceiveContentProps) {
+ {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..ce4a6f61
--- /dev/null
+++ b/frontend/lib/services/payment/types.ts
@@ -0,0 +1,33 @@
+import {BackendResponse} from '../project/types';
+
+/**
+ * 用户的支付配置视图(后端不返回明文 secret)
+ */
+export interface PaymentConfigData {
+ /** 是否已配置 */
+ has_config: boolean;
+ /** 商户 pid */
+ client_id: string;
+ /** clientSecret 末 4 位,展示用 */
+ secret_last4: string;
+ /** 上次校验成功时间 */
+ last_verified_at?: 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..2d7fe325
--- /dev/null
+++ b/internal/apps/payment/epay.go
@@ -0,0 +1,142 @@
+/*
+ * 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/http"
+ "net/url"
+ "strings"
+ "time"
+
+ "github.com/linux-do/cdk/internal/config"
+)
+
+// httpClientTimeout 调用易支付接口时的默认超时
+func httpClientTimeout() time.Duration {
+ if s := config.Config.Payment.HTTPTimeoutSeconds; s > 0 {
+ return time.Duration(s) * time.Second
+ }
+ return 10 * time.Second
+}
+
+// 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, "/") + "/api/v1/payment/return"
+}
+
+// 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"
+ req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, strings.NewReader(form.Encode()))
+ if err != nil {
+ return err
+ }
+ req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+
+ client := &http.Client{Timeout: httpClientTimeout()}
+ resp, err := client.Do(req)
+ 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..48487731
--- /dev/null
+++ b/internal/apps/payment/models.go
@@ -0,0 +1,86 @@
+/*
+ * 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"`
+ LastVerifiedAt *time.Time `json:"last_verified_at"`
+ LastVerifyStatus int8 `gorm:"default:0" json:"last_verify_status"`
+ CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"`
+ UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at"`
+}
+
+// PaymentOrder 支付订单(一次付费领取 = 一个订单)
+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;index;not null" json:"project_id"`
+ ItemID uint64 `gorm:"index;not null" json:"item_id"`
+ PayerID uint64 `gorm:"index;not null" 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" 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" 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..ee4578c6
--- /dev/null
+++ b/internal/apps/payment/router.go
@@ -0,0 +1,205 @@
+/*
+ * 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"`
+ LastVerifiedAt string `json:"last_verified_at,omitempty"`
+ 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
+ if cfg.LastVerifiedAt != nil {
+ resp.LastVerifiedAt = cfg.LastVerifiedAt.Format("2006-01-02 15:04:05")
+ }
+ }
+ 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")
+ }
+}
+
+// HandleReturnHTTP GET /api/v1/payment/return
+// 同步回跳,302 到前端 /receive/{projectId}?trade_no=...
+func HandleReturnHTTP(c *gin.Context) {
+ path := ReturnRedirectPath(extractQueryMap(c.Request.URL.Query()))
+ c.Redirect(http.StatusFound, path)
+}
+
+// 保留 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..b72d3502
--- /dev/null
+++ b/internal/apps/payment/service.go
@@ -0,0 +1,338 @@
+/*
+ * 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"
+ "strings"
+ "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{
+ UserID: userID,
+ ClientID: clientID,
+ ClientSecretEnc: enc,
+ SecretLast4: last4,
+ }
+ // Upsert:按主键冲突时更新
+ 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"
+ }
+
+ // 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)
+ })
+}
+
+// ReturnRedirectPath 构造同步回跳路径(相对路径,调用方决定是否附加 host)
+// 包含 project_id 与 trade_no,便于前端在 /receive 页展示等待发放的状态
+func ReturnRedirectPath(q map[string]string) string {
+ outTradeNo := q["out_trade_no"]
+ if outTradeNo == "" {
+ return "/"
+ }
+ var order PaymentOrder
+ if err := db.DB(context.Background()).Where("out_trade_no = ?", outTradeNo).First(&order).Error; err != nil {
+ return "/"
+ }
+ return fmt.Sprintf("/receive/%s?trade_no=%s", order.ProjectID, strings.TrimSpace(q["trade_no"]))
+}
+
+// CallbackURLs 返回当前平台配置的回调地址,用于前端展示给用户。
+func CallbackURLs() (notifyURL, returnURL string) {
+ return callbackNotifyURL(), callbackReturnURL()
+}
+
+// ErrOrderNotFoundSentinel 外部判断
+var ErrOrderNotFoundSentinel = errors.New(ErrOrderNotFound)
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..d784ce6d 100644
--- a/internal/apps/project/routers.go
+++ b/internal/apps/project/routers.go
@@ -25,6 +25,7 @@
package project
import (
+ "context"
"errors"
"net/http"
"strings"
@@ -36,9 +37,47 @@ 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"
)
+// 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
+}
+
type ProjectResponse struct {
ErrorMsg string `json:"error_msg"`
Data interface{} `json:"data"`
@@ -54,6 +93,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 +203,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 +224,7 @@ func CreateProject(c *gin.Context) {
CreatorID: currentUser.ID,
IsCompleted: false,
HideFromExplore: req.HideFromExplore,
+ Price: req.Price,
}
// create project
@@ -233,6 +280,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 +295,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
@@ -711,6 +765,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..25bfd498 100644
--- a/internal/apps/project/utils.go
+++ b/internal/apps/project/utils.go
@@ -70,7 +70,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 +121,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
diff --git a/internal/config/model.go b/internal/config/model.go
index 0c3f15a0..2b19d6ef 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 应用基本配置
@@ -151,3 +152,21 @@ 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"`
+ // HTTPTimeoutSeconds 调用易支付接口的超时(秒),默认 10
+ HTTPTimeoutSeconds int `mapstructure:"http_timeout_seconds"`
+}
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..4659933a 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,29 @@ 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)
+ paymentRouter.GET("/return", payment.HandleReturnHTTP)
+ }
+
// Tag
tagRouter := apiV1Router.Group("/tags")
tagRouter.Use(oauth.LoginRequired())
From c7c4532480e2e74e408718f3862d4dce41cb6afa Mon Sep 17 00:00:00 2001
From: orenzhang <41963680+OrenZhang@users.noreply.github.com>
Date: Mon, 20 Apr 2026 22:01:53 +0800
Subject: [PATCH 03/12] feat(payment): implement stale order expiration
handling
---
config.example.yaml | 1 +
internal/apps/payment/cleanup.go | 82 ++++++++++++++++++++++++++++++
internal/config/model.go | 1 +
internal/task/constants.go | 2 +
internal/task/schedule/schedule.go | 9 ++++
internal/task/worker/worker.go | 2 +
6 files changed, 97 insertions(+)
create mode 100644 internal/apps/payment/cleanup.go
diff --git a/config.example.yaml b/config.example.yaml
index 26877b19..5f28f892 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:
diff --git a/internal/apps/payment/cleanup.go b/internal/apps/payment/cleanup.go
new file mode 100644
index 00000000..e75811a0
--- /dev/null
+++ b/internal/apps/payment/cleanup.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/config/model.go b/internal/config/model.go
index 2b19d6ef..33b22a93 100644
--- a/internal/config/model.go
+++ b/internal/config/model.go
@@ -136,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 工作配置
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..1e644248 100644
--- a/internal/task/schedule/schedule.go
+++ b/internal/task/schedule/schedule.go
@@ -68,6 +68,15 @@ func StartScheduler() error {
return
}
+ // 每分钟扫描一次未付款超时订单
+ expireCron := config.Config.Schedule.ExpireStalePaymentOrdersCron
+ if expireCron == "" {
+ expireCron = "*/1 * * * *"
+ }
+ if _, err = scheduler.Register(expireCron, 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)
}
From 50d26bf053d582c0f715ad6538cc71ff116e93e8 Mon Sep 17 00:00:00 2001
From: orenzhang <41963680+OrenZhang@users.noreply.github.com>
Date: Mon, 20 Apr 2026 22:03:47 +0800
Subject: [PATCH 04/12] feat(payment): enhance PaymentOrder model with indexed
fields for optimized queries
---
internal/apps/payment/models.go | 12 ++++++++----
1 file changed, 8 insertions(+), 4 deletions(-)
diff --git a/internal/apps/payment/models.go b/internal/apps/payment/models.go
index 48487731..2b93605d 100644
--- a/internal/apps/payment/models.go
+++ b/internal/apps/payment/models.go
@@ -59,21 +59,25 @@ type UserPaymentConfig struct {
}
// 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;index;not null" json:"project_id"`
+ 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:"index;not null" json:"payer_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" json:"status"`
+ 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" json:"expire_at"`
+ 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"`
From 00fa6634d0f9f4edda335df144d4756763f76940 Mon Sep 17 00:00:00 2001
From: orenzhang <41963680+OrenZhang@users.noreply.github.com>
Date: Mon, 20 Apr 2026 22:05:51 +0800
Subject: [PATCH 05/12] feat(payment): remove ReceiveProject function from
routers
---
internal/apps/project/routers.go | 90 --------------------------------
1 file changed, 90 deletions(-)
diff --git a/internal/apps/project/routers.go b/internal/apps/project/routers.go
index d784ce6d..fbd5a16a 100644
--- a/internal/apps/project/routers.go
+++ b/internal/apps/project/routers.go
@@ -463,96 +463,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"`
}
From b2d6a61ed4c677de6dcc1f22e1e9795dd658ec8a Mon Sep 17 00:00:00 2001
From: orenzhang <41963680+OrenZhang@users.noreply.github.com>
Date: Mon, 20 Apr 2026 22:09:10 +0800
Subject: [PATCH 06/12] feat(payment): remove deprecated receive endpoint from
API documentation
---
docs/docs.go | 30 ------------------------------
docs/swagger.json | 30 ------------------------------
docs/swagger.yaml | 19 -------------------
3 files changed, 79 deletions(-)
diff --git a/docs/docs.go b/docs/docs.go
index 3ba7f762..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": [
diff --git a/docs/swagger.json b/docs/swagger.json
index 37f79757..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": [
diff --git a/docs/swagger.yaml b/docs/swagger.yaml
index 6e7a1afa..2f7bc96d 100644
--- a/docs/swagger.yaml
+++ b/docs/swagger.yaml
@@ -766,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:
From 607e14a2f055762e1c12637459d8610e35697202 Mon Sep 17 00:00:00 2001
From: orenzhang <41963680+OrenZhang@users.noreply.github.com>
Date: Tue, 21 Apr 2026 10:56:06 +0800
Subject: [PATCH 07/12] feat(payment): refactor HTTP request handling and
remove timeout configuration
---
config.example.yaml | 1 -
internal/apps/payment/epay.go | 26 +++++++++-----------------
internal/config/model.go | 2 --
3 files changed, 9 insertions(+), 20 deletions(-)
diff --git a/config.example.yaml b/config.example.yaml
index 5f28f892..96b83e07 100644
--- a/config.example.yaml
+++ b/config.example.yaml
@@ -120,4 +120,3 @@ payment:
notify_base_url: "https://your-domain.com" # 本项目公网基址,用于拼接回调 URL
config_encryption_key: "<32-char-secret-key!!>" # AES-256 密钥,恰好 32 字节,首次部署后不可更改
order_expire_minutes: 10 # 订单未付款超时时间(分钟)
- http_timeout_seconds: 10 # 调用易支付 API 的 HTTP 超时(秒)
diff --git a/internal/apps/payment/epay.go b/internal/apps/payment/epay.go
index 2d7fe325..5a766b32 100644
--- a/internal/apps/payment/epay.go
+++ b/internal/apps/payment/epay.go
@@ -30,22 +30,13 @@ import (
"errors"
"fmt"
"io"
- "net/http"
"net/url"
"strings"
- "time"
"github.com/linux-do/cdk/internal/config"
+ "github.com/linux-do/cdk/internal/utils"
)
-// httpClientTimeout 调用易支付接口时的默认超时
-func httpClientTimeout() time.Duration {
- if s := config.Config.Payment.HTTPTimeoutSeconds; s > 0 {
- return time.Duration(s) * time.Second
- }
- return 10 * time.Second
-}
-
// submitURL 构造 /epay/pay/submit.php 的完整 GET 跳转 URL,由浏览器直接访问。
// 不在后端跟随 302,以确保 credit.linux.do/paying 的付款会话对用户浏览器可见。
func submitURL(clientID, secret, name, money, outTradeNo, notifyURL, returnURL string) string {
@@ -95,14 +86,15 @@ func doEpayRefund(ctx context.Context, clientID, clientSecret, tradeNo, money st
form.Set("money", money)
endpoint := strings.TrimRight(config.Config.Payment.ApiUrl, "/") + "/api.php"
- req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, strings.NewReader(form.Encode()))
- if err != nil {
- return err
- }
- req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
- client := &http.Client{Timeout: httpClientTimeout()}
- resp, err := client.Do(req)
+ 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
}
diff --git a/internal/config/model.go b/internal/config/model.go
index 33b22a93..bada9772 100644
--- a/internal/config/model.go
+++ b/internal/config/model.go
@@ -168,6 +168,4 @@ type PaymentConfig struct {
ConfigEncryptionKey string `mapstructure:"config_encryption_key"`
// OrderExpireMinutes 订单 PENDING 状态的最长保留时间(分钟),默认 10
OrderExpireMinutes int `mapstructure:"order_expire_minutes"`
- // HTTPTimeoutSeconds 调用易支付接口的超时(秒),默认 10
- HTTPTimeoutSeconds int `mapstructure:"http_timeout_seconds"`
}
From ed80ad8cc97ee80e764b0ac55d8b360600ee80af Mon Sep 17 00:00:00 2001
From: orenzhang <41963680+OrenZhang@users.noreply.github.com>
Date: Tue, 21 Apr 2026 11:40:23 +0800
Subject: [PATCH 08/12] feat(payment): update callback return URL and remove
unused return handler
---
internal/apps/payment/epay.go | 2 +-
internal/apps/payment/router.go | 7 -------
internal/apps/payment/service.go | 15 ---------------
internal/router/router.go | 1 -
4 files changed, 1 insertion(+), 24 deletions(-)
diff --git a/internal/apps/payment/epay.go b/internal/apps/payment/epay.go
index 5a766b32..418c3bb7 100644
--- a/internal/apps/payment/epay.go
+++ b/internal/apps/payment/epay.go
@@ -65,7 +65,7 @@ func callbackNotifyURL() string {
// callbackReturnURL 返回同步回跳地址。
func callbackReturnURL() string {
- return strings.TrimRight(config.Config.Payment.NotifyBaseURL, "/") + "/api/v1/payment/return"
+ return strings.TrimRight(config.Config.Payment.NotifyBaseURL, "/") + "/received"
}
// refundResponse 易支付退款接口响应
diff --git a/internal/apps/payment/router.go b/internal/apps/payment/router.go
index ee4578c6..fd18116e 100644
--- a/internal/apps/payment/router.go
+++ b/internal/apps/payment/router.go
@@ -194,12 +194,5 @@ func HandleNotifyHTTP(c *gin.Context) {
}
}
-// HandleReturnHTTP GET /api/v1/payment/return
-// 同步回跳,302 到前端 /receive/{projectId}?trade_no=...
-func HandleReturnHTTP(c *gin.Context) {
- path := ReturnRedirectPath(extractQueryMap(c.Request.URL.Query()))
- c.Redirect(http.StatusFound, path)
-}
-
// 保留 GORM ErrRecordNotFound 的判断以防将来需要细分
var _ = errors.Is
diff --git a/internal/apps/payment/service.go b/internal/apps/payment/service.go
index b72d3502..91592565 100644
--- a/internal/apps/payment/service.go
+++ b/internal/apps/payment/service.go
@@ -30,7 +30,6 @@ import (
"encoding/hex"
"errors"
"fmt"
- "strings"
"time"
"github.com/linux-do/cdk/internal/apps/oauth"
@@ -315,20 +314,6 @@ func fulfillPaidOrder(ctx context.Context, order *PaymentOrder) error {
})
}
-// ReturnRedirectPath 构造同步回跳路径(相对路径,调用方决定是否附加 host)
-// 包含 project_id 与 trade_no,便于前端在 /receive 页展示等待发放的状态
-func ReturnRedirectPath(q map[string]string) string {
- outTradeNo := q["out_trade_no"]
- if outTradeNo == "" {
- return "/"
- }
- var order PaymentOrder
- if err := db.DB(context.Background()).Where("out_trade_no = ?", outTradeNo).First(&order).Error; err != nil {
- return "/"
- }
- return fmt.Sprintf("/receive/%s?trade_no=%s", order.ProjectID, strings.TrimSpace(q["trade_no"]))
-}
-
// CallbackURLs 返回当前平台配置的回调地址,用于前端展示给用户。
func CallbackURLs() (notifyURL, returnURL string) {
return callbackNotifyURL(), callbackReturnURL()
diff --git a/internal/router/router.go b/internal/router/router.go
index 4659933a..e6ebf2bf 100644
--- a/internal/router/router.go
+++ b/internal/router/router.go
@@ -135,7 +135,6 @@ func Serve() {
paymentRouter := apiV1Router.Group("/payment")
{
paymentRouter.GET("/notify", payment.HandleNotifyHTTP)
- paymentRouter.GET("/return", payment.HandleReturnHTTP)
}
// Tag
From 823c944228cb4d002a62f4bb56a94e8a0cd9dacc Mon Sep 17 00:00:00 2001
From: orenzhang <41963680+OrenZhang@users.noreply.github.com>
Date: Tue, 21 Apr 2026 11:50:40 +0800
Subject: [PATCH 09/12] feat(payment): refactor ManagementBar and update
UserPaymentConfig structure
---
.../common/layout/ManagementBar.tsx | 27 ++++++++++---------
frontend/lib/services/payment/types.ts | 2 --
internal/apps/payment/models.go | 14 +++++-----
internal/apps/payment/router.go | 4 ---
internal/apps/payment/service.go | 21 ++++++++++-----
5 files changed, 35 insertions(+), 33 deletions(-)
diff --git a/frontend/components/common/layout/ManagementBar.tsx b/frontend/components/common/layout/ManagementBar.tsx
index aeb77b24..c7ec1eef 100644
--- a/frontend/components/common/layout/ManagementBar.tsx
+++ b/frontend/components/common/layout/ManagementBar.tsx
@@ -28,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',
@@ -193,18 +194,20 @@ export function ManagementBar() {
账户设置
-
-
-
-
-
- 支付设置
- 配置你作为收款商户的 clientID / clientSecret
-
-
+
+
+
+
+
+
+ 支付设置
+ 配置你作为收款商户的 clientID / clientSecret
+
+
+
diff --git a/frontend/lib/services/payment/types.ts b/frontend/lib/services/payment/types.ts
index ce4a6f61..8ca3203d 100644
--- a/frontend/lib/services/payment/types.ts
+++ b/frontend/lib/services/payment/types.ts
@@ -10,8 +10,6 @@ export interface PaymentConfigData {
client_id: string;
/** clientSecret 末 4 位,展示用 */
secret_last4: string;
- /** 上次校验成功时间 */
- last_verified_at?: string;
/** 应该填写在 LDC 商户后台的异步通知地址 */
callback_notify_url: string;
/** 应该填写在 LDC 商户后台的同步回跳地址 */
diff --git a/internal/apps/payment/models.go b/internal/apps/payment/models.go
index 2b93605d..44dc90a5 100644
--- a/internal/apps/payment/models.go
+++ b/internal/apps/payment/models.go
@@ -48,14 +48,12 @@ const (
// 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"`
- LastVerifiedAt *time.Time `json:"last_verified_at"`
- LastVerifyStatus int8 `gorm:"default:0" json:"last_verify_status"`
- CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"`
- UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at"`
+ 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 支付订单(一次付费领取 = 一个订单)
diff --git a/internal/apps/payment/router.go b/internal/apps/payment/router.go
index fd18116e..acd26156 100644
--- a/internal/apps/payment/router.go
+++ b/internal/apps/payment/router.go
@@ -47,7 +47,6 @@ type GetPaymentConfigResponseData struct {
HasConfig bool `json:"has_config"`
ClientID string `json:"client_id"`
SecretLast4 string `json:"secret_last4"`
- LastVerifiedAt string `json:"last_verified_at,omitempty"`
CallbackNotifyURL string `json:"callback_notify_url"`
CallbackReturnURL string `json:"callback_return_url"`
PaymentEnabled bool `json:"payment_enabled"`
@@ -72,9 +71,6 @@ func GetPaymentConfig(c *gin.Context) {
resp.HasConfig = true
resp.ClientID = cfg.ClientID
resp.SecretLast4 = cfg.SecretLast4
- if cfg.LastVerifiedAt != nil {
- resp.LastVerifiedAt = cfg.LastVerifiedAt.Format("2006-01-02 15:04:05")
- }
}
c.JSON(http.StatusOK, Response{Data: resp})
}
diff --git a/internal/apps/payment/service.go b/internal/apps/payment/service.go
index 91592565..db4796fb 100644
--- a/internal/apps/payment/service.go
+++ b/internal/apps/payment/service.go
@@ -69,13 +69,20 @@ func SaveUserPaymentConfig(ctx context.Context, userID uint64, clientID, clientS
if len(last4) > 4 {
last4 = last4[len(last4)-4:]
}
- cfg := UserPaymentConfig{
- UserID: userID,
- ClientID: clientID,
- ClientSecretEnc: enc,
- SecretLast4: last4,
- }
- // Upsert:按主键冲突时更新
+ 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
}
From 74bd3e4c9b462a753194c2268fd15956e0bd4934 Mon Sep 17 00:00:00 2001
From: orenzhang <41963680+OrenZhang@users.noreply.github.com>
Date: Tue, 21 Apr 2026 11:53:01 +0800
Subject: [PATCH 10/12] feat(payment): standardize import statement for
DialogClose in ManagementBar
---
frontend/components/common/layout/ManagementBar.tsx | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/frontend/components/common/layout/ManagementBar.tsx b/frontend/components/common/layout/ManagementBar.tsx
index c7ec1eef..61446b1b 100644
--- a/frontend/components/common/layout/ManagementBar.tsx
+++ b/frontend/components/common/layout/ManagementBar.tsx
@@ -28,7 +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";
+import {DialogClose} from '@/components/ui/dialog';
const IconOptions = {
className: 'h-4 w-4',
From 92d2fa442a300ff71260d8cd5c1c0c15267ce5e3 Mon Sep 17 00:00:00 2001
From: orenzhang <41963680+OrenZhang@users.noreply.github.com>
Date: Tue, 21 Apr 2026 14:03:58 +0800
Subject: [PATCH 11/12] feat(payment): add handling for refunding order status
---
internal/apps/payment/service.go | 18 ++++++++++++++++++
1 file changed, 18 insertions(+)
diff --git a/internal/apps/payment/service.go b/internal/apps/payment/service.go
index db4796fb..740fed44 100644
--- a/internal/apps/payment/service.go
+++ b/internal/apps/payment/service.go
@@ -258,6 +258,24 @@ func HandleNotify(ctx context.Context, q map[string]string) (bool, string) {
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{}).
From a115755453701726531e7e6d440c3bb56175f333 Mon Sep 17 00:00:00 2001
From: orenzhang <41963680+OrenZhang@users.noreply.github.com>
Date: Tue, 21 Apr 2026 15:37:15 +0800
Subject: [PATCH 12/12] feat(payment): refactor project price validation and
clean up code structure
---
.../apps/payment/{cleanup.go => tasks.go} | 0
internal/apps/project/routers.go | 38 -----------------
internal/apps/project/utils.go | 42 +++++++++++++++++++
internal/task/schedule/schedule.go | 11 ++---
4 files changed, 46 insertions(+), 45 deletions(-)
rename internal/apps/payment/{cleanup.go => tasks.go} (100%)
diff --git a/internal/apps/payment/cleanup.go b/internal/apps/payment/tasks.go
similarity index 100%
rename from internal/apps/payment/cleanup.go
rename to internal/apps/payment/tasks.go
diff --git a/internal/apps/project/routers.go b/internal/apps/project/routers.go
index fbd5a16a..650d3e53 100644
--- a/internal/apps/project/routers.go
+++ b/internal/apps/project/routers.go
@@ -25,7 +25,6 @@
package project
import (
- "context"
"errors"
"net/http"
"strings"
@@ -41,43 +40,6 @@ import (
"gorm.io/gorm"
)
-// 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
-}
-
type ProjectResponse struct {
ErrorMsg string `json:"error_msg"`
Data interface{} `json:"data"`
diff --git a/internal/apps/project/utils.go b/internal/apps/project/utils.go
index 25bfd498..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"
)
@@ -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/task/schedule/schedule.go b/internal/task/schedule/schedule.go
index 1e644248..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"
)
@@ -69,11 +70,7 @@ func StartScheduler() error {
}
// 每分钟扫描一次未付款超时订单
- expireCron := config.Config.Schedule.ExpireStalePaymentOrdersCron
- if expireCron == "" {
- expireCron = "*/1 * * * *"
- }
- if _, err = scheduler.Register(expireCron, asynq.NewTask(task.ExpireStalePaymentOrdersTask, nil)); err != nil {
+ if _, err = scheduler.Register(config.Config.Schedule.ExpireStalePaymentOrdersCron, asynq.NewTask(task.ExpireStalePaymentOrdersTask, nil)); err != nil {
return
}