feat: improve operations admin
This commit is contained in:
@@ -25,7 +25,7 @@ Python 3.12 / Django 5.2 LTS + DRF / django-admin / 用户端 Django 模板 SSR
|
||||
|
||||
## 当前状态
|
||||
|
||||
Phase 2 计费核心已完成,Phase 3 对外 API 与充值已完成到 T-306,Phase 4 用户端已完成 T-501~T-505:用户可通过 allauth 自助注册、邮箱验证、登录、登出,扫码充值并轮询到账,生成 / 删除(吊销)API Key,查看余额、充值总额、分页充值记录与分页消费记录;用户端静态资源已改为本地自托管。下一步进入 T-401 运营后台完善。详见 [`docs/current-state.md`](docs/current-state.md)。
|
||||
Phase 2 计费核心已完成,Phase 3 对外 API 与充值已完成到 T-306,Phase 4 用户端已完成 T-501~T-505,Phase 5 已完成 T-401 运营后台完善:用户可通过 allauth 自助注册、邮箱验证、登录、登出,扫码充值并轮询到账,生成 / 删除(吊销)API Key,查看余额、充值总额、分页充值记录与分页消费记录;运营可在 django-admin 检索用户、钱包、API Key、计费规则、汇率、充值订单、点数流水和调用记录,并通过计费层带原因手工调点。下一步进入 T-402 完整验收 MVP。详见 [`docs/current-state.md`](docs/current-state.md)。
|
||||
|
||||
> ⚠️ 涉及资金/点数。改动充值、扣费、退款、对账相关代码前,先读 [`docs/05-coding-rules.md`](docs/05-coding-rules.md) 第 8 节与 [`docs/04-architecture.md`](docs/04-architecture.md) 第四节计费时序。
|
||||
|
||||
|
||||
+11
-1
@@ -29,8 +29,10 @@ class PointsLedgerAdmin(ReadOnlyLedgerAdmin):
|
||||
"ref_call",
|
||||
)
|
||||
list_filter = ("change_type", "created_at")
|
||||
search_fields = ("user__username", "user__email", "reason")
|
||||
search_fields = ("user__username", "user__email", "=ref_order_id", "reason")
|
||||
ordering = ("-created_at", "-id")
|
||||
date_hierarchy = "created_at"
|
||||
list_select_related = ("user", "ref_call")
|
||||
|
||||
|
||||
@admin.register(PricingRule)
|
||||
@@ -47,6 +49,7 @@ class PricingRuleAdmin(admin.ModelAdmin):
|
||||
search_fields = ("alias", "resolution")
|
||||
ordering = ("operation_type", "alias", "resolution")
|
||||
readonly_fields = ("created_at", "updated_at")
|
||||
list_editable = ("points_cost", "is_active")
|
||||
|
||||
@admin.display(description="resolution")
|
||||
def resolution_display(self, obj):
|
||||
@@ -66,6 +69,8 @@ class ExchangeRateAdmin(admin.ModelAdmin):
|
||||
search_fields = ("currency", "note")
|
||||
ordering = ("-effective_from", "-id")
|
||||
readonly_fields = ("created_at", "updated_at")
|
||||
list_editable = ("is_active",)
|
||||
date_hierarchy = "effective_from"
|
||||
|
||||
|
||||
@admin.register(RechargeOrder)
|
||||
@@ -90,6 +95,8 @@ class RechargeOrderAdmin(ReadOnlyLedgerAdmin):
|
||||
"payment_txn_no",
|
||||
)
|
||||
ordering = ("-created_at", "-id")
|
||||
date_hierarchy = "created_at"
|
||||
list_select_related = ("user",)
|
||||
|
||||
|
||||
@admin.register(CallRecord)
|
||||
@@ -112,8 +119,11 @@ class CallRecordAdmin(ReadOnlyLedgerAdmin):
|
||||
"api_key__key_prefix",
|
||||
"alias",
|
||||
"model_used",
|
||||
"prompt",
|
||||
"error_message",
|
||||
"result_ref",
|
||||
"result_summary",
|
||||
)
|
||||
ordering = ("-created_at", "-id")
|
||||
date_hierarchy = "created_at"
|
||||
list_select_related = ("user", "api_key")
|
||||
|
||||
@@ -85,6 +85,19 @@ class InvalidRechargePayMethodError(RechargeOrderCreateError):
|
||||
super().__init__("Unsupported recharge payment method.")
|
||||
|
||||
|
||||
class WalletAdjustmentError(BillingOperationError):
|
||||
code = "invalid_wallet_adjustment"
|
||||
|
||||
|
||||
class WalletAdjustmentWouldOverdrawError(WalletAdjustmentError):
|
||||
code = "adjustment_would_overdraw"
|
||||
|
||||
def __init__(self, *, points_delta: int, available_points: int):
|
||||
self.points_delta = points_delta
|
||||
self.available_points = available_points
|
||||
super().__init__("Wallet adjustment would make the balance negative.")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class CallCharge:
|
||||
call_record: CallRecord
|
||||
@@ -126,12 +139,56 @@ class RechargeResult:
|
||||
applied: bool
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class WalletAdjustment:
|
||||
wallet: UserWallet
|
||||
ledger_entry: PointsLedger
|
||||
points_delta: int
|
||||
balance_after: int
|
||||
|
||||
|
||||
def _validate_positive_points(points: int) -> int:
|
||||
if isinstance(points, bool) or not isinstance(points, int) or points <= 0:
|
||||
raise ValueError("points must be a positive integer")
|
||||
return points
|
||||
|
||||
|
||||
def _validate_adjustment_delta(points_delta: int) -> int:
|
||||
if (
|
||||
isinstance(points_delta, bool)
|
||||
or not isinstance(points_delta, int)
|
||||
or points_delta == 0
|
||||
):
|
||||
raise WalletAdjustmentError("points_delta must be a non-zero integer.")
|
||||
return points_delta
|
||||
|
||||
|
||||
def _normalize_adjustment_reason(reason: str) -> str:
|
||||
normalized_reason = str(reason or "").strip()
|
||||
if not normalized_reason:
|
||||
raise WalletAdjustmentError("Adjustment reason is required.")
|
||||
return normalized_reason
|
||||
|
||||
|
||||
def _format_operator(actor) -> str:
|
||||
if actor is None:
|
||||
return ""
|
||||
username_getter = getattr(actor, "get_username", None)
|
||||
username = (
|
||||
username_getter()
|
||||
if callable(username_getter)
|
||||
else getattr(actor, "username", "")
|
||||
)
|
||||
actor_pk = getattr(actor, "pk", None)
|
||||
if username and actor_pk:
|
||||
return f"{username}#{actor_pk}"
|
||||
if username:
|
||||
return str(username)
|
||||
if actor_pk:
|
||||
return f"id#{actor_pk}"
|
||||
return str(actor)
|
||||
|
||||
|
||||
def _locked_wallet_for_user(user) -> UserWallet:
|
||||
wallet, _created = UserWallet.objects.select_for_update().get_or_create(user=user)
|
||||
return wallet
|
||||
@@ -234,6 +291,48 @@ def get_balance_snapshot(user) -> BalanceSnapshot:
|
||||
)
|
||||
|
||||
|
||||
def adjust_wallet_points(
|
||||
*,
|
||||
user,
|
||||
points_delta: int,
|
||||
reason: str,
|
||||
actor=None,
|
||||
) -> WalletAdjustment:
|
||||
points_delta = _validate_adjustment_delta(points_delta)
|
||||
normalized_reason = _normalize_adjustment_reason(reason)
|
||||
operator = _format_operator(actor)
|
||||
ledger_reason = (
|
||||
f"{normalized_reason}\n\noperator={operator}" if operator else normalized_reason
|
||||
)
|
||||
|
||||
with transaction.atomic():
|
||||
wallet = _locked_wallet_for_user(user)
|
||||
balance_after = wallet.points_balance + points_delta
|
||||
if balance_after < 0:
|
||||
raise WalletAdjustmentWouldOverdrawError(
|
||||
points_delta=points_delta,
|
||||
available_points=wallet.points_balance,
|
||||
)
|
||||
|
||||
wallet.points_balance = balance_after
|
||||
wallet.save(update_fields=("points_balance", "updated_at"))
|
||||
|
||||
ledger_entry = PointsLedger.objects.create(
|
||||
user=user,
|
||||
change_type=PointsLedger.ChangeType.ADJUST,
|
||||
points_delta=points_delta,
|
||||
balance_after=wallet.points_balance,
|
||||
reason=ledger_reason,
|
||||
)
|
||||
|
||||
return WalletAdjustment(
|
||||
wallet=wallet,
|
||||
ledger_entry=ledger_entry,
|
||||
points_delta=points_delta,
|
||||
balance_after=ledger_entry.balance_after,
|
||||
)
|
||||
|
||||
|
||||
def apply_recharge_payment(payment: RechargePayment) -> RechargeResult:
|
||||
order_no = str(payment.order_no or "").strip()
|
||||
pay_method = _normalize_pay_method(payment.pay_method)
|
||||
|
||||
@@ -34,6 +34,9 @@ from apps.billing.services import (
|
||||
RechargeAmountMismatchError,
|
||||
RechargeOrderCreateError,
|
||||
RechargePayment,
|
||||
WalletAdjustmentError,
|
||||
WalletAdjustmentWouldOverdrawError,
|
||||
adjust_wallet_points,
|
||||
apply_recharge_payment,
|
||||
create_recharge_order,
|
||||
mark_call_success,
|
||||
@@ -683,6 +686,72 @@ class BillingServiceTests(TestCase):
|
||||
self.assertEqual(order.status, RechargeOrder.Status.PAID)
|
||||
self.assertEqual(order.payment_txn_no, "queried-txn-001")
|
||||
|
||||
def test_adjust_wallet_points_credits_wallet_and_writes_adjust_ledger(self):
|
||||
result = adjust_wallet_points(
|
||||
user=self.user,
|
||||
points_delta=25,
|
||||
reason="运营补偿",
|
||||
)
|
||||
|
||||
self.wallet.refresh_from_db()
|
||||
self.assertEqual(result.points_delta, 25)
|
||||
self.assertEqual(result.balance_after, 125)
|
||||
self.assertEqual(self.wallet.points_balance, 125)
|
||||
|
||||
ledger = PointsLedger.objects.get(
|
||||
user=self.user,
|
||||
change_type=PointsLedger.ChangeType.ADJUST,
|
||||
)
|
||||
self.assertEqual(ledger.points_delta, 25)
|
||||
self.assertEqual(ledger.balance_after, 125)
|
||||
self.assertEqual(ledger.reason, "运营补偿")
|
||||
|
||||
def test_adjust_wallet_points_debits_wallet_without_overdraft(self):
|
||||
result = adjust_wallet_points(
|
||||
user=self.user,
|
||||
points_delta=-40,
|
||||
reason="纠正误充值",
|
||||
)
|
||||
|
||||
self.wallet.refresh_from_db()
|
||||
self.assertEqual(result.balance_after, 60)
|
||||
self.assertEqual(self.wallet.points_balance, 60)
|
||||
self.assertEqual(result.ledger_entry.points_delta, -40)
|
||||
self.assertEqual(result.ledger_entry.reason, "纠正误充值")
|
||||
|
||||
def test_adjust_wallet_points_requires_non_zero_delta_and_reason(self):
|
||||
with self.assertRaises(WalletAdjustmentError):
|
||||
adjust_wallet_points(user=self.user, points_delta=0, reason="无变化")
|
||||
with self.assertRaises(WalletAdjustmentError):
|
||||
adjust_wallet_points(user=self.user, points_delta=10, reason=" ")
|
||||
|
||||
self.wallet.refresh_from_db()
|
||||
self.assertEqual(self.wallet.points_balance, 100)
|
||||
self.assertFalse(
|
||||
PointsLedger.objects.filter(
|
||||
user=self.user,
|
||||
change_type=PointsLedger.ChangeType.ADJUST,
|
||||
).exists()
|
||||
)
|
||||
|
||||
def test_adjust_wallet_points_rejects_overdraft_without_writing_ledger(self):
|
||||
with self.assertRaises(WalletAdjustmentWouldOverdrawError) as context:
|
||||
adjust_wallet_points(
|
||||
user=self.user,
|
||||
points_delta=-101,
|
||||
reason="扣回异常点数",
|
||||
)
|
||||
|
||||
self.assertEqual(context.exception.available_points, 100)
|
||||
self.wallet.refresh_from_db()
|
||||
self.assertEqual(self.wallet.points_balance, 100)
|
||||
self.assertFalse(
|
||||
PointsLedger.objects.filter(
|
||||
user=self.user,
|
||||
change_type=PointsLedger.ChangeType.ADJUST,
|
||||
).exists()
|
||||
)
|
||||
|
||||
|
||||
class ConcurrentDebitTests(TransactionTestCase):
|
||||
def setUp(self):
|
||||
|
||||
+134
-4
@@ -1,9 +1,42 @@
|
||||
from django.contrib import admin
|
||||
from django import forms
|
||||
from django.contrib import admin, messages
|
||||
from django.contrib.admin.utils import quote, unquote
|
||||
from django.contrib.auth.admin import UserAdmin as DjangoUserAdmin
|
||||
from django.core.exceptions import PermissionDenied
|
||||
from django.http import Http404, HttpResponseRedirect
|
||||
from django.template.response import TemplateResponse
|
||||
from django.urls import path, reverse
|
||||
from django.utils.html import format_html
|
||||
|
||||
from apps.billing.services import WalletAdjustmentError, adjust_wallet_points
|
||||
|
||||
from .models import ApiKey, User, UserWallet
|
||||
|
||||
|
||||
class WalletAdjustmentForm(forms.Form):
|
||||
points_delta = forms.IntegerField(
|
||||
label="点数变动",
|
||||
help_text="正数加点,负数扣点;不可为 0。",
|
||||
)
|
||||
reason = forms.CharField(
|
||||
label="调整原因",
|
||||
widget=forms.Textarea(attrs={"rows": 4}),
|
||||
help_text="必填,会写入点数流水用于审计。",
|
||||
)
|
||||
|
||||
def clean_points_delta(self):
|
||||
points_delta = self.cleaned_data["points_delta"]
|
||||
if points_delta == 0:
|
||||
raise forms.ValidationError("点数变动不可为 0。")
|
||||
return points_delta
|
||||
|
||||
def clean_reason(self):
|
||||
reason = self.cleaned_data["reason"].strip()
|
||||
if not reason:
|
||||
raise forms.ValidationError("必须填写调整原因。")
|
||||
return reason
|
||||
|
||||
|
||||
@admin.register(User)
|
||||
class UserAdmin(DjangoUserAdmin):
|
||||
fieldsets = DjangoUserAdmin.fieldsets + (
|
||||
@@ -14,6 +47,7 @@ class UserAdmin(DjangoUserAdmin):
|
||||
"username",
|
||||
"email",
|
||||
"status",
|
||||
"wallet_points_balance",
|
||||
"is_staff",
|
||||
"is_active",
|
||||
"created_at",
|
||||
@@ -21,13 +55,35 @@ class UserAdmin(DjangoUserAdmin):
|
||||
list_filter = DjangoUserAdmin.list_filter + ("status",)
|
||||
search_fields = ("username", "email", "payment_user_id")
|
||||
|
||||
def get_queryset(self, request):
|
||||
return super().get_queryset(request).select_related("wallet")
|
||||
|
||||
@admin.display(description="points")
|
||||
def wallet_points_balance(self, obj):
|
||||
wallet = getattr(obj, "wallet", None)
|
||||
return wallet.points_balance if wallet else 0
|
||||
|
||||
|
||||
@admin.register(UserWallet)
|
||||
class UserWalletAdmin(admin.ModelAdmin):
|
||||
list_display = ("user", "points_balance", "updated_at")
|
||||
search_fields = ("user__username", "user__email")
|
||||
readonly_fields = ("user", "points_balance", "created_at", "updated_at")
|
||||
list_display = ("user", "points_balance", "updated_at", "adjust_points_link")
|
||||
search_fields = ("user__username", "user__email", "user__payment_user_id")
|
||||
readonly_fields = (
|
||||
"user",
|
||||
"points_balance",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
"adjust_points_link",
|
||||
)
|
||||
fields = (
|
||||
"user",
|
||||
"points_balance",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
"adjust_points_link",
|
||||
)
|
||||
ordering = ("user__username",)
|
||||
list_select_related = ("user",)
|
||||
|
||||
def has_add_permission(self, request):
|
||||
return False
|
||||
@@ -35,12 +91,86 @@ class UserWalletAdmin(admin.ModelAdmin):
|
||||
def has_delete_permission(self, request, obj=None):
|
||||
return False
|
||||
|
||||
def get_urls(self):
|
||||
urls = super().get_urls()
|
||||
custom_urls = [
|
||||
path(
|
||||
"<path:object_id>/adjust-points/",
|
||||
self.admin_site.admin_view(self.adjust_points_view),
|
||||
name="users_userwallet_adjust_points",
|
||||
),
|
||||
]
|
||||
return custom_urls + urls
|
||||
|
||||
@admin.display(description="手工调点")
|
||||
def adjust_points_link(self, obj):
|
||||
if obj is None:
|
||||
return ""
|
||||
url = reverse(
|
||||
"admin:users_userwallet_adjust_points",
|
||||
args=(quote(obj.pk),),
|
||||
current_app=self.admin_site.name,
|
||||
)
|
||||
return format_html('<a class="button" href="{}">调点</a>', url)
|
||||
|
||||
def adjust_points_view(self, request, object_id):
|
||||
wallet = self.get_object(request, unquote(object_id))
|
||||
if wallet is None:
|
||||
raise Http404("UserWallet does not exist.")
|
||||
if not self.has_change_permission(request, wallet):
|
||||
raise PermissionDenied
|
||||
|
||||
if request.method == "POST":
|
||||
form = WalletAdjustmentForm(request.POST)
|
||||
if form.is_valid():
|
||||
try:
|
||||
result = adjust_wallet_points(
|
||||
user=wallet.user,
|
||||
points_delta=form.cleaned_data["points_delta"],
|
||||
reason=form.cleaned_data["reason"],
|
||||
actor=request.user,
|
||||
)
|
||||
except WalletAdjustmentError as exc:
|
||||
form.add_error(None, str(exc))
|
||||
else:
|
||||
messages.success(
|
||||
request,
|
||||
(
|
||||
f"已调整 {wallet.user} 点数 {result.points_delta:+d},"
|
||||
f"当前余额 {result.balance_after}。"
|
||||
),
|
||||
)
|
||||
change_url = reverse(
|
||||
"admin:users_userwallet_change",
|
||||
args=(quote(wallet.pk),),
|
||||
current_app=self.admin_site.name,
|
||||
)
|
||||
return HttpResponseRedirect(change_url)
|
||||
else:
|
||||
form = WalletAdjustmentForm()
|
||||
|
||||
context = {
|
||||
**self.admin_site.each_context(request),
|
||||
"title": "手工调整点数",
|
||||
"opts": self.model._meta,
|
||||
"original": wallet,
|
||||
"form": form,
|
||||
"media": self.media + form.media,
|
||||
"has_change_permission": self.has_change_permission(request, wallet),
|
||||
}
|
||||
return TemplateResponse(
|
||||
request,
|
||||
"admin/users/userwallet/adjust_points.html",
|
||||
context,
|
||||
)
|
||||
|
||||
|
||||
@admin.register(ApiKey)
|
||||
class ApiKeyAdmin(admin.ModelAdmin):
|
||||
list_display = ("key_prefix", "user", "name", "status", "last_used_at", "created_at")
|
||||
list_filter = ("status", "created_at")
|
||||
search_fields = ("key_prefix", "user__username", "user__email", "name")
|
||||
list_select_related = ("user",)
|
||||
readonly_fields = (
|
||||
"user",
|
||||
"key_prefix",
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
{% extends "admin/base_site.html" %}
|
||||
{% load admin_urls i18n %}
|
||||
|
||||
{% block breadcrumbs %}
|
||||
<div class="breadcrumbs">
|
||||
<a href="{% url 'admin:index' %}">首页</a>
|
||||
› <a href="{% url 'admin:app_list' app_label=opts.app_label %}">{{ opts.app_config.verbose_name }}</a>
|
||||
› <a href="{% url opts|admin_urlname:'changelist' %}">{{ opts.verbose_name_plural|capfirst }}</a>
|
||||
› <a href="{% url opts|admin_urlname:'change' original.pk|admin_urlquote %}">{{ original }}</a>
|
||||
› 手工调整点数
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div id="content-main">
|
||||
<form method="post" novalidate>
|
||||
{% csrf_token %}
|
||||
<div>
|
||||
{% if form.non_field_errors %}
|
||||
<p class="errornote">{{ form.non_field_errors }}</p>
|
||||
{% endif %}
|
||||
|
||||
<fieldset class="module aligned">
|
||||
<div class="form-row">
|
||||
<div>
|
||||
<label>用户:</label>
|
||||
<div class="readonly">{{ original.user }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<div>
|
||||
<label>当前余额:</label>
|
||||
<div class="readonly">{{ original.points_balance }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% for field in form %}
|
||||
<div class="form-row{% if field.errors %} errors{% endif %}">
|
||||
{{ field.errors }}
|
||||
<div>
|
||||
{{ field.label_tag }}
|
||||
{{ field }}
|
||||
{% if field.help_text %}
|
||||
<div class="help">{{ field.help_text }}</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</fieldset>
|
||||
|
||||
<div class="submit-row">
|
||||
<input type="submit" value="保存调整" class="default">
|
||||
<a class="closelink" href="{% url opts|admin_urlname:'change' original.pk|admin_urlquote %}">取消</a>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -3,6 +3,9 @@ from django.contrib.auth import get_user_model
|
||||
from django.test import TestCase
|
||||
from django.urls import reverse
|
||||
|
||||
from apps.billing.models import PointsLedger
|
||||
from apps.users.models import ApiKey, UserWallet
|
||||
|
||||
|
||||
class AdminSmokeTests(TestCase):
|
||||
def test_custom_user_is_registered_in_admin(self):
|
||||
@@ -21,3 +24,96 @@ class AdminSmokeTests(TestCase):
|
||||
|
||||
response = self.client.get(reverse("admin:index"))
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
|
||||
class UserAdminOperationTests(TestCase):
|
||||
def setUp(self):
|
||||
user_model = get_user_model()
|
||||
self.admin_user = user_model.objects.create_superuser(
|
||||
username="ops-admin",
|
||||
email="ops-admin@example.com",
|
||||
password="test-password",
|
||||
)
|
||||
self.user = user_model.objects.create_user(
|
||||
username="client-user",
|
||||
email="client-user@example.com",
|
||||
password="test-password",
|
||||
)
|
||||
self.wallet = UserWallet.objects.create(user=self.user, points_balance=100)
|
||||
self.client.force_login(self.admin_user)
|
||||
|
||||
def test_wallet_adjust_admin_view_calls_billing_service_and_writes_ledger(self):
|
||||
url = reverse("admin:users_userwallet_adjust_points", args=(self.wallet.pk,))
|
||||
|
||||
get_response = self.client.get(url)
|
||||
self.assertEqual(get_response.status_code, 200)
|
||||
self.assertContains(get_response, "手工调整点数")
|
||||
self.assertContains(get_response, "当前余额")
|
||||
|
||||
response = self.client.post(
|
||||
url,
|
||||
{
|
||||
"points_delta": "30",
|
||||
"reason": "运营补偿",
|
||||
},
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, 302)
|
||||
self.wallet.refresh_from_db()
|
||||
self.assertEqual(self.wallet.points_balance, 130)
|
||||
|
||||
ledger = PointsLedger.objects.get(
|
||||
user=self.user,
|
||||
change_type=PointsLedger.ChangeType.ADJUST,
|
||||
)
|
||||
self.assertEqual(ledger.points_delta, 30)
|
||||
self.assertEqual(ledger.balance_after, 130)
|
||||
self.assertIn("运营补偿", ledger.reason)
|
||||
self.assertIn("operator=ops-admin#", ledger.reason)
|
||||
|
||||
def test_wallet_adjust_admin_requires_reason_and_keeps_balance_unchanged(self):
|
||||
url = reverse("admin:users_userwallet_adjust_points", args=(self.wallet.pk,))
|
||||
|
||||
response = self.client.post(
|
||||
url,
|
||||
{
|
||||
"points_delta": "30",
|
||||
"reason": "",
|
||||
},
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.wallet.refresh_from_db()
|
||||
self.assertEqual(self.wallet.points_balance, 100)
|
||||
self.assertFalse(
|
||||
PointsLedger.objects.filter(
|
||||
user=self.user,
|
||||
change_type=PointsLedger.ChangeType.ADJUST,
|
||||
).exists()
|
||||
)
|
||||
|
||||
def test_wallet_admin_change_form_does_not_directly_edit_balance(self):
|
||||
url = reverse("admin:users_userwallet_change", args=(self.wallet.pk,))
|
||||
|
||||
response = self.client.post(url, {"points_balance": "999"}, follow=True)
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.wallet.refresh_from_db()
|
||||
self.assertEqual(self.wallet.points_balance, 100)
|
||||
self.assertFalse(
|
||||
PointsLedger.objects.filter(
|
||||
user=self.user,
|
||||
change_type=PointsLedger.ChangeType.ADJUST,
|
||||
).exists()
|
||||
)
|
||||
|
||||
def test_api_key_admin_hides_plaintext_and_full_hash(self):
|
||||
api_key, raw_key = ApiKey.create_for_user(self.user, name="desktop")
|
||||
url = reverse("admin:users_apikey_change", args=(api_key.pk,))
|
||||
|
||||
response = self.client.get(url)
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertContains(response, api_key.key_prefix)
|
||||
self.assertNotContains(response, raw_key)
|
||||
self.assertNotContains(response, api_key.key_hash)
|
||||
|
||||
@@ -38,7 +38,7 @@
|
||||
|
||||
## 当前阶段
|
||||
|
||||
当前项目处于:**Phase 5 后台与发布**。Phase 2 计费核心已完成到 T-204;Phase 3 已完成 T-301 API Key 鉴权、T-302 生成标题 / 图片接口、T-303 余额查询接口、T-304 充值回调、T-305 扫码充值下单 + 轮询与 T-306 对外 API 安全加固;Phase 4 已完成 T-501 注册 / 登录(allauth)、T-502 API Key 自助管理页、T-503 个人中心 / 记录页、T-504 充值页与 T-505 用户端审核优化。下一步进入 T-401 运营后台完善。
|
||||
当前项目处于:**Phase 5 后台与发布**。Phase 2 计费核心已完成到 T-204;Phase 3 已完成 T-301 API Key 鉴权、T-302 生成标题 / 图片接口、T-303 余额查询接口、T-304 充值回调、T-305 扫码充值下单 + 轮询与 T-306 对外 API 安全加固;Phase 4 已完成 T-501 注册 / 登录(allauth)、T-502 API Key 自助管理页、T-503 个人中心 / 记录页、T-504 充值页与 T-505 用户端审核优化;Phase 5 已完成 T-401 运营后台完善。下一步进入 T-402 完整验收 MVP。
|
||||
|
||||
优先路径:
|
||||
|
||||
@@ -47,7 +47,7 @@
|
||||
3. Phase 2:计费核心 —— T-201/T-202/T-203 已完成 UserWallet/ApiKey/PointsLedger/CallRecord、计费规则、汇率、计费计算、并发安全扣点与失败退点。
|
||||
4. Phase 3:对外 API 与充值 —— T-301 Key 鉴权、T-302 生成接口、T-303 余额查询、T-304 充值回调、T-305 扫码下单与轮询、T-306 安全加固已完成。
|
||||
5. Phase 4:用户端(Django 模板 SSR)—— T-501 注册登录、T-502 API Key 管理、T-503 个人中心 / 记录页、T-504 充值页与 T-505 用户端审核优化已完成。
|
||||
6. Phase 5:后台与发布 —— 下一步 T-401 运营后台完善,随后做完整验收与部署 / 运行文档。
|
||||
6. Phase 5:后台与发布 —— T-401 运营后台完善已完成;下一步 T-402 完整验收 MVP,随后做部署 / 运行文档。
|
||||
|
||||
## 领取任务规则
|
||||
|
||||
|
||||
@@ -23,7 +23,7 @@
|
||||
- **API 层(DRF)**:对外生成接口、余额查询、支付回调接收、扫码下单。入口 `apps/api/`。生成/余额这类对外业务 API **只认 API Key,不接受 Web session**;充值下单/状态查询属于用户端流程,走 Web session + CSRF;支付回调走平台验签。
|
||||
- **计费层**:点数计算、原子扣减(锁 `UserWallet` 行)、退点、充值入账、流水记账。入口 `apps/billing/`。
|
||||
- **AI 调用层(Provider Adapter 架构)**:对外只暴露稳定能力,内部用「能力别名 → 具体供应商适配器」解耦。入口 `apps/ai/`,适配器在 `apps/ai/providers/`。
|
||||
- **运营后台**:django-admin,注册各模型的 Admin。入口各 app 的 `admin.py`。
|
||||
- **运营后台**:django-admin,注册各模型的 Admin。入口各 app 的 `admin.py`。T-401 已补齐用户、钱包、API Key(脱敏)、计费规则、汇率、充值订单、点数流水和调用记录的管理/检索;点数手工调整只能从钱包专用入口提交原因和非 0 变动,经计费层写 `adjust` 流水。
|
||||
- **外部服务**:上游 AI(vectorengine)、外部支付系统(跳转 + 回调)。
|
||||
- **数据库**:MySQL 8.4 LTS(cmhub **专用独立实例**,不复用 VPS 已有的 MySQL 5.7);引擎必须 InnoDB、字符集 utf8mb4。开发与生产同用 MySQL,勿用 SQLite(SQLite 会静默忽略 `select_for_update`,测不出并发扣点)。
|
||||
|
||||
@@ -55,6 +55,7 @@ T-306 已实现对外 API 安全加固:`download_image_input()` 在请求前
|
||||
- 计费规则查询:按「操作类型 + 能力别名(+ 可选分辨率)」算出本次点数 N。**按别名定价,不按具体供应商 SKU 定价**,这样后台换底层模型时计费不变。
|
||||
- 点数原子扣减与退回:数据库事务 + 行锁,保证并发不超扣、不为负。
|
||||
- 充值入账:接收已验签的支付回调数据,按汇率换算点数,幂等入账,写流水。
|
||||
- 手工调点:运营后台只收集点数变动和原因,必须调用计费层 `adjust_wallet_points()`;服务在事务内锁 `UserWallet`,拒绝扣成负数,并写 `PointsLedger(change_type=adjust)`。
|
||||
- 点数流水记账:所有点数变动(充值/消费/调整/冲正)都生成一条流水。
|
||||
- 是点数余额的唯一写入方。
|
||||
|
||||
@@ -174,7 +175,7 @@ CREATE TABLE exchange_rate (
|
||||
);
|
||||
```
|
||||
|
||||
T-102 已实现 `AiModel` / `ModelAlias` 的 Django models、admin、迁移与别名解析。T-103 已补 `AiConfigAuditLog`,admin 里保存/删除模型配置或能力别名时自动写审计日志。T-202 已实现 `PricingRule` / `ExchangeRate` 与 `apps.billing.pricing` 计算函数:定价按 `operation_type + alias + resolution` 查 active 规则,优先 exact resolution,再回退到空 resolution 默认价;缺规则抛 `NoPricingRuleError(code="no_pricing_rule")`。T-203 已实现 `apps.billing.services`:`precharge_call()` 锁 `UserWallet` 行预扣并写 pending 调用与 consume 流水;`mark_call_success()` 确认成功不再改余额;`refund_call_points()` 锁调用记录并幂等退点,写 refund 流水。默认别名唯一性由 model validation、admin 与导入器保证;MySQL 不支持通用 partial unique index,后续若要强制数据库层约束可在 T-401 评估触发器或约束表。
|
||||
T-102 已实现 `AiModel` / `ModelAlias` 的 Django models、admin、迁移与别名解析。T-103 已补 `AiConfigAuditLog`,admin 里保存/删除模型配置或能力别名时自动写审计日志。T-202 已实现 `PricingRule` / `ExchangeRate` 与 `apps.billing.pricing` 计算函数:定价按 `operation_type + alias + resolution` 查 active 规则,优先 exact resolution,再回退到空 resolution 默认价;缺规则抛 `NoPricingRuleError(code="no_pricing_rule")`。T-203 已实现 `apps.billing.services`:`precharge_call()` 锁 `UserWallet` 行预扣并写 pending 调用与 consume 流水;`mark_call_success()` 确认成功不再改余额;`refund_call_points()` 锁调用记录并幂等退点,写 refund 流水。T-401 已在同一计费层新增 `adjust_wallet_points()`,供 admin 手工调点使用。默认别名唯一性由 model validation、admin 与导入器保证;MySQL 不支持通用 partial unique index,若后续要强制数据库层默认别名唯一,可另评估触发器或约束表。
|
||||
|
||||
> 可选增强(接口预留、MVP 不实现):`account_alias_permission`(按账号授权可用别名,防止调用方点用未授权/昂贵模型);别名按比例分流到多个模型(灰度/AB/故障转移)。适配器接口需为此留口子。
|
||||
|
||||
@@ -274,7 +275,7 @@ CREATE TABLE call_record (
|
||||
- `payment_user_id`、`payment_txn_no` 为对账预留,字段先建。
|
||||
- `recharge_order.exchange_rate` 与 `points_granted` 在下单时写入,状态为 `pending` 时也必须有值;支付回调金额必须与订单金额一致,入账时不得按新的汇率重算。
|
||||
- `call_record.status` 状态机为 `pending -> success / failed`。上游失败退点后仍保持 `failed`,退款流水通过 `points_ledger(change_type=refund, ref_call_id=call_record.id)` 关联,不单独增加 `refunded` 状态,避免调用结果与账务动作混在一个字段里。
|
||||
- T-201 已落地 `UserWallet` / `ApiKey` 于 `apps.users`,`PointsLedger` / `CallRecord` 于 `apps.billing`;T-203 已落地扣点/退点服务;T-304 已落地 `RechargeOrder`、回调幂等入账服务和 `points_ledger(ref_order_id, change_type)` 复合唯一约束,`ref_order_id` 当前仍为数值引用 `RechargeOrder.id`;T-305 已落地 `create_recharge_order()`,负责创建 pending 订单、锁定汇率/点数并回填二维码票据;T-501 已保证 allauth 自助注册路径创建 `UserWallet(points_balance=0)`,且不写 `PointsLedger`,避免把注册初始化误记为赠点或充值;T-502 已把 API Key 自助管理接到 `ApiKey.create_for_user()`,删除动作写为 `revoked` 状态而非物理删除;T-503 已把个人中心和记录页接到只读查询,余额用 `get_balance_snapshot()`,充值总额按 paid `RechargeOrder` 汇总,入账 / 消费 / 退款按 `PointsLedger` 汇总;T-504 已把 `/recharge` 页面接到 `create_recharge_order()` 与 `/api/v1/recharge/status`。
|
||||
- T-201 已落地 `UserWallet` / `ApiKey` 于 `apps.users`,`PointsLedger` / `CallRecord` 于 `apps.billing`;T-203 已落地扣点/退点服务;T-304 已落地 `RechargeOrder`、回调幂等入账服务和 `points_ledger(ref_order_id, change_type)` 复合唯一约束,`ref_order_id` 当前仍为数值引用 `RechargeOrder.id`;T-305 已落地 `create_recharge_order()`,负责创建 pending 订单、锁定汇率/点数并回填二维码票据;T-401 已落地 `adjust_wallet_points()`,手工调整点数必须带原因并写 `adjust` 流水,后台钱包余额字段只读;T-501 已保证 allauth 自助注册路径创建 `UserWallet(points_balance=0)`,且不写 `PointsLedger`,避免把注册初始化误记为赠点或充值;T-502 已把 API Key 自助管理接到 `ApiKey.create_for_user()`,删除动作写为 `revoked` 状态而非物理删除;T-503 已把个人中心和记录页接到只读查询,余额用 `get_balance_snapshot()`,充值总额按 paid `RechargeOrder` 汇总,入账 / 消费 / 退款按 `PointsLedger` 汇总;T-504 已把 `/recharge` 页面接到 `create_recharge_order()` 与 `/api/v1/recharge/status`。
|
||||
|
||||
## 四、计费时序(核心,务必照此实现)
|
||||
|
||||
@@ -368,7 +369,7 @@ CREATE TABLE call_record (
|
||||
3. 计费:点数扣减(并发安全)+ 计费规则 + 调用记录(Phase 2,T-201~T-203 已完成)。
|
||||
4. 对外 API 鉴权 + 余额查询 + 充值下单/回调 + 安全加固(Phase 3,已完成到 T-306)。
|
||||
5. 用户端注册登录、API Key 管理、个人中心、充值页(Phase 4;T-501 注册登录、T-502 API Key 管理、T-503 个人中心 / 记录页与 T-504 充值页已完成)。
|
||||
6. 运营后台完善、完整验收、部署(Phase 5)。
|
||||
6. 运营后台完善(T-401 已完成)、完整验收、部署(Phase 5)。
|
||||
|
||||
## 七、项目结构建议
|
||||
|
||||
|
||||
+1
-1
@@ -71,7 +71,7 @@
|
||||
|
||||
| ID | 任务 | 依赖 | 验收要点 | 状态 |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| T-401 | 运营后台完善 | T-302, T-304 | admin 可管理用户/钱包/ApiKey(脱敏)、配规则/汇率、检索充值订单/流水/调用记录;手工调点带原因并经计费层写流水 | TODO |
|
||||
| T-401 | 运营后台完善 | T-302, T-304 | admin 可管理用户/钱包/ApiKey(脱敏)、配规则/汇率、检索充值订单/流水/调用记录;手工调点带原因并经计费层写流水 | DONE |
|
||||
| T-402 | 完整验收 MVP | T-401, T-505 | `02-requirements.md` 的 P0 验收全部通过(含用户端注册/充值/API Key/记录) | TODO |
|
||||
| T-403 | 部署 / 运行文档 | T-402 | 新环境可按文档运行;明确图片接口超时配置;上线前必须引用一次真实图片生成耗时来设置 Gunicorn `--timeout`、Nginx `proxy_read_timeout`、客户端 read timeout;若尚无真实耗时,部署文档必须显式标注图片同步风险未退,不得声称已验证;生产必须配置真实 `DJANGO_EMAIL_BACKEND` / `DJANGO_DEFAULT_FROM_EMAIL`,否则 allauth 邮箱验证无法发信、用户无法完成登录;**生产静态文件 serving**:T-505 已把 Bootstrap/qrcode 自托管到 `apps/portal/static/`,但 `settings` 目前只有 `STATIC_URL`,`DEBUG=False` 下 Django 不发静态文件——必须配置 `STATIC_ROOT` + `collectstatic` + WhiteNoise 或 Nginx 托管 `/static/`,否则用户端 CSS 错版、充值二维码 404(P2-1 的国内可用性目标在生产失效);**DRF 限流依赖共享缓存**:多 Gunicorn worker 下默认 `LocMemCache` 是每进程的,会让 `generate`/`api_auth_failure` 限流按 worker 各算一份(实际速率≈worker 数×配置值),部署必须配置 Redis/Memcached 等共享 `CACHES` 后端并在文档说明,否则限流形同虚设 | TODO |
|
||||
|
||||
|
||||
+10
-10
@@ -12,16 +12,16 @@
|
||||
## 当前快照
|
||||
|
||||
- 日期:2026-07-03
|
||||
- 阶段:Phase 5 后台与发布;Phase 3 对外 API 与充值已完成到 T-306,Phase 4 用户端 T-501 注册 / 登录(allauth)、T-502 API Key 自助管理页、T-503 个人中心 / 记录页、T-504 充值页与 T-505 用户端审核优化已完成,下一步 T-401 运营后台完善
|
||||
- 阶段:Phase 5 后台与发布;Phase 3 对外 API 与充值已完成到 T-306,Phase 4 用户端 T-501 注册 / 登录(allauth)、T-502 API Key 自助管理页、T-503 个人中心 / 记录页、T-504 充值页与 T-505 用户端审核优化已完成,Phase 5 T-401 运营后台完善已完成,下一步 T-402 完整验收 MVP
|
||||
- 技术栈:系统 Python 3.12.3 + Django 5.2.15 + DRF 3.16.1 + django-allauth 65.18.0 + PyMySQL 1.1.3 + cryptography 46.0.7 + requests 2.34.2 + django-admin;MySQL 8.4 已接入 settings,并支持 `MYSQL_CONNECT_TIMEOUT` / `MYSQL_READ_TIMEOUT` / `MYSQL_WRITE_TIMEOUT`;用户端已用 Django 模板 SSR + Bootstrap + allauth 落地注册登录;详见 `03-tech-stack.md`
|
||||
- 生产代码:已有最小 Django 工程骨架:`manage.py`、`config/`;T-002 已创建 `apps/users|portal|billing|ai|api`;T-003 已把自定义 `User` 注册进 django-admin;T-004 已完成 email 唯一性、init 版本断言、app 顺序、`.env.example` 与 `pyproject.toml`;T-101 已新增 `apps/ai/providers/`(Provider 接口、注册表、chat/gemini/images/images_edits 适配器);T-102 已新增 `AiModel` / `ModelAlias`、Fernet 加密密钥存储、别名解析、admin 配置页、`import_ai_models` 导入命令;T-103 已新增 `AiConfigAuditLog` 审计表、admin 只读页面和后台保存/删除审计 hook;T-104/T-105 已完成录制 title/image smoke 与审核修补;T-201 已新增 `UserWallet` / `ApiKey`、`PointsLedger` / `CallRecord`、对应 admin 与迁移;T-202 已新增 `PricingRule` / `ExchangeRate`、`apps.billing.pricing` 计费计算函数、admin 配置页与迁移;T-203 已新增 `apps.billing.services`,实现并发安全预扣、成功确认与幂等失败退点;T-204 已新增 `billing.0003_pointsledger_unique_ledger_change_type_per_call`,用 MySQL 可落地的 `ref_call + change_type` 复合唯一约束兜底防重复 refund;T-301 已新增 `apps.api.authentication.ApiKeyAuthentication` 与 `ExternalApiView`;T-302 已新增生成接口编排、序列化器、图片本地存储和 `/api/v1/generate/title|image` 路由;T-303 已新增 `apps.billing.services.get_balance_snapshot()` 与 `/api/v1/balance` 余额查询接口;T-304 已新增 `RechargeOrder`、充值回调验签适配器、幂等入账服务、微信/支付宝回调路由与迁移 `billing.0004_rechargeorder_and_more`;T-305 已新增 `create_recharge_order()`、微信/支付宝扫码下单 mock/SDK 入口、`/api/v1/recharge/create` 与 `/api/v1/recharge/status`;T-306 已新增 `apps.api.throttles`、`apps.api.exceptions`、`REST_FRAMEWORK` 安全默认认证、生成/认证失败限流、`image_url` SSRF 防护与响应大小上限、充值单笔金额上限;T-501 已接入 allauth,新增 portal 路由、注册适配器、登录/注册/登出模板和最小 dashboard,注册成功创建 0 点钱包且不写赠点流水;T-502 已新增 `/apikeys`、API Key 创建表单、列表页和删除(吊销)动作,生成后明文只显示一次,列表只显示 prefix;T-503 已扩展 `/dashboard` 为个人中心汇总,并新增 `/records/recharge` 充值记录与 `/records/usage` 消费记录,只读展示当前用户数据;T-504 已新增 `/recharge` 页面、`RechargeCreateForm`、充值导航入口和轮询脚本,页面创建 pending 订单、展示二维码票据、轮询 `/api/v1/recharge/status`,订单 paid 后刷新余额;T-505 已把 Bootstrap 5 CSS 与 qrcode.js vendoring 到 `apps/portal/static/portal/vendor/`,页面不再依赖 jsdelivr,并把充值记录 / 消费记录改为 Django `Paginator` 分页
|
||||
- 测试:T-505 已验证:`./init.ps1` 开工前通过;`py -3.12 -m py_compile apps\portal\views.py apps\portal\tests.py` 通过;`py -3.12 manage.py check` 通过;`py -3.12 manage.py findstatic portal/vendor/bootstrap/bootstrap.min.css portal/vendor/qrcode/qrcode.js --verbosity 1` 找到本地 static 文件;`py -3.12 manage.py makemigrations --check --dry-run` 无变化;`py -3.12 -m compileall apps config` 通过;`rg -n "cdn\.jsdelivr|jsdelivr|qrcode@1\.5\.4" apps\portal\templates apps\portal\static` 无命中;`py -3.12 manage.py test apps.portal --noinput --keepdb --verbosity 2` 通过(21 tests OK,含 >50 条分页测试);新增分页测试单跑通过(2 tests OK);`./init.ps1` 收尾通过。曾尝试 `py -3.12 manage.py test apps.users apps.portal apps.api --noinput --keepdb --verbosity 2` 与 `py -3.12 manage.py test apps.api --noinput --keepdb --verbosity 2`,但远程 MySQL 在运行中多次出现 `OperationalError 2003/2013`、`Packet sequence number wrong` 或连接重置,未取得单次全绿;已通过的用例无断言失败。测试/迁移阶段仍有 allauth `account.EmailAddress` 条件唯一约束在 MySQL 上不可创建的 `models.W036` 警告;本项目用户账本唯一性由 `user.email` 唯一约束承担。
|
||||
- 生产代码:已有最小 Django 工程骨架:`manage.py`、`config/`;T-002 已创建 `apps/users|portal|billing|ai|api`;T-003 已把自定义 `User` 注册进 django-admin;T-004 已完成 email 唯一性、init 版本断言、app 顺序、`.env.example` 与 `pyproject.toml`;T-101 已新增 `apps/ai/providers/`(Provider 接口、注册表、chat/gemini/images/images_edits 适配器);T-102 已新增 `AiModel` / `ModelAlias`、Fernet 加密密钥存储、别名解析、admin 配置页、`import_ai_models` 导入命令;T-103 已新增 `AiConfigAuditLog` 审计表、admin 只读页面和后台保存/删除审计 hook;T-104/T-105 已完成录制 title/image smoke 与审核修补;T-201 已新增 `UserWallet` / `ApiKey`、`PointsLedger` / `CallRecord`、对应 admin 与迁移;T-202 已新增 `PricingRule` / `ExchangeRate`、`apps.billing.pricing` 计费计算函数、admin 配置页与迁移;T-203 已新增 `apps.billing.services`,实现并发安全预扣、成功确认与幂等失败退点;T-204 已新增 `billing.0003_pointsledger_unique_ledger_change_type_per_call`,用 MySQL 可落地的 `ref_call + change_type` 复合唯一约束兜底防重复 refund;T-301 已新增 `apps.api.authentication.ApiKeyAuthentication` 与 `ExternalApiView`;T-302 已新增生成接口编排、序列化器、图片本地存储和 `/api/v1/generate/title|image` 路由;T-303 已新增 `apps.billing.services.get_balance_snapshot()` 与 `/api/v1/balance` 余额查询接口;T-304 已新增 `RechargeOrder`、充值回调验签适配器、幂等入账服务、微信/支付宝回调路由与迁移 `billing.0004_rechargeorder_and_more`;T-305 已新增 `create_recharge_order()`、微信/支付宝扫码下单 mock/SDK 入口、`/api/v1/recharge/create` 与 `/api/v1/recharge/status`;T-306 已新增 `apps.api.throttles`、`apps.api.exceptions`、`REST_FRAMEWORK` 安全默认认证、生成/认证失败限流、`image_url` SSRF 防护与响应大小上限、充值单笔金额上限;T-501 已接入 allauth,新增 portal 路由、注册适配器、登录/注册/登出模板和最小 dashboard,注册成功创建 0 点钱包且不写赠点流水;T-502 已新增 `/apikeys`、API Key 创建表单、列表页和删除(吊销)动作,生成后明文只显示一次,列表只显示 prefix;T-503 已扩展 `/dashboard` 为个人中心汇总,并新增 `/records/recharge` 充值记录与 `/records/usage` 消费记录,只读展示当前用户数据;T-504 已新增 `/recharge` 页面、`RechargeCreateForm`、充值导航入口和轮询脚本,页面创建 pending 订单、展示二维码票据、轮询 `/api/v1/recharge/status`,订单 paid 后刷新余额;T-505 已把 Bootstrap 5 CSS 与 qrcode.js vendoring 到 `apps/portal/static/portal/vendor/`,页面不再依赖 jsdelivr,并把充值记录 / 消费记录改为 Django `Paginator` 分页;T-401 已新增 `adjust_wallet_points()` 手工调点服务、钱包 admin 专用调点表单与模板,后台可管理/检索用户、钱包、API Key(脱敏)、计费规则、汇率、充值订单、点数流水和调用记录,流水/订单/调用记录保持只读
|
||||
- 测试:T-401 已验证:`./init.ps1` 开工前通过;`py -3.12 -m py_compile apps\billing\services.py apps\billing\admin.py apps\billing\tests.py apps\users\admin.py apps\users\tests.py` 通过;`py -3.12 manage.py check` 通过;`py -3.12 manage.py makemigrations --check --dry-run` 无变化;`Test-NetConnection 43.128.3.240 -Port 3306` 端口可达;`py -3.12 manage.py test apps.billing.tests.BillingServiceTests apps.users.tests.UserAdminOperationTests --noinput --keepdb --verbosity 2` 通过(18 tests OK);`py -3.12 manage.py test apps.billing.tests.BillingCoreModelTests apps.billing.tests.PricingCalculationTests apps.billing.tests.BillingServiceTests apps.users.tests --noinput --keepdb --verbosity 2` 通过(32 tests OK);曾尝试完整 `py -3.12 manage.py test apps.billing apps.users --noinput --keepdb --verbosity 2`,但远程 MySQL 在运行中出现 `OperationalError 2003/2013`、连接重置和事务中断,未取得单次全绿;单跑 `apps.billing.tests.ConcurrentDebitTests` 的测试体显示 `ok`,但 teardown/flush 阶段再次因 MySQL 连接超时被 Django 记为 ERROR。`./init.ps1` 收尾通过;`git diff --check` 通过(仅 Windows CRLF 提示)。测试/迁移阶段仍有 allauth `account.EmailAddress` 条件唯一约束在 MySQL 上不可创建的 `models.W036` 警告;本项目用户账本邮箱唯一性由 `user.email` 唯一约束承担。
|
||||
- 数据:AI 上游调用与模型配置参考 `D:\chengma\cmbot`(`src/services/ai_text_service.py`、`ai_image_service.py`、`config/ai_models.json`);真实 `ai_models.json` 不提交,需通过 `import_ai_models` 命令加密导入
|
||||
- 标准启动路径:Windows 用 `./init.ps1`;Unix/WSL 用 `./init.sh`
|
||||
- 标准验证路径:Windows 用 `py -3.12 manage.py check` / `py -3.12 manage.py test`
|
||||
- 设计基线:**自助用户端 + 对外 API + 运营后台**三合一单体;用户模型 `User`(auth)/`UserWallet`(点数,锁 wallet 扣点)/`ApiKey`(1:N,哈希存储);对外两接口 + **能力别名 + Provider 适配器**(可插拔供应商);自助扫码充值;注册不送点数。详见 `04-architecture.md` 与 2026-06-29 / 2026-07-01 的 `progress.md` 决策
|
||||
- 配置基线:运行环境变量集中见 `docs/env.md`;真实密钥/支付凭证不得写入代码或文档样例。allauth 邮箱验证发信由 `DJANGO_EMAIL_BACKEND` / `DJANGO_DEFAULT_FROM_EMAIL` 控制,本地默认 console backend,生产需配置真实邮件服务。充值订单在创建时锁定汇率与预计点数,回调入账使用订单值,不按新汇率重算。支付回调与下单由 `PAYMENT_CALLBACK_MODE` 控制:本地/测试可用 HMAC `mock`,生产应为 `sdk`;二维码本地有效期提示由 `PAYMENT_QR_EXPIRES_MINUTES` 控制。T-306 起对外 API 安全配置包含 `API_GENERATE_THROTTLE_RATE`、`API_AUTH_FAILURE_THROTTLE_RATE`、`IMAGE_URL_MAX_BYTES`、`IMAGE_URL_MAX_REDIRECTS`、`IMAGE_URL_CONNECT_TIMEOUT_SECONDS`、`IMAGE_URL_READ_TIMEOUT_SECONDS`、`RECHARGE_MAX_AMOUNT_CNY`。
|
||||
- 当前 blocker:T-306 上线前 SSRF 风险已修复;远程 MySQL 连接仍可能很慢或间歇超时,完整测试需预留较长时间并优先用 `--keepdb` 串行跑。微信/支付宝真实商户密钥/证书与生产 SDK 依赖仍待提供/安装;真实 AI 上游 smoke 需要先配置 `AI_KEY_ENCRYPTION_KEY` 并导入 AiModel/ModelAlias。图片同步真实耗时风险仍未退,已登记到 T-403。
|
||||
- 当前 blocker:T-306 上线前 SSRF 风险已修复;远程 MySQL 连接仍可能很慢或间歇超时,完整测试需预留较长时间并优先用 `--keepdb` 串行跑,必要时改用更稳定的测试库。微信/支付宝真实商户密钥/证书与生产 SDK 依赖仍待提供/安装;真实 AI 上游 smoke 需要先配置 `AI_KEY_ENCRYPTION_KEY` 并导入 AiModel/ModelAlias。图片同步真实耗时风险仍未退,已登记到 T-403。
|
||||
|
||||
## 当前目录要点
|
||||
|
||||
@@ -33,7 +33,7 @@
|
||||
| `init.sh` / `init.ps1` | 已有 | 启动验证入口,已固定系统 Python 3.12 命令,并校验解释器版本 `>=3.12,<3.14` |
|
||||
| `requirements.txt` / `pyproject.toml` | 已有 | `requirements.txt` 管运行依赖;`pyproject.toml` 落地 `requires-python`;T-101 新增 `requests`;T-102 使用既有 `cryptography` 做 Fernet 加密;T-501 新增 `django-allauth` |
|
||||
| `config/`(Django 工程) | 已有 | T-001 创建,含 settings / urls / wsgi / asgi |
|
||||
| `apps/`(users/portal/billing/ai/api) | 已有 | T-002 创建;`apps/users` 已定义自定义 `User`;T-003 已注册 admin 与 admin smoke test;T-004 已给 `User.email` 加唯一约束;T-101 已新增 `apps/ai/providers`;T-102 已新增 `apps/ai/security.py`、`aliases.py`、`importers.py`、management command 与 `ai.0001_initial` 迁移;T-103 已新增 `apps/ai/audit.py` 与 `ai.0002_aiconfigauditlog` 迁移;T-104/T-105 已新增 `smoke_ai_generation` 录制 title/image smoke 命令;T-201 已在 users 落 `UserWallet` / `ApiKey`,在 billing 落 `PointsLedger` / `CallRecord`;T-202 已在 billing 落 `PricingRule` / `ExchangeRate` 与 `pricing.py`;T-203/T-303/T-304/T-305 已在 `apps/billing/services.py` 落扣点/退点、余额快照、充值入账与充值下单;T-304/T-305 已在 `apps/billing/payment_gateways.py` 落回调验签、mock 下单与 SDK 入口;T-301~T-306 已在 api 落鉴权、生成接口编排、序列化器、图片存储、余额查询、充值回调、充值下单/状态查询、`image_url` SSRF 防护、生成/认证限流与统一 429 错误响应;T-501 已在 portal 落 allauth 注册/登录/登出路由、模板、adapter 与 dashboard;T-502 已在 portal 落 `/apikeys`、API Key 创建表单、列表模板与删除(吊销)动作;T-503 已在 portal 落个人中心汇总、充值记录和消费记录页;T-504 已在 portal 落 `/recharge` 充值页、充值表单、二维码票据展示和状态轮询;T-505 已在 portal 落本地 vendor static 与记录分页 |
|
||||
| `apps/`(users/portal/billing/ai/api) | 已有 | T-002 创建;`apps/users` 已定义自定义 `User`;T-003 已注册 admin 与 admin smoke test;T-004 已给 `User.email` 加唯一约束;T-101 已新增 `apps/ai/providers`;T-102 已新增 `apps/ai/security.py`、`aliases.py`、`importers.py`、management command 与 `ai.0001_initial` 迁移;T-103 已新增 `apps/ai/audit.py` 与 `ai.0002_aiconfigauditlog` 迁移;T-104/T-105 已新增 `smoke_ai_generation` 录制 title/image smoke 命令;T-201 已在 users 落 `UserWallet` / `ApiKey`,在 billing 落 `PointsLedger` / `CallRecord`;T-202 已在 billing 落 `PricingRule` / `ExchangeRate` 与 `pricing.py`;T-203/T-303/T-304/T-305 已在 `apps/billing/services.py` 落扣点/退点、余额快照、充值入账与充值下单;T-401 已在 `apps/billing/services.py` 落 `adjust_wallet_points()`,在 `apps/users/admin.py` 与 `apps/users/templates/admin/users/userwallet/adjust_points.html` 落钱包手工调点入口;T-304/T-305 已在 `apps/billing/payment_gateways.py` 落回调验签、mock 下单与 SDK 入口;T-301~T-306 已在 api 落鉴权、生成接口编排、序列化器、图片存储、余额查询、充值回调、充值下单/状态查询、`image_url` SSRF 防护、生成/认证限流与统一 429 错误响应;T-501 已在 portal 落 allauth 注册/登录/登出路由、模板、adapter 与 dashboard;T-502 已在 portal 落 `/apikeys`、API Key 创建表单、列表模板与删除(吊销)动作;T-503 已在 portal 落个人中心汇总、充值记录和消费记录页;T-504 已在 portal 落 `/recharge` 充值页、充值表单、二维码票据展示和状态轮询;T-505 已在 portal 落本地 vendor static 与记录分页 |
|
||||
| `manage.py` | 已有 | T-001 创建 |
|
||||
| `tests/` | 待建 | 随各任务补充 |
|
||||
|
||||
@@ -41,10 +41,10 @@
|
||||
|
||||
任务状态以 [`06-tasks.md`](06-tasks.md) 为准,历史执行记录见 [`../progress.md`](../progress.md)。
|
||||
|
||||
- 已完成:T-001 初始化 Django + DRF 项目骨架;T-002 建立 apps 目录、自定义 User 与配置;T-003 接通 django-admin 与最小测试;T-004 Phase 0 骨架审核修补;T-101 Provider 适配器层 + 移植 cmbot 调用;T-102 AiModel + ModelAlias 模型 + 别名解析;T-103 配置变更审计;T-104 跑通一次录制标题生成;T-105 Phase 1 AI 层审核修补;T-201 User / UserWallet / ApiKey / PointsLedger / CallRecord 模型;T-202 PricingRule / ExchangeRate 模型 + 计费计算;T-203 并发安全扣点 / 退点;T-204 Phase 2 计费核心审核加固;T-301 API Key 鉴权;T-302 生成标题 / 图片接口;T-303 余额查询接口;T-304 充值回调;T-305 扫码充值下单 + 轮询;T-306 Phase 3 对外 API 安全加固;T-501 注册 / 登录(allauth);T-502 API Key 自助管理页;T-503 个人中心 / 记录页;T-504 充值页(扫码 + 轮询到账);T-505 Phase 4 用户端审核优化。
|
||||
- 已完成:T-001 初始化 Django + DRF 项目骨架;T-002 建立 apps 目录、自定义 User 与配置;T-003 接通 django-admin 与最小测试;T-004 Phase 0 骨架审核修补;T-101 Provider 适配器层 + 移植 cmbot 调用;T-102 AiModel + ModelAlias 模型 + 别名解析;T-103 配置变更审计;T-104 跑通一次录制标题生成;T-105 Phase 1 AI 层审核修补;T-201 User / UserWallet / ApiKey / PointsLedger / CallRecord 模型;T-202 PricingRule / ExchangeRate 模型 + 计费计算;T-203 并发安全扣点 / 退点;T-204 Phase 2 计费核心审核加固;T-301 API Key 鉴权;T-302 生成标题 / 图片接口;T-303 余额查询接口;T-304 充值回调;T-305 扫码充值下单 + 轮询;T-306 Phase 3 对外 API 安全加固;T-501 注册 / 登录(allauth);T-502 API Key 自助管理页;T-503 个人中心 / 记录页;T-504 充值页(扫码 + 轮询到账);T-505 Phase 4 用户端审核优化;T-401 运营后台完善。
|
||||
- 正在进行:无。
|
||||
- 当前 blocker:远程 MySQL 连接当前不稳定或很慢;支付商户真实密钥/证书与生产 SDK 依赖仍待提供;真实 AI 上游 smoke 仍需配置密钥和模型数据后执行。
|
||||
- 下一个可领取任务:**T-401 运营后台完善**。
|
||||
- 当前 blocker:远程 MySQL 连接当前不稳定或很慢,完整测试单次全绿可能需要稳定测试库;支付商户真实密钥/证书与生产 SDK 依赖仍待提供;真实 AI 上游 smoke 仍需配置密钥和模型数据后执行。
|
||||
- 下一个可领取任务:**T-402 完整验收 MVP**。
|
||||
|
||||
## 当前可运行内容
|
||||
|
||||
@@ -90,14 +90,14 @@ python3.12 manage.py smoke_ai_generation image --recorded
|
||||
- `GET /records/recharge`
|
||||
- `GET /records/usage`
|
||||
|
||||
当前骨架可运行。T-002 已在首次迁移前创建自定义 User,并按 `env.md` 接入 MySQL 8.4 / utf8mb4;远程 MySQL 已完成 Django 初始迁移。T-003 已接通 django-admin,测试可创建/销毁 `test_cmhub` 测试库;当前远程 MySQL 对频繁建库/销库仍可能间歇超时,必要时用 `--keepdb` 且串行跑测试。T-004 已应用 `users.0002_alter_user_email`,`user.email` 已有唯一索引。T-101 的 AI provider 层只做 HTTP 调用与响应解析;T-102 已把 provider 运行配置接到数据库 `AiModel` / `ModelAlias`,`resolve_alias()` 每次查当前 active 配置并按 `text` / `image` 能力校验。T-103 已补 `AiConfigAuditLog`,admin 保存/删除 `AiModel` / `ModelAlias` 时记录 actor、action、target、changed_fields、changes、created_at,密钥只记录 empty/set 状态。T-104/T-105 已用临时回滚配置跑通录制标题和录制图片生成。T-201 已落地钱包、API Key、点数流水和调用记录:API Key 明文只在创建 helper 返回,库内只存 hash/prefix;CallRecord 只存 `result_ref`/`result_summary`,没有 provider raw 字段。T-202 已落地 `PricingRule` / `ExchangeRate`:计费按 `operation_type + alias + resolution` 查 active 规则,优先精确分辨率,再回退默认价;缺规则抛 `NoPricingRuleError(code="no_pricing_rule")`;金额换点数按当前 active 汇率向下取整。T-203 已落地 `precharge_call()` / `mark_call_success()` / `refund_call_points()`:预扣锁钱包行,余额不足不写调用/流水;失败退点锁调用记录并幂等写 refund 流水。T-204 已完成复合唯一约束加固,并取得一次完整 `manage.py test` 单次全绿。T-301 已落地 `Authorization: Bearer <API_KEY>` 鉴权:成功后 `request.user` 为所属用户、`request.auth` 为 `ApiKey`,缺失/无效 Key 返回 401,用户或 Key 禁用返回 403,外部 API 不接受 Web session。T-302 已落地生成接口:请求别名解析后按规则计费,预扣成功才调用 Provider,成功确认调用记录,`AiProviderError` / `AiCapabilityError` 等失败路径会退点;图片结果保存到本地 media 并返回 URL。T-303 已落地余额查询接口:`GET /api/v1/balance` 继承外部 API Key 鉴权,读取 billing 余额快照并返回 `user` 与 `points_balance`,测试覆盖余额与流水累加一致。T-304 已落地充值回调:`RechargeOrder` 保存下单锁定的金额/汇率/点数,微信/支付宝回调先验签再按订单幂等入账,重复回调不重复加点,金额不一致不入账;主动查单兜底可调用 `query_and_apply_recharge_payment(order_no, query_func)` 复用同一入账路径。T-305 已落地扫码下单与轮询:用户端 session 登录后可 `POST /api/v1/recharge/create` 创建 pending 订单并拿到 mock/SDK 二维码票据,`GET /api/v1/recharge/status` 只返回本人订单并在 pending 时尝试主动查单补入账;API Key 不能调用这两个用户端接口。T-306 已落地对外 API 安全加固:`image_url` 下载在扣点前做协议白名单、公网地址校验、重定向逐跳校验和响应大小上限;DRF 全局默认不再隐式启用 Session/Basic;生成接口按 Key 限流,认证失败按 IP 限流;充值下单有单笔金额上限。T-501 已落地 allauth 注册 / 登录:`ACCOUNT_EMAIL_VERIFICATION="mandatory"`,注册成功创建 0 点钱包、不写点数流水;未验证邮箱不能建立登录 session。T-502 已落地 API Key 自助管理:`/apikeys` 登录访问,生成后完整明文只显示一次,列表只显示 prefix,不显示 hash 或历史明文;删除为吊销 `revoked`,吊销后外部 API 返回 403。T-503 已落地个人中心与记录页:`/dashboard` 展示剩余点数、充值总额、入账点数、净消耗点数和最近记录;`/records/recharge` 展示当前用户充值订单;`/records/usage` 展示当前用户 consume/refund 点数流水并关联调用信息;所有页面均只读且只查本人。T-504 已落地充值页:`/recharge` GET 展示余额、充值表单、当前订单和最近充值,POST 创建 pending 订单并展示二维码票据,浏览器轮询 `/api/v1/recharge/status`,paid 后刷新页面重新读取余额;页面不直接写钱包或流水。真实上游生成未执行,原因是当前环境未配置 `AI_KEY_ENCRYPTION_KEY` 且数据库没有 AiModel/ModelAlias;后续配置后可用 `import_ai_models` 导入,再通过接口跑真实标题/图片。
|
||||
当前骨架可运行。T-002 已在首次迁移前创建自定义 User,并按 `env.md` 接入 MySQL 8.4 / utf8mb4;远程 MySQL 已完成 Django 初始迁移。T-003 已接通 django-admin,测试可创建/销毁 `test_cmhub` 测试库;当前远程 MySQL 对频繁建库/销库仍可能间歇超时,必要时用 `--keepdb` 且串行跑测试。T-004 已应用 `users.0002_alter_user_email`,`user.email` 已有唯一索引。T-101 的 AI provider 层只做 HTTP 调用与响应解析;T-102 已把 provider 运行配置接到数据库 `AiModel` / `ModelAlias`,`resolve_alias()` 每次查当前 active 配置并按 `text` / `image` 能力校验。T-103 已补 `AiConfigAuditLog`,admin 保存/删除 `AiModel` / `ModelAlias` 时记录 actor、action、target、changed_fields、changes、created_at,密钥只记录 empty/set 状态。T-104/T-105 已用临时回滚配置跑通录制标题和录制图片生成。T-201 已落地钱包、API Key、点数流水和调用记录:API Key 明文只在创建 helper 返回,库内只存 hash/prefix;CallRecord 只存 `result_ref`/`result_summary`,没有 provider raw 字段。T-202 已落地 `PricingRule` / `ExchangeRate`:计费按 `operation_type + alias + resolution` 查 active 规则,优先精确分辨率,再回退默认价;缺规则抛 `NoPricingRuleError(code="no_pricing_rule")`;金额换点数按当前 active 汇率向下取整。T-203 已落地 `precharge_call()` / `mark_call_success()` / `refund_call_points()`:预扣锁钱包行,余额不足不写调用/流水;失败退点锁调用记录并幂等写 refund 流水。T-204 已完成复合唯一约束加固,并取得一次完整 `manage.py test` 单次全绿。T-301 已落地 `Authorization: Bearer <API_KEY>` 鉴权:成功后 `request.user` 为所属用户、`request.auth` 为 `ApiKey`,缺失/无效 Key 返回 401,用户或 Key 禁用返回 403,外部 API 不接受 Web session。T-302 已落地生成接口:请求别名解析后按规则计费,预扣成功才调用 Provider,成功确认调用记录,`AiProviderError` / `AiCapabilityError` 等失败路径会退点;图片结果保存到本地 media 并返回 URL。T-303 已落地余额查询接口:`GET /api/v1/balance` 继承外部 API Key 鉴权,读取 billing 余额快照并返回 `user` 与 `points_balance`,测试覆盖余额与流水累加一致。T-304 已落地充值回调:`RechargeOrder` 保存下单锁定的金额/汇率/点数,微信/支付宝回调先验签再按订单幂等入账,重复回调不重复加点,金额不一致不入账;主动查单兜底可调用 `query_and_apply_recharge_payment(order_no, query_func)` 复用同一入账路径。T-305 已落地扫码下单与轮询:用户端 session 登录后可 `POST /api/v1/recharge/create` 创建 pending 订单并拿到 mock/SDK 二维码票据,`GET /api/v1/recharge/status` 只返回本人订单并在 pending 时尝试主动查单补入账;API Key 不能调用这两个用户端接口。T-306 已落地对外 API 安全加固:`image_url` 下载在扣点前做协议白名单、公网地址校验、重定向逐跳校验和响应大小上限;DRF 全局默认不再隐式启用 Session/Basic;生成接口按 Key 限流,认证失败按 IP 限流;充值下单有单笔金额上限。T-501 已落地 allauth 注册 / 登录:`ACCOUNT_EMAIL_VERIFICATION="mandatory"`,注册成功创建 0 点钱包、不写点数流水;未验证邮箱不能建立登录 session。T-502 已落地 API Key 自助管理:`/apikeys` 登录访问,生成后完整明文只显示一次,列表只显示 prefix,不显示 hash 或历史明文;删除为吊销 `revoked`,吊销后外部 API 返回 403。T-503 已落地个人中心与记录页:`/dashboard` 展示剩余点数、充值总额、入账点数、净消耗点数和最近记录;`/records/recharge` 展示当前用户充值订单;`/records/usage` 展示当前用户 consume/refund 点数流水并关联调用信息;所有页面均只读且只查本人。T-504 已落地充值页:`/recharge` GET 展示余额、充值表单、当前订单和最近充值,POST 创建 pending 订单并展示二维码票据,浏览器轮询 `/api/v1/recharge/status`,paid 后刷新页面重新读取余额;页面不直接写钱包或流水。T-401 已落地运营后台完善:用户列表显示钱包余额,钱包余额只读且通过专用表单手工调点,调点必须填原因、非 0、不得扣成负数,并经 `adjust_wallet_points()` 锁钱包写 `PointsLedger(adjust)`;API Key admin 只展示 prefix 和 hash 摘要,不回显明文或完整 hash;订单、流水、调用记录继续只读并增强检索。真实上游生成未执行,原因是当前环境未配置 `AI_KEY_ENCRYPTION_KEY` 且数据库没有 AiModel/ModelAlias;后续配置后可用 `import_ai_models` 导入,再通过接口跑真实标题/图片。
|
||||
|
||||
## 开始编码前检查
|
||||
|
||||
1. 读仓库级 `AGENTS.md` / `CLAUDE.md`。
|
||||
2. 读 `docs/00-ai-start-here.md`。
|
||||
3. 读 `docs/05-coding-rules.md`(尤其第 8 节资金安全)。
|
||||
4. 在 `docs/06-tasks.md` 领取第一个 `TODO` 且依赖均 `DONE` 的任务(当前为 T-401)。
|
||||
4. 在 `docs/06-tasks.md` 领取第一个 `TODO` 且依赖均 `DONE` 的任务(当前为 T-402)。
|
||||
5. 将该任务状态改为 `DOING`。
|
||||
|
||||
## 维护规则
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# cmhub 项目介绍(给管理层)
|
||||
|
||||
> 面向决策与汇报的项目概览。技术细节见同目录架构与需求文档。
|
||||
> 日期:2026-07-03 | 阶段:Phase 5 后台与发布(T-505 已完成)
|
||||
> 日期:2026-07-03 | 阶段:Phase 5 后台与发布(T-401 已完成)
|
||||
|
||||
## 一句话概括
|
||||
|
||||
@@ -105,7 +105,8 @@
|
||||
- Phase 2 计费核心已完成:PricingRule / ExchangeRate、计费计算、并发安全扣点与失败退点已落地,并覆盖并发测试。
|
||||
- Phase 3 对外 API 与充值已完成到 T-306:API Key 鉴权、生成/余额接口、扫码充值下单与轮询、支付回调幂等入账、`image_url` SSRF 防护、生成/认证限流和充值金额上限已落地。
|
||||
- Phase 4 已完成 T-501~T-505:用户端注册 / 登录(allauth)、邮箱验证、登出、API Key 自助管理、个人中心汇总、充值记录、消费记录和充值页已落地;用户可创建充值订单、查看二维码票据并轮询到账;Key 明文只显示一次,库内只保留 hash 和 prefix,删除即吊销;记录页仅见本人数据并支持分页;Bootstrap 与 qrcode.js 已改为本地 static 自托管。
|
||||
- 下一步是 T-401:完善运营后台,补齐用户/钱包/API Key/计费规则/充值订单/流水/调用记录的运营检索与手工调点能力。
|
||||
- Phase 5 已完成 T-401:运营后台可管理/检索用户、钱包、API Key(脱敏)、计费规则、汇率、充值订单、点数流水和调用记录;手工调点必须填写原因,并经计费层锁钱包、写 `adjust` 流水。
|
||||
- 下一步是 T-402:完整验收 MVP,按 `02-requirements.md` 的 P0 标准走通注册、充值、API Key、调用、余额和记录闭环。
|
||||
|
||||
---
|
||||
*更多细节:愿景 `01-vision.md` | 需求与验收 `02-requirements.md` | 架构 `04-architecture.md` | 任务计划 `06-tasks.md`。*
|
||||
|
||||
@@ -40,7 +40,7 @@
|
||||
|
||||
## 进度
|
||||
|
||||
M1 骨架已完成;M2 已通过录制生成 smoke 验证 AI 调用链路;M3 计费、对外 API、充值闭环与上线前安全加固已完成到 T-306;M4 用户端已完成注册 / 登录(T-501)、API Key 管理页(T-502)、个人中心 / 记录页(T-503)、充值页(T-504)和审核优化(T-505)。下一步做运营后台完善。里程碑:M1 骨架可跑 · M2 跑通生成 · M3 计费充值闭环 · M4 用户端可用 · M5 验收上线。
|
||||
M1 骨架已完成;M2 已通过录制生成 smoke 验证 AI 调用链路;M3 计费、对外 API、充值闭环与上线前安全加固已完成到 T-306;M4 用户端已完成注册 / 登录、API Key 管理、个人中心 / 记录页、充值页和审核优化;M5 已完成 T-401 运营后台完善,下一步做 T-402 MVP 完整验收。里程碑:M1 骨架可跑 · M2 跑通生成 · M3 计费充值闭环 · M4 用户端可用 · M5 验收上线。
|
||||
|
||||
---
|
||||
*详见 `project-brief.md`(完整介绍)。*
|
||||
|
||||
+5
-5
@@ -35,13 +35,13 @@ T-501 已落地 `/signup`、`/login`、`/logout` 与最小 `/dashboard`。T-502
|
||||
| 管理项 | 对应模型 | 运营能做什么 |
|
||||
| --- | --- | --- |
|
||||
| 注册用户 | User | 查看/禁用注册用户;查看其钱包点数余额 |
|
||||
| 点数钱包 | UserWallet | T-201 先只读查看余额;手工调整点数留到 T-401,必须经计费层写流水,不直接改字段 |
|
||||
| 点数钱包 | UserWallet | 查看余额;通过专用“手工调点”入口填写点数变动和原因,经计费层锁钱包并写 `adjust` 流水;余额字段本身只读 |
|
||||
| API Key | ApiKey | 查看 / 吊销用户的 Key(只显示 prefix 和 hash 摘要,不回显明文) |
|
||||
| 计费规则 | PricingRule | 配置「操作类型 × 能力别名(+ 可选分辨率)→ 点数单价」 |
|
||||
| 汇率 | ExchangeRate | 配置金额→点数汇率 |
|
||||
| 充值订单 | RechargeOrder | 检索订单、查看状态/金额/入账点数/支付流水号 |
|
||||
| 点数流水 | PointsLedger | 检索充值/消费/调整/冲正流水(只读,对账用) |
|
||||
| 调用记录 | CallRecord | 按账号/时间/状态检索调用、查看别名/实际模型/消耗/错误(只读) |
|
||||
| 充值订单 | RechargeOrder | 检索订单、查看状态/金额/入账点数/支付流水号(只读) |
|
||||
| 点数流水 | PointsLedger | 按账号、类型、时间检索充值/消费/调整/冲正流水(只读,对账用) |
|
||||
| 调用记录 | CallRecord | 按账号、API Key、时间、状态检索调用,查看别名/实际模型/消耗/错误(只读) |
|
||||
| 模型配置 | AiModel | 维护上游模型(url/model/api_key[加密脱敏]/api_type/capabilities/timeout) |
|
||||
| 能力别名 | ModelAlias | 维护对外别名 → 具体模型的映射;换供应商在此改指向 |
|
||||
| 配置审计 | AiConfigAuditLog | 只读查看 AiModel / ModelAlias / 密钥变更:谁、何时、改了什么 |
|
||||
@@ -52,7 +52,7 @@ T-501 已落地 `/signup`、`/login`、`/logout` 与最小 `/dashboard`。T-502
|
||||
|
||||
- 用户自助注册;API Key 由用户在用户端自助生成(服务端生成、哈希存储、明文只显示一次),运营侧只能查看 prefix / 吊销,不回显明文。
|
||||
- 禁用用户或吊销 Key 后,相关调用返回 403;无效、缺失或不存在的 Key 返回 401。
|
||||
- 手工调整点数必须经计费层方法(写 `PointsLedger`、锁 `UserWallet`),不允许直接编辑 `points_balance` 字段。
|
||||
- T-401 已实现运营手工调点:后台从 `UserWallet` 进入专用表单,必须填写非 0 点数变动和原因;服务层 `adjust_wallet_points()` 在事务内锁 `UserWallet`、拒绝扣成负数,并写 `PointsLedger(change_type=adjust)`;不允许直接编辑 `points_balance` 字段。
|
||||
|
||||
### 模型与别名管理
|
||||
|
||||
|
||||
+25
@@ -891,3 +891,28 @@
|
||||
- 说明:本条为补录——T-306 复核实际发生在 2026-07-03 稍早,当时只在对话中给出结论、未落 progress.md,现补齐。
|
||||
- 结论:**达标,且实现超出文档要求**。P1-1 SSRF:协议白名单 + `getaddrinfo` 解析后逐 IP 校验(not_global/private/loopback/link_local/reserved/multicast/unspecified)+ 处理 IPv6 映射 IPv4(`::ffff:169.254.169.254`)+ 重定向手动逐跳 re-validate(堵 30x 绕过)+ Content-Length 与流式双重大小上限;测试覆盖回环/云元数据/重定向内网/超大响应均「拒绝且不扣点」。P2-1 全局 `REST_FRAMEWORK` 默认空认证、P2-2 生成/认证限流、P2-3 充值金额上限均落地含测试。
|
||||
- 复核发现的残留(已登记):① DNS rebinding TOCTOU(校验解析与 requests 各解析一次,未钉 IP)→ 已登记 Backlog;② DRF 限流依赖共享缓存,多 worker 下 LocMemCache 每进程失效 → 已登记 T-403。
|
||||
|
||||
## 2026-07-03 T-401 运营后台完善
|
||||
|
||||
- 状态:DONE
|
||||
- 变更:
|
||||
- `apps/billing/services.py`:新增 `adjust_wallet_points()`、`WalletAdjustment` 和调点错误类型;手工调点必须非 0、必须有原因,在事务内锁 `UserWallet`,拒绝扣成负数,并写 `PointsLedger(change_type=adjust)`。
|
||||
- `apps/users/admin.py` / `apps/users/templates/admin/users/userwallet/adjust_points.html`:新增钱包后台专用“手工调点”入口;钱包余额字段继续只读,提交表单后才调用计费层服务写余额和流水;用户列表显示钱包余额;API Key 后台保持 prefix/hash 摘要展示,不回显明文或完整 hash。
|
||||
- `apps/billing/admin.py`:增强计费规则、汇率、充值订单、点数流水和调用记录的后台检索/列表配置;订单、流水、调用记录保持只读。
|
||||
- `apps/billing/tests.py` / `apps/users/tests.py`:新增调点服务和 admin 测试,覆盖加点/扣点、原因必填、拒绝透支、后台调点写流水、直接提交钱包 change form 不改余额、API Key 后台不泄露明文和完整 hash。
|
||||
- 同步更新 `README.md`、`docs/00-ai-start-here.md`、`docs/04-architecture.md`、`docs/routes.md`、`docs/06-tasks.md`、`docs/current-state.md`、`docs/project-brief.md`、`docs/project-onepager.md`。
|
||||
- 验证:
|
||||
- `./init.ps1`:开工前通过;收尾通过。
|
||||
- `py -3.12 -m py_compile apps\billing\services.py apps\billing\admin.py apps\billing\tests.py apps\users\admin.py apps\users\tests.py`:通过。
|
||||
- `py -3.12 manage.py check`:通过,0 issues。
|
||||
- `py -3.12 manage.py makemigrations --check --dry-run`:通过,No changes detected。
|
||||
- `Test-NetConnection 43.128.3.240 -Port 3306`:`TcpTestSucceeded=True`。
|
||||
- `py -3.12 manage.py test apps.billing.tests.BillingServiceTests apps.users.tests.UserAdminOperationTests --noinput --keepdb --verbosity 2`:通过,18 tests OK。
|
||||
- `py -3.12 manage.py test apps.billing.tests.BillingCoreModelTests apps.billing.tests.PricingCalculationTests apps.billing.tests.BillingServiceTests apps.users.tests --noinput --keepdb --verbosity 2`:通过,32 tests OK。
|
||||
- 尝试 `py -3.12 manage.py test apps.billing apps.users --noinput --keepdb --verbosity 2`:未取得单次全绿;运行中远程 MySQL 出现 `OperationalError 2003/2013`、连接重置与事务中断,已通过用例无 T-401 断言失败。
|
||||
- 尝试 `py -3.12 manage.py test apps.billing.tests.ConcurrentDebitTests --noinput --keepdb --verbosity 2`:测试体显示 `ok`,但 teardown/flush 阶段远程 MySQL 再次连接超时,被 Django 记为 ERROR。
|
||||
- `py -3.12 -m compileall apps config`:通过。
|
||||
- `git diff --check`:通过,仅 Windows CRLF 提示。
|
||||
- 阻塞:T-401 功能无阻塞;完整大套件单次全绿仍受远程 MySQL 连接稳定性影响。
|
||||
- 决策:运营手工调点不开放直接编辑 `UserWallet.points_balance`,只通过 `adjust_wallet_points()` 统一落账;后台订单、流水、调用记录继续只读,避免运营后台成为账目篡改入口。
|
||||
- 下一步:领取 T-402 完整验收 MVP。
|
||||
|
||||
Reference in New Issue
Block a user