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:
erio
2026-04-05 22:17:56 +08:00
parent 1b215c88b5
commit 5cde1bcc59
8 changed files with 26 additions and 15 deletions
@@ -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")
+2 -1
View File
@@ -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)
+2 -1
View File
@@ -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>
+1
View File
@@ -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
+6 -3
View File
@@ -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)
}