mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-09-24 16:05:44 +08:00
fix: code quality audit — critical bugs + convention compliance
Critical: - Refund idempotency keys now include UnixNano timestamp (alipay/wxpay) - PaymentView passes qr/pay_url params to QR code page - Fix dead code branch in order result handling Quality: - Currency symbol $ → ¥ in admin stats and refund dialog - StripePaymentView setTimeout cleanup on unmount - Webhook body size limited to 1MB (io.LimitReader) - SubscriptionPlan features type documented
This commit is contained in:
@@ -51,7 +51,7 @@ func (h *PaymentWebhookHandler) StripeWebhook(c *gin.Context) {
|
||||
|
||||
// handleNotify is the shared logic for all provider webhook handlers.
|
||||
func (h *PaymentWebhookHandler) handleNotify(c *gin.Context, providerKey string) {
|
||||
body, err := io.ReadAll(c.Request.Body)
|
||||
body, err := io.ReadAll(io.LimitReader(c.Request.Body, 1<<20)) // 1MB limit
|
||||
if err != nil {
|
||||
log.Printf("[Payment Webhook] failed to read body for %s: %v", providerKey, err)
|
||||
c.String(http.StatusBadRequest, "failed to read body")
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/payment"
|
||||
"github.com/smartwalle/alipay/v3"
|
||||
@@ -207,7 +208,7 @@ func (a *Alipay) Refund(ctx context.Context, req payment.RefundRequest) (*paymen
|
||||
OutTradeNo: req.OrderID,
|
||||
RefundAmount: req.Amount,
|
||||
RefundReason: req.Reason,
|
||||
OutRequestNo: req.OrderID + "-refund",
|
||||
OutRequestNo: fmt.Sprintf("%s-refund-%d", req.OrderID, time.Now().UnixNano()),
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("alipay TradeRefund: %w", err)
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/payment"
|
||||
"github.com/wechatpay-apiv3/wechatpay-go/core"
|
||||
@@ -272,7 +273,7 @@ func (w *Wxpay) Refund(ctx context.Context, req payment.RefundRequest) (*payment
|
||||
cur := "CNY"
|
||||
res, _, err := rs.Create(ctx, refunddomestic.CreateRequest{
|
||||
OutTradeNo: core.String(req.OrderID),
|
||||
OutRefundNo: core.String(fmt.Sprintf("refund-%s", req.OrderID)),
|
||||
OutRefundNo: core.String(fmt.Sprintf("%s-refund-%d", req.OrderID, time.Now().UnixNano())),
|
||||
Reason: core.String(req.Reason),
|
||||
Amount: &refunddomestic.AmountReq{Refund: core.Int64(rf), Total: core.Int64(tf), Currency: &cur},
|
||||
})
|
||||
|
||||
@@ -14,11 +14,11 @@
|
||||
</div>
|
||||
<div class="mt-1 flex justify-between text-sm">
|
||||
<span class="text-gray-500 dark:text-gray-400">{{ t('payment.orders.amount') }}</span>
|
||||
<span class="font-medium text-gray-900 dark:text-white">${{ order?.pay_amount?.toFixed(2) }}</span>
|
||||
<span class="font-medium text-gray-900 dark:text-white">¥{{ order?.pay_amount?.toFixed(2) }}</span>
|
||||
</div>
|
||||
<div v-if="order?.refund_amount" class="mt-1 flex justify-between text-sm">
|
||||
<span class="text-gray-500 dark:text-gray-400">{{ t('payment.admin.alreadyRefunded') }}</span>
|
||||
<span class="font-medium text-red-600 dark:text-red-400">${{ order.refund_amount.toFixed(2) }}</span>
|
||||
<span class="font-medium text-red-600 dark:text-red-400">¥{{ order.refund_amount.toFixed(2) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -26,7 +26,7 @@
|
||||
<div>
|
||||
<label class="input-label">{{ t('payment.admin.refundAmount') }}</label>
|
||||
<div class="relative">
|
||||
<span class="absolute left-3 top-1/2 -translate-y-1/2 text-gray-500">$</span>
|
||||
<span class="absolute left-3 top-1/2 -translate-y-1/2 text-gray-500">¥</span>
|
||||
<input
|
||||
v-model.number="form.amount"
|
||||
type="number"
|
||||
@@ -38,7 +38,7 @@
|
||||
/>
|
||||
</div>
|
||||
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ t('payment.admin.maxRefundable') }}: ${{ maxRefundable.toFixed(2) }}
|
||||
{{ t('payment.admin.maxRefundable') }}: ¥{{ maxRefundable.toFixed(2) }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-xs font-medium text-gray-500 dark:text-gray-400">{{ t('payment.admin.todayRevenue') }}</p>
|
||||
<p class="text-xl font-bold text-gray-900 dark:text-white">${{ formatMoney(stats.today_amount) }}</p>
|
||||
<p class="text-xl font-bold text-gray-900 dark:text-white">¥{{ formatMoney(stats.today_amount) }}</p>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ stats.today_count }} {{ t('payment.admin.orders') }}
|
||||
</p>
|
||||
@@ -24,7 +24,7 @@
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-xs font-medium text-gray-500 dark:text-gray-400">{{ t('payment.admin.totalRevenue') }}</p>
|
||||
<p class="text-xl font-bold text-gray-900 dark:text-white">${{ formatMoney(stats.total_amount) }}</p>
|
||||
<p class="text-xl font-bold text-gray-900 dark:text-white">¥{{ formatMoney(stats.total_amount) }}</p>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ stats.total_count }} {{ t('payment.admin.orders') }}
|
||||
</p>
|
||||
@@ -53,7 +53,7 @@
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-xs font-medium text-gray-500 dark:text-gray-400">{{ t('payment.admin.avgAmount') }}</p>
|
||||
<p class="text-xl font-bold text-gray-900 dark:text-white">${{ formatMoney(stats.avg_amount) }}</p>
|
||||
<p class="text-xl font-bold text-gray-900 dark:text-white">¥{{ formatMoney(stats.avg_amount) }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -79,6 +79,7 @@ export interface SubscriptionPlan {
|
||||
original_price?: number
|
||||
validity_days: number
|
||||
validity_unit: string
|
||||
/** Stored as JSON string in backend; API layer should parse before use */
|
||||
features: string[]
|
||||
for_sale: boolean
|
||||
sort_order: number
|
||||
|
||||
@@ -214,12 +214,15 @@ async function createOrder(orderAmount: number, orderType: string, planId?: numb
|
||||
order_type: orderType,
|
||||
plan_id: planId,
|
||||
})
|
||||
if (selectedMethod.value === 'stripe' && result.client_secret) {
|
||||
if (result.client_secret) {
|
||||
router.push({ path: '/payment/stripe', query: { order_id: String(result.order_id), client_secret: result.client_secret } })
|
||||
} else if (result.qr_code || result.pay_url) {
|
||||
router.push({ path: '/payment/qrcode', query: { order_id: String(result.order_id) } })
|
||||
} else if (result.qr_code) {
|
||||
router.push({ path: '/payment/qrcode', query: { order_id: String(result.order_id), qr: result.qr_code || '', pay_url: result.pay_url || '' } })
|
||||
} else if (result.pay_url) {
|
||||
window.location.href = result.pay_url
|
||||
} else {
|
||||
errorMessage.value = t('payment.result.failed')
|
||||
appStore.showError(errorMessage.value)
|
||||
}
|
||||
} catch (err: any) {
|
||||
errorMessage.value = err.response?.data?.detail || err.message || t('payment.result.failed')
|
||||
|
||||
@@ -43,7 +43,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { ref, onMounted, onUnmounted } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { usePaymentStore } from '@/stores/payment'
|
||||
@@ -66,6 +66,7 @@ const stripeReady = ref(false)
|
||||
const order = ref<PaymentOrder | null>(null)
|
||||
|
||||
let stripeInstance: any = null
|
||||
let redirectTimer: ReturnType<typeof setTimeout> | null = null
|
||||
let elementsInstance: any = null
|
||||
|
||||
onMounted(async () => {
|
||||
@@ -107,6 +108,10 @@ onMounted(async () => {
|
||||
}
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
if (redirectTimer) clearTimeout(redirectTimer)
|
||||
})
|
||||
|
||||
async function handlePay() {
|
||||
if (!stripeInstance || !elementsInstance || stripeSubmitting.value) return
|
||||
stripeSubmitting.value = true
|
||||
@@ -123,7 +128,7 @@ async function handlePay() {
|
||||
stripeError.value = error.message || t('payment.result.failed')
|
||||
} else {
|
||||
stripeSuccess.value = true
|
||||
setTimeout(() => {
|
||||
redirectTimer = setTimeout(() => {
|
||||
router.push({ path: '/payment/result', query: { order_id: route.query.order_id as string, status: 'success' } })
|
||||
}, 2000)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user