feat: add offline payment proof workflow

This commit is contained in:
2026-05-21 15:37:40 +08:00
parent 3df85fdb04
commit 5caaa40fb3
11 changed files with 413 additions and 12 deletions
+11 -1
View File
@@ -222,11 +222,19 @@ def issue_current_tenant_commercial_invoice(
def activate_current_tenant_commercial_order(
order_id: str,
payment_provider: str = Form(default="offline"),
payment_proof_file_id: Optional[str] = Form(default=None),
payment_reference: Optional[str] = Form(default=None),
tenant_service: TenantService = Depends(get_tenant_service),
audit_log_service: AuditLogService = Depends(get_audit_log_service),
current_user: dict = Depends(require_current_user),
):
result = tenant_service.activate_commercial_order(current_user, order_id, payment_provider=payment_provider)
result = tenant_service.activate_commercial_order(
current_user,
order_id,
payment_provider=payment_provider,
payment_proof_file_id=payment_proof_file_id,
payment_reference=payment_reference,
)
audit_log_service.record_event(
tenant_id=current_user.get("tenant_id"),
user_id=current_user.get("id"),
@@ -238,6 +246,8 @@ def activate_current_tenant_commercial_order(
"plan_code": result["tenant"]["plan_code"],
"billing_status": result["tenant"]["billing_status"],
"payment_provider": result["order"]["payment_provider"],
"payment_reference": result["order"]["payment_reference"],
"payment_proof_file_id": result["order"]["payment_proof_file_id"],
},
)
return success_response(result)
+2
View File
@@ -22,6 +22,8 @@ class CommercialOrder(Base):
amount_cents: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
currency: Mapped[str] = mapped_column(String(16), nullable=False, default="CNY")
payment_provider: Mapped[str] = mapped_column(String(32), nullable=False, default="offline")
payment_reference: Mapped[Optional[str]] = mapped_column(String(120), nullable=True)
payment_proof_file_id: Mapped[Optional[str]] = mapped_column(String(64), nullable=True)
invoice_status: Mapped[str] = mapped_column(String(32), nullable=False, default="not_requested")
contact_name: Mapped[Optional[str]] = mapped_column(String(100), nullable=True)
contact_phone: Mapped[Optional[str]] = mapped_column(String(64), nullable=True)
+21 -3
View File
@@ -352,15 +352,27 @@ class TenantService:
self.db.refresh(order)
return self._to_order_item(order)
def activate_commercial_order(self, current_user: Dict[str, Any], order_id: str, payment_provider: str = "offline") -> dict:
def activate_commercial_order(
self,
current_user: Dict[str, Any],
order_id: str,
payment_provider: str = "offline",
payment_proof_file_id: Optional[str] = None,
payment_reference: Optional[str] = None,
) -> dict:
self._require_admin(current_user)
tenant = self._get_or_create_current_tenant(current_user["tenant_id"])
order = self._get_commercial_order(tenant.id, order_id)
if order.status == "canceled":
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="commercial order is canceled")
normalized_payment_proof_file_id = self._normalize_text(payment_proof_file_id)
if normalized_payment_proof_file_id:
self._get_active_file(tenant.id, normalized_payment_proof_file_id, error_detail="payment proof file not found")
now = datetime.now(timezone.utc)
order.status = "activated"
order.payment_provider = self._normalize_payment_provider(payment_provider)
order.payment_reference = self._normalize_text(payment_reference)
order.payment_proof_file_id = normalized_payment_proof_file_id
order.paid_at = order.paid_at or now
order.activated_at = order.activated_at or now
order.updated_at = now
@@ -825,6 +837,9 @@ class TenantService:
"amount_cents": order.amount_cents,
"currency": order.currency,
"payment_provider": order.payment_provider,
"payment_reference": order.payment_reference,
"payment_proof_file_id": order.payment_proof_file_id,
"payment_proof_file": self._to_file_item(order.tenant_id, order.payment_proof_file_id),
"invoice_status": order.invoice_status,
"contact_name": order.contact_name,
"contact_phone": order.contact_phone,
@@ -871,17 +886,20 @@ class TenantService:
"updated_at": invoice.updated_at.isoformat(),
}
def _get_active_file(self, tenant_id: str, file_id: str) -> File:
def _get_active_file(self, tenant_id: str, file_id: str, error_detail: str = "invoice file not found") -> File:
file_record = (
self.db.query(File)
.filter(File.id == file_id, File.tenant_id == tenant_id, File.status == "active")
.first()
)
if file_record is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="invoice file not found")
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=error_detail)
return file_record
def _to_invoice_file_item(self, tenant_id: str, file_id: Optional[str]) -> Optional[dict]:
return self._to_file_item(tenant_id, file_id)
def _to_file_item(self, tenant_id: str, file_id: Optional[str]) -> Optional[dict]:
if not file_id:
return None
file_record = (
@@ -0,0 +1,42 @@
"""add commercial order payment evidence"""
from __future__ import annotations
import sqlalchemy as sa
from alembic import op
revision = "20260521_0035"
down_revision = "20260521_0034"
branch_labels = None
depends_on = None
def _column_names(inspector: sa.Inspector, table_name: str) -> set[str]:
if table_name not in set(inspector.get_table_names()):
return set()
return {column["name"] for column in inspector.get_columns(table_name)}
def upgrade() -> None:
bind = op.get_bind()
inspector = sa.inspect(bind)
columns = _column_names(inspector, "commercial_orders")
if not columns:
return
if "payment_reference" not in columns:
op.add_column("commercial_orders", sa.Column("payment_reference", sa.String(length=120), nullable=True))
if "payment_proof_file_id" not in columns:
op.add_column("commercial_orders", sa.Column("payment_proof_file_id", sa.String(length=64), nullable=True))
def downgrade() -> None:
bind = op.get_bind()
inspector = sa.inspect(bind)
columns = _column_names(inspector, "commercial_orders")
if not columns:
return
if "payment_proof_file_id" in columns:
op.drop_column("commercial_orders", "payment_proof_file_id")
if "payment_reference" in columns:
op.drop_column("commercial_orders", "payment_reference")
+86 -3
View File
@@ -110,6 +110,9 @@ class StubTenantService:
"amount_cents": 199900,
"currency": "CNY",
"payment_provider": "offline",
"payment_reference": None,
"payment_proof_file_id": None,
"payment_proof_file": None,
"invoice_status": "requested",
"contact_name": "demo",
"contact_phone": "13800000000",
@@ -177,6 +180,9 @@ class StubTenantService:
"amount_cents": kwargs["amount_cents"],
"currency": kwargs["currency"],
"payment_provider": kwargs["payment_provider"],
"payment_reference": None,
"payment_proof_file_id": None,
"payment_proof_file": None,
"invoice_status": "requested" if kwargs["invoice_required"] else "not_requested",
"contact_name": kwargs.get("contact_name"),
"contact_phone": kwargs.get("contact_phone"),
@@ -195,7 +201,7 @@ class StubTenantService:
"updated_at": "2026-05-18T00:00:00+00:00",
}
def activate_commercial_order(self, current_user, order_id, payment_provider="offline"):
def activate_commercial_order(self, current_user, order_id, payment_provider="offline", payment_proof_file_id=None, payment_reference=None):
if current_user["role"] != "admin":
raise HTTPException(status_code=403, detail="forbidden")
return {
@@ -212,6 +218,19 @@ class StubTenantService:
"amount_cents": 199900,
"currency": "CNY",
"payment_provider": payment_provider,
"payment_reference": payment_reference,
"payment_proof_file_id": payment_proof_file_id,
"payment_proof_file": {
"id": payment_proof_file_id,
"file_name": "payment-proof.pdf",
"mime_type": "application/pdf",
"file_size": 12,
"file_type": "payment_proof",
"download_url": f"/api/v1/files/{payment_proof_file_id}/download" if payment_proof_file_id else None,
"created_at": "2026-05-18T00:00:00+00:00",
}
if payment_proof_file_id
else None,
"invoice_status": "requested",
"contact_name": "demo",
"contact_phone": "13800000000",
@@ -445,14 +464,21 @@ class TenantSettingsApiTestCase(unittest.TestCase):
response = self.client.post(
"/api/v1/tenant-settings/billing/orders/commercial-order-created/activate",
data={"payment_provider": "bank_transfer"},
data={
"payment_provider": "bank_transfer",
"payment_reference": "BANK-20260521-001",
"payment_proof_file_id": "file-payment-proof-1",
},
)
self.assertEqual(response.status_code, 200)
body = response.json()["data"]
self.assertEqual(body["order"]["status"], "activated")
self.assertEqual(body["order"]["payment_reference"], "BANK-20260521-001")
self.assertEqual(body["order"]["payment_proof_file"]["file_name"], "payment-proof.pdf")
self.assertEqual(body["tenant"]["billing_status"], "active")
self.assertEqual(self.audit_log_service.events[0]["action"], "activate_commercial_order")
self.assertEqual(self.audit_log_service.events[0]["detail_json"]["payment_proof_file_id"], "file-payment-proof-1")
def test_admin_can_request_commercial_invoice(self):
app.dependency_overrides[require_current_user] = lambda: {
@@ -800,10 +826,35 @@ class TenantSettingsApiTestCase(unittest.TestCase):
payment_provider="offline",
invoice_required=True,
)
result = service.activate_commercial_order(current_user, order["id"], payment_provider="bank_transfer")
session.add(
File(
id="file-payment-proof-1",
tenant_id="tenant-demo-001",
uploaded_by="user-admin",
bucket="payment-proofs",
object_key="tenant-demo-001/payment-proof/payment-proof.pdf",
file_name="payment-proof.pdf",
mime_type="application/pdf",
file_size=12,
file_hash="hash-payment-proof-1",
file_type="payment_proof",
status="active",
)
)
session.commit()
result = service.activate_commercial_order(
current_user,
order["id"],
payment_provider="bank_transfer",
payment_reference="BANK-20260521-001",
payment_proof_file_id="file-payment-proof-1",
)
self.assertEqual(result["order"]["status"], "activated")
self.assertEqual(result["order"]["payment_provider"], "bank_transfer")
self.assertEqual(result["order"]["payment_reference"], "BANK-20260521-001")
self.assertEqual(result["order"]["payment_proof_file_id"], "file-payment-proof-1")
self.assertEqual(result["order"]["payment_proof_file"]["download_url"], "/api/v1/files/file-payment-proof-1/download")
self.assertEqual(result["tenant"]["plan_code"], "team")
self.assertEqual(result["tenant"]["billing_status"], "active")
self.assertEqual(result["tenant"]["review_quota_monthly"], 800)
@@ -815,6 +866,38 @@ class TenantSettingsApiTestCase(unittest.TestCase):
session.close()
engine.dispose()
def test_commercial_order_activation_rejects_missing_payment_proof_file(self):
engine = create_engine("sqlite:///:memory:", future=True)
Base.metadata.create_all(engine)
session = sessionmaker(bind=engine, future=True)()
try:
service = TenantService(session)
service.ensure_default_tenant()
current_user = {"id": "user-admin", "tenant_id": "tenant-demo-001", "role": "admin"}
order = service.create_commercial_order(
current_user=current_user,
order_type="subscription",
plan_code="team",
billing_cycle="monthly",
amount_cents=199900,
payment_provider="offline",
invoice_required=True,
)
with self.assertRaises(HTTPException) as context:
service.activate_commercial_order(
current_user,
order["id"],
payment_provider="bank_transfer",
payment_proof_file_id="file-missing",
)
self.assertEqual(context.exception.status_code, 404)
self.assertEqual(context.exception.detail, "payment proof file not found")
finally:
session.close()
engine.dispose()
def test_commercial_invoice_request_and_issue_updates_order_invoice_status(self):
engine = create_engine("sqlite:///:memory:", future=True)
Base.metadata.create_all(engine)
+174 -3
View File
@@ -68,6 +68,12 @@ type InvoiceIssueFormValues = {
note?: string;
};
type PaymentConfirmFormValues = {
payment_provider: string;
payment_reference?: string;
payment_proof_file_id?: string;
};
const statusMeta: Record<TenantBillingMetric["status"], { color: string; label: string }> = {
ok: { color: "green", label: "正常" },
warning: { color: "orange", label: "接近上限" },
@@ -143,20 +149,26 @@ export default function BillingPage() {
const [planForm] = Form.useForm<BillingPlanFormValues>();
const [invoiceForm] = Form.useForm<InvoiceRequestFormValues>();
const [issueForm] = Form.useForm<InvoiceIssueFormValues>();
const [paymentForm] = Form.useForm<PaymentConfirmFormValues>();
const [loading, setLoading] = useState(true);
const [summary, setSummary] = useState<TenantBillingSummary | null>(null);
const [orders, setOrders] = useState<CommercialOrderItem[]>([]);
const [invoices, setInvoices] = useState<CommercialInvoiceItem[]>([]);
const [submittingOrder, setSubmittingOrder] = useState(false);
const [submittingPlan, setSubmittingPlan] = useState(false);
const [activatingOrderId, setActivatingOrderId] = useState<string | null>(null);
const [, setActivatingOrderId] = useState<string | null>(null);
const [invoiceOrder, setInvoiceOrder] = useState<CommercialOrderItem | null>(null);
const [paymentOrder, setPaymentOrder] = useState<CommercialOrderItem | null>(null);
const [invoiceRecord, setInvoiceRecord] = useState<CommercialInvoiceItem | null>(null);
const [invoiceModalOpen, setInvoiceModalOpen] = useState(false);
const [paymentModalOpen, setPaymentModalOpen] = useState(false);
const [issueModalOpen, setIssueModalOpen] = useState(false);
const [confirmingPayment, setConfirmingPayment] = useState(false);
const [submittingInvoice, setSubmittingInvoice] = useState(false);
const [issuingInvoice, setIssuingInvoice] = useState(false);
const [uploadingPaymentProof, setUploadingPaymentProof] = useState(false);
const [uploadingInvoiceFile, setUploadingInvoiceFile] = useState(false);
const [uploadedPaymentProofFile, setUploadedPaymentProofFile] = useState<CommercialInvoiceFileItem | FileItem | null>(null);
const [uploadedInvoiceFile, setUploadedInvoiceFile] = useState<CommercialInvoiceFileItem | FileItem | null>(null);
const [downloadingInvoiceFileId, setDownloadingInvoiceFileId] = useState<string | null>(null);
@@ -251,6 +263,7 @@ export default function BillingPage() {
[],
);
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const handleActivateOrder = useCallback(
async (order: CommercialOrderItem) => {
Modal.confirm({
@@ -279,6 +292,74 @@ export default function BillingPage() {
[loadBillingData, messageApi],
);
const handleOpenPaymentConfirm = useCallback(
(order: CommercialOrderItem) => {
setPaymentOrder(order);
setUploadedPaymentProofFile(order.payment_proof_file);
paymentForm.setFieldsValue({
payment_provider: order.payment_provider === "offline" ? "bank_transfer" : order.payment_provider,
payment_reference: order.payment_reference ?? undefined,
payment_proof_file_id: order.payment_proof_file_id ?? undefined,
});
setPaymentModalOpen(true);
},
[paymentForm],
);
const handleUploadPaymentProof = useCallback(
async (file: File) => {
setUploadingPaymentProof(true);
try {
const result = await uploadFile({
file,
file_type: "payment_proof",
bucket: "payment-proofs",
});
if (result.code !== 0) {
messageApi.error(result.message || "收款凭证上传失败");
return false;
}
setUploadedPaymentProofFile(result.data);
paymentForm.setFieldValue("payment_proof_file_id", result.data.id);
messageApi.success("收款凭证已上传到文件中心");
} catch (error: unknown) {
messageApi.error(getErrorMessage(error, "收款凭证上传失败,请稍后重试"));
} finally {
setUploadingPaymentProof(false);
}
return false;
},
[messageApi, paymentForm],
);
const handleConfirmPayment = async (values: PaymentConfirmFormValues) => {
if (!paymentOrder) {
return;
}
setConfirmingPayment(true);
try {
const result = await activateCommercialOrder(paymentOrder.id, {
payment_provider: values.payment_provider,
payment_reference: values.payment_reference,
payment_proof_file_id: values.payment_proof_file_id,
});
if (result.code !== 0) {
messageApi.error(result.message || "订单生效失败");
return;
}
messageApi.success("订单已生效,套餐额度和收款凭证已同步");
setPaymentModalOpen(false);
setPaymentOrder(null);
setUploadedPaymentProofFile(null);
paymentForm.resetFields();
await loadBillingData();
} catch (error: unknown) {
messageApi.error(getErrorMessage(error, "订单生效失败,请确认收款凭证后重试"));
} finally {
setConfirmingPayment(false);
}
};
const handleOpenInvoiceRequest = useCallback(
(order: CommercialOrderItem) => {
setInvoiceOrder(order);
@@ -405,7 +486,7 @@ export default function BillingPage() {
(
<Space size={8} wrap>
{order.status === "pending_payment" ? (
<Button size="small" loading={activatingOrderId === order.id} onClick={() => void handleActivateOrder(order)}>
<Button size="small" onClick={() => handleOpenPaymentConfirm(order)}>
确认收款并生效
</Button>
) : (
@@ -416,11 +497,23 @@ export default function BillingPage() {
登记发票资料
</Button>
) : null}
{order.payment_proof_file ? (
<Button
size="small"
icon={<DownloadOutlined />}
loading={downloadingInvoiceFileId === order.payment_proof_file.id}
onClick={() => void handleDownloadInvoiceFile(order.payment_proof_file!)}
>
下载收款凭证
</Button>
) : order.payment_proof_file_id ? (
<Text type="secondary">{order.payment_proof_file_id}</Text>
) : null}
</Space>
),
},
],
[activatingOrderId, handleActivateOrder, handleOpenInvoiceRequest],
[downloadingInvoiceFileId, handleDownloadInvoiceFile, handleOpenInvoiceRequest, handleOpenPaymentConfirm],
);
const invoiceColumns = useMemo<ColumnsType<CommercialInvoiceItem>>(
@@ -852,6 +945,84 @@ export default function BillingPage() {
) : null}
</Space>
<Modal
title={paymentOrder ? `确认线下收款:${paymentOrder.order_no}` : "确认线下收款"}
open={paymentModalOpen}
onCancel={() => {
setPaymentModalOpen(false);
setPaymentOrder(null);
setUploadedPaymentProofFile(null);
paymentForm.resetFields();
}}
footer={null}
destroyOnHidden
>
<Form form={paymentForm} layout="vertical" onFinish={handleConfirmPayment}>
<Alert
showIcon
type="info"
style={{ marginBottom: 16 }}
message="测试服务器没有公网时,先走线下收款确认。真实微信/支付宝回调以后会复用同一张订单。"
/>
<Form.Item label="收款方式" name="payment_provider" rules={[{ required: true, message: "请选择收款方式" }]}>
<Select
options={[
{ value: "bank_transfer", label: "银行转账" },
{ value: "offline", label: "线下收款" },
{ value: "manual", label: "人工确认" },
{ value: "wechat_pay", label: "微信支付(待公网回调)" },
{ value: "alipay", label: "支付宝(待公网回调)" },
]}
/>
</Form.Item>
<Form.Item label="收款流水号/备注" name="payment_reference">
<Input placeholder="例如银行回单号、内部收款单号或销售确认编号" />
</Form.Item>
<Form.Item label="收款凭证文件编号" name="payment_proof_file_id">
<Input placeholder="上传后自动回填,也可以填写文件中心 file_id" />
</Form.Item>
<Form.Item label="上传收款凭证">
<Upload
accept=".pdf,.png,.jpg,.jpeg,.ofd"
maxCount={1}
showUploadList={false}
beforeUpload={(file) => handleUploadPaymentProof(file as File)}
disabled={uploadingPaymentProof}
>
<Button icon={<UploadOutlined />} loading={uploadingPaymentProof}>
上传到文件中心
</Button>
</Upload>
{uploadedPaymentProofFile ? (
<Space direction="vertical" size={4} style={{ width: "100%", marginTop: 8 }}>
<Text type="secondary">
已选择:{uploadedPaymentProofFile.file_name}({formatFileSize(uploadedPaymentProofFile.file_size)})
</Text>
<Space size={8} wrap>
<Text copyable={{ text: uploadedPaymentProofFile.id }}>文件 ID:{uploadedPaymentProofFile.id}</Text>
<Button
size="small"
icon={<DownloadOutlined />}
loading={downloadingInvoiceFileId === uploadedPaymentProofFile.id}
onClick={() => void handleDownloadInvoiceFile(uploadedPaymentProofFile)}
>
下载校验
</Button>
<Link href={`/files?highlight=${uploadedPaymentProofFile.id}`}>打开文件中心</Link>
</Space>
</Space>
) : (
<Text type="secondary" style={{ display: "block", marginTop: 8 }}>
支持 PDF、图片或 OFD 回单;确认后会绑定到本订单,作为无公网阶段的收款证据。
</Text>
)}
</Form.Item>
<Button type="primary" htmlType="submit" loading={confirmingPayment}>
确认收款并生效套餐
</Button>
</Form>
</Modal>
<Modal
title={invoiceOrder ? `登记发票资料:${invoiceOrder.order_no}` : "登记发票资料"}
open={invoiceModalOpen}
+9 -2
View File
@@ -6,6 +6,7 @@ import type {
CommercialInvoiceItem,
CommercialInvoiceRequestPayload,
CommercialOrderActivationResponse,
CommercialOrderActivationPayload,
CommercialOrderCreatePayload,
CommercialOrderItem,
TenantBillingSummary,
@@ -119,9 +120,15 @@ export async function createCommercialOrder(payload: CommercialOrderCreatePayloa
return response.data;
}
export async function activateCommercialOrder(orderId: string, paymentProvider = "offline") {
export async function activateCommercialOrder(orderId: string, payload: CommercialOrderActivationPayload | string = "offline") {
const formData = new FormData();
formData.append("payment_provider", paymentProvider);
if (typeof payload === "string") {
formData.append("payment_provider", payload);
} else {
formData.append("payment_provider", payload.payment_provider ?? "offline");
appendOptionalFormValue(formData, "payment_reference", payload.payment_reference);
appendOptionalFormValue(formData, "payment_proof_file_id", payload.payment_proof_file_id);
}
const response = await http.post<ApiResponse<CommercialOrderActivationResponse>>(
`/tenant-settings/billing/orders/${orderId}/activate`,
formData,
+9
View File
@@ -849,6 +849,9 @@ export type CommercialOrderItem = {
amount_cents: number;
currency: string;
payment_provider: string;
payment_reference: string | null;
payment_proof_file_id: string | null;
payment_proof_file: CommercialInvoiceFileItem | null;
invoice_status: "not_requested" | "requested" | "issued" | string;
contact_name: string | null;
contact_phone: string | null;
@@ -893,6 +896,12 @@ export type CommercialOrderActivationResponse = {
tenant: TenantSettingsItem;
};
export type CommercialOrderActivationPayload = {
payment_provider?: string;
payment_reference?: string;
payment_proof_file_id?: string;
};
export type CommercialInvoiceFileItem = {
id: string;
file_name: string;
@@ -3885,3 +3885,38 @@
- 剩余边界:
- 当前工作区仍有大量未提交改动,后续进入正式发布前需要单独整理提交。
- 真实微信/支付宝支付仍需要商户号、证书、密钥和公网 HTTPS 回调域名。
## 2026-05-21 无公网测试服务器线下收款凭证闭环
- 背景调整:
- 当前只有开发测试服务器,没有公网 HTTPS 回调地址。
- 因此本轮跳过微信/支付宝公网回调链路,先完成内网可验证、可运营的线下收款凭证闭环。
- 后端能力:
- 新增 Alembic 迁移 `20260521_0035_add_commercial_order_payment_evidence.py`。
- `commercial_orders` 新增 `payment_reference`、`payment_proof_file_id` 字段。
- 管理员确认商业订单收款时,可同时写入收款方式、收款流水号/备注和收款凭证文件。
- 后端会校验收款凭证文件必须属于当前租户且处于可用状态。
- 订单列表会返回 `payment_proof_file`,用于管理员后续下载核验。
- 前端能力:
- `/billing` 的待收款订单不再直接一键激活,而是打开“确认线下收款”窗口。
- 管理员可上传 PDF、图片或 OFD 收款凭证到文件中心 `payment-proofs`。
- 管理员填写收款流水号/备注后确认收款,系统自动激活套餐和额度。
- 订单台账可展示并下载已绑定的收款凭证。
- 测试服务器部署调整:
- 测试服务器暂时没有公网,且本轮拉取 MinIO 镜像超时。
- 已将远端文件中心切到本地持久化存储:`FILE_STORAGE_BACKEND=local`,`LOCAL_STORAGE_DIR=/data/ai-contract-storage`。
- 部署脚本 `scripts/deploy-ai-contract-restore-docker.ps1` 已挂载 `$project/storage:/data/ai-contract-storage`,避免后端容器重建后丢失上传凭证。
- 本轮验证:
- `$env:PYTHONPATH='backend'; python -m compileall backend/app/models/commercial_order.py backend/app/services/tenant_service.py backend/app/api/tenant_settings.py`
- `python -m py_compile backend\migrations\versions\20260521_0035_add_commercial_order_payment_evidence.py`
- `$env:PYTHONPATH='backend'; python -m unittest backend.tests.test_tenant_settings_api backend.tests.test_sales_followup_service backend.tests.test_sales_followups_api -v`
- `cd frontend; npm run lint -- --max-warnings=0`
- `cd frontend; npm run build`
- 远端部署产物:`project-docs/qa-artifacts/restore-docker-deploy/2026-05-21T15-22-00`
- 远端 Alembic:`20260521_0035 (head)`。
- `http://192.168.3.90:8001/health` 返回健康。
- `http://192.168.3.90:3003/billing` 返回 HTTP 200。
- 远端 API 烟测已覆盖:管理员登录 -> 创建商业订单 -> 上传收款凭证 -> 确认线下收款 -> 套餐激活 -> 订单台账可查询凭证 -> 凭证可下载。
- 当前闭环结论:
- 在没有公网回调的测试服务器上,已经形成“商业订单待收款 -> 管理员上传收款凭证 -> 确认收款 -> 套餐/额度自动生效 -> 订单留痕 -> 凭证下载核验”的闭环。
- 真实微信/支付宝在线支付仍需要商户号、证书、密钥和公网 HTTPS 回调域名;这部分不再阻塞测试服务器上的商业化运营验证。
@@ -176,6 +176,28 @@ Remove-Item Env:\SSHPASS
如果只是默认演练通过,但没有打开 `-RunProductionReleaseGate`,结论应写作“部署前证据链通过”,不能写作“已完成真实生产发布”。
## 2026-05-21 测试服务器无公网时的收费验证口径
当前 `192.168.3.90` 仍是开发测试服务器,没有公网 HTTPS 回调地址。
因此测试服务器上的商业化收费验证采用线下收款凭证闭环:
1. 管理员在 `/billing` 创建或查看待收款订单。
2. 管理员上传收款凭证到文件中心 `payment-proofs`。
3. 管理员填写收款方式和流水号/备注后确认收款。
4. 系统激活套餐和额度,并在商业订单台账保留凭证下载入口。
当前远端文件中心使用本地持久化存储:
```text
FILE_STORAGE_BACKEND=local
LOCAL_STORAGE_DIR=/data/ai-contract-storage
```
部署脚本会把远端项目目录下的 `storage` 挂载到后端容器 `/data/ai-contract-storage`。这能支撑内网测试服务器完成订单、凭证、套餐生效闭环。
真实微信/支付宝在线支付仍然需要商户号、证书、密钥和公网 HTTPS 回调域名;在这些外部条件具备前,不作为当前测试服务器 required 阻塞项。
## 下一步建议
下一步可以把这套演练脚本接入正式上线流程:每次部署前先跑默认演练,部署后再跑生产发布门禁。这样后续上线不再靠口头确认,而是靠同一条命令产出的证据目录来判断是否放行。
@@ -309,12 +309,14 @@ docker run --rm -v "$project/frontend:/app" -w /app "$frontend_image" sh -lc 'if
docker rm -f "$backend_container" >/dev/null 2>&1 || true
docker rm -f "$frontend_container" >/dev/null 2>&1 || true
mkdir -p "$project/storage"
docker run -d \
--name "$backend_container" \
--network host \
--env-file "$project/backend/.env" \
-v "$project/backend:/app" \
-v "$project/storage:/data/ai-contract-storage" \
-w /app \
"$backend_image" \
sh -lc "alembic upgrade head && python -m uvicorn app.main:app --host 0.0.0.0 --port $backend_port"