diff --git a/backend/app/api/tenant_settings.py b/backend/app/api/tenant_settings.py index e29e295a..f251958a 100644 --- a/backend/app/api/tenant_settings.py +++ b/backend/app/api/tenant_settings.py @@ -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) diff --git a/backend/app/models/commercial_order.py b/backend/app/models/commercial_order.py index 3e33a207..94ef1ca8 100644 --- a/backend/app/models/commercial_order.py +++ b/backend/app/models/commercial_order.py @@ -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) diff --git a/backend/app/services/tenant_service.py b/backend/app/services/tenant_service.py index 88d7ed7b..a0a9383a 100644 --- a/backend/app/services/tenant_service.py +++ b/backend/app/services/tenant_service.py @@ -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 = ( diff --git a/backend/migrations/versions/20260521_0035_add_commercial_order_payment_evidence.py b/backend/migrations/versions/20260521_0035_add_commercial_order_payment_evidence.py new file mode 100644 index 00000000..385bf4f3 --- /dev/null +++ b/backend/migrations/versions/20260521_0035_add_commercial_order_payment_evidence.py @@ -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") diff --git a/backend/tests/test_tenant_settings_api.py b/backend/tests/test_tenant_settings_api.py index b6633d78..d38266db 100644 --- a/backend/tests/test_tenant_settings_api.py +++ b/backend/tests/test_tenant_settings_api.py @@ -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) diff --git a/frontend/src/app/billing/page.tsx b/frontend/src/app/billing/page.tsx index 2a3fe88d..31236e78 100644 --- a/frontend/src/app/billing/page.tsx +++ b/frontend/src/app/billing/page.tsx @@ -68,6 +68,12 @@ type InvoiceIssueFormValues = { note?: string; }; +type PaymentConfirmFormValues = { + payment_provider: string; + payment_reference?: string; + payment_proof_file_id?: string; +}; + const statusMeta: Record = { ok: { color: "green", label: "正常" }, warning: { color: "orange", label: "接近上限" }, @@ -143,20 +149,26 @@ export default function BillingPage() { const [planForm] = Form.useForm(); const [invoiceForm] = Form.useForm(); const [issueForm] = Form.useForm(); + const [paymentForm] = Form.useForm(); const [loading, setLoading] = useState(true); const [summary, setSummary] = useState(null); const [orders, setOrders] = useState([]); const [invoices, setInvoices] = useState([]); const [submittingOrder, setSubmittingOrder] = useState(false); const [submittingPlan, setSubmittingPlan] = useState(false); - const [activatingOrderId, setActivatingOrderId] = useState(null); + const [, setActivatingOrderId] = useState(null); const [invoiceOrder, setInvoiceOrder] = useState(null); + const [paymentOrder, setPaymentOrder] = useState(null); const [invoiceRecord, setInvoiceRecord] = useState(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(null); const [uploadedInvoiceFile, setUploadedInvoiceFile] = useState(null); const [downloadingInvoiceFileId, setDownloadingInvoiceFileId] = useState(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() { ( {order.status === "pending_payment" ? ( - ) : ( @@ -416,11 +497,23 @@ export default function BillingPage() { 登记发票资料 ) : null} + {order.payment_proof_file ? ( + + ) : order.payment_proof_file_id ? ( + {order.payment_proof_file_id} + ) : null} ), }, ], - [activatingOrderId, handleActivateOrder, handleOpenInvoiceRequest], + [downloadingInvoiceFileId, handleDownloadInvoiceFile, handleOpenInvoiceRequest, handleOpenPaymentConfirm], ); const invoiceColumns = useMemo>( @@ -852,6 +945,84 @@ export default function BillingPage() { ) : null} + { + setPaymentModalOpen(false); + setPaymentOrder(null); + setUploadedPaymentProofFile(null); + paymentForm.resetFields(); + }} + footer={null} + destroyOnHidden + > +
+ + + + + + + + + handleUploadPaymentProof(file as File)} + disabled={uploadingPaymentProof} + > + + + {uploadedPaymentProofFile ? ( + + + 已选择:{uploadedPaymentProofFile.file_name}({formatFileSize(uploadedPaymentProofFile.file_size)}) + + + 文件 ID:{uploadedPaymentProofFile.id} + + 打开文件中心 + + + ) : ( + + 支持 PDF、图片或 OFD 回单;确认后会绑定到本订单,作为无公网阶段的收款证据。 + + )} + + + +
+ >( `/tenant-settings/billing/orders/${orderId}/activate`, formData, diff --git a/frontend/src/types/api.ts b/frontend/src/types/api.ts index b4d10937..b4d39a08 100644 --- a/frontend/src/types/api.ts +++ b/frontend/src/types/api.ts @@ -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; diff --git a/project-docs/AI合同起草与审查助手-当前工作进度保存-2026-05-11.md b/project-docs/AI合同起草与审查助手-当前工作进度保存-2026-05-11.md index 234e7ace..f7197ad9 100644 --- a/project-docs/AI合同起草与审查助手-当前工作进度保存-2026-05-11.md +++ b/project-docs/AI合同起草与审查助手-当前工作进度保存-2026-05-11.md @@ -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 回调域名;这部分不再阻塞测试服务器上的商业化运营验证。 diff --git a/project-docs/AI合同起草与审查助手-正式生产部署演练与上线检查清单-2026-05-16.md b/project-docs/AI合同起草与审查助手-正式生产部署演练与上线检查清单-2026-05-16.md index ecf32037..0aa58bde 100644 --- a/project-docs/AI合同起草与审查助手-正式生产部署演练与上线检查清单-2026-05-16.md +++ b/project-docs/AI合同起草与审查助手-正式生产部署演练与上线检查清单-2026-05-16.md @@ -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 阻塞项。 + ## 下一步建议 下一步可以把这套演练脚本接入正式上线流程:每次部署前先跑默认演练,部署后再跑生产发布门禁。这样后续上线不再靠口头确认,而是靠同一条命令产出的证据目录来判断是否放行。 diff --git a/scripts/deploy-ai-contract-restore-docker.ps1 b/scripts/deploy-ai-contract-restore-docker.ps1 index d994f714..e3d5c896 100644 --- a/scripts/deploy-ai-contract-restore-docker.ps1 +++ b/scripts/deploy-ai-contract-restore-docker.ps1 @@ -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"