feat: add portal account records
This commit is contained in:
@@ -80,10 +80,12 @@
|
||||
<nav class="navbar navbar-expand">
|
||||
<div class="container">
|
||||
<a class="navbar-brand fw-semibold" href="{% url 'portal-home' %}">cmhub</a>
|
||||
<div class="ms-auto d-flex gap-2">
|
||||
<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-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>
|
||||
<form method="post" action="{% url 'portal-logout' %}">
|
||||
{% csrf_token %}
|
||||
<button class="btn btn-sm btn-outline-secondary" type="submit">退出</button>
|
||||
|
||||
@@ -8,13 +8,39 @@
|
||||
<h1 class="h3 mb-1">控制台</h1>
|
||||
<div class="text-secondary">{{ user.email }}</div>
|
||||
</div>
|
||||
{% if balance.points_balance != balance.ledger_balance %}
|
||||
<div class="alert alert-warning mb-0">
|
||||
钱包余额与点数流水余额不一致,请联系运营核对。
|
||||
</div>
|
||||
{% endif %}
|
||||
<div class="row g-3">
|
||||
<div class="col-md-4">
|
||||
<div class="col-md-3">
|
||||
<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-3">
|
||||
<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-3">
|
||||
<div class="metric">
|
||||
<div class="text-secondary small">入账点数</div>
|
||||
<div class="display-6 fw-semibold">{{ recharge_points_total }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<div class="metric">
|
||||
<div class="text-secondary small">净消耗点数</div>
|
||||
<div class="display-6 fw-semibold">{{ net_used_points }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row g-3">
|
||||
<div class="col-md-4">
|
||||
<div class="metric d-flex flex-column gap-3">
|
||||
<div>
|
||||
@@ -24,6 +50,94 @@
|
||||
<a class="btn btn-primary align-self-start" href="{% url 'portal-apikeys' %}">管理</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<div class="metric d-flex flex-column gap-3">
|
||||
<div>
|
||||
<div class="text-secondary small">充值</div>
|
||||
<div class="h5 mb-0">充值记录</div>
|
||||
</div>
|
||||
<a class="btn btn-primary align-self-start" href="{% url 'portal-recharge-records' %}">查看</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<div class="metric d-flex flex-column gap-3">
|
||||
<div>
|
||||
<div class="text-secondary small">点数使用</div>
|
||||
<div class="h5 mb-0">消费记录</div>
|
||||
</div>
|
||||
<a class="btn btn-primary align-self-start" href="{% url 'portal-usage-records' %}">查看</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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>
|
||||
|
||||
<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-usage-records' %}">全部</a>
|
||||
</div>
|
||||
{% if recent_usage_entries %}
|
||||
<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 entry in recent_usage_entries %}
|
||||
<tr>
|
||||
<td>{{ entry.created_at|date:"Y-m-d H:i" }}</td>
|
||||
<td>{{ entry.change_type }}</td>
|
||||
<td>{{ entry.ref_call.alias|default:"-" }}</td>
|
||||
<td>{{ entry.points_delta }}</td>
|
||||
<td>{{ entry.balance_after }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="text-secondary">暂无消费记录</div>
|
||||
{% endif %}
|
||||
</section>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
{% extends "portal/base.html" %}
|
||||
|
||||
{% block title %}充值记录 - cmhub{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="d-flex flex-column gap-4">
|
||||
<div>
|
||||
<h1 class="h3 mb-1">充值记录</h1>
|
||||
<div class="text-secondary">{{ user.email }}</div>
|
||||
</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">{{ 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 class="col-md-4">
|
||||
<div class="metric">
|
||||
<div class="text-secondary small">已支付订单</div>
|
||||
<div class="display-6 fw-semibold">{{ paid_recharge_count }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section class="cmhub-surface">
|
||||
<h2 class="h5 mb-3">订单列表</h2>
|
||||
{% if 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>
|
||||
<th scope="col">支付流水</th>
|
||||
<th scope="col">创建时间</th>
|
||||
<th scope="col">到账时间</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for order in recharge_orders %}
|
||||
<tr>
|
||||
<td><code>{{ order.order_no }}</code></td>
|
||||
<td>{{ order.amount_money|floatformat:2 }} {{ order.currency }}</td>
|
||||
<td>{{ order.pay_method }}</td>
|
||||
<td>{{ order.points_granted }}</td>
|
||||
<td>{{ order.status }}</td>
|
||||
<td>{{ order.payment_txn_no|default:"-" }}</td>
|
||||
<td>{{ order.created_at|date:"Y-m-d H:i" }}</td>
|
||||
<td>{{ order.paid_at|date:"Y-m-d H:i"|default:"-" }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="text-secondary">暂无充值记录</div>
|
||||
{% endif %}
|
||||
</section>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,95 @@
|
||||
{% extends "portal/base.html" %}
|
||||
|
||||
{% block title %}消费记录 - cmhub{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="d-flex flex-column gap-4">
|
||||
<div>
|
||||
<h1 class="h3 mb-1">消费记录</h1>
|
||||
<div class="text-secondary">{{ user.email }}</div>
|
||||
</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">{{ consumed_points_total }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<div class="metric">
|
||||
<div class="text-secondary small">失败退点</div>
|
||||
<div class="display-6 fw-semibold">{{ refunded_points_total }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<div class="metric">
|
||||
<div class="text-secondary small">净消耗点数</div>
|
||||
<div class="display-6 fw-semibold">{{ net_used_points }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section class="cmhub-surface">
|
||||
<h2 class="h5 mb-3">点数使用流水</h2>
|
||||
{% if usage_entries %}
|
||||
<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>
|
||||
<th scope="col">API Key</th>
|
||||
<th scope="col">点数变动</th>
|
||||
<th scope="col">余额</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for entry in usage_entries %}
|
||||
<tr>
|
||||
<td>{{ entry.created_at|date:"Y-m-d H:i" }}</td>
|
||||
<td>{{ entry.change_type }}</td>
|
||||
<td>
|
||||
{% if entry.ref_call %}
|
||||
{{ entry.ref_call.operation_type }}
|
||||
{% else %}
|
||||
-
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
{% if entry.ref_call %}
|
||||
{{ entry.ref_call.alias|default:"-" }}
|
||||
{% else %}
|
||||
-
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
{% if entry.ref_call %}
|
||||
{{ entry.ref_call.status }}
|
||||
{% else %}
|
||||
-
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
{% if entry.ref_call and entry.ref_call.api_key %}
|
||||
<code>{{ entry.ref_call.api_key.key_prefix }}</code>
|
||||
{% else %}
|
||||
-
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>{{ entry.points_delta }}</td>
|
||||
<td>{{ entry.balance_after }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="text-secondary">暂无消费记录</div>
|
||||
{% endif %}
|
||||
</section>
|
||||
</div>
|
||||
{% endblock %}
|
||||
+224
-1
@@ -1,13 +1,15 @@
|
||||
import uuid
|
||||
from decimal import Decimal
|
||||
|
||||
from allauth.account.models import EmailAddress
|
||||
from django.contrib.auth import get_user, get_user_model
|
||||
from django.core import mail
|
||||
from django.core.cache import cache
|
||||
from django.test import Client, TestCase, override_settings
|
||||
from django.utils import timezone
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
from apps.billing.models import PointsLedger
|
||||
from apps.billing.models import CallRecord, PointsLedger, RechargeOrder
|
||||
from apps.users.models import ApiKey, UserWallet
|
||||
|
||||
|
||||
@@ -33,6 +35,47 @@ class PortalAccountFlowTests(TestCase):
|
||||
)
|
||||
return user
|
||||
|
||||
def create_recharge_order(
|
||||
self,
|
||||
user,
|
||||
*,
|
||||
order_no: str,
|
||||
amount: str,
|
||||
points: int,
|
||||
status: str = RechargeOrder.Status.PAID,
|
||||
):
|
||||
return RechargeOrder.objects.create(
|
||||
user=user,
|
||||
order_no=order_no,
|
||||
amount_money=Decimal(amount),
|
||||
currency="CNY",
|
||||
pay_method=RechargeOrder.PayMethod.WEIXIN,
|
||||
exchange_rate=Decimal("10.0000"),
|
||||
points_granted=points,
|
||||
status=status,
|
||||
payment_txn_no=f"txn-{order_no}" if status == RechargeOrder.Status.PAID else "",
|
||||
paid_at=timezone.now() if status == RechargeOrder.Status.PAID else None,
|
||||
)
|
||||
|
||||
def create_call_record(
|
||||
self,
|
||||
user,
|
||||
api_key,
|
||||
*,
|
||||
alias: str,
|
||||
points_cost: int,
|
||||
status: str = CallRecord.Status.SUCCESS,
|
||||
):
|
||||
return CallRecord.objects.create(
|
||||
user=user,
|
||||
api_key=api_key,
|
||||
operation_type=CallRecord.OperationType.TITLE,
|
||||
alias=alias,
|
||||
model_used="gpt-5.5",
|
||||
points_cost=points_cost,
|
||||
status=status,
|
||||
)
|
||||
|
||||
def test_signup_creates_unverified_user_wallet_with_zero_points_and_no_ledger(self):
|
||||
suffix = uuid.uuid4().hex[:8]
|
||||
email = f"signup-{suffix}@example.com"
|
||||
@@ -211,3 +254,183 @@ class PortalAccountFlowTests(TestCase):
|
||||
self.assertEqual(delete_response.status_code, 403)
|
||||
api_key.refresh_from_db()
|
||||
self.assertEqual(api_key.status, ApiKey.Status.ACTIVE)
|
||||
|
||||
def test_dashboard_shows_account_summary_recent_records_for_current_user_only(self):
|
||||
user = self.create_verified_user()
|
||||
other_user = self.create_verified_user()
|
||||
UserWallet.objects.create(user=user, points_balance=160)
|
||||
UserWallet.objects.create(user=other_user, points_balance=999)
|
||||
api_key, _raw_key = ApiKey.create_for_user(user, name="desktop")
|
||||
other_key, _other_raw_key = ApiKey.create_for_user(other_user, name="other")
|
||||
order = self.create_recharge_order(
|
||||
user,
|
||||
order_no="R-OWN-PAID-503",
|
||||
amount="20.00",
|
||||
points=200,
|
||||
)
|
||||
self.create_recharge_order(
|
||||
other_user,
|
||||
order_no="R-OTHER-PAID-503",
|
||||
amount="99.00",
|
||||
points=990,
|
||||
)
|
||||
PointsLedger.objects.create(
|
||||
user=user,
|
||||
change_type=PointsLedger.ChangeType.RECHARGE,
|
||||
points_delta=200,
|
||||
balance_after=200,
|
||||
ref_order_id=order.id,
|
||||
)
|
||||
call = self.create_call_record(
|
||||
user,
|
||||
api_key,
|
||||
alias="title-standard",
|
||||
points_cost=50,
|
||||
status=CallRecord.Status.FAILED,
|
||||
)
|
||||
other_call = self.create_call_record(
|
||||
other_user,
|
||||
other_key,
|
||||
alias="other-alias",
|
||||
points_cost=70,
|
||||
)
|
||||
PointsLedger.objects.create(
|
||||
user=user,
|
||||
change_type=PointsLedger.ChangeType.CONSUME,
|
||||
points_delta=-50,
|
||||
balance_after=150,
|
||||
ref_call=call,
|
||||
)
|
||||
PointsLedger.objects.create(
|
||||
user=user,
|
||||
change_type=PointsLedger.ChangeType.REFUND,
|
||||
points_delta=10,
|
||||
balance_after=160,
|
||||
ref_call=call,
|
||||
)
|
||||
PointsLedger.objects.create(
|
||||
user=other_user,
|
||||
change_type=PointsLedger.ChangeType.CONSUME,
|
||||
points_delta=-70,
|
||||
balance_after=929,
|
||||
ref_call=other_call,
|
||||
)
|
||||
self.client.force_login(user)
|
||||
|
||||
response = self.client.get("/dashboard")
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertEqual(response.context["balance"].points_balance, 160)
|
||||
self.assertEqual(response.context["balance"].ledger_balance, 160)
|
||||
self.assertEqual(response.context["recharge_total_amount"], Decimal("20.00"))
|
||||
self.assertEqual(response.context["recharge_points_total"], 200)
|
||||
self.assertEqual(response.context["consumed_points_total"], 50)
|
||||
self.assertEqual(response.context["refunded_points_total"], 10)
|
||||
self.assertEqual(response.context["net_used_points"], 40)
|
||||
self.assertContains(response, "R-OWN-PAID-503")
|
||||
self.assertContains(response, "title-standard")
|
||||
self.assertNotContains(response, "R-OTHER-PAID-503")
|
||||
self.assertNotContains(response, "other-alias")
|
||||
|
||||
def test_recharge_records_require_login_and_only_show_current_user_orders(self):
|
||||
user = self.create_verified_user()
|
||||
other_user = self.create_verified_user()
|
||||
paid_order = self.create_recharge_order(
|
||||
user,
|
||||
order_no="R-OWN-RECHARGE-503",
|
||||
amount="30.00",
|
||||
points=300,
|
||||
)
|
||||
self.create_recharge_order(
|
||||
user,
|
||||
order_no="R-OWN-PENDING-503",
|
||||
amount="88.00",
|
||||
points=880,
|
||||
status=RechargeOrder.Status.PENDING,
|
||||
)
|
||||
self.create_recharge_order(
|
||||
other_user,
|
||||
order_no="R-OTHER-RECHARGE-503",
|
||||
amount="99.00",
|
||||
points=990,
|
||||
)
|
||||
PointsLedger.objects.create(
|
||||
user=user,
|
||||
change_type=PointsLedger.ChangeType.RECHARGE,
|
||||
points_delta=300,
|
||||
balance_after=300,
|
||||
ref_order_id=paid_order.id,
|
||||
)
|
||||
|
||||
anonymous_response = self.client.get("/records/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("/records/recharge")
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertEqual(response.context["recharge_total_amount"], Decimal("30.00"))
|
||||
self.assertEqual(response.context["recharge_points_total"], 300)
|
||||
self.assertEqual(response.context["paid_recharge_count"], 1)
|
||||
self.assertContains(response, "R-OWN-RECHARGE-503")
|
||||
self.assertContains(response, "R-OWN-PENDING-503")
|
||||
self.assertNotContains(response, "R-OTHER-RECHARGE-503")
|
||||
|
||||
def test_usage_records_require_login_and_only_show_current_user_usage(self):
|
||||
user = self.create_verified_user()
|
||||
other_user = self.create_verified_user()
|
||||
api_key, _raw_key = ApiKey.create_for_user(user, name="desktop")
|
||||
other_key, _other_raw_key = ApiKey.create_for_user(other_user, name="other")
|
||||
call = self.create_call_record(
|
||||
user,
|
||||
api_key,
|
||||
alias="title-standard",
|
||||
points_cost=40,
|
||||
status=CallRecord.Status.FAILED,
|
||||
)
|
||||
other_call = self.create_call_record(
|
||||
other_user,
|
||||
other_key,
|
||||
alias="other-alias",
|
||||
points_cost=90,
|
||||
)
|
||||
PointsLedger.objects.create(
|
||||
user=user,
|
||||
change_type=PointsLedger.ChangeType.CONSUME,
|
||||
points_delta=-40,
|
||||
balance_after=60,
|
||||
ref_call=call,
|
||||
)
|
||||
PointsLedger.objects.create(
|
||||
user=user,
|
||||
change_type=PointsLedger.ChangeType.REFUND,
|
||||
points_delta=15,
|
||||
balance_after=75,
|
||||
ref_call=call,
|
||||
)
|
||||
PointsLedger.objects.create(
|
||||
user=other_user,
|
||||
change_type=PointsLedger.ChangeType.CONSUME,
|
||||
points_delta=-90,
|
||||
balance_after=10,
|
||||
ref_call=other_call,
|
||||
)
|
||||
|
||||
anonymous_response = self.client.get("/records/usage")
|
||||
self.assertEqual(anonymous_response.status_code, 302)
|
||||
self.assertTrue(anonymous_response["Location"].startswith("/login?next="))
|
||||
|
||||
self.client.force_login(user)
|
||||
response = self.client.get("/records/usage")
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertEqual(response.context["consumed_points_total"], 40)
|
||||
self.assertEqual(response.context["refunded_points_total"], 15)
|
||||
self.assertEqual(response.context["net_used_points"], 25)
|
||||
self.assertContains(response, "title-standard")
|
||||
self.assertContains(response, api_key.key_prefix)
|
||||
self.assertContains(response, "-40")
|
||||
self.assertContains(response, "15")
|
||||
self.assertNotContains(response, "other-alias")
|
||||
self.assertNotContains(response, other_key.key_prefix)
|
||||
|
||||
+9
-1
@@ -2,7 +2,13 @@ from allauth.account.views import LoginView, LogoutView, SignupView
|
||||
from django.urls import path
|
||||
from django.views.generic import RedirectView
|
||||
|
||||
from .views import ApiKeyDeleteView, ApiKeyListCreateView, DashboardView
|
||||
from .views import (
|
||||
ApiKeyDeleteView,
|
||||
ApiKeyListCreateView,
|
||||
DashboardView,
|
||||
RechargeRecordListView,
|
||||
UsageRecordListView,
|
||||
)
|
||||
|
||||
urlpatterns = [
|
||||
path("", RedirectView.as_view(pattern_name="portal-dashboard", permanent=False), name="portal-home"),
|
||||
@@ -12,4 +18,6 @@ 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("records/recharge", RechargeRecordListView.as_view(), name="portal-recharge-records"),
|
||||
path("records/usage", UsageRecordListView.as_view(), name="portal-usage-records"),
|
||||
]
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
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.views import View
|
||||
from django.views.generic import FormView, TemplateView
|
||||
|
||||
from apps.billing.models import PointsLedger, RechargeOrder
|
||||
from apps.billing.services import get_balance_snapshot
|
||||
from apps.users.models import ApiKey
|
||||
|
||||
@@ -12,6 +14,58 @@ from .forms import ApiKeyCreateForm
|
||||
|
||||
|
||||
NEW_API_KEY_SESSION_KEY = "portal_new_api_key"
|
||||
RECORDS_PAGE_SIZE = 50
|
||||
|
||||
|
||||
def _sum_or_zero(queryset, field_name: str):
|
||||
return queryset.aggregate(total=Sum(field_name))["total"] or 0
|
||||
|
||||
|
||||
def get_portal_account_summary(user) -> dict:
|
||||
paid_orders = RechargeOrder.objects.filter(
|
||||
user=user,
|
||||
status=RechargeOrder.Status.PAID,
|
||||
)
|
||||
consume_entries = PointsLedger.objects.filter(
|
||||
user=user,
|
||||
change_type=PointsLedger.ChangeType.CONSUME,
|
||||
)
|
||||
refund_entries = PointsLedger.objects.filter(
|
||||
user=user,
|
||||
change_type=PointsLedger.ChangeType.REFUND,
|
||||
)
|
||||
recharge_entries = PointsLedger.objects.filter(
|
||||
user=user,
|
||||
change_type=PointsLedger.ChangeType.RECHARGE,
|
||||
)
|
||||
consumed_points = abs(int(_sum_or_zero(consume_entries, "points_delta")))
|
||||
refunded_points = int(_sum_or_zero(refund_entries, "points_delta"))
|
||||
return {
|
||||
"recharge_total_amount": _sum_or_zero(paid_orders, "amount_money"),
|
||||
"recharge_points_total": int(_sum_or_zero(recharge_entries, "points_delta")),
|
||||
"consumed_points_total": consumed_points,
|
||||
"refunded_points_total": refunded_points,
|
||||
"net_used_points": consumed_points - refunded_points,
|
||||
"paid_recharge_count": paid_orders.count(),
|
||||
}
|
||||
|
||||
|
||||
def get_recharge_orders_for_user(user):
|
||||
return RechargeOrder.objects.filter(user=user).order_by("-created_at", "-id")
|
||||
|
||||
|
||||
def get_usage_ledger_entries_for_user(user):
|
||||
return (
|
||||
PointsLedger.objects.filter(
|
||||
user=user,
|
||||
change_type__in=(
|
||||
PointsLedger.ChangeType.CONSUME,
|
||||
PointsLedger.ChangeType.REFUND,
|
||||
),
|
||||
)
|
||||
.select_related("ref_call", "ref_call__api_key")
|
||||
.order_by("-created_at", "-id")
|
||||
)
|
||||
|
||||
|
||||
class DashboardView(LoginRequiredMixin, TemplateView):
|
||||
@@ -20,6 +74,13 @@ class DashboardView(LoginRequiredMixin, TemplateView):
|
||||
def get_context_data(self, **kwargs):
|
||||
context = super().get_context_data(**kwargs)
|
||||
context["balance"] = get_balance_snapshot(self.request.user)
|
||||
context.update(get_portal_account_summary(self.request.user))
|
||||
context["recent_recharge_orders"] = get_recharge_orders_for_user(
|
||||
self.request.user
|
||||
)[:5]
|
||||
context["recent_usage_entries"] = get_usage_ledger_entries_for_user(
|
||||
self.request.user
|
||||
)[:5]
|
||||
return context
|
||||
|
||||
|
||||
@@ -57,3 +118,25 @@ class ApiKeyDeleteView(LoginRequiredMixin, View):
|
||||
api_key.save(update_fields=("status", "updated_at"))
|
||||
messages.success(request, "API Key 已删除")
|
||||
return redirect("portal-apikeys")
|
||||
|
||||
|
||||
class RechargeRecordListView(LoginRequiredMixin, TemplateView):
|
||||
template_name = "portal/recharge_records.html"
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
context = super().get_context_data(**kwargs)
|
||||
orders = get_recharge_orders_for_user(self.request.user)
|
||||
context.update(get_portal_account_summary(self.request.user))
|
||||
context["recharge_orders"] = orders[:RECORDS_PAGE_SIZE]
|
||||
return context
|
||||
|
||||
|
||||
class UsageRecordListView(LoginRequiredMixin, TemplateView):
|
||||
template_name = "portal/usage_records.html"
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
context = super().get_context_data(**kwargs)
|
||||
entries = get_usage_ledger_entries_for_user(self.request.user)
|
||||
context.update(get_portal_account_summary(self.request.user))
|
||||
context["usage_entries"] = entries[:RECORDS_PAGE_SIZE]
|
||||
return context
|
||||
|
||||
Reference in New Issue
Block a user