feat: add portal recharge page

This commit is contained in:
QiuSW
2026-07-03 14:49:45 +08:00
parent 4a10c2bb92
commit c2e411fe45
18 changed files with 518 additions and 31 deletions
+39
View File
@@ -1,4 +1,9 @@
from decimal import Decimal
from django import forms
from django.conf import settings
from apps.billing.models import RechargeOrder
class ApiKeyCreateForm(forms.Form):
@@ -14,3 +19,37 @@ class ApiKeyCreateForm(forms.Form):
}
),
)
class RechargeCreateForm(forms.Form):
amount = forms.DecimalField(
label="充值金额",
max_digits=12,
decimal_places=2,
min_value=Decimal("0.01"),
widget=forms.NumberInput(
attrs={
"class": "form-control",
"inputmode": "decimal",
"min": "0.01",
"step": "0.01",
"placeholder": "例如:100.00",
}
),
)
pay_method = forms.ChoiceField(
label="支付方式",
choices=(
(RechargeOrder.PayMethod.WEIXIN, "微信"),
(RechargeOrder.PayMethod.ALIPAY, "支付宝"),
),
initial=RechargeOrder.PayMethod.WEIXIN,
widget=forms.RadioSelect(attrs={"class": "form-check-input"}),
)
def clean_amount(self):
amount = self.cleaned_data["amount"]
max_amount = Decimal(str(settings.RECHARGE_MAX_AMOUNT_CNY))
if amount > max_amount:
raise forms.ValidationError(f"单笔充值金额不能超过 {max_amount:.2f} CNY")
return amount
+1
View File
@@ -83,6 +83,7 @@
<div class="ms-auto d-flex gap-2 flex-wrap justify-content-end">
{% if user.is_authenticated %}
<a class="btn btn-sm btn-outline-secondary" href="{% url 'portal-dashboard' %}">控制台</a>
<a class="btn btn-sm btn-primary" href="{% url 'portal-recharge' %}">充值</a>
<a class="btn btn-sm btn-outline-secondary" href="{% url 'portal-apikeys' %}">API Key</a>
<a class="btn btn-sm btn-outline-secondary" href="{% url 'portal-recharge-records' %}">充值记录</a>
<a class="btn btn-sm btn-outline-secondary" href="{% url 'portal-usage-records' %}">消费记录</a>
+2 -2
View File
@@ -54,9 +54,9 @@
<div class="metric d-flex flex-column gap-3">
<div>
<div class="text-secondary small">充值</div>
<div class="h5 mb-0">充值记录</div>
<div class="h5 mb-0">扫码充值</div>
</div>
<a class="btn btn-primary align-self-start" href="{% url 'portal-recharge-records' %}">查看</a>
<a class="btn btn-primary align-self-start" href="{% url 'portal-recharge' %}">充值</a>
</div>
</div>
<div class="col-md-4">
+254
View File
@@ -0,0 +1,254 @@
{% extends "portal/base.html" %}
{% block title %}充值 - cmhub{% endblock %}
{% block content %}
<div class="d-flex flex-column gap-4">
<div class="d-flex justify-content-between align-items-start gap-3 flex-wrap">
<div>
<h1 class="h3 mb-1">充值</h1>
<div class="text-secondary">{{ user.email }}</div>
</div>
<a class="btn btn-outline-secondary" href="{% url 'portal-recharge-records' %}">充值记录</a>
</div>
<div class="row g-3">
<div class="col-md-4">
<div class="metric">
<div class="text-secondary small">当前点数</div>
<div class="display-6 fw-semibold">{{ balance.points_balance }}</div>
</div>
</div>
<div class="col-md-4">
<div class="metric">
<div class="text-secondary small">充值总额</div>
<div class="display-6 fw-semibold">{{ recharge_total_amount|floatformat:2 }}</div>
</div>
</div>
<div class="col-md-4">
<div class="metric">
<div class="text-secondary small">入账点数</div>
<div class="display-6 fw-semibold">{{ recharge_points_total }}</div>
</div>
</div>
</div>
<section class="cmhub-surface">
<h2 class="h5 mb-3">发起充值</h2>
<form method="post" class="row g-3 align-items-end" novalidate>
{% csrf_token %}
{% if form.non_field_errors %}
<div class="col-12">
<div class="alert alert-danger mb-0">{{ form.non_field_errors|striptags }}</div>
</div>
{% endif %}
<div class="col-md-5">
<label class="form-label" for="{{ form.amount.id_for_label }}">{{ form.amount.label }}</label>
{{ form.amount }}
{% if form.amount.errors %}
<div class="text-danger small mt-1">{{ form.amount.errors|striptags }}</div>
{% endif %}
</div>
<div class="col-md-4">
<div class="form-label">{{ form.pay_method.label }}</div>
<div class="d-flex gap-3 flex-wrap">
{% for radio in form.pay_method %}
<div class="form-check">
{{ radio.tag }}
<label class="form-check-label" for="{{ radio.id_for_label }}">{{ radio.choice_label }}</label>
</div>
{% endfor %}
</div>
{% if form.pay_method.errors %}
<div class="text-danger small mt-1">{{ form.pay_method.errors|striptags }}</div>
{% endif %}
</div>
<div class="col-md-3">
<button class="btn btn-primary w-100" type="submit">创建订单</button>
</div>
</form>
</section>
{% if current_order %}
<section
class="cmhub-surface"
data-recharge-order
data-order-no="{{ current_order.order_no }}"
data-status="{{ current_order.status }}"
data-code-url="{{ current_order.code_url }}"
data-status-url="{% url 'api-recharge-status' %}"
>
<div class="d-flex justify-content-between align-items-start gap-3 flex-wrap mb-3">
<div>
<h2 class="h5 mb-1">待支付订单</h2>
<div class="text-secondary small">订单号:<code>{{ current_order.order_no }}</code></div>
</div>
<span class="badge text-bg-secondary" id="recharge-status-badge">{{ current_order.status }}</span>
</div>
<div class="row g-4 align-items-start">
<div class="col-md-5">
<canvas id="recharge-qr" class="border rounded bg-white p-2" width="240" height="240"></canvas>
<div id="recharge-qr-fallback" class="d-none mt-3">
<label class="form-label" for="recharge-code-url">支付票据</label>
<textarea id="recharge-code-url" class="form-control font-monospace key-value" rows="4" readonly>{{ current_order.code_url }}</textarea>
</div>
</div>
<div class="col-md-7">
<dl class="row mb-0">
<dt class="col-sm-4">金额</dt>
<dd class="col-sm-8">{{ current_order.amount_money|floatformat:2 }} {{ current_order.currency }}</dd>
<dt class="col-sm-4">预计到账</dt>
<dd class="col-sm-8">{{ current_order.points_granted }} 点</dd>
<dt class="col-sm-4">支付方式</dt>
<dd class="col-sm-8">{{ current_order.pay_method }}</dd>
<dt class="col-sm-4">创建时间</dt>
<dd class="col-sm-8">{{ current_order.created_at|date:"Y-m-d H:i" }}</dd>
<dt class="col-sm-4">二维码有效期</dt>
<dd class="col-sm-8">{{ current_order.expires_at|date:"Y-m-d H:i"|default:"-" }}</dd>
<dt class="col-sm-4">到账时间</dt>
<dd class="col-sm-8" id="recharge-paid-at">{{ current_order.paid_at|date:"Y-m-d H:i"|default:"-" }}</dd>
</dl>
<div class="alert alert-info mt-3 mb-0" id="recharge-poll-hint" aria-live="polite">
{% if current_order.status == "pending" %}
正在等待支付结果,页面会自动轮询订单状态。
{% elif current_order.status == "paid" %}
订单已到账,余额已刷新。
{% else %}
订单当前状态为 {{ current_order.status }}。
{% endif %}
</div>
</div>
</div>
</section>
{% endif %}
<section class="cmhub-surface">
<div class="d-flex justify-content-between align-items-center mb-3">
<h2 class="h5 mb-0">最近充值</h2>
<a class="btn btn-sm btn-outline-secondary" href="{% url 'portal-recharge-records' %}">全部</a>
</div>
{% if recent_recharge_orders %}
<div class="table-responsive">
<table class="table align-middle mb-0">
<thead>
<tr>
<th scope="col">订单号</th>
<th scope="col">金额</th>
<th scope="col">点数</th>
<th scope="col">状态</th>
<th scope="col">创建时间</th>
</tr>
</thead>
<tbody>
{% for order in recent_recharge_orders %}
<tr>
<td><code>{{ order.order_no }}</code></td>
<td>{{ order.amount_money|floatformat:2 }} {{ order.currency }}</td>
<td>{{ order.points_granted }}</td>
<td>{{ order.status }}</td>
<td>{{ order.created_at|date:"Y-m-d H:i" }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<div class="text-secondary">暂无充值记录</div>
{% endif %}
</section>
</div>
<script src="https://cdn.jsdelivr.net/npm/qrcode@1.5.4/build/qrcode.min.js"></script>
<script>
(function () {
const panel = document.querySelector("[data-recharge-order]");
if (!panel) {
return;
}
const orderNo = panel.dataset.orderNo;
const codeUrl = panel.dataset.codeUrl;
const statusUrl = panel.dataset.statusUrl;
const statusBadge = document.getElementById("recharge-status-badge");
const paidAt = document.getElementById("recharge-paid-at");
const pollHint = document.getElementById("recharge-poll-hint");
const fallback = document.getElementById("recharge-qr-fallback");
const canvas = document.getElementById("recharge-qr");
const labels = {
pending: "pending",
paid: "paid",
failed: "failed",
expired: "expired"
};
function showQrFallback() {
if (fallback) {
fallback.classList.remove("d-none");
}
}
if (codeUrl && window.QRCode && typeof window.QRCode.toCanvas === "function") {
window.QRCode.toCanvas(canvas, codeUrl, { width: 240, margin: 1 }, function (error) {
if (error) {
showQrFallback();
}
});
} else {
showQrFallback();
}
function setStatus(data) {
const status = data.status || "pending";
statusBadge.textContent = labels[status] || status;
statusBadge.className = status === "paid" ? "badge text-bg-success" : "badge text-bg-secondary";
paidAt.textContent = data.paid_at ? new Date(data.paid_at).toLocaleString() : "-";
if (status === "paid") {
pollHint.textContent = "订单已到账,正在刷新余额。";
} else if (status === "pending" && data.is_expired) {
pollHint.textContent = "二维码已过期,可重新发起一笔充值;若已支付,到账仍以回调为准。";
} else if (status === "pending") {
pollHint.textContent = "正在等待支付结果,页面会自动轮询订单状态。";
} else {
pollHint.textContent = "订单当前状态为 " + status + "。";
}
}
function pollStatus() {
fetch(statusUrl + "?order_no=" + encodeURIComponent(orderNo), {
method: "GET",
credentials: "same-origin",
headers: { Accept: "application/json" }
})
.then(function (response) {
return response.json().then(function (data) {
if (!response.ok) {
throw new Error((data.error && data.error.message) || "status request failed");
}
return data;
});
})
.then(function (data) {
setStatus(data);
if (data.status === "paid") {
window.setTimeout(function () {
window.location.reload();
}, 1200);
return;
}
if (data.status === "pending") {
window.setTimeout(pollStatus, 1000);
}
})
.catch(function () {
pollHint.textContent = "状态查询失败,稍后自动重试。";
window.setTimeout(pollStatus, 3000);
});
}
if (panel.dataset.status === "pending") {
window.setTimeout(pollStatus, 1000);
}
}());
</script>
{% endblock %}
+113 -1
View File
@@ -9,7 +9,7 @@ from django.test import Client, TestCase, override_settings
from django.utils import timezone
from rest_framework.test import APIClient
from apps.billing.models import CallRecord, PointsLedger, RechargeOrder
from apps.billing.models import CallRecord, ExchangeRate, PointsLedger, RechargeOrder
from apps.users.models import ApiKey, UserWallet
@@ -434,3 +434,115 @@ class PortalAccountFlowTests(TestCase):
self.assertContains(response, "15")
self.assertNotContains(response, "other-alias")
self.assertNotContains(response, other_key.key_prefix)
def test_recharge_page_requires_login_and_shows_form(self):
user = self.create_verified_user()
UserWallet.objects.create(user=user, points_balance=25)
anonymous_response = self.client.get("/recharge")
self.assertEqual(anonymous_response.status_code, 302)
self.assertTrue(anonymous_response["Location"].startswith("/login?next="))
self.client.force_login(user)
response = self.client.get("/recharge")
self.assertEqual(response.status_code, 200)
self.assertEqual(response.context["balance"].points_balance, 25)
self.assertContains(response, "充值金额")
self.assertContains(response, "创建订单")
self.assertContains(response, "qrcode@1.5.4")
self.assertContains(response, "QRCode.toCanvas")
def test_recharge_page_post_creates_pending_order_without_crediting_wallet_or_ledger(self):
user = self.create_verified_user()
UserWallet.objects.create(user=user, points_balance=5)
ExchangeRate.objects.create(
currency="CNY",
points_per_unit=Decimal("10.0000"),
effective_from=timezone.now(),
)
self.client.force_login(user)
response = self.client.post(
"/recharge",
{"amount": "20.00", "pay_method": RechargeOrder.PayMethod.WEIXIN},
)
self.assertEqual(response.status_code, 302)
self.assertTrue(response["Location"].startswith("/recharge?order_no="))
order = RechargeOrder.objects.get(user=user)
self.assertEqual(order.amount_money, Decimal("20.00"))
self.assertEqual(order.exchange_rate, Decimal("10.0000"))
self.assertEqual(order.points_granted, 200)
self.assertEqual(order.status, RechargeOrder.Status.PENDING)
self.assertTrue(order.code_url.startswith("weixin://wxpay/cmhub-mock"))
self.assertIsNotNone(order.expires_at)
wallet = UserWallet.objects.get(user=user)
self.assertEqual(wallet.points_balance, 5)
self.assertFalse(
PointsLedger.objects.filter(
user=user,
ref_order_id=order.id,
).exists()
)
follow_response = self.client.get(response["Location"])
self.assertEqual(follow_response.status_code, 200)
self.assertEqual(follow_response.context["current_order"], order)
self.assertContains(follow_response, order.order_no)
self.assertContains(follow_response, "weixin://wxpay/cmhub-mock")
self.assertContains(follow_response, 'data-status-url="/api/v1/recharge/status"')
self.assertContains(follow_response, "data-recharge-order")
def test_recharge_page_current_order_only_shows_current_user_order(self):
user = self.create_verified_user()
other_user = self.create_verified_user()
UserWallet.objects.create(user=user, points_balance=0)
other_order = self.create_recharge_order(
other_user,
order_no="R-OTHER-RECHARGE-PAGE-504",
amount="50.00",
points=500,
status=RechargeOrder.Status.PENDING,
)
self.client.force_login(user)
response = self.client.get(f"/recharge?order_no={other_order.order_no}")
self.assertEqual(response.status_code, 200)
self.assertIsNone(response.context["current_order"])
self.assertNotContains(response, other_order.order_no)
@override_settings(RECHARGE_MAX_AMOUNT_CNY="100.00")
def test_recharge_page_rejects_amount_above_configured_maximum(self):
user = self.create_verified_user()
UserWallet.objects.create(user=user, points_balance=0)
ExchangeRate.objects.create(
currency="CNY",
points_per_unit=Decimal("10.0000"),
effective_from=timezone.now(),
)
self.client.force_login(user)
response = self.client.post(
"/recharge",
{"amount": "100.01", "pay_method": RechargeOrder.PayMethod.ALIPAY},
)
self.assertEqual(response.status_code, 200)
self.assertContains(response, "单笔充值金额不能超过 100.00 CNY")
self.assertFalse(RechargeOrder.objects.filter(user=user).exists())
def test_recharge_page_post_is_csrf_protected(self):
user = self.create_verified_user()
csrf_client = Client(enforce_csrf_checks=True)
csrf_client.force_login(user)
response = csrf_client.post(
"/recharge",
{"amount": "20.00", "pay_method": RechargeOrder.PayMethod.WEIXIN},
)
self.assertEqual(response.status_code, 403)
self.assertFalse(RechargeOrder.objects.filter(user=user).exists())
+2
View File
@@ -6,6 +6,7 @@ from .views import (
ApiKeyDeleteView,
ApiKeyListCreateView,
DashboardView,
RechargePageView,
RechargeRecordListView,
UsageRecordListView,
)
@@ -18,6 +19,7 @@ urlpatterns = [
path("dashboard", DashboardView.as_view(), name="portal-dashboard"),
path("apikeys", ApiKeyListCreateView.as_view(), name="portal-apikeys"),
path("apikeys/<int:pk>/delete", ApiKeyDeleteView.as_view(), name="portal-apikey-delete"),
path("recharge", RechargePageView.as_view(), name="portal-recharge"),
path("records/recharge", RechargeRecordListView.as_view(), name="portal-recharge-records"),
path("records/usage", UsageRecordListView.as_view(), name="portal-usage-records"),
]
+52 -3
View File
@@ -2,15 +2,21 @@ from django.contrib import messages
from django.contrib.auth.mixins import LoginRequiredMixin
from django.db.models import Sum
from django.shortcuts import get_object_or_404, redirect
from django.urls import reverse_lazy
from django.urls import reverse, reverse_lazy
from django.views import View
from django.views.generic import FormView, TemplateView
from apps.billing.payment_gateways import PaymentOrderCreateError
from apps.billing.pricing import NoExchangeRateError
from apps.billing.models import PointsLedger, RechargeOrder
from apps.billing.services import get_balance_snapshot
from apps.billing.services import (
RechargeOrderCreateError,
create_recharge_order,
get_balance_snapshot,
)
from apps.users.models import ApiKey
from .forms import ApiKeyCreateForm
from .forms import ApiKeyCreateForm, RechargeCreateForm
NEW_API_KEY_SESSION_KEY = "portal_new_api_key"
@@ -120,6 +126,49 @@ class ApiKeyDeleteView(LoginRequiredMixin, View):
return redirect("portal-apikeys")
class RechargePageView(LoginRequiredMixin, FormView):
template_name = "portal/recharge.html"
form_class = RechargeCreateForm
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
order_no = str(self.request.GET.get("order_no") or "").strip()
current_order = None
if order_no:
current_order = RechargeOrder.objects.filter(
user=self.request.user,
order_no=order_no,
).first()
context["balance"] = get_balance_snapshot(self.request.user)
context.update(get_portal_account_summary(self.request.user))
context["current_order"] = current_order
context["recent_recharge_orders"] = get_recharge_orders_for_user(
self.request.user
)[:5]
return context
def form_valid(self, form):
try:
order = create_recharge_order(
user=self.request.user,
amount=form.cleaned_data["amount"],
pay_method=form.cleaned_data["pay_method"],
)
except NoExchangeRateError:
form.add_error(None, "未配置当前币种汇率,暂时无法充值。")
return self.form_invalid(form)
except RechargeOrderCreateError:
form.add_error(None, "充值下单参数错误,请检查金额和支付方式。")
return self.form_invalid(form)
except PaymentOrderCreateError:
form.add_error(None, "支付下单失败,请稍后重试。")
return self.form_invalid(form)
messages.success(self.request, "充值订单已创建,请扫码支付。")
recharge_url = reverse("portal-recharge")
return redirect(f"{recharge_url}?order_no={order.order_no}")
class RechargeRecordListView(LoginRequiredMixin, TemplateView):
template_name = "portal/recharge_records.html"