feat(m6): 额度流水、套餐开通、数据删除、运营后台(M6 剩下的 P0)
决策 M6-1–M6-10(docs/architecture/m6-plan.md、docs/decisions.md)。 用户端: - 额度与套餐页:本期额度、按功能分布、扣点规则、流水(逐条余额、关联项目和任务、导出 CSV) - 开通 / 续费申请(对公转账,运营确认到账后开通),80% / 100% 提醒 - 专业版到期:整个账号只读、额度清零,到期前 30 / 7 / 0 天提醒(worker 每小时 billing_tick) - 删除项目(彻底删除、销毁数据密钥),注销账号(企业名称 + 短信验证码) - 通知铃铛、账号页 运营后台: - 独立进程 bid-doctor ops(/ops/v1)和 apps/admin;用户名 + 密码 + TOTP + IP 白名单 - 开通申请(到账 / 开通 / 退回 / 发票)、企业查询和调额度、误报 / 漏报、失败任务、审计;不看业务内容 - 左侧菜单(标准 logo、可展开收起的分组、图标、待办数、环境标签) - 运营人员角色只留结构:ops_admin.role、权限点、每个接口的检查点、菜单按权限过滤 数据库:迁移 0010(排队的订阅、申请单号、通知类型、billing_due)、0011(bd_ops 权限)、0012(运营角色)。 另修:取消解析 / 检查接口报 500;uuid7 进程内单调;data_key 销毁后不再新建;审计 None 存 SQL NULL。 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
66
server/migrations/versions/0010_subscription_lifecycle.py
Normal file
66
server/migrations/versions/0010_subscription_lifecycle.py
Normal file
@@ -0,0 +1,66 @@
|
||||
"""套餐开通、续费和到期(m6-plan §1.2、§1.3,决策 M6-5):
|
||||
|
||||
- subscription 加「scheduled」:续费到账时当前一期还没到期,新一期排队,到旧一期结束时由定时任务生效并发放额度;
|
||||
- activation_request 加 request_no(界面和转账备注用的申请单号,如 KT-7Q3M2X);
|
||||
- 通知加 subscription_expired(到期后只读)、activation_rejected(申请被退回);
|
||||
- billing_due(now):定时任务找到期、该生效、该提醒的订阅。worker 受行级安全策略约束,看不到别的企业,
|
||||
这里照登录前查询的做法(data-model §2.3)用 bd_auth 所有的 SECURITY DEFINER 函数,只返回编号。
|
||||
|
||||
Revision ID: 0010
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from alembic import op
|
||||
|
||||
revision = "0010"
|
||||
down_revision = "0009"
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.get_bind().exec_driver_sql("""
|
||||
ALTER TABLE subscription DROP CONSTRAINT subscription_status_check;
|
||||
ALTER TABLE subscription ADD CONSTRAINT subscription_status_check
|
||||
CHECK (status IN ('scheduled', 'active', 'expired', 'cancelled'));
|
||||
CREATE INDEX ON subscription (tenant_id, starts_at) WHERE status = 'scheduled';
|
||||
|
||||
ALTER TABLE activation_request ADD COLUMN request_no text UNIQUE;
|
||||
|
||||
ALTER TABLE notification DROP CONSTRAINT notification_kind_check;
|
||||
ALTER TABLE notification ADD CONSTRAINT notification_kind_check
|
||||
CHECK (kind IN ('parse_succeeded', 'parse_failed', 'check_succeeded', 'check_failed', 'report_succeeded',
|
||||
'report_failed', 'quota_80', 'quota_100', 'subscription_activated', 'subscription_expiring',
|
||||
'subscription_expired', 'activation_rejected'));
|
||||
|
||||
CREATE FUNCTION billing_due(p_now timestamptz)
|
||||
RETURNS TABLE (tenant_id uuid, subscription_id uuid, action text)
|
||||
LANGUAGE sql STABLE SECURITY DEFINER SET search_path = app AS $$
|
||||
SELECT s.tenant_id, s.id, 'expire' FROM subscription s
|
||||
WHERE s.status = 'active' AND s.ends_at IS NOT NULL AND s.ends_at <= p_now
|
||||
UNION ALL
|
||||
SELECT s.tenant_id, s.id, 'start' FROM subscription s
|
||||
WHERE s.status = 'scheduled' AND s.starts_at <= p_now
|
||||
UNION ALL
|
||||
SELECT s.tenant_id, s.id, 'remind' FROM subscription s
|
||||
WHERE s.status = 'active' AND s.ends_at IS NOT NULL AND s.ends_at > p_now AND s.ends_at <= p_now + interval '30 days'
|
||||
$$;
|
||||
GRANT SELECT ON subscription TO bd_auth;
|
||||
ALTER FUNCTION billing_due(timestamptz) OWNER TO bd_auth;
|
||||
GRANT EXECUTE ON FUNCTION billing_due(timestamptz) TO bd_app;
|
||||
""")
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.get_bind().exec_driver_sql("""
|
||||
DROP FUNCTION billing_due(timestamptz);
|
||||
REVOKE SELECT ON subscription FROM bd_auth;
|
||||
ALTER TABLE notification DROP CONSTRAINT notification_kind_check;
|
||||
ALTER TABLE notification ADD CONSTRAINT notification_kind_check
|
||||
CHECK (kind IN ('parse_succeeded', 'parse_failed', 'check_succeeded', 'check_failed', 'report_succeeded',
|
||||
'report_failed', 'quota_80', 'quota_100', 'subscription_activated', 'subscription_expiring'));
|
||||
ALTER TABLE activation_request DROP COLUMN request_no;
|
||||
DROP INDEX IF EXISTS subscription_tenant_id_starts_at_idx;
|
||||
ALTER TABLE subscription DROP CONSTRAINT subscription_status_check;
|
||||
ALTER TABLE subscription ADD CONSTRAINT subscription_status_check
|
||||
CHECK (status IN ('active', 'expired', 'cancelled'));
|
||||
""")
|
||||
37
server/migrations/versions/0011_ops_role_grants.py
Normal file
37
server/migrations/versions/0011_ops_role_grants.py
Normal file
@@ -0,0 +1,37 @@
|
||||
"""运营后台的数据库权限(决策 M6-2):
|
||||
|
||||
- bd_ops(部署脚本建,LOGIN BYPASSRLS):跨企业只读全部业务表;运营账号和会话表可写;能写审计(查看企业详情也留痕)。
|
||||
对某个企业做的修改(开通、退回、开票、调额度)仍走 bd_app + 租户事务,行级安全策略照样生效;
|
||||
- 运营账号和会话表只给 bd_ops:用户端的 api / worker(bd_app)读不到。
|
||||
|
||||
Revision ID: 0011
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from alembic import op
|
||||
|
||||
revision = "0011"
|
||||
down_revision = "0010"
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.get_bind().exec_driver_sql("""
|
||||
GRANT USAGE ON SCHEMA app TO bd_ops;
|
||||
GRANT SELECT ON ALL TABLES IN SCHEMA app TO bd_ops;
|
||||
ALTER DEFAULT PRIVILEGES IN SCHEMA app GRANT SELECT ON TABLES TO bd_ops;
|
||||
GRANT INSERT, UPDATE ON ops_admin, ops_session TO bd_ops;
|
||||
GRANT INSERT ON audit_event TO bd_ops;
|
||||
REVOKE ALL ON ops_admin, ops_session FROM bd_app;
|
||||
""")
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.get_bind().exec_driver_sql("""
|
||||
GRANT SELECT, INSERT, UPDATE, DELETE ON ops_admin, ops_session TO bd_app;
|
||||
REVOKE INSERT ON audit_event FROM bd_ops;
|
||||
REVOKE INSERT, UPDATE ON ops_admin, ops_session FROM bd_ops;
|
||||
ALTER DEFAULT PRIVILEGES IN SCHEMA app REVOKE SELECT ON TABLES FROM bd_ops;
|
||||
REVOKE SELECT ON ALL TABLES IN SCHEMA app FROM bd_ops;
|
||||
REVOKE USAGE ON SCHEMA app FROM bd_ops;
|
||||
""")
|
||||
22
server/migrations/versions/0012_ops_admin_role.py
Normal file
22
server/migrations/versions/0012_ops_admin_role.py
Normal file
@@ -0,0 +1,22 @@
|
||||
"""运营人员的角色(只留结构):以后会有管理员、运营、销售、运维等不同权限的人。
|
||||
|
||||
这里只加 ops_admin.role,不加 CHECK:角色和权限点的对应关系在代码里(ops/auth.py 的 ROLE_PERMISSIONS),
|
||||
加角色时只改那里;接口都已经过权限检查点,菜单按权限点过滤。现在只有「管理员」一种,拥有全部权限。
|
||||
|
||||
Revision ID: 0012
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from alembic import op
|
||||
|
||||
revision = "0012"
|
||||
down_revision = "0011"
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.get_bind().exec_driver_sql("ALTER TABLE ops_admin ADD COLUMN role text NOT NULL DEFAULT 'admin'")
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.get_bind().exec_driver_sql("ALTER TABLE ops_admin DROP COLUMN role")
|
||||
@@ -1,8 +1,11 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from sqlalchemy.orm import Mapped
|
||||
from typing import Any
|
||||
|
||||
from bid_doctor.core.columns import PK, Bytes, DbTs, Json, UuidCol
|
||||
from sqlalchemy.dialects.postgresql import JSONB
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from bid_doctor.core.columns import PK, Bytes, DbTs, UuidCol
|
||||
from bid_doctor.core.db import Base
|
||||
|
||||
|
||||
@@ -12,13 +15,15 @@ class AuditEvent(Base):
|
||||
tenant_id: Mapped[UuidCol]
|
||||
actor_kind: Mapped[str]
|
||||
actor_user_id: Mapped[UuidCol | None]
|
||||
actor_ops_id: Mapped[UuidCol | None] = mapped_column(default=None)
|
||||
actor_name: Mapped[str | None]
|
||||
action: Mapped[str]
|
||||
object_type: Mapped[str]
|
||||
object_id: Mapped[UuidCol | None]
|
||||
project_id: Mapped[UuidCol | None]
|
||||
before: Mapped[Json | None]
|
||||
after: Mapped[Json | None]
|
||||
# None 存成 SQL NULL(不是 JSON 的 null):抹掉内容的触发器和「还有没有内容」的判断都按 IS NULL
|
||||
before: Mapped[Any | None] = mapped_column(JSONB(none_as_null=True))
|
||||
after: Mapped[Any | None] = mapped_column(JSONB(none_as_null=True))
|
||||
content_digest: Mapped[Bytes | None]
|
||||
request_id: Mapped[str | None]
|
||||
created_at: Mapped[DbTs]
|
||||
|
||||
@@ -26,6 +26,7 @@ async def record(
|
||||
before: dict[str, Any] | None = None,
|
||||
after: dict[str, Any] | None = None,
|
||||
actor_kind: str = "user",
|
||||
actor_ops_id: UUID | None = None,
|
||||
) -> None:
|
||||
digest = (
|
||||
sha256(json.dumps([before, after], ensure_ascii=False, sort_keys=True, default=str))
|
||||
@@ -38,6 +39,7 @@ async def record(
|
||||
tenant_id=ctx.tenant_id,
|
||||
actor_kind=actor_kind,
|
||||
actor_user_id=ctx.user_id if actor_kind == "user" else None,
|
||||
actor_ops_id=actor_ops_id,
|
||||
actor_name=ctx.actor_name or None,
|
||||
action=action,
|
||||
object_type=object_type,
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
from typing import Any, Literal
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import APIRouter
|
||||
from fastapi import APIRouter, Query
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlalchemy import select
|
||||
|
||||
from bid_doctor.billing import service
|
||||
@@ -29,3 +31,42 @@ async def list_plans() -> dict[str, Any]:
|
||||
async def get_quota(ctx: Ctx) -> dict[str, Any]:
|
||||
async with tenant_tx(ctx) as s:
|
||||
return await service.overview(s, ctx)
|
||||
|
||||
|
||||
@router.get("/quota/entries")
|
||||
async def list_quota_entries(
|
||||
ctx: Ctx,
|
||||
unit: Literal["parse", "check", "generate", "dedupe"] | None = None,
|
||||
project_id: UUID | None = None,
|
||||
cursor: str | None = None,
|
||||
limit: int = Query(default=20, ge=1, le=100),
|
||||
) -> dict[str, Any]:
|
||||
async with tenant_tx(ctx) as s:
|
||||
return await service.entries(s, ctx, unit=unit, project_id=project_id, cursor=cursor, limit=limit)
|
||||
|
||||
|
||||
class InvoiceIn(BaseModel):
|
||||
title: str = Field(min_length=2, max_length=100)
|
||||
tax_no: str = Field(min_length=15, max_length=20)
|
||||
invoice_type: Literal["general", "special"] = "general"
|
||||
address_phone: str | None = Field(default=None, max_length=200)
|
||||
bank_account: str | None = Field(default=None, max_length=200)
|
||||
contact_name: str = Field(min_length=1, max_length=40)
|
||||
contact_phone: str = Field(pattern=r"^1[3-9][0-9]{9}$")
|
||||
|
||||
|
||||
class ActivationIn(BaseModel):
|
||||
plan_code: Literal["pro"] = "pro"
|
||||
invoice_info: InvoiceIn
|
||||
|
||||
|
||||
@router.get("/activation-requests")
|
||||
async def list_activation_requests(ctx: Ctx) -> dict[str, Any]:
|
||||
async with tenant_tx(ctx) as s:
|
||||
return {"items": [service.request_to_api(r) for r in await service.list_requests(s, ctx)]}
|
||||
|
||||
|
||||
@router.post("/activation-requests", status_code=201)
|
||||
async def create_activation_request(body: ActivationIn, ctx: Ctx) -> dict[str, Any]:
|
||||
async with tenant_tx(ctx) as s:
|
||||
return service.request_to_api(await service.create_request(s, ctx, body.invoice_info.model_dump()))
|
||||
|
||||
@@ -28,6 +28,7 @@ class Subscription(Base):
|
||||
seats: Mapped[int] = mapped_column(server_default=FetchedValue())
|
||||
starts_at: Mapped[Ts]
|
||||
ends_at: Mapped[Ts | None]
|
||||
activation_request_id: Mapped[UuidCol | None]
|
||||
|
||||
|
||||
class QuotaAccount(Base):
|
||||
@@ -56,4 +57,37 @@ class QuotaEntry(Base):
|
||||
project_id: Mapped[UuidCol | None]
|
||||
reason: Mapped[str | None]
|
||||
actor_user_id: Mapped[UuidCol | None]
|
||||
actor_ops_id: Mapped[UuidCol | None] = mapped_column(default=None)
|
||||
created_at: Mapped[DbTs]
|
||||
|
||||
|
||||
class ActivationRequest(Base):
|
||||
"""专业版开通 / 续费申请(G8):提交 → 对公转账 → 运营确认到账后开通、开发票。注销后按财务要求保留。"""
|
||||
|
||||
__tablename__ = "activation_request"
|
||||
id: Mapped[PK]
|
||||
tenant_id: Mapped[UuidCol]
|
||||
applicant_user_id: Mapped[UuidCol]
|
||||
request_no: Mapped[str | None]
|
||||
plan_code: Mapped[str]
|
||||
plan_version: Mapped[int]
|
||||
invoice_info: Mapped[Json]
|
||||
status: Mapped[str] = mapped_column(server_default=FetchedValue())
|
||||
ops_note: Mapped[str | None]
|
||||
handled_by_ops: Mapped[UuidCol | None]
|
||||
handled_at: Mapped[Ts | None]
|
||||
invoice_no: Mapped[str | None]
|
||||
invoiced_at: Mapped[Ts | None]
|
||||
created_at: Mapped[DbTs]
|
||||
updated_at: Mapped[DbTs]
|
||||
|
||||
|
||||
class QuotaAlert(Base):
|
||||
"""80% / 100% 提醒:每个订阅周期每档只提醒一次(主键保证)。"""
|
||||
|
||||
__tablename__ = "quota_alert"
|
||||
tenant_id: Mapped[UuidCol] = mapped_column(primary_key=True)
|
||||
account_id: Mapped[UuidCol] = mapped_column(primary_key=True)
|
||||
subscription_id: Mapped[UuidCol] = mapped_column(primary_key=True)
|
||||
threshold: Mapped[int] = mapped_column(primary_key=True)
|
||||
sent_at: Mapped[DbTs]
|
||||
|
||||
@@ -5,17 +5,27 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import re
|
||||
from datetime import UTC, datetime
|
||||
from typing import Any
|
||||
from uuid import UUID
|
||||
|
||||
from sqlalchemy import select, text
|
||||
from sqlalchemy import func, select, text
|
||||
from sqlalchemy.dialects.postgresql import insert
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from bid_doctor.billing.models import Plan, QuotaAccount, QuotaEntry, Subscription
|
||||
from bid_doctor.billing.models import ActivationRequest, Plan, QuotaAccount, QuotaAlert, QuotaEntry, Subscription
|
||||
from bid_doctor.core.config import get_settings
|
||||
from bid_doctor.core.db import TenantContext
|
||||
from bid_doctor.core.errors import AppError
|
||||
from bid_doctor.core.ids import uuid7
|
||||
from bid_doctor.identity.models import Membership
|
||||
from bid_doctor.notification.models import Notification
|
||||
|
||||
PERIOD_MONTHS = 12 # 专业版按年开通、续费(原型「专业版 · 1 年」)
|
||||
# 现在用得上的额度单位:检查也从解析扣(data-model §4.3);套餐里的 generate(M4 标书生成)等功能上线再发放
|
||||
LIVE_UNITS = ("parse",)
|
||||
|
||||
|
||||
async def start_free_subscription(session: AsyncSession, ctx: TenantContext) -> None:
|
||||
@@ -53,16 +63,24 @@ async def start_free_subscription(session: AsyncSession, ctx: TenantContext) ->
|
||||
|
||||
|
||||
async def current_plan(session: AsyncSession, ctx: TenantContext) -> tuple[Subscription, Plan]:
|
||||
"""当前这一期:生效中的;没有(专业版到期、还没续费)时取最近过期的那一期,只读(决策 M6-5)。"""
|
||||
row = (
|
||||
await session.execute(
|
||||
select(Subscription, Plan)
|
||||
.join(Plan, (Plan.code == Subscription.plan_code) & (Plan.version == Subscription.plan_version))
|
||||
.where(Subscription.tenant_id == ctx.tenant_id, Subscription.status == "active")
|
||||
.where(Subscription.tenant_id == ctx.tenant_id, Subscription.status.in_(("active", "expired")))
|
||||
.order_by((Subscription.status == "active").desc(), Subscription.starts_at.desc())
|
||||
.limit(1)
|
||||
)
|
||||
).one()
|
||||
return row[0], row[1]
|
||||
|
||||
|
||||
async def read_only(session: AsyncSession, ctx: TenantContext) -> bool:
|
||||
sub, _ = await current_plan(session, ctx)
|
||||
return sub.status == "expired"
|
||||
|
||||
|
||||
async def hold(
|
||||
session: AsyncSession, ctx: TenantContext, *, unit: str, ref_type: str, ref_id: UUID, project_id: UUID
|
||||
) -> UUID:
|
||||
@@ -87,9 +105,37 @@ async def hold(
|
||||
ref_id=ref_id,
|
||||
project_id=project_id,
|
||||
)
|
||||
await _alert(session, ctx, acct)
|
||||
return entry.id
|
||||
|
||||
|
||||
async def _alert(session: AsyncSession, ctx: TenantContext, acct: QuotaAccount) -> None:
|
||||
"""用到本期发放的 80%、100% 时发站内提醒,每期每档一次(FR-M6-03;quota_alert 的主键保证不重复)。"""
|
||||
sub, _ = await current_plan(session, ctx)
|
||||
granted = (
|
||||
await session.execute(
|
||||
select(func.coalesce(func.sum(QuotaEntry.amount), 0)).where(
|
||||
QuotaEntry.account_id == acct.id, QuotaEntry.subscription_id == sub.id, QuotaEntry.kind == "grant"
|
||||
)
|
||||
)
|
||||
).scalar_one()
|
||||
if not granted:
|
||||
return
|
||||
used = 1 - acct.balance / granted
|
||||
for threshold in (80, 100):
|
||||
if used * 100 < threshold:
|
||||
continue
|
||||
added = await session.execute(
|
||||
insert(QuotaAlert)
|
||||
.values(tenant_id=ctx.tenant_id, account_id=acct.id, subscription_id=sub.id, threshold=threshold)
|
||||
.on_conflict_do_nothing()
|
||||
.returning(QuotaAlert.threshold)
|
||||
)
|
||||
if added.first():
|
||||
payload = {"unit": acct.unit, "balance": acct.balance, "granted": granted}
|
||||
await notify(session, ctx, f"quota_{threshold}", payload, user_id=ctx.user_id)
|
||||
|
||||
|
||||
async def balance(session: AsyncSession, ctx: TenantContext, unit: str) -> int:
|
||||
acct = (
|
||||
await session.execute(
|
||||
@@ -170,7 +216,7 @@ async def _append(
|
||||
kind=kind,
|
||||
amount=amount,
|
||||
idempotency_key=key,
|
||||
actor_user_id=ctx.user_id,
|
||||
actor_user_id=ctx.user_id if ctx.user_id.int else None, # 定时任务、运营操作没有用户
|
||||
**fields,
|
||||
)
|
||||
session.add(entry)
|
||||
@@ -181,20 +227,24 @@ async def _append(
|
||||
|
||||
async def overview(session: AsyncSession, ctx: TenantContext) -> dict[str, Any]:
|
||||
sub, plan = await current_plan(session, ctx)
|
||||
# 每笔预扣扣 1 次:已用 = 预扣数 − 退回数;正在预扣 = 预扣数 − 退回数 − 结算数。
|
||||
# TODO(专业版续费):按订阅周期切分「本期」;MVP 的免费版只有一个周期。
|
||||
# 本期 = 这一期订阅的起止。每笔预扣扣 1 次:已用 = 预扣数 − 退回数;正在预扣 = 已用 − 结算数。
|
||||
period = {"t": ctx.tenant_id, "s": sub.id, "start": sub.starts_at, "end": sub.ends_at}
|
||||
# 本期的流水:发放按订阅算,用量按这一期的起止时间算
|
||||
rows = (
|
||||
await session.execute(
|
||||
text(
|
||||
"SELECT a.unit, a.balance,"
|
||||
" coalesce(sum(e.amount) FILTER (WHERE e.kind = 'grant' AND e.subscription_id = :s), 0),"
|
||||
"WITH e AS (SELECT * FROM quota_entry"
|
||||
" WHERE created_at >= :start AND (CAST(:end AS timestamptz) IS NULL OR created_at < :end))"
|
||||
" SELECT a.unit, a.balance,"
|
||||
" (SELECT coalesce(sum(g.amount), 0) FROM quota_entry g"
|
||||
" WHERE g.account_id = a.id AND g.kind = 'grant' AND g.subscription_id = :s),"
|
||||
" count(e.id) FILTER (WHERE e.kind = 'hold'),"
|
||||
" count(e.id) FILTER (WHERE e.kind = 'release'),"
|
||||
" count(e.id) FILTER (WHERE e.kind = 'settle')"
|
||||
" FROM quota_account a LEFT JOIN quota_entry e ON e.account_id = a.id"
|
||||
" WHERE a.tenant_id = :t GROUP BY a.unit, a.balance ORDER BY a.unit"
|
||||
" FROM quota_account a LEFT JOIN e ON e.account_id = a.id"
|
||||
" WHERE a.tenant_id = :t GROUP BY a.id, a.unit, a.balance ORDER BY a.unit"
|
||||
),
|
||||
{"t": ctx.tenant_id, "s": sub.id},
|
||||
period,
|
||||
)
|
||||
).all()
|
||||
accounts: list[dict[str, Any]] = []
|
||||
@@ -207,10 +257,48 @@ async def overview(session: AsyncSession, ctx: TenantContext) -> dict[str, Any]:
|
||||
"held": used - settles,
|
||||
"period_granted": granted,
|
||||
"period_used": used,
|
||||
"period_released": releases,
|
||||
"used_ratio": round(used / granted, 3) if granted else 0.0,
|
||||
}
|
||||
)
|
||||
return {"plan": plan_summary(sub, plan), "accounts": accounts}
|
||||
# 按功能分布(原型额度页):没被退回的预扣按用途分;免扣另计
|
||||
usage = dict.fromkeys(("tender", "addendum", "check", "deleted", "waived"), 0)
|
||||
for cat, n in (
|
||||
await session.execute(
|
||||
text(
|
||||
"WITH e AS (SELECT * FROM quota_entry"
|
||||
" WHERE created_at >= :start AND (CAST(:end AS timestamptz) IS NULL OR created_at < :end))"
|
||||
" SELECT CASE WHEN e.kind = 'waive' THEN 'waived' WHEN e.ref_type = 'check_run' THEN 'check'"
|
||||
" WHEN d.role = 'tender' THEN 'tender' WHEN d.role IS NULL THEN 'deleted' ELSE 'addendum' END,"
|
||||
" count(*) FROM e"
|
||||
" LEFT JOIN parse_job j ON e.ref_type = 'parse_job' AND j.id = e.ref_id"
|
||||
" LEFT JOIN source_document d ON d.id = j.document_id"
|
||||
" WHERE e.kind IN ('hold', 'waive')"
|
||||
" AND NOT EXISTS (SELECT 1 FROM quota_entry r WHERE r.hold_entry_id = e.id AND r.kind = 'release')"
|
||||
" GROUP BY 1"
|
||||
),
|
||||
period,
|
||||
)
|
||||
).all():
|
||||
usage[cat] = n
|
||||
pending = await _open_request(session, ctx)
|
||||
return {
|
||||
"plan": plan_summary(sub, plan),
|
||||
"accounts": accounts,
|
||||
"usage": usage,
|
||||
"renewal": await _renewal(session, ctx),
|
||||
"pending_request": request_to_api(pending) if pending else None,
|
||||
}
|
||||
|
||||
|
||||
async def _renewal(session: AsyncSession, ctx: TenantContext) -> dict[str, Any] | None:
|
||||
"""已经续费、排队等当前一期结束后生效的那一期。"""
|
||||
nxt = (
|
||||
await session.execute(
|
||||
select(Subscription).where(Subscription.tenant_id == ctx.tenant_id, Subscription.status == "scheduled")
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
return {"starts_at": nxt.starts_at, "ends_at": nxt.ends_at} if nxt else None
|
||||
|
||||
|
||||
def plan_summary(sub: Subscription, plan: Plan) -> dict[str, Any]:
|
||||
@@ -219,5 +307,474 @@ def plan_summary(sub: Subscription, plan: Plan) -> dict[str, Any]:
|
||||
"name": plan.name,
|
||||
"starts_at": sub.starts_at,
|
||||
"ends_at": sub.ends_at,
|
||||
"read_only": False,
|
||||
"read_only": sub.status == "expired",
|
||||
}
|
||||
|
||||
|
||||
async def notify(
|
||||
session: AsyncSession, ctx: TenantContext, kind: str, payload: dict[str, Any], *, user_id: UUID | None = None
|
||||
) -> None:
|
||||
"""站内通知。没指定人时发给企业的所有者(定时任务、运营操作没有「当前用户」)。"""
|
||||
users = [user_id] if user_id else await _owners(session, ctx)
|
||||
for u in users:
|
||||
session.add(Notification(id=uuid7(), tenant_id=ctx.tenant_id, user_id=u, kind=kind, payload=payload))
|
||||
await session.flush()
|
||||
|
||||
|
||||
async def _owners(session: AsyncSession, ctx: TenantContext) -> list[UUID]:
|
||||
rows = await session.execute(
|
||||
select(Membership.user_id).where(Membership.status == "active", Membership.role.in_(("owner", "admin")))
|
||||
)
|
||||
return list(rows.scalars())
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- 额度流水(FR-M6-03)
|
||||
|
||||
PARSE_LABEL = {"tender": "招标文件", "addendum": "补遗", "clarification": "澄清"}
|
||||
|
||||
|
||||
def _cursor(entry_id: UUID) -> str:
|
||||
return base64.urlsafe_b64encode(entry_id.bytes).decode().rstrip("=")
|
||||
|
||||
|
||||
def _uncursor(cursor: str) -> UUID:
|
||||
try:
|
||||
return UUID(bytes=base64.urlsafe_b64decode(cursor + "=" * (-len(cursor) % 4)))
|
||||
except ValueError as e:
|
||||
raise AppError("validation_failed", "分页参数不对") from e
|
||||
|
||||
|
||||
async def entries(
|
||||
session: AsyncSession,
|
||||
ctx: TenantContext,
|
||||
*,
|
||||
unit: str | None = None,
|
||||
project_id: UUID | None = None,
|
||||
cursor: str | None = None,
|
||||
limit: int = 20,
|
||||
) -> dict[str, Any]:
|
||||
"""按时间倒序(uuid7 编号同一进程内单调,按编号排即按时间)。余额是这条记完以后的账户余额。
|
||||
项目删了以后流水还在(G10):项目名称为空,界面写「已删除的项目」。"""
|
||||
params = {
|
||||
"limit": limit + 1,
|
||||
"unit": unit,
|
||||
"project": project_id,
|
||||
"cursor": _uncursor(cursor) if cursor else None,
|
||||
}
|
||||
rows = (
|
||||
await session.execute(
|
||||
text(
|
||||
"SELECT x.id, x.unit, x.kind, x.amount, x.balance_after, x.reason, x.created_at, x.project_id,"
|
||||
" p.name, x.ref_type, x.ref_id, d.role, d.seq_no, c.run_no, x.hold_entry_id"
|
||||
" FROM (SELECT e.*, a.unit,"
|
||||
" sum(e.amount) OVER (PARTITION BY e.account_id ORDER BY e.id) AS balance_after"
|
||||
" FROM quota_entry e JOIN quota_account a ON a.id = e.account_id) x"
|
||||
" LEFT JOIN project p ON p.id = x.project_id"
|
||||
" LEFT JOIN parse_job j ON x.ref_type = 'parse_job' AND j.id = x.ref_id"
|
||||
" LEFT JOIN source_document d ON d.id = j.document_id"
|
||||
" LEFT JOIN check_run c ON x.ref_type = 'check_run' AND c.id = x.ref_id"
|
||||
" WHERE (CAST(:unit AS text) IS NULL OR x.unit = :unit)"
|
||||
" AND (CAST(:project AS uuid) IS NULL OR x.project_id = :project)"
|
||||
" AND (CAST(:cursor AS uuid) IS NULL OR x.id < :cursor)"
|
||||
" ORDER BY x.id DESC LIMIT :limit"
|
||||
),
|
||||
params,
|
||||
)
|
||||
).all()
|
||||
more = len(rows) > limit
|
||||
rows = rows[:limit]
|
||||
items = []
|
||||
for r in rows:
|
||||
(eid, u, kind, amount, bal, reason, at, pid, pname, ref_type, ref_id, role, seq, run_no, hold_id) = r
|
||||
label = None
|
||||
if ref_type == "parse_job" and role:
|
||||
label = PARSE_LABEL.get(role, "文件") + (f" {seq}" if role != "tender" and seq else "")
|
||||
elif ref_type == "check_run" and run_no:
|
||||
label = f"第 {run_no} 次检查"
|
||||
items.append(
|
||||
{
|
||||
"id": eid,
|
||||
"unit": u,
|
||||
"kind": kind,
|
||||
"amount": amount,
|
||||
"balance_after": bal,
|
||||
"reason": reason,
|
||||
"project": {"id": pid, "name": pname, "deleted": pname is None} if pid else None,
|
||||
"ref": {"type": ref_type, "id": ref_id, "label": label} if ref_type else None,
|
||||
"hold_entry_id": hold_id,
|
||||
"created_at": at,
|
||||
}
|
||||
)
|
||||
return {"items": items, "next_cursor": _cursor(rows[-1][0]) if more else None}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- 开通申请(FR-M6-05、G8,决策 M6-6)
|
||||
|
||||
TAX_NO = re.compile(r"^[0-9A-Z]{15,20}$")
|
||||
OPEN = ("submitted", "paid")
|
||||
|
||||
|
||||
def payment_instructions() -> dict[str, str]:
|
||||
s = get_settings()
|
||||
return {
|
||||
"payee_name": s.payee_name or "(待定)",
|
||||
"payee_bank": s.payee_bank or "(待定)",
|
||||
"payee_account": s.payee_account or "(待定)",
|
||||
}
|
||||
|
||||
|
||||
def request_to_api(r: ActivationRequest) -> dict[str, Any]:
|
||||
return {
|
||||
"id": r.id,
|
||||
"request_no": r.request_no,
|
||||
"plan_code": r.plan_code,
|
||||
"status": r.status,
|
||||
"invoice_info": r.invoice_info,
|
||||
"payment": payment_instructions(),
|
||||
"ops_note": r.ops_note,
|
||||
"invoice_no": r.invoice_no,
|
||||
"handled_at": r.handled_at,
|
||||
"created_at": r.created_at,
|
||||
}
|
||||
|
||||
|
||||
async def _open_request(session: AsyncSession, ctx: TenantContext) -> ActivationRequest | None:
|
||||
return (
|
||||
await session.execute(
|
||||
select(ActivationRequest)
|
||||
.where(ActivationRequest.tenant_id == ctx.tenant_id, ActivationRequest.status.in_(OPEN))
|
||||
.order_by(ActivationRequest.created_at.desc())
|
||||
.limit(1)
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
|
||||
|
||||
def _request_no(request_id: UUID) -> str:
|
||||
"""申请单号,写在转账备注里:KT- 加编号随机部分的 6 位(大写字母和数字,去掉易混的 0 / O / 1 / I)。"""
|
||||
alphabet = "23456789ABCDEFGHJKLMNPQRSTUVWXYZ"
|
||||
n = request_id.int & ((1 << 60) - 1)
|
||||
return "KT-" + "".join(alphabet[(n >> (5 * i)) & 31] for i in range(6))
|
||||
|
||||
|
||||
async def create_request(session: AsyncSession, ctx: TenantContext, invoice: dict[str, Any]) -> ActivationRequest:
|
||||
if await _open_request(session, ctx):
|
||||
raise AppError("activation_pending")
|
||||
tax_no = str(invoice.get("tax_no") or "").strip().upper()
|
||||
if not TAX_NO.match(tax_no):
|
||||
raise AppError("validation_failed", "纳税人识别号是 15 到 20 位数字或大写字母,请核对", field="tax_no")
|
||||
if invoice.get("invoice_type") == "special" and not (invoice.get("address_phone") and invoice.get("bank_account")):
|
||||
raise AppError("validation_failed", "增值税专用发票要填地址电话和开户行账号", field="bank_account")
|
||||
plan = (
|
||||
await session.execute(
|
||||
select(Plan).where(Plan.code == "pro", Plan.active).order_by(Plan.version.desc()).limit(1)
|
||||
)
|
||||
).scalar_one()
|
||||
rid = uuid7()
|
||||
r = ActivationRequest(
|
||||
id=rid,
|
||||
tenant_id=ctx.tenant_id,
|
||||
applicant_user_id=ctx.user_id,
|
||||
request_no=_request_no(rid),
|
||||
plan_code=plan.code,
|
||||
plan_version=plan.version,
|
||||
invoice_info={**{k: v for k, v in invoice.items() if v not in (None, "")}, "tax_no": tax_no},
|
||||
)
|
||||
session.add(r)
|
||||
await session.flush()
|
||||
await session.refresh(r)
|
||||
return r
|
||||
|
||||
|
||||
async def list_requests(session: AsyncSession, ctx: TenantContext) -> list[ActivationRequest]:
|
||||
rows = await session.execute(select(ActivationRequest).order_by(ActivationRequest.created_at.desc()))
|
||||
return list(rows.scalars())
|
||||
|
||||
|
||||
def _add_months(at: datetime, months: int) -> datetime:
|
||||
y, m = divmod(at.month - 1 + months, 12)
|
||||
year, month = at.year + y, m + 1
|
||||
for day in (at.day, 30, 29, 28): # 1 月 31 日加一个月 → 2 月最后一天
|
||||
try:
|
||||
return at.replace(year=year, month=month, day=day)
|
||||
except ValueError:
|
||||
continue
|
||||
raise AssertionError("unreachable")
|
||||
|
||||
|
||||
async def activate(
|
||||
session: AsyncSession, ctx: TenantContext, request_id: UUID, *, ops_id: UUID, months: int = PERIOD_MONTHS
|
||||
) -> Subscription:
|
||||
"""运营确认到账后开通(G8)。
|
||||
- 当前是免费版或已过期:马上开始新一期,免费版剩下的额度清零,发放专业版额度;
|
||||
- 当前专业版还没到期(续费):新一期排队,从旧一期结束时开始,由定时任务生效、发放(旧一期剩下的不结转)。"""
|
||||
r = (
|
||||
await session.execute(select(ActivationRequest).where(ActivationRequest.id == request_id).with_for_update())
|
||||
).scalar_one()
|
||||
if r.status not in OPEN:
|
||||
raise AppError("invalid_transition", "这份申请已经处理过了")
|
||||
plan = (
|
||||
await session.execute(select(Plan).where(Plan.code == r.plan_code, Plan.version == r.plan_version))
|
||||
).scalar_one()
|
||||
now = datetime.now(UTC)
|
||||
cur = (
|
||||
await session.execute(
|
||||
select(Subscription).where(Subscription.tenant_id == ctx.tenant_id, Subscription.status == "active")
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
renew = cur is not None and cur.plan_code != "free" and cur.ends_at is not None and cur.ends_at > now
|
||||
if renew and await _renewal(session, ctx):
|
||||
raise AppError("invalid_transition", "已经续过一期了,等它生效后再续")
|
||||
start = cur.ends_at if renew and cur and cur.ends_at else now
|
||||
sub = Subscription(
|
||||
id=uuid7(),
|
||||
tenant_id=ctx.tenant_id,
|
||||
plan_code=plan.code,
|
||||
plan_version=plan.version,
|
||||
status="scheduled" if renew else "active",
|
||||
source="manual",
|
||||
starts_at=start,
|
||||
ends_at=_add_months(start, months),
|
||||
activation_request_id=r.id,
|
||||
)
|
||||
if not renew:
|
||||
if cur is not None: # 免费版(或别的生效中的一期)结束,剩下的额度清零
|
||||
await _expire_balances(session, ctx, cur, reason=f"开通专业版(申请单 {r.request_no}),原套餐结束")
|
||||
cur.status, cur.ends_at = "cancelled", now
|
||||
await session.flush()
|
||||
session.add(sub)
|
||||
await session.flush()
|
||||
await _grant(session, ctx, sub, plan, reason=f"专业版开通(申请单 {r.request_no})")
|
||||
else:
|
||||
session.add(sub)
|
||||
await session.flush()
|
||||
r.status, r.handled_by_ops, r.handled_at = "activated", ops_id, now
|
||||
await notify(
|
||||
session,
|
||||
ctx,
|
||||
"subscription_activated",
|
||||
{"request_no": r.request_no, "starts_at": start.isoformat(), "renewal": renew},
|
||||
user_id=r.applicant_user_id,
|
||||
)
|
||||
await session.flush()
|
||||
return sub
|
||||
|
||||
|
||||
async def reject(session: AsyncSession, ctx: TenantContext, request_id: UUID, *, ops_id: UUID, note: str) -> None:
|
||||
r = (
|
||||
await session.execute(select(ActivationRequest).where(ActivationRequest.id == request_id).with_for_update())
|
||||
).scalar_one()
|
||||
if r.status not in OPEN:
|
||||
raise AppError("invalid_transition", "这份申请已经处理过了")
|
||||
r.status, r.ops_note, r.handled_by_ops, r.handled_at = "rejected", note, ops_id, datetime.now(UTC)
|
||||
await notify(session, ctx, "activation_rejected", {"request_no": r.request_no}, user_id=r.applicant_user_id)
|
||||
|
||||
|
||||
async def _grant(session: AsyncSession, ctx: TenantContext, sub: Subscription, plan: Plan, *, reason: str) -> None:
|
||||
for unit, amount in plan.quotas.items():
|
||||
if unit not in LIVE_UNITS:
|
||||
continue
|
||||
acct = (
|
||||
await session.execute(
|
||||
select(QuotaAccount)
|
||||
.where(QuotaAccount.tenant_id == ctx.tenant_id, QuotaAccount.unit == unit)
|
||||
.with_for_update()
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
if acct is None:
|
||||
acct = QuotaAccount(id=uuid7(), tenant_id=ctx.tenant_id, unit=unit, balance=0)
|
||||
session.add(acct)
|
||||
await session.flush()
|
||||
await _append(
|
||||
session,
|
||||
ctx,
|
||||
acct,
|
||||
kind="grant",
|
||||
amount=amount,
|
||||
key=f"grant:{sub.id}:{unit}",
|
||||
subscription_id=sub.id,
|
||||
reason=reason,
|
||||
)
|
||||
|
||||
|
||||
async def _expire_balances(session: AsyncSession, ctx: TenantContext, sub: Subscription, *, reason: str) -> None:
|
||||
accts = (
|
||||
await session.execute(select(QuotaAccount).where(QuotaAccount.tenant_id == ctx.tenant_id).with_for_update())
|
||||
).scalars()
|
||||
for acct in accts:
|
||||
if acct.balance > 0:
|
||||
await _append(
|
||||
session,
|
||||
ctx,
|
||||
acct,
|
||||
kind="expire",
|
||||
amount=-acct.balance,
|
||||
key=f"expire:{sub.id}:{acct.unit}",
|
||||
subscription_id=sub.id,
|
||||
reason=reason,
|
||||
)
|
||||
|
||||
|
||||
async def _open_holds(session: AsyncSession, ctx: TenantContext) -> int:
|
||||
return (
|
||||
await session.execute(
|
||||
text(
|
||||
"SELECT count(*) FROM quota_entry h WHERE h.kind = 'hold' AND NOT EXISTS"
|
||||
" (SELECT 1 FROM quota_entry r WHERE r.hold_entry_id = h.id AND r.kind IN ('settle', 'release'))"
|
||||
)
|
||||
)
|
||||
).scalar_one()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- 到期与续费生效(定时任务,决策 M6-5)
|
||||
|
||||
REMIND_DAYS = (30, 7, 0)
|
||||
|
||||
|
||||
async def due(session: AsyncSession, now: datetime) -> list[tuple[UUID, UUID, str]]:
|
||||
"""跨企业找到期、该生效、该提醒的订阅:只拿编号(billing_due 是 bd_auth 所有的函数,data-model §2.3)。"""
|
||||
rows = await session.execute(text("SELECT tenant_id, subscription_id, action FROM billing_due(:now)"), {"now": now})
|
||||
return [(r[0], r[1], r[2]) for r in rows]
|
||||
|
||||
|
||||
async def apply_due(session: AsyncSession, ctx: TenantContext, sub_id: UUID, action: str, now: datetime) -> None:
|
||||
sub = (
|
||||
await session.execute(select(Subscription).where(Subscription.id == sub_id).with_for_update())
|
||||
).scalar_one_or_none()
|
||||
if sub is None:
|
||||
return
|
||||
if action == "expire" and sub.status == "active":
|
||||
if await _open_holds(session, ctx):
|
||||
return # 还有没了结的预扣:等它结算或退回后(下一次定时任务)再清零,否则退回的那 1 次会漏在余额里
|
||||
await _expire_balances(session, ctx, sub, reason="套餐到期")
|
||||
sub.status = "expired"
|
||||
await session.flush()
|
||||
nxt = (
|
||||
await session.execute(
|
||||
select(Subscription).where(
|
||||
Subscription.tenant_id == ctx.tenant_id,
|
||||
Subscription.status == "scheduled",
|
||||
Subscription.starts_at <= now,
|
||||
)
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
if nxt is None:
|
||||
await notify(
|
||||
session, ctx, "subscription_expired", {"ends_at": sub.ends_at.isoformat() if sub.ends_at else None}
|
||||
)
|
||||
else:
|
||||
await _start(session, ctx, nxt)
|
||||
elif action == "start" and sub.status == "scheduled":
|
||||
cur = (
|
||||
await session.execute(
|
||||
select(Subscription).where(Subscription.tenant_id == ctx.tenant_id, Subscription.status == "active")
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
if cur is None: # 旧一期已经结束(或者本来就没有):排队的这一期生效
|
||||
await _start(session, ctx, sub)
|
||||
elif action == "remind" and sub.status == "active" and sub.ends_at:
|
||||
days = (sub.ends_at - now).days
|
||||
marks = [d for d in REMIND_DAYS if days <= d]
|
||||
if not marks or await _renewal(session, ctx):
|
||||
return # 已经续费了就不提醒
|
||||
mark = min(marks) # 提醒档位:30、7、当天各一次
|
||||
sent = (
|
||||
await session.execute(
|
||||
select(func.count())
|
||||
.select_from(Notification)
|
||||
.where(
|
||||
Notification.kind == "subscription_expiring",
|
||||
Notification.payload["subscription_id"].astext == str(sub.id),
|
||||
Notification.payload["mark"].astext == str(mark),
|
||||
)
|
||||
)
|
||||
).scalar_one()
|
||||
if not sent:
|
||||
await notify(
|
||||
session,
|
||||
ctx,
|
||||
"subscription_expiring",
|
||||
{"subscription_id": str(sub.id), "mark": mark, "days": days, "ends_at": sub.ends_at.isoformat()},
|
||||
)
|
||||
|
||||
|
||||
async def _start(session: AsyncSession, ctx: TenantContext, sub: Subscription) -> None:
|
||||
plan = (
|
||||
await session.execute(select(Plan).where(Plan.code == sub.plan_code, Plan.version == sub.plan_version))
|
||||
).scalar_one()
|
||||
sub.status = "active"
|
||||
await session.flush()
|
||||
await _grant(session, ctx, sub, plan, reason="续费的新一期发放")
|
||||
|
||||
|
||||
async def void_for_closure(session: AsyncSession, ctx: TenantContext) -> None:
|
||||
"""注销账号:剩余额度作废(流水记「过期」),订阅都结束;开通申请和订阅记录保留(J4)。"""
|
||||
subs = (
|
||||
(
|
||||
await session.execute(
|
||||
select(Subscription).where(Subscription.status.in_(("active", "scheduled"))).with_for_update()
|
||||
)
|
||||
)
|
||||
.scalars()
|
||||
.all()
|
||||
)
|
||||
for sub in subs:
|
||||
if sub.status == "active":
|
||||
await _expire_balances(session, ctx, sub, reason="注销账号,剩余额度作废")
|
||||
sub.status = "cancelled"
|
||||
await session.flush()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- 运营操作(FR-M6-09;ops 进程在租户事务里调用)
|
||||
|
||||
|
||||
async def mark_paid(session: AsyncSession, ctx: TenantContext, request_id: UUID, *, ops_id: UUID) -> None:
|
||||
r = (
|
||||
await session.execute(select(ActivationRequest).where(ActivationRequest.id == request_id).with_for_update())
|
||||
).scalar_one()
|
||||
if r.status != "submitted":
|
||||
raise AppError("invalid_transition", "只有等待转账的申请能标记到账")
|
||||
r.status, r.handled_by_ops, r.handled_at = "paid", ops_id, datetime.now(UTC)
|
||||
|
||||
|
||||
async def set_invoice(session: AsyncSession, ctx: TenantContext, request_id: UUID, invoice_no: str) -> None:
|
||||
r = (
|
||||
await session.execute(select(ActivationRequest).where(ActivationRequest.id == request_id).with_for_update())
|
||||
).scalar_one()
|
||||
if r.status != "activated":
|
||||
raise AppError("invalid_transition", "开通以后才能登记发票")
|
||||
r.invoice_no, r.invoiced_at = invoice_no, datetime.now(UTC)
|
||||
|
||||
|
||||
async def adjust(
|
||||
session: AsyncSession, ctx: TenantContext, *, unit: str, amount: int, reason: str, ops_id: UUID
|
||||
) -> QuotaEntry:
|
||||
"""人工调整额度(G6:MVP 不做线上加量包,运营按申请调)。原因必填;扣减不能扣成负数。"""
|
||||
if not reason.strip():
|
||||
raise AppError("validation_failed", "调整额度要写原因", field="reason")
|
||||
if amount == 0:
|
||||
raise AppError("validation_failed", "调整的数量不能是 0", field="amount")
|
||||
sub, _ = await current_plan(session, ctx)
|
||||
acct = (
|
||||
await session.execute(
|
||||
select(QuotaAccount)
|
||||
.where(QuotaAccount.tenant_id == ctx.tenant_id, QuotaAccount.unit == unit)
|
||||
.with_for_update()
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
if acct is None:
|
||||
acct = QuotaAccount(id=uuid7(), tenant_id=ctx.tenant_id, unit=unit, balance=0)
|
||||
session.add(acct)
|
||||
await session.flush()
|
||||
if acct.balance + amount < 0:
|
||||
raise AppError("validation_failed", f"余额只有 {acct.balance},不能扣成负数", field="amount")
|
||||
return await _append(
|
||||
session,
|
||||
ctx,
|
||||
acct,
|
||||
kind="adjust",
|
||||
amount=amount,
|
||||
key=f"adjust:{uuid7()}",
|
||||
subscription_id=sub.id,
|
||||
reason=reason.strip(),
|
||||
actor_ops_id=ops_id,
|
||||
)
|
||||
|
||||
25
server/src/bid_doctor/billing/tasks.py
Normal file
25
server/src/bid_doctor/billing/tasks.py
Normal file
@@ -0,0 +1,25 @@
|
||||
"""额度和套餐的定时任务(决策 M6-5):worker 每小时跑一次 tick。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from uuid import UUID
|
||||
|
||||
from bid_doctor.billing import service
|
||||
from bid_doctor.core.db import TenantContext, tenant_tx
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
SYSTEM = UUID(int=0)
|
||||
|
||||
|
||||
async def tick(now: datetime) -> None:
|
||||
async with tenant_tx(TenantContext(tenant_id=SYSTEM, user_id=SYSTEM)) as s:
|
||||
due = await service.due(s, now)
|
||||
for tenant_id, sub_id, action in due:
|
||||
ctx = TenantContext(tenant_id=tenant_id, user_id=SYSTEM, actor_name="系统")
|
||||
try:
|
||||
async with tenant_tx(ctx) as s:
|
||||
await service.apply_due(s, ctx, sub_id, action, now)
|
||||
except Exception: # 一个企业出错不影响别的企业,下一次再试
|
||||
log.exception("billing tick %s %s %s failed", tenant_id, sub_id, action)
|
||||
@@ -223,6 +223,7 @@ async def request_cancel(s: AsyncSession, run_id: UUID) -> CheckRun:
|
||||
raise AppError("job_finished")
|
||||
run.cancel_requested_at = datetime.now(UTC)
|
||||
await s.flush()
|
||||
await s.refresh(run) # 同解析的取消:触发器改过的列过期了,转 API 时不能再懒加载
|
||||
return run
|
||||
|
||||
|
||||
|
||||
@@ -26,6 +26,15 @@ def main() -> None:
|
||||
api.add_argument("--host", default="127.0.0.1")
|
||||
api.add_argument("--port", type=int, default=8009)
|
||||
api.add_argument("--reload", action="store_true")
|
||||
ops = sub.add_parser("ops", help="启动运营后台接口(单独端口,只对内网开放)")
|
||||
ops.add_argument("--host", default="127.0.0.1")
|
||||
ops.add_argument("--port", type=int, default=8019)
|
||||
admin = sub.add_parser("ops-admin", help="管理运营账号")
|
||||
admin_sub = admin.add_subparsers(dest="admin_cmd", required=True)
|
||||
create = admin_sub.add_parser("create", help="新建运营账号(会提示输入密码,打印 TOTP 二维码地址)")
|
||||
create.add_argument("--username", required=True)
|
||||
create.add_argument("--display-name", required=True)
|
||||
create.add_argument("--role", default="admin", help="角色(现在只有 admin)")
|
||||
sub.add_parser("worker", help="启动后台任务进程")
|
||||
sub.add_parser("migrate", help="把数据库升级到最新版本")
|
||||
args = parser.parse_args()
|
||||
@@ -34,6 +43,21 @@ def main() -> None:
|
||||
import uvicorn
|
||||
|
||||
uvicorn.run("bid_doctor.app:create_app", factory=True, host=args.host, port=args.port, reload=args.reload)
|
||||
elif args.cmd == "ops":
|
||||
import uvicorn
|
||||
|
||||
uvicorn.run("bid_doctor.ops.app:create_ops_app", factory=True, host=args.host, port=args.port)
|
||||
elif args.cmd == "ops-admin":
|
||||
import getpass
|
||||
|
||||
from bid_doctor.ops.auth import create_admin
|
||||
|
||||
password = getpass.getpass("密码(至少 12 位):")
|
||||
if password != getpass.getpass("再输一次:"):
|
||||
raise SystemExit("两次输入的密码不一样")
|
||||
uri = asyncio.run(create_admin(args.username, args.display_name, password, args.role))
|
||||
print("用身份验证器 App 添加这个账号(只显示这一次):")
|
||||
print(uri)
|
||||
elif args.cmd == "worker":
|
||||
from bid_doctor.worker import run_worker
|
||||
|
||||
|
||||
@@ -20,6 +20,8 @@ class Settings(BaseSettings):
|
||||
# api / worker 用 bd_app(受行级安全策略约束);迁移用 bd_owner
|
||||
database_url: str = "postgresql+psycopg://bd_app:dev-app@127.0.0.1:54329/bid_doctor"
|
||||
migrate_database_url: str = "postgresql+psycopg://bd_owner:dev-owner@127.0.0.1:54329/bid_doctor"
|
||||
# 运营后台的跨企业查询用 bd_ops(绕过行级安全策略,只读 + 运营账号表);对某个企业的操作仍走 bd_app(决策 M6-2)
|
||||
ops_database_url: str = "postgresql+psycopg://bd_ops:dev-ops@127.0.0.1:54329/bid_doctor"
|
||||
valkey_url: str = "redis://127.0.0.1:63799/0"
|
||||
gotenberg_url: str = "http://127.0.0.1:3109"
|
||||
|
||||
@@ -49,6 +51,16 @@ class Settings(BaseSettings):
|
||||
# 每次调用的输出上限(token)。输出用到 92% 以上就按截断处理、拆小重跑(ai/providers/base.near_limit)
|
||||
ai_max_output_tokens: int = 8192
|
||||
|
||||
# 专业版对公转账的收款信息(决策 M6-6):不进代码;没配时开通申请里显示「(待定)」,生产环境必须配
|
||||
payee_name: str = ""
|
||||
payee_bank: str = ""
|
||||
payee_account: str = ""
|
||||
|
||||
# 运营后台(决策 M6-4):只放行这些来源地址(CIDR);会话时长;前端单独部署时的来源
|
||||
ops_allowed_ips: list[str] = Field(default_factory=lambda: ["127.0.0.1/32", "::1/128"])
|
||||
ops_session_ttl_hours: int = 8
|
||||
ops_cors_origins: list[str] = Field(default_factory=lambda: ["http://127.0.0.1:5174", "http://localhost:5174"])
|
||||
|
||||
upload_part_size: int = 8 * 1024 * 1024
|
||||
download_url_ttl_s: int = 300
|
||||
|
||||
@@ -69,6 +81,10 @@ class Settings(BaseSettings):
|
||||
raise ValueError("生产环境不能使用假模型")
|
||||
if not self.cookie_secure:
|
||||
raise ValueError("生产环境必须开启 cookie_secure")
|
||||
if not self.ops_allowed_ips or any(c in ("0.0.0.0/0", "::/0") for c in self.ops_allowed_ips):
|
||||
raise ValueError("生产环境要给运营后台配置 IP 白名单(BD_OPS_ALLOWED_IPS),不能放开全部地址")
|
||||
if not (self.payee_name and self.payee_bank and self.payee_account):
|
||||
raise ValueError("生产环境要配置对公转账的收款信息(BD_PAYEE_NAME / BANK / ACCOUNT)")
|
||||
return self
|
||||
|
||||
|
||||
|
||||
@@ -92,3 +92,43 @@ def verify_download(payload: str, exp: int, sig: str) -> bool:
|
||||
return False
|
||||
mac = hmac.new(_key(get_settings().secret_key, "download"), f"{payload}|{exp}".encode(), hashlib.sha256).digest()
|
||||
return hmac.compare_digest(base64.urlsafe_b64encode(mac).decode().rstrip("="), sig)
|
||||
|
||||
|
||||
# ---- 运营账号(决策 M6-4):密码 scrypt;TOTP 密钥用主密钥加密存库
|
||||
|
||||
|
||||
def hash_password(password: str) -> str:
|
||||
salt = os.urandom(16)
|
||||
digest = hashlib.scrypt(password.encode(), salt=salt, n=2**14, r=8, p=1)
|
||||
return "scrypt$16384$8$1$" + base64.b64encode(salt).decode() + "$" + base64.b64encode(digest).decode()
|
||||
|
||||
|
||||
def verify_password(password: str, stored: str) -> bool:
|
||||
try:
|
||||
_, n, r, p, salt, digest = stored.split("$")
|
||||
got = hashlib.scrypt(password.encode(), salt=base64.b64decode(salt), n=int(n), r=int(r), p=int(p))
|
||||
except (ValueError, TypeError):
|
||||
return False
|
||||
return hmac.compare_digest(got, base64.b64decode(digest))
|
||||
|
||||
|
||||
def seal_secret(value: bytes) -> bytes:
|
||||
return _seal(_key(get_settings().master_key, "ops-totp"), value)
|
||||
|
||||
|
||||
def open_secret(sealed: bytes) -> bytes:
|
||||
return _open(_key(get_settings().master_key, "ops-totp"), sealed)
|
||||
|
||||
|
||||
def totp(secret: bytes, at: float | None = None, step: int = 30) -> str:
|
||||
"""RFC 6238:6 位,30 秒一换(和常见的身份验证器 App 一致)。"""
|
||||
counter = int((time.time() if at is None else at) // step)
|
||||
mac = hmac.new(secret, counter.to_bytes(8, "big"), hashlib.sha1).digest()
|
||||
offset = mac[-1] & 0x0F
|
||||
code = (int.from_bytes(mac[offset : offset + 4], "big") & 0x7FFFFFFF) % 1_000_000
|
||||
return f"{code:06d}"
|
||||
|
||||
|
||||
def verify_totp(secret: bytes, code: str, at: float | None = None) -> bool:
|
||||
now = time.time() if at is None else at
|
||||
return any(hmac.compare_digest(totp(secret, now + d * 30), code) for d in (-1, 0, 1)) # 前后各容 30 秒
|
||||
|
||||
@@ -66,3 +66,21 @@ async def anonymous_tx() -> AsyncGenerator[AsyncSession]:
|
||||
async def set_tenant(session: AsyncSession, tenant_id: UUID) -> None:
|
||||
"""注册时先建租户再写它的数据:在同一个事务里切换到新租户。"""
|
||||
await session.execute(text("SELECT set_config('app.tenant_id', :t, true)"), {"t": str(tenant_id)})
|
||||
|
||||
|
||||
@lru_cache
|
||||
def _ops_sessions() -> async_sessionmaker[AsyncSession]:
|
||||
eng = create_async_engine(
|
||||
get_settings().ops_database_url,
|
||||
pool_size=4,
|
||||
pool_pre_ping=True,
|
||||
connect_args={"options": "-c search_path=app,public"},
|
||||
)
|
||||
return async_sessionmaker(eng, expire_on_commit=False)
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def ops_tx() -> AsyncGenerator[AsyncSession]:
|
||||
"""运营后台的跨企业查询(bd_ops,绕过行级安全策略)。只给 ops 进程用;对某个企业做的修改仍用 tenant_tx。"""
|
||||
async with _ops_sessions()() as session, session.begin():
|
||||
yield session
|
||||
|
||||
@@ -16,6 +16,8 @@ CODES: dict[str, tuple[int, str]] = {
|
||||
"session_revoked": (401, "登录已失效,请重新登录"),
|
||||
"csrf_failed": (403, "请求校验失败,请刷新页面后重试"),
|
||||
"forbidden": (403, "没有权限进行这个操作"),
|
||||
"ops_ip_denied": (403, "这个地址不能访问运营后台"),
|
||||
"ops_login_failed": (401, "用户名、密码或动态验证码不对"),
|
||||
"not_found": (404, "没有找到"),
|
||||
"phone_not_registered": (404, "这个手机号还没有注册"),
|
||||
"phone_already_registered": (409, "这个手机号已经注册"),
|
||||
@@ -26,6 +28,9 @@ CODES: dict[str, tuple[int, str]] = {
|
||||
"job_already_running": (409, "任务正在进行"),
|
||||
"job_finished": (409, "任务已经结束"),
|
||||
"project_read_only": (409, "项目已递交或已归档,要先撤销才能修改"),
|
||||
"plan_expired": (409, "套餐已到期,数据只读;续费到账后恢复"),
|
||||
"activation_pending": (409, "已经有一份待处理的开通申请"),
|
||||
"tenant_name_mismatch": (422, "企业名称不一致,请照上面的名称输入"),
|
||||
"not_superseded": (409, "这一条没有待确认的补遗更新"),
|
||||
"no_pending_value": (409, "这个字段没有待确认的补遗新值"),
|
||||
"document_role_not_parsable": (409, "这类文件不需要单独解析"),
|
||||
|
||||
@@ -1,16 +1,31 @@
|
||||
"""UUIDv7:按时间有序、不可猜测(架构 §9)。Python 3.14 起标准库自带 uuid.uuid7,这里兼容 3.12。"""
|
||||
"""UUIDv7:按时间有序、不可猜测(架构 §9)。Python 3.14 起标准库自带 uuid.uuid7,这里兼容 3.12。
|
||||
|
||||
同一进程内单调递增(RFC 9562 §6.2 方法 2:同一毫秒内接着上一个加一):额度流水按编号排序算余额,
|
||||
同一个事务里先后写的两条(开通时先清掉免费版余额、再发放专业版额度)顺序不能乱。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import threading
|
||||
import time
|
||||
import uuid
|
||||
|
||||
_lock = threading.Lock()
|
||||
_last = 0
|
||||
|
||||
|
||||
def _stamp(value: int) -> int:
|
||||
value = (value & ~(0xF << 76)) | (0x7 << 76) # 版本 7
|
||||
return (value & ~(0x3 << 62)) | (0x2 << 62) # RFC 4122 变体
|
||||
|
||||
|
||||
def uuid7() -> uuid.UUID:
|
||||
global _last
|
||||
ms = time.time_ns() // 1_000_000
|
||||
rand = int.from_bytes(os.urandom(10), "big")
|
||||
value = (ms & 0xFFFF_FFFF_FFFF) << 80 | rand
|
||||
value = (value & ~(0xF << 76)) | (0x7 << 76) # 版本 7
|
||||
value = (value & ~(0x3 << 62)) | (0x2 << 62) # RFC 4122 变体
|
||||
value = _stamp((ms & 0xFFFF_FFFF_FFFF) << 80 | int.from_bytes(os.urandom(10), "big"))
|
||||
with _lock:
|
||||
if value <= _last: # 同一毫秒(或时钟回拨):排在上一个后面,只动最低的 62 位随机部分
|
||||
value = _last + 1
|
||||
_last = value
|
||||
return uuid.UUID(int=value)
|
||||
|
||||
@@ -9,6 +9,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from bid_doctor.core.crypto import KEK_REF, new_data_key, unwrap_data_key
|
||||
from bid_doctor.core.db import TenantContext
|
||||
from bid_doctor.core.errors import AppError
|
||||
from bid_doctor.core.ids import uuid7
|
||||
|
||||
|
||||
@@ -26,6 +27,17 @@ async def data_key(session: AsyncSession, ctx: TenantContext, project_id: UUID |
|
||||
).first()
|
||||
if row:
|
||||
return unwrap_data_key(row[0])
|
||||
destroyed = (
|
||||
await session.execute(
|
||||
text(
|
||||
"SELECT 1 FROM data_key WHERE tenant_id = :t AND scope = :s "
|
||||
"AND project_id IS NOT DISTINCT FROM :p AND destroyed_at IS NOT NULL LIMIT 1"
|
||||
),
|
||||
{"t": ctx.tenant_id, "s": scope, "p": project_id},
|
||||
)
|
||||
).first()
|
||||
if destroyed: # 项目删了、账号注销了(G10):不再建新密钥,免得还在跑的任务把文件写回去
|
||||
raise AppError("not_found")
|
||||
plain, wrapped = new_data_key()
|
||||
await session.execute(
|
||||
text(
|
||||
@@ -45,3 +57,13 @@ async def destroy_project_key(session: AsyncSession, ctx: TenantContext, project
|
||||
),
|
||||
{"t": ctx.tenant_id, "p": project_id},
|
||||
)
|
||||
|
||||
|
||||
async def destroy_tenant_keys(session: AsyncSession, ctx: TenantContext) -> None:
|
||||
"""注销账号:企业级和所有项目的密钥一起销毁(G10),备份里的文件随之不可读。"""
|
||||
await session.execute(
|
||||
text(
|
||||
"UPDATE data_key SET wrapped_key = NULL, destroyed_at = now() WHERE tenant_id = :t AND destroyed_at IS NULL"
|
||||
),
|
||||
{"t": ctx.tenant_id},
|
||||
)
|
||||
|
||||
@@ -69,3 +69,7 @@ def upload_prefix(tenant_id: object, upload_id: object) -> str:
|
||||
|
||||
def qualification_prefix(tenant_id: object, qualification_id: object) -> str:
|
||||
return f"t/{tenant_id}/q/{qualification_id}"
|
||||
|
||||
|
||||
def tenant_prefix(tenant_id: object) -> str:
|
||||
return f"t/{tenant_id}"
|
||||
|
||||
@@ -188,6 +188,7 @@ async def request_cancel(s: AsyncSession, job_id: UUID) -> ParseJob:
|
||||
raise AppError("job_finished")
|
||||
job.cancel_requested_at = datetime.now(UTC)
|
||||
await s.flush()
|
||||
await s.refresh(job) # 触发器改过的列(updated_at)过期了,转 API 时不能再懒加载
|
||||
return job
|
||||
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ from typing import Any, Literal
|
||||
from fastapi import APIRouter, Request, Response
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from bid_doctor.identity import service, sms
|
||||
from bid_doctor.identity import closure, service, sms
|
||||
from bid_doctor.identity.deps import Auth, clear_session_cookies, set_session_cookies
|
||||
|
||||
router = APIRouter(tags=["auth", "me"])
|
||||
@@ -79,3 +79,24 @@ async def logout(p: Auth, response: Response) -> None:
|
||||
@router.get("/me")
|
||||
async def get_me(p: Auth) -> dict[str, Any]:
|
||||
return await service.me(p)
|
||||
|
||||
|
||||
class MeSmsCodeIn(BaseModel):
|
||||
purpose: Literal["close_tenant"]
|
||||
|
||||
|
||||
@router.post("/me/sms-codes", status_code=202)
|
||||
async def send_my_sms_code(body: MeSmsCodeIn, p: Auth) -> dict[str, int]:
|
||||
"""登录后的二次确认(注销账号):验证码发到自己的手机号,前端不用知道完整号码。"""
|
||||
return {"resend_after_s": await sms.send(await closure.own_phone(p.ctx.user_id), body.purpose)}
|
||||
|
||||
|
||||
class CloseTenantIn(BaseModel):
|
||||
sms_code: str = Field(pattern=CODE)
|
||||
confirm_tenant_name: str = Field(min_length=1, max_length=100)
|
||||
|
||||
|
||||
@router.post("/tenant/close", status_code=202)
|
||||
async def close_tenant(body: CloseTenantIn, p: Auth, response: Response) -> None:
|
||||
await closure.close(p, body.sms_code, body.confirm_tenant_name)
|
||||
clear_session_cookies(response)
|
||||
|
||||
111
server/src/bid_doctor/identity/closure.py
Normal file
111
server/src/bid_doctor/identity/closure.py
Normal file
@@ -0,0 +1,111 @@
|
||||
"""注销账号(J4、data-model §6.2,决策 M6-8)。MVP 一个企业只有一个成员,注销账号就是注销企业。
|
||||
|
||||
请求里(同步):核对短信验证码和企业名称 → 剩余额度作废、订阅结束 → 写审计 → 企业标「已注销」、吊销所有会话、
|
||||
成员身份改「已离开」→ 删登录方式、匿名化人(不再属于任何企业时)。这一步完成后手机号就可以重新注册。
|
||||
后台(close_tenant 任务):逐个删项目(同删除项目)、删资质库和通知、抹掉审计内容、销毁全部密钥、清对象存储。
|
||||
开通申请、订阅、额度流水按财务要求保留(不含业务内容)。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime
|
||||
from uuid import UUID
|
||||
|
||||
from sqlalchemy import delete, select, text, update
|
||||
|
||||
from bid_doctor.audit import service as audit
|
||||
from bid_doctor.billing import service as billing
|
||||
from bid_doctor.core import keys, queue
|
||||
from bid_doctor.core.authz import Permission, require
|
||||
from bid_doctor.core.crypto import decrypt_contact
|
||||
from bid_doctor.core.db import TenantContext, anonymous_tx, tenant_tx
|
||||
from bid_doctor.core.errors import AppError
|
||||
from bid_doctor.core.storage import storage, tenant_prefix
|
||||
from bid_doctor.identity import sms
|
||||
from bid_doctor.identity.models import LoginIdentity, Tenant, UserSession
|
||||
from bid_doctor.identity.service import Principal
|
||||
from bid_doctor.project import service as projects
|
||||
from bid_doctor.project.models import Project
|
||||
|
||||
|
||||
async def own_phone(user_id: UUID) -> str:
|
||||
async with anonymous_tx() as s:
|
||||
sealed = (
|
||||
await s.execute(
|
||||
select(LoginIdentity.contact_cipher).where(
|
||||
LoginIdentity.user_id == user_id, LoginIdentity.provider == "phone"
|
||||
)
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
if not sealed:
|
||||
raise AppError("not_found")
|
||||
return decrypt_contact(sealed)
|
||||
|
||||
|
||||
async def close(p: Principal, sms_code: str, confirm_tenant_name: str) -> None:
|
||||
require(p.ctx, Permission.TENANT_CLOSE)
|
||||
ctx = p.ctx
|
||||
async with tenant_tx(ctx) as s:
|
||||
tenant = (await s.execute(select(Tenant).where(Tenant.id == ctx.tenant_id))).scalar_one()
|
||||
if confirm_tenant_name.strip() != tenant.name:
|
||||
raise AppError("tenant_name_mismatch")
|
||||
await sms.verify(await own_phone(ctx.user_id), "close_tenant", sms_code)
|
||||
now = datetime.now(UTC)
|
||||
async with tenant_tx(ctx) as s:
|
||||
tenant = (await s.execute(select(Tenant).where(Tenant.id == ctx.tenant_id).with_for_update())).scalar_one()
|
||||
await billing.void_for_closure(s, ctx)
|
||||
await audit.record(s, ctx, "tenant.close", "tenant", tenant.id)
|
||||
tenant.status = "closed"
|
||||
await s.execute(text("UPDATE tenant SET closed_at = now() WHERE id = :t"), {"t": tenant.id})
|
||||
await s.execute(
|
||||
update(UserSession)
|
||||
.where(UserSession.revoked_at.is_(None))
|
||||
.values(revoked_at=now, revoke_reason="tenant_closed")
|
||||
)
|
||||
await s.execute(text("UPDATE membership SET status = 'left', left_at = now() WHERE status <> 'left'"))
|
||||
await queue.enqueue(s, "close_tenant", {"tenant_id": str(tenant.id)}, lock=f"close:{tenant.id}")
|
||||
# 人和登录方式是全局表:这个人不再属于任何企业时删登录方式(手机号可以重新注册)、匿名化
|
||||
async with anonymous_tx() as s:
|
||||
others = (
|
||||
await s.execute(
|
||||
text("SELECT count(*) FROM auth_list_memberships(:u) WHERE status = 'active'"), {"u": ctx.user_id}
|
||||
)
|
||||
).scalar_one()
|
||||
if not others:
|
||||
await s.execute(delete(LoginIdentity).where(LoginIdentity.user_id == ctx.user_id))
|
||||
await s.execute(
|
||||
text(
|
||||
"UPDATE user_account SET display_name = '已注销用户', status = 'anonymized', anonymized_at = now()"
|
||||
" WHERE id = :u"
|
||||
),
|
||||
{"u": ctx.user_id},
|
||||
)
|
||||
|
||||
|
||||
async def finish(tenant_id: UUID) -> None:
|
||||
"""后台:删光业务数据。可以重复执行(任务重试、中途失败后再跑)。"""
|
||||
ctx = TenantContext(tenant_id=tenant_id, user_id=UUID(int=0), actor_name="系统")
|
||||
async with tenant_tx(ctx) as s:
|
||||
status = (await s.execute(select(Tenant.status).where(Tenant.id == tenant_id))).scalar_one()
|
||||
if status != "closed":
|
||||
return
|
||||
ids = list((await s.execute(select(Project.id))).scalars())
|
||||
for pid in ids: # 一个项目一个事务:大企业也不会一个事务跑太久
|
||||
async with tenant_tx(ctx) as s:
|
||||
p = (await s.execute(select(Project).where(Project.id == pid))).scalar_one_or_none()
|
||||
if p is not None:
|
||||
await projects.remove(s, ctx, p, actor_kind="system")
|
||||
async with tenant_tx(ctx) as s:
|
||||
await s.execute(text("DELETE FROM qualification"))
|
||||
await s.execute(text("DELETE FROM notification"))
|
||||
await s.execute(text("DELETE FROM feedback"))
|
||||
# 审计只留动作、时间、对象编号:内容和姓名快照都抹掉(触发器只允许这一种修改)
|
||||
await s.execute(
|
||||
text(
|
||||
"UPDATE audit_event SET before = NULL, after = NULL, actor_name = NULL,"
|
||||
" content_scrubbed_at = coalesce(content_scrubbed_at, now())"
|
||||
" WHERE before IS NOT NULL OR after IS NOT NULL OR actor_name IS NOT NULL"
|
||||
)
|
||||
)
|
||||
await keys.destroy_tenant_keys(s, ctx)
|
||||
storage().delete_prefix(tenant_prefix(tenant_id))
|
||||
@@ -7,15 +7,27 @@ from typing import Annotated
|
||||
|
||||
from fastapi import Depends, Request, Response
|
||||
|
||||
from bid_doctor.billing import service as billing
|
||||
from bid_doctor.core.config import get_settings
|
||||
from bid_doctor.core.crypto import new_token
|
||||
from bid_doctor.core.db import TenantContext
|
||||
from bid_doctor.core.db import TenantContext, tenant_tx
|
||||
from bid_doctor.core.errors import AppError
|
||||
from bid_doctor.identity.service import Principal, resolve, why_invalid
|
||||
|
||||
SESSION_COOKIE = "bd_session"
|
||||
CSRF_COOKIE = "bd_csrf"
|
||||
SAFE_METHODS = {"GET", "HEAD", "OPTIONS"}
|
||||
# 套餐到期后只读(决策 M6-5):这些写操作照常放行——退出、续费、删除项目、注销、通知已读、反馈、管理登录设备
|
||||
READ_ONLY_ALLOWED = {
|
||||
("POST", "/auth/logout"),
|
||||
("POST", "/activation-requests"),
|
||||
("DELETE", "/projects/{project_id}"),
|
||||
("POST", "/me/sms-codes"),
|
||||
("POST", "/tenant/close"),
|
||||
("POST", "/notifications/read"),
|
||||
("POST", "/feedback"),
|
||||
("DELETE", "/me/sessions/{session_id}"),
|
||||
}
|
||||
|
||||
|
||||
async def current_principal(request: Request) -> Principal:
|
||||
@@ -34,9 +46,19 @@ async def current_principal(request: Request) -> Principal:
|
||||
if p is None:
|
||||
code, reason = await why_invalid(token)
|
||||
raise AppError(code, **({"reason": reason} if reason else {}))
|
||||
if request.method not in SAFE_METHODS and (request.method, _route(request)) not in READ_ONLY_ALLOWED:
|
||||
async with tenant_tx(p.ctx) as s:
|
||||
if await billing.read_only(s, p.ctx):
|
||||
raise AppError("plan_expired")
|
||||
return p
|
||||
|
||||
|
||||
def _route(request: Request) -> str:
|
||||
route = request.scope.get("route")
|
||||
path = str(getattr(route, "path", request.url.path))
|
||||
return path.removeprefix("/api/v1") # app.API_PREFIX(这里不 import app,避免循环)
|
||||
|
||||
|
||||
async def current_ctx(p: Annotated[Principal, Depends(current_principal)]) -> TenantContext:
|
||||
return p.ctx
|
||||
|
||||
|
||||
@@ -1,15 +1,18 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Annotated, Any
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import APIRouter, Header
|
||||
from fastapi import APIRouter, Header, Query
|
||||
from fastapi.responses import StreamingResponse
|
||||
from sqlalchemy import func, select
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlalchemy import func, select, update
|
||||
|
||||
from bid_doctor.core import events
|
||||
from bid_doctor.core.db import tenant_tx
|
||||
from bid_doctor.identity.deps import Ctx
|
||||
from bid_doctor.notification.models import Notification
|
||||
from bid_doctor.project.models import Project
|
||||
|
||||
router = APIRouter(tags=["notifications"])
|
||||
|
||||
@@ -24,13 +27,56 @@ async def stream_events(ctx: Ctx, last_event_id: Annotated[str | None, Header()]
|
||||
)
|
||||
|
||||
|
||||
QUOTA_UNIT = {"parse": "解析", "check": "检查", "generate": "生成", "dedupe": "查重"}
|
||||
|
||||
|
||||
def _title(kind: str, payload: dict[str, Any]) -> str:
|
||||
unit = QUOTA_UNIT.get(str(payload.get("unit")), "")
|
||||
days = payload.get("days")
|
||||
fixed = {
|
||||
"parse_succeeded": "招标文件解析完成",
|
||||
"parse_failed": "招标文件解析失败,额度已退回",
|
||||
"check_succeeded": "投标文件检查完成",
|
||||
"check_failed": "投标文件检查没有完成,不扣额度",
|
||||
"report_succeeded": "检查报告已生成",
|
||||
"report_failed": "检查报告生成失败",
|
||||
"quota_80": f"本期{unit}额度已用到 80%",
|
||||
"quota_100": f"本期{unit}额度已用完",
|
||||
"subscription_activated": "续费已到账,新一期到期后接上" if payload.get("renewal") else "专业版已开通",
|
||||
"subscription_expiring": "专业版今天到期" if days == 0 else f"专业版还有 {days} 天到期",
|
||||
"subscription_expired": "专业版已到期,数据只读;续费后恢复",
|
||||
"activation_rejected": f"开通申请 {payload.get('request_no') or ''} 被退回",
|
||||
}
|
||||
return fixed.get(kind, kind)
|
||||
|
||||
|
||||
def _link(kind: str, project_id: UUID | None, payload: dict[str, Any]) -> str | None:
|
||||
if kind.startswith(("quota_", "subscription_", "activation_")):
|
||||
return "/quota"
|
||||
if project_id is None:
|
||||
return None
|
||||
if kind == "check_succeeded" and payload.get("run_id"):
|
||||
return f"/projects/{project_id}/results/{payload['run_id']}"
|
||||
if kind.startswith("report_"):
|
||||
return f"/projects/{project_id}/report"
|
||||
return f"/projects/{project_id}/checklist"
|
||||
|
||||
|
||||
@router.get("/notifications")
|
||||
async def list_notifications(ctx: Ctx, unread: bool | None = None) -> dict[str, Any]:
|
||||
async def list_notifications(
|
||||
ctx: Ctx, unread: bool | None = None, cursor: UUID | None = None, limit: int = Query(default=20, ge=1, le=100)
|
||||
) -> dict[str, Any]:
|
||||
async with tenant_tx(ctx) as s:
|
||||
q = select(Notification).where(Notification.user_id == ctx.user_id)
|
||||
q = (
|
||||
select(Notification, Project.name)
|
||||
.outerjoin(Project, Project.id == Notification.project_id)
|
||||
.where(Notification.user_id == ctx.user_id)
|
||||
)
|
||||
if unread:
|
||||
q = q.where(Notification.read_at.is_(None))
|
||||
rows = (await s.execute(q.order_by(Notification.created_at.desc()).limit(50))).scalars().all()
|
||||
if cursor:
|
||||
q = q.where(Notification.id < cursor)
|
||||
rows = (await s.execute(q.order_by(Notification.id.desc()).limit(limit + 1))).all()
|
||||
n = (
|
||||
await s.execute(
|
||||
select(func.count())
|
||||
@@ -38,27 +84,35 @@ async def list_notifications(ctx: Ctx, unread: bool | None = None) -> dict[str,
|
||||
.where(Notification.user_id == ctx.user_id, Notification.read_at.is_(None))
|
||||
)
|
||||
).scalar_one()
|
||||
titles = {
|
||||
"parse_succeeded": "招标文件解析完成",
|
||||
"parse_failed": "招标文件解析失败,额度已退回",
|
||||
"check_succeeded": "投标文件检查完成",
|
||||
"check_failed": "投标文件检查没有完成,不扣额度",
|
||||
"report_succeeded": "检查报告已生成",
|
||||
"report_failed": "检查报告生成失败",
|
||||
}
|
||||
more = len(rows) > limit
|
||||
rows = rows[:limit]
|
||||
return {
|
||||
"unread_count": n,
|
||||
"next_cursor": None,
|
||||
"next_cursor": str(rows[-1][0].id) if more else None,
|
||||
"items": [
|
||||
{
|
||||
"id": r.id,
|
||||
"kind": r.kind,
|
||||
"title": titles.get(r.kind, r.kind),
|
||||
"title": _title(r.kind, r.payload or {}),
|
||||
"project_id": r.project_id,
|
||||
"link": f"/projects/{r.project_id}/checklist" if r.project_id else None,
|
||||
"project_name": name,
|
||||
"link": _link(r.kind, r.project_id, r.payload or {}),
|
||||
"read_at": r.read_at,
|
||||
"created_at": r.created_at,
|
||||
}
|
||||
for r in rows
|
||||
for r, name in rows
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
class ReadIn(BaseModel):
|
||||
ids: list[UUID] | None = Field(default=None, max_length=200)
|
||||
|
||||
|
||||
@router.post("/notifications/read", status_code=204)
|
||||
async def mark_read(ctx: Ctx, body: ReadIn | None = None) -> None:
|
||||
async with tenant_tx(ctx) as s:
|
||||
q = update(Notification).where(Notification.user_id == ctx.user_id, Notification.read_at.is_(None))
|
||||
if body and body.ids:
|
||||
q = q.where(Notification.id.in_(body.ids))
|
||||
await s.execute(q.values(read_at=func.now()))
|
||||
|
||||
52
server/src/bid_doctor/ops/app.py
Normal file
52
server/src/bid_doctor/ops/app.py
Normal file
@@ -0,0 +1,52 @@
|
||||
"""ops 进程:运营后台接口(决策 M6-2)。和 api、worker 同一个包、同一个镜像,单独端口;部署时这个端口只对内网开放,
|
||||
再加一层 IP 白名单(决策 M6-4)。用户端的 api 进程里没有这些接口。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from collections.abc import Awaitable, Callable
|
||||
|
||||
from fastapi import FastAPI, Request, Response
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
|
||||
from bid_doctor.core import errors
|
||||
from bid_doctor.core.config import get_settings
|
||||
from bid_doctor.core.ids import uuid7
|
||||
|
||||
OPS_PREFIX = "/ops/v1"
|
||||
|
||||
|
||||
def create_ops_app() -> FastAPI:
|
||||
from bid_doctor.ops.auth import ip_allowed
|
||||
from bid_doctor.ops.console_api import router
|
||||
|
||||
settings = get_settings()
|
||||
logging.basicConfig(level=logging.INFO, format="%(levelname)s %(name)s %(message)s")
|
||||
app = FastAPI(title="标博士运营后台接口", version="0.1.0", openapi_url=None, docs_url=None, redoc_url=None)
|
||||
errors.install(app)
|
||||
|
||||
@app.middleware("http")
|
||||
async def guard(request: Request, call_next: Callable[[Request], Awaitable[Response]]) -> Response: # pyright: ignore[reportUnusedFunction]
|
||||
rid = request.headers.get("x-request-id") or f"req_{uuid7().hex}"
|
||||
request.state.request_id = rid
|
||||
if not ip_allowed(request.client.host if request.client else None):
|
||||
return errors.problem(request, "ops_ip_denied")
|
||||
response = await call_next(request)
|
||||
response.headers["X-Request-ID"] = rid
|
||||
response.headers["Cache-Control"] = "no-store"
|
||||
return response
|
||||
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=settings.ops_cors_origins,
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
app.include_router(router, prefix=OPS_PREFIX)
|
||||
|
||||
@app.get("/healthz", include_in_schema=False)
|
||||
async def healthz() -> dict[str, str]: # pyright: ignore[reportUnusedFunction]
|
||||
return {"status": "ok"}
|
||||
|
||||
return app
|
||||
180
server/src/bid_doctor/ops/auth.py
Normal file
180
server/src/bid_doctor/ops/auth.py
Normal file
@@ -0,0 +1,180 @@
|
||||
"""运营后台的登录(决策 M6-4):用户名 + 密码 + TOTP,IP 白名单;账号用命令行建,没有自助注册。
|
||||
|
||||
会话令牌只发一次,库里存 SHA-256;cookie bd_ops_session(HttpOnly、SameSite=Strict)+ CSRF 头,和用户端同一套做法。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import hmac
|
||||
import ipaddress
|
||||
import os
|
||||
from dataclasses import dataclass
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from enum import StrEnum
|
||||
from typing import Annotated
|
||||
from urllib.parse import quote
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import Depends, Request, Response
|
||||
from sqlalchemy import select, update
|
||||
|
||||
from bid_doctor.core.config import get_settings
|
||||
from bid_doctor.core.crypto import (
|
||||
hash_password,
|
||||
new_token,
|
||||
open_secret,
|
||||
seal_secret,
|
||||
sha256,
|
||||
verify_password,
|
||||
verify_totp,
|
||||
)
|
||||
from bid_doctor.core.db import ops_tx
|
||||
from bid_doctor.core.errors import AppError
|
||||
from bid_doctor.core.ids import uuid7
|
||||
from bid_doctor.core.valkey import hit_limit
|
||||
from bid_doctor.ops.models import OpsAdmin, OpsSession
|
||||
|
||||
SESSION_COOKIE = "bd_ops_session"
|
||||
CSRF_COOKIE = "bd_ops_csrf"
|
||||
SAFE_METHODS = {"GET", "HEAD", "OPTIONS"}
|
||||
|
||||
|
||||
class OpsPermission(StrEnum):
|
||||
"""运营后台的权限点(只留结构):每个接口都过检查点,前端菜单按它过滤。"""
|
||||
|
||||
TENANT_VIEW = "tenant.view"
|
||||
QUOTA_ADJUST = "quota.adjust"
|
||||
ACTIVATION_MANAGE = "activation.manage"
|
||||
INVOICE_MANAGE = "invoice.manage"
|
||||
FEEDBACK_VIEW = "feedback.view"
|
||||
JOBS_VIEW = "jobs.view"
|
||||
AUDIT_VIEW = "audit.view"
|
||||
|
||||
|
||||
# 角色 → 权限点。现在只有管理员;以后加运营、销售、运维等角色时只改这里(表里的 role 不加 CHECK)
|
||||
ROLE_PERMISSIONS: dict[str, frozenset[OpsPermission]] = {"admin": frozenset(OpsPermission)}
|
||||
ROLE_LABEL = {"admin": "管理员"}
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class Operator:
|
||||
id: UUID
|
||||
username: str
|
||||
display_name: str
|
||||
session_id: UUID
|
||||
role: str = "admin"
|
||||
|
||||
@property
|
||||
def permissions(self) -> frozenset[OpsPermission]:
|
||||
return ROLE_PERMISSIONS.get(self.role, frozenset())
|
||||
|
||||
|
||||
def require(op: Operator, permission: OpsPermission) -> None:
|
||||
if permission not in op.permissions:
|
||||
raise AppError("forbidden")
|
||||
|
||||
|
||||
def ip_allowed(ip: str | None) -> bool:
|
||||
if not ip:
|
||||
return False
|
||||
addr = ipaddress.ip_address(ip)
|
||||
return any(addr in ipaddress.ip_network(c, strict=False) for c in get_settings().ops_allowed_ips)
|
||||
|
||||
|
||||
async def create_admin(username: str, display_name: str, password: str, role: str = "admin") -> str:
|
||||
"""命令行建运营账号。返回 otpauth:// 地址,用身份验证器 App 扫码(只显示这一次)。"""
|
||||
if len(password) < 12:
|
||||
raise ValueError("密码至少 12 位")
|
||||
if role not in ROLE_PERMISSIONS:
|
||||
raise ValueError(f"没有这个角色:{role}(现有:{', '.join(ROLE_PERMISSIONS)})")
|
||||
secret = os.urandom(20)
|
||||
async with ops_tx() as s:
|
||||
s.add(
|
||||
OpsAdmin(
|
||||
id=uuid7(),
|
||||
username=username,
|
||||
display_name=display_name,
|
||||
role=role,
|
||||
secret_hash=hash_password(password),
|
||||
totp_cipher=seal_secret(secret),
|
||||
)
|
||||
)
|
||||
b32 = base64.b32encode(secret).decode().rstrip("=")
|
||||
return f"otpauth://totp/{quote('标博士运营:' + username)}?secret={b32}&issuer={quote('标博士运营')}"
|
||||
|
||||
|
||||
async def login(username: str, password: str, code: str, ip: str) -> tuple[str, Operator]:
|
||||
wait = await hit_limit(f"ops:login:{username}", 5, 900) # 15 分钟内最多试 5 次
|
||||
if wait:
|
||||
raise AppError("rate_limited", retry_after_s=wait)
|
||||
async with ops_tx() as s:
|
||||
admin = (await s.execute(select(OpsAdmin).where(OpsAdmin.username == username))).scalar_one_or_none()
|
||||
ok = (
|
||||
admin is not None
|
||||
and admin.status == "active"
|
||||
and verify_password(password, admin.secret_hash)
|
||||
and verify_totp(open_secret(admin.totp_cipher), code)
|
||||
)
|
||||
if not ok or admin is None:
|
||||
raise AppError("ops_login_failed")
|
||||
token = new_token()
|
||||
sess = OpsSession(
|
||||
id=uuid7(),
|
||||
admin_id=admin.id,
|
||||
token_hash=sha256(token),
|
||||
ip=ip,
|
||||
expires_at=datetime.now(UTC) + timedelta(hours=get_settings().ops_session_ttl_hours),
|
||||
)
|
||||
s.add(sess)
|
||||
await s.flush()
|
||||
return token, Operator(admin.id, admin.username, admin.display_name, sess.id, admin.role)
|
||||
|
||||
|
||||
async def logout(op: Operator) -> None:
|
||||
async with ops_tx() as s:
|
||||
await s.execute(update(OpsSession).where(OpsSession.id == op.session_id).values(revoked_at=datetime.now(UTC)))
|
||||
|
||||
|
||||
async def current_operator(request: Request) -> Operator:
|
||||
token = request.cookies.get(SESSION_COOKIE, "")
|
||||
if token and request.method not in SAFE_METHODS:
|
||||
sent, expected = request.headers.get("x-csrf-token", ""), request.cookies.get(CSRF_COOKIE, "")
|
||||
if not expected or not hmac.compare_digest(sent, expected):
|
||||
raise AppError("csrf_failed")
|
||||
if not token:
|
||||
raise AppError("unauthenticated")
|
||||
async with ops_tx() as s:
|
||||
row = (
|
||||
await s.execute(
|
||||
select(OpsSession, OpsAdmin)
|
||||
.join(OpsAdmin, OpsAdmin.id == OpsSession.admin_id)
|
||||
.where(
|
||||
OpsSession.token_hash == sha256(token),
|
||||
OpsSession.revoked_at.is_(None),
|
||||
OpsSession.expires_at > datetime.now(UTC),
|
||||
OpsAdmin.status == "active",
|
||||
)
|
||||
)
|
||||
).first()
|
||||
if row is None:
|
||||
raise AppError("session_expired")
|
||||
sess, admin = row
|
||||
return Operator(admin.id, admin.username, admin.display_name, sess.id, admin.role)
|
||||
|
||||
|
||||
Op = Annotated[Operator, Depends(current_operator)]
|
||||
|
||||
|
||||
def set_cookies(response: Response, token: str) -> None:
|
||||
s = get_settings()
|
||||
age = s.ops_session_ttl_hours * 3600
|
||||
response.set_cookie(SESSION_COOKIE, token, max_age=age, httponly=True, secure=s.cookie_secure, samesite="strict")
|
||||
response.set_cookie(
|
||||
CSRF_COOKIE, new_token(), max_age=age, httponly=False, secure=s.cookie_secure, samesite="strict"
|
||||
)
|
||||
|
||||
|
||||
def clear_cookies(response: Response) -> None:
|
||||
response.delete_cookie(SESSION_COOKIE)
|
||||
response.delete_cookie(CSRF_COOKIE)
|
||||
336
server/src/bid_doctor/ops/console.py
Normal file
336
server/src/bid_doctor/ops/console.py
Normal file
@@ -0,0 +1,336 @@
|
||||
"""运营后台的查询(FR-M6-09,m6-plan §2.2):跨企业,用 bd_ops 只读(ops_tx)。
|
||||
|
||||
不显示业务内容(决策 M6-7、红线 10):项目名称、招标 / 投标原文、审计里的修改前后都不出现;
|
||||
反馈只给标题、理由、规则编号、提示词版本、页码。查看企业详情也写一条审计。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from typing import Any
|
||||
from uuid import UUID
|
||||
|
||||
from sqlalchemy import text
|
||||
|
||||
from bid_doctor.audit.models import AuditEvent
|
||||
from bid_doctor.core.crypto import phone_lookup_key
|
||||
from bid_doctor.core.db import ops_tx
|
||||
from bid_doctor.core.errors import AppError
|
||||
from bid_doctor.core.ids import uuid7
|
||||
|
||||
PHONE = re.compile(r"^1[3-9]\d{9}$")
|
||||
|
||||
TENANT_ROW = """
|
||||
SELECT t.id, t.name, t.status, t.created_at, s.plan_code, s.status AS plan_status, s.ends_at,
|
||||
(SELECT a.balance FROM quota_account a WHERE a.tenant_id = t.id AND a.unit = 'parse') AS balance,
|
||||
(SELECT count(*) FROM project p WHERE p.tenant_id = t.id) AS projects,
|
||||
(SELECT li.contact_hint FROM membership m JOIN login_identity li ON li.user_id = m.user_id
|
||||
WHERE m.tenant_id = t.id AND m.role = 'owner' AND li.provider = 'phone' LIMIT 1) AS phone_hint,
|
||||
(SELECT count(*) FROM activation_request r WHERE r.tenant_id = t.id AND r.status IN ('submitted', 'paid'))
|
||||
AS open_requests
|
||||
FROM tenant t
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT plan_code, status, ends_at FROM subscription x
|
||||
WHERE x.tenant_id = t.id AND x.status IN ('active', 'expired')
|
||||
ORDER BY (x.status = 'active') DESC, x.starts_at DESC LIMIT 1
|
||||
) s ON true
|
||||
"""
|
||||
|
||||
|
||||
def _tenant(r: Any) -> dict[str, Any]:
|
||||
return {
|
||||
"id": r.id,
|
||||
"name": r.name,
|
||||
"status": r.status,
|
||||
"created_at": r.created_at,
|
||||
"plan": {"code": r.plan_code, "expired": r.plan_status == "expired", "ends_at": r.ends_at},
|
||||
"balance": r.balance,
|
||||
"projects": r.projects,
|
||||
"phone_hint": r.phone_hint,
|
||||
"open_requests": r.open_requests,
|
||||
}
|
||||
|
||||
|
||||
async def search_tenants(q: str) -> list[dict[str, Any]]:
|
||||
"""按企业名称(模糊)、手机号(精确,用 HMAC 查)、申请单号搜;不带条件时列最近注册的。"""
|
||||
q = q.strip()
|
||||
async with ops_tx() as s:
|
||||
if PHONE.match(q):
|
||||
where, params = (
|
||||
"WHERE t.id IN (SELECT m.tenant_id FROM membership m JOIN login_identity li ON li.user_id = m.user_id"
|
||||
" WHERE li.provider = 'phone' AND li.external_user_id = :v)",
|
||||
{"v": phone_lookup_key(q)},
|
||||
)
|
||||
elif q.upper().startswith("KT-"):
|
||||
where, params = (
|
||||
"WHERE t.id IN (SELECT tenant_id FROM activation_request WHERE request_no = :v)",
|
||||
{"v": q.upper()},
|
||||
)
|
||||
elif q:
|
||||
where, params = "WHERE t.name ILIKE :v", {"v": f"%{q}%"}
|
||||
else:
|
||||
where, params = "", {}
|
||||
rows = (await s.execute(text(TENANT_ROW + where + " ORDER BY t.created_at DESC LIMIT 50"), params)).all()
|
||||
return [_tenant(r) for r in rows]
|
||||
|
||||
|
||||
async def tenant_detail(tenant_id: UUID, ops_id: UUID) -> dict[str, Any]:
|
||||
async with ops_tx() as s:
|
||||
head = (await s.execute(text(TENANT_ROW + "WHERE t.id = :t"), {"t": tenant_id})).first()
|
||||
if head is None:
|
||||
raise AppError("not_found")
|
||||
p = {"t": tenant_id}
|
||||
members = (
|
||||
await s.execute(
|
||||
text(
|
||||
"SELECT u.display_name, m.role, m.status, li.contact_hint, li.last_login_at FROM membership m"
|
||||
" JOIN user_account u ON u.id = m.user_id"
|
||||
" LEFT JOIN login_identity li ON li.user_id = m.user_id AND li.provider = 'phone'"
|
||||
" WHERE m.tenant_id = :t ORDER BY m.created_at"
|
||||
),
|
||||
p,
|
||||
)
|
||||
).all()
|
||||
subs = (
|
||||
await s.execute(
|
||||
text(
|
||||
"SELECT x.plan_code, x.status, x.source, x.starts_at, x.ends_at, r.request_no FROM subscription x"
|
||||
" LEFT JOIN activation_request r ON r.id = x.activation_request_id"
|
||||
" WHERE x.tenant_id = :t ORDER BY x.starts_at DESC"
|
||||
),
|
||||
p,
|
||||
)
|
||||
).all()
|
||||
entries = (
|
||||
await s.execute(
|
||||
text(
|
||||
"SELECT e.kind, e.amount, e.reason, e.ref_type, e.created_at, a.unit,"
|
||||
" e.actor_ops_id IS NOT NULL AS by_ops FROM quota_entry e"
|
||||
" JOIN quota_account a ON a.id = e.account_id WHERE e.tenant_id = :t ORDER BY e.id DESC LIMIT 30"
|
||||
),
|
||||
p,
|
||||
)
|
||||
).all()
|
||||
jobs = (
|
||||
await s.execute(
|
||||
text(
|
||||
"SELECT 'parse_job' AS kind, status, count(*) FROM parse_job WHERE tenant_id = :t GROUP BY status"
|
||||
" UNION ALL SELECT 'check_run', status, count(*) FROM check_run WHERE tenant_id = :t"
|
||||
" GROUP BY status"
|
||||
" UNION ALL SELECT 'report_job', status, count(*) FROM report_job WHERE tenant_id = :t"
|
||||
" GROUP BY status"
|
||||
),
|
||||
p,
|
||||
)
|
||||
).all()
|
||||
s.add(
|
||||
AuditEvent(
|
||||
id=uuid7(),
|
||||
tenant_id=tenant_id,
|
||||
actor_kind="ops",
|
||||
actor_ops_id=ops_id,
|
||||
action="ops.tenant.view",
|
||||
object_type="tenant",
|
||||
object_id=tenant_id,
|
||||
)
|
||||
)
|
||||
stats: dict[str, dict[str, int]] = {}
|
||||
for kind, status, n in jobs:
|
||||
stats.setdefault(kind, {})[status] = n
|
||||
return {
|
||||
**_tenant(head),
|
||||
"members": [
|
||||
{"display_name": m[0], "role": m[1], "status": m[2], "phone_hint": m[3], "last_login_at": m[4]}
|
||||
for m in members
|
||||
],
|
||||
"subscriptions": [
|
||||
{"plan_code": x[0], "status": x[1], "source": x[2], "starts_at": x[3], "ends_at": x[4], "request_no": x[5]}
|
||||
for x in subs
|
||||
],
|
||||
"quota_entries": [
|
||||
{
|
||||
"kind": e[0],
|
||||
"amount": e[1],
|
||||
"reason": e[2],
|
||||
"ref_type": e[3],
|
||||
"created_at": e[4],
|
||||
"unit": e[5],
|
||||
"by_ops": e[6],
|
||||
}
|
||||
for e in entries
|
||||
],
|
||||
"jobs": stats,
|
||||
}
|
||||
|
||||
|
||||
async def activation_requests(status: str | None) -> list[dict[str, Any]]:
|
||||
async with ops_tx() as s:
|
||||
rows = (
|
||||
await s.execute(
|
||||
text(
|
||||
"SELECT r.id, r.request_no, r.tenant_id, t.name, r.plan_code, r.status, r.invoice_info, r.ops_note,"
|
||||
" r.invoice_no, r.created_at, r.handled_at, li.contact_hint FROM activation_request r"
|
||||
" JOIN tenant t ON t.id = r.tenant_id"
|
||||
" LEFT JOIN login_identity li ON li.user_id = r.applicant_user_id AND li.provider = 'phone'"
|
||||
" WHERE CAST(:st AS text) IS NULL OR r.status = :st"
|
||||
" ORDER BY (r.status IN ('submitted', 'paid')) DESC, r.created_at DESC LIMIT 200"
|
||||
),
|
||||
{"st": status},
|
||||
)
|
||||
).all()
|
||||
return [
|
||||
{
|
||||
"id": r.id,
|
||||
"request_no": r.request_no,
|
||||
"tenant": {"id": r.tenant_id, "name": r.name},
|
||||
"plan_code": r.plan_code,
|
||||
"status": r.status,
|
||||
"invoice_info": r.invoice_info,
|
||||
"ops_note": r.ops_note,
|
||||
"invoice_no": r.invoice_no,
|
||||
"applicant_phone_hint": r.contact_hint,
|
||||
"created_at": r.created_at,
|
||||
"handled_at": r.handled_at,
|
||||
}
|
||||
for r in rows
|
||||
]
|
||||
|
||||
|
||||
async def request_tenant(request_id: UUID) -> UUID:
|
||||
async with ops_tx() as s:
|
||||
t = (
|
||||
await s.execute(text("SELECT tenant_id FROM activation_request WHERE id = :r"), {"r": request_id})
|
||||
).scalar_one_or_none()
|
||||
if t is None:
|
||||
raise AppError("not_found")
|
||||
return t
|
||||
|
||||
|
||||
async def feedback(kind: str | None) -> list[dict[str, Any]]:
|
||||
"""误报 / 漏报(J5):不显示招标 / 投标原文(M6-7),只给标题、理由、规则、提示词版本、页码。"""
|
||||
async with ops_tx() as s:
|
||||
rows = (
|
||||
await s.execute(
|
||||
text(
|
||||
"SELECT f.id, f.kind, f.source, f.note, f.object_type, f.created_at, t.name AS tenant_name,"
|
||||
" fi.title AS finding_title, fi.severity, fi.rule_code, fi.detector, fi.type AS finding_type,"
|
||||
" ci.title AS item_title, cr.rule_set_version, cr.prompt_versions,"
|
||||
" (SELECT array_agg(DISTINCT c.page_no ORDER BY c.page_no) FROM finding_citation fc"
|
||||
" JOIN citation c ON c.id = fc.citation_id WHERE fc.finding_id = fi.id) AS pages"
|
||||
" FROM feedback f JOIN tenant t ON t.id = f.tenant_id"
|
||||
" LEFT JOIN finding fi ON f.object_type = 'finding' AND fi.id = f.object_id"
|
||||
" LEFT JOIN checklist_item ci ON ci.id = coalesce(fi.checklist_item_id,"
|
||||
" CASE WHEN f.object_type = 'checklist_item' THEN f.object_id END)"
|
||||
" LEFT JOIN check_run cr ON cr.id = fi.check_run_id"
|
||||
" WHERE CAST(:k AS text) IS NULL OR f.kind = :k ORDER BY f.created_at DESC LIMIT 200"
|
||||
),
|
||||
{"k": kind},
|
||||
)
|
||||
).all()
|
||||
return [
|
||||
{
|
||||
"id": r.id,
|
||||
"kind": r.kind,
|
||||
"source": r.source,
|
||||
"note": r.note,
|
||||
"object_type": r.object_type,
|
||||
"tenant_name": r.tenant_name,
|
||||
"title": r.finding_title or r.item_title,
|
||||
"severity": r.severity,
|
||||
"rule_code": r.rule_code,
|
||||
"detector": r.detector,
|
||||
"finding_type": r.finding_type,
|
||||
"rule_set_version": r.rule_set_version,
|
||||
"prompt_versions": r.prompt_versions,
|
||||
"pages": r.pages or [],
|
||||
"created_at": r.created_at,
|
||||
}
|
||||
for r in rows
|
||||
]
|
||||
|
||||
|
||||
async def failed_jobs() -> list[dict[str, Any]]:
|
||||
"""解析、检查、报告失败的任务:错误类别、错误码、阶段、耗时、模型调用次数;不含文档内容。"""
|
||||
async with ops_tx() as s:
|
||||
rows = (
|
||||
await s.execute(
|
||||
text(
|
||||
"SELECT j.kind, j.id, j.tenant_id, t.name, j.stage, j.error_class, j.error_code, j.error_detail,"
|
||||
" j.created_at, j.started_at, j.finished_at,"
|
||||
" (SELECT count(*) FROM ai_call a WHERE a.job_id = j.id) AS ai_calls FROM ("
|
||||
" SELECT 'parse_job' AS kind, id, tenant_id, stage, error_class, error_code, error_detail,"
|
||||
" created_at, started_at, finished_at FROM parse_job WHERE status = 'failed'"
|
||||
" UNION ALL SELECT 'check_run', id, tenant_id, stage, error_class, error_code, error_detail,"
|
||||
" created_at, started_at, finished_at FROM check_run WHERE status = 'failed'"
|
||||
" UNION ALL SELECT 'report_job', id, tenant_id, NULL, NULL, error_code, NULL,"
|
||||
" created_at, created_at, finished_at FROM report_job WHERE status = 'failed'"
|
||||
" ) j JOIN tenant t ON t.id = j.tenant_id ORDER BY j.created_at DESC LIMIT 200"
|
||||
)
|
||||
)
|
||||
).all()
|
||||
return [
|
||||
{
|
||||
"kind": r.kind,
|
||||
"id": r.id,
|
||||
"tenant": {"id": r.tenant_id, "name": r.name},
|
||||
"stage": r.stage,
|
||||
"error_class": r.error_class,
|
||||
"error_code": r.error_code,
|
||||
"error_detail": r.error_detail,
|
||||
"created_at": r.created_at,
|
||||
"duration_s": (r.finished_at - r.started_at).total_seconds() if r.finished_at and r.started_at else None,
|
||||
"ai_calls": r.ai_calls,
|
||||
}
|
||||
for r in rows
|
||||
]
|
||||
|
||||
|
||||
async def audit_events(tenant_id: UUID | None, action: str | None) -> list[dict[str, Any]]:
|
||||
"""审计:动作、对象、操作人、时间;修改前后的内容不给(业务内容)。"""
|
||||
async with ops_tx() as s:
|
||||
rows = (
|
||||
await s.execute(
|
||||
text(
|
||||
"SELECT e.id, e.tenant_id, t.name, e.actor_kind, e.actor_name, e.action, e.object_type,"
|
||||
" e.object_id, e.project_id, e.content_scrubbed_at, e.created_at, o.display_name AS ops_name"
|
||||
" FROM audit_event e JOIN tenant t ON t.id = e.tenant_id"
|
||||
" LEFT JOIN ops_admin o ON o.id = e.actor_ops_id"
|
||||
" WHERE (CAST(:t AS uuid) IS NULL OR e.tenant_id = :t)"
|
||||
" AND (CAST(:a AS text) IS NULL OR e.action LIKE :a || '%')"
|
||||
" ORDER BY e.created_at DESC LIMIT 200"
|
||||
),
|
||||
{"t": tenant_id, "a": action},
|
||||
)
|
||||
).all()
|
||||
return [
|
||||
{
|
||||
"id": r.id,
|
||||
"tenant": {"id": r.tenant_id, "name": r.name},
|
||||
"actor_kind": r.actor_kind,
|
||||
"actor_name": r.ops_name if r.actor_kind == "ops" else r.actor_name,
|
||||
"action": r.action,
|
||||
"object_type": r.object_type,
|
||||
"object_id": r.object_id,
|
||||
"project_id": r.project_id,
|
||||
"scrubbed": r.content_scrubbed_at is not None,
|
||||
"created_at": r.created_at,
|
||||
}
|
||||
for r in rows
|
||||
]
|
||||
|
||||
|
||||
async def summary() -> dict[str, int]:
|
||||
async with ops_tx() as s:
|
||||
row = (
|
||||
await s.execute(
|
||||
text(
|
||||
"WITH f AS (SELECT created_at FROM parse_job WHERE status = 'failed'"
|
||||
" UNION ALL SELECT created_at FROM check_run WHERE status = 'failed'"
|
||||
" UNION ALL SELECT created_at FROM report_job WHERE status = 'failed')"
|
||||
" SELECT (SELECT count(*) FROM activation_request WHERE status IN ('submitted', 'paid')),"
|
||||
" (SELECT count(*) FROM f WHERE created_at > now() - interval '7 days'),"
|
||||
" (SELECT count(*) FROM feedback WHERE created_at > now() - interval '7 days')"
|
||||
)
|
||||
)
|
||||
).one()
|
||||
return {"open_requests": row[0], "failed_jobs_7d": row[1], "feedback_7d": row[2]}
|
||||
222
server/src/bid_doctor/ops/console_api.py
Normal file
222
server/src/bid_doctor/ops/console_api.py
Normal file
@@ -0,0 +1,222 @@
|
||||
"""运营后台接口(/ops/v1,只挂在 ops 进程上,决策 M6-2)。跨企业的查询走 bd_ops;对某个企业的操作走它自己的租户事务,
|
||||
每个操作都写审计(操作人是运营账号)。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Literal
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import APIRouter, Request, Response
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from bid_doctor.audit import service as audit
|
||||
from bid_doctor.billing import service as billing
|
||||
from bid_doctor.core.config import get_settings
|
||||
from bid_doctor.core.db import TenantContext, tenant_tx
|
||||
from bid_doctor.ops import auth, console
|
||||
from bid_doctor.ops.auth import Op
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def _ctx(tenant_id: UUID, op: auth.Operator) -> TenantContext:
|
||||
return TenantContext(tenant_id=tenant_id, user_id=UUID(int=0), actor_name=f"运营 · {op.display_name}")
|
||||
|
||||
|
||||
class LoginIn(BaseModel):
|
||||
username: str = Field(min_length=1, max_length=60)
|
||||
password: str = Field(min_length=1, max_length=200)
|
||||
totp: str = Field(pattern=r"^\d{6}$")
|
||||
|
||||
|
||||
@router.post("/auth/login")
|
||||
async def login(body: LoginIn, request: Request, response: Response) -> dict[str, Any]:
|
||||
ip = request.client.host if request.client else ""
|
||||
token, op = await auth.login(body.username, body.password, body.totp, ip)
|
||||
auth.set_cookies(response, token)
|
||||
return _me(op)
|
||||
|
||||
|
||||
@router.post("/auth/logout", status_code=204)
|
||||
async def logout(op: Op, response: Response) -> None:
|
||||
await auth.logout(op)
|
||||
auth.clear_cookies(response)
|
||||
|
||||
|
||||
def _me(op: auth.Operator) -> dict[str, Any]:
|
||||
return {
|
||||
"id": op.id,
|
||||
"username": op.username,
|
||||
"display_name": op.display_name,
|
||||
"role": op.role,
|
||||
"role_label": auth.ROLE_LABEL.get(op.role, op.role),
|
||||
"permissions": sorted(op.permissions),
|
||||
"env": get_settings().env,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/me")
|
||||
async def me(op: Op) -> dict[str, Any]:
|
||||
return _me(op)
|
||||
|
||||
|
||||
@router.get("/summary")
|
||||
async def summary(op: Op) -> dict[str, int]:
|
||||
"""菜单上的待办数:没权限的那一项给 0(菜单本来也不显示)。"""
|
||||
counts = await console.summary()
|
||||
allowed = {
|
||||
"open_requests": auth.OpsPermission.ACTIVATION_MANAGE,
|
||||
"failed_jobs_7d": auth.OpsPermission.JOBS_VIEW,
|
||||
"feedback_7d": auth.OpsPermission.FEEDBACK_VIEW,
|
||||
}
|
||||
return {k: (v if allowed[k] in op.permissions else 0) for k, v in counts.items()}
|
||||
|
||||
|
||||
@router.get("/tenants")
|
||||
async def tenants(op: Op, q: str = "") -> dict[str, Any]:
|
||||
auth.require(op, auth.OpsPermission.TENANT_VIEW)
|
||||
return {"items": await console.search_tenants(q)}
|
||||
|
||||
|
||||
@router.get("/tenants/{tenant_id}")
|
||||
async def tenant(tenant_id: UUID, op: Op) -> dict[str, Any]:
|
||||
auth.require(op, auth.OpsPermission.TENANT_VIEW)
|
||||
return await console.tenant_detail(tenant_id, op.id)
|
||||
|
||||
|
||||
class AdjustIn(BaseModel):
|
||||
unit: Literal["parse"] = "parse"
|
||||
amount: int = Field(ge=-10000, le=10000)
|
||||
reason: str = Field(min_length=2, max_length=200)
|
||||
|
||||
|
||||
@router.post("/tenants/{tenant_id}/quota-adjustments", status_code=201)
|
||||
async def adjust(tenant_id: UUID, body: AdjustIn, op: Op) -> dict[str, Any]:
|
||||
auth.require(op, auth.OpsPermission.QUOTA_ADJUST)
|
||||
ctx = _ctx(tenant_id, op)
|
||||
async with tenant_tx(ctx) as s:
|
||||
e = await billing.adjust(s, ctx, unit=body.unit, amount=body.amount, reason=body.reason, ops_id=op.id)
|
||||
await audit.record(
|
||||
s,
|
||||
ctx,
|
||||
"ops.quota.adjust",
|
||||
"quota_entry",
|
||||
e.id,
|
||||
actor_kind="ops",
|
||||
actor_ops_id=op.id,
|
||||
after={"unit": body.unit, "amount": body.amount, "reason": body.reason},
|
||||
)
|
||||
return {"id": e.id, "amount": e.amount}
|
||||
|
||||
|
||||
@router.get("/activation-requests")
|
||||
async def requests(op: Op, status: str | None = None) -> dict[str, Any]:
|
||||
auth.require(op, auth.OpsPermission.ACTIVATION_MANAGE)
|
||||
return {"items": await console.activation_requests(status)}
|
||||
|
||||
|
||||
class ActivateIn(BaseModel):
|
||||
months: int = Field(default=billing.PERIOD_MONTHS, ge=1, le=36)
|
||||
|
||||
|
||||
class RejectIn(BaseModel):
|
||||
note: str = Field(min_length=2, max_length=500)
|
||||
|
||||
|
||||
class InvoiceIn(BaseModel):
|
||||
invoice_no: str = Field(min_length=2, max_length=60)
|
||||
|
||||
|
||||
async def _on_request(request_id: UUID, op: auth.Operator) -> TenantContext:
|
||||
"""申请所属企业的租户事务里做:开通、退回、到账、开票。"""
|
||||
return _ctx(await console.request_tenant(request_id), op)
|
||||
|
||||
|
||||
@router.post("/activation-requests/{request_id}/paid")
|
||||
async def mark_paid(request_id: UUID, op: Op) -> dict[str, str]:
|
||||
auth.require(op, auth.OpsPermission.ACTIVATION_MANAGE)
|
||||
ctx = await _on_request(request_id, op)
|
||||
async with tenant_tx(ctx) as s:
|
||||
await billing.mark_paid(s, ctx, request_id, ops_id=op.id)
|
||||
await audit.record(
|
||||
s, ctx, "ops.activation.paid", "activation_request", request_id, actor_kind="ops", actor_ops_id=op.id
|
||||
)
|
||||
return {"status": "paid"}
|
||||
|
||||
|
||||
@router.post("/activation-requests/{request_id}/activate")
|
||||
async def activate(request_id: UUID, body: ActivateIn, op: Op) -> dict[str, Any]:
|
||||
auth.require(op, auth.OpsPermission.ACTIVATION_MANAGE)
|
||||
ctx = await _on_request(request_id, op)
|
||||
async with tenant_tx(ctx) as s:
|
||||
sub = await billing.activate(s, ctx, request_id, ops_id=op.id, months=body.months)
|
||||
await audit.record(
|
||||
s,
|
||||
ctx,
|
||||
"ops.activation.activate",
|
||||
"activation_request",
|
||||
request_id,
|
||||
actor_kind="ops",
|
||||
actor_ops_id=op.id,
|
||||
after={"months": body.months, "starts_at": sub.starts_at.isoformat(), "subscription_status": sub.status},
|
||||
)
|
||||
return {
|
||||
"status": "activated",
|
||||
"subscription": {"status": sub.status, "starts_at": sub.starts_at, "ends_at": sub.ends_at},
|
||||
}
|
||||
|
||||
|
||||
@router.post("/activation-requests/{request_id}/reject")
|
||||
async def reject(request_id: UUID, body: RejectIn, op: Op) -> dict[str, str]:
|
||||
auth.require(op, auth.OpsPermission.ACTIVATION_MANAGE)
|
||||
ctx = await _on_request(request_id, op)
|
||||
async with tenant_tx(ctx) as s:
|
||||
await billing.reject(s, ctx, request_id, ops_id=op.id, note=body.note)
|
||||
await audit.record(
|
||||
s,
|
||||
ctx,
|
||||
"ops.activation.reject",
|
||||
"activation_request",
|
||||
request_id,
|
||||
actor_kind="ops",
|
||||
actor_ops_id=op.id,
|
||||
after={"note": body.note},
|
||||
)
|
||||
return {"status": "rejected"}
|
||||
|
||||
|
||||
@router.post("/activation-requests/{request_id}/invoice")
|
||||
async def invoice(request_id: UUID, body: InvoiceIn, op: Op) -> dict[str, str]:
|
||||
auth.require(op, auth.OpsPermission.INVOICE_MANAGE)
|
||||
ctx = await _on_request(request_id, op)
|
||||
async with tenant_tx(ctx) as s:
|
||||
await billing.set_invoice(s, ctx, request_id, body.invoice_no)
|
||||
await audit.record(
|
||||
s,
|
||||
ctx,
|
||||
"ops.activation.invoice",
|
||||
"activation_request",
|
||||
request_id,
|
||||
actor_kind="ops",
|
||||
actor_ops_id=op.id,
|
||||
after={"invoice_no": body.invoice_no},
|
||||
)
|
||||
return {"invoice_no": body.invoice_no}
|
||||
|
||||
|
||||
@router.get("/feedback")
|
||||
async def feedback(op: Op, kind: Literal["false_positive", "false_negative"] | None = None) -> dict[str, Any]:
|
||||
auth.require(op, auth.OpsPermission.FEEDBACK_VIEW)
|
||||
return {"items": await console.feedback(kind)}
|
||||
|
||||
|
||||
@router.get("/failed-jobs")
|
||||
async def failed_jobs(op: Op) -> dict[str, Any]:
|
||||
auth.require(op, auth.OpsPermission.JOBS_VIEW)
|
||||
return {"items": await console.failed_jobs()}
|
||||
|
||||
|
||||
@router.get("/audit-events")
|
||||
async def audit_events(op: Op, tenant_id: UUID | None = None, action: str | None = None) -> dict[str, Any]:
|
||||
auth.require(op, auth.OpsPermission.AUDIT_VIEW)
|
||||
return {"items": await console.audit_events(tenant_id, action)}
|
||||
@@ -1,6 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from sqlalchemy import FetchedValue
|
||||
from sqlalchemy import FetchedValue, LargeBinary
|
||||
from sqlalchemy.dialects.postgresql import INET
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from bid_doctor.core.columns import PK, DbTs, Ts, UuidCol
|
||||
@@ -25,3 +26,29 @@ class Feedback(Base):
|
||||
created_by: Mapped[UuidCol]
|
||||
created_at: Mapped[DbTs]
|
||||
updated_at: Mapped[DbTs]
|
||||
|
||||
|
||||
class OpsAdmin(Base):
|
||||
"""运营账号(全局表,只有 bd_ops 能读写,决策 M6-4)。"""
|
||||
|
||||
__tablename__ = "ops_admin"
|
||||
id: Mapped[PK]
|
||||
username: Mapped[str]
|
||||
display_name: Mapped[str]
|
||||
secret_hash: Mapped[str]
|
||||
role: Mapped[str] = mapped_column(server_default=FetchedValue())
|
||||
totp_cipher: Mapped[bytes] = mapped_column(LargeBinary)
|
||||
status: Mapped[str] = mapped_column(server_default=FetchedValue())
|
||||
created_at: Mapped[DbTs]
|
||||
updated_at: Mapped[DbTs]
|
||||
|
||||
|
||||
class OpsSession(Base):
|
||||
__tablename__ = "ops_session"
|
||||
id: Mapped[PK]
|
||||
admin_id: Mapped[UuidCol]
|
||||
token_hash: Mapped[bytes] = mapped_column(LargeBinary)
|
||||
ip: Mapped[str] = mapped_column(INET)
|
||||
created_at: Mapped[DbTs]
|
||||
expires_at: Mapped[Ts]
|
||||
revoked_at: Mapped[Ts | None]
|
||||
|
||||
@@ -76,6 +76,12 @@ async def get_project(project_id: UUID, ctx: Ctx) -> dict[str, Any]:
|
||||
return await workbench.detail(s, ctx, project_id)
|
||||
|
||||
|
||||
@router.delete("/projects/{project_id}", status_code=204)
|
||||
async def delete_project(project_id: UUID, ctx: Ctx) -> None:
|
||||
async with tenant_tx(ctx) as s:
|
||||
await service.delete(s, ctx, project_id)
|
||||
|
||||
|
||||
@router.post("/projects/{project_id}/transitions")
|
||||
async def transition_project(project_id: UUID, body: TransitionIn, ctx: Ctx) -> dict[str, Any]:
|
||||
async with tenant_tx(ctx) as s:
|
||||
|
||||
@@ -5,11 +5,12 @@ from __future__ import annotations
|
||||
from typing import Any
|
||||
from uuid import UUID
|
||||
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy import func, select, text
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from bid_doctor.audit import service as audit
|
||||
from bid_doctor.billing import service as billing
|
||||
from bid_doctor.core import keys, queue
|
||||
from bid_doctor.core.authz import Permission, require
|
||||
from bid_doctor.core.db import TenantContext
|
||||
from bid_doctor.core.errors import AppError
|
||||
@@ -52,6 +53,45 @@ async def get(s: AsyncSession, ctx: TenantContext, project_id: UUID) -> Project:
|
||||
return p
|
||||
|
||||
|
||||
async def delete(s: AsyncSession, ctx: TenantContext, project_id: UUID) -> None:
|
||||
"""删除项目(J4、G10,决策 M6-1):彻底删除,不能恢复。同一个事务里:
|
||||
写一条不含业务内容的审计 → 抹掉这个项目审计里的内容 → 删项目(下面的文件、清单、检查、报告级联删除)→ 销毁数据密钥。
|
||||
对象存储里的文件由后台任务清理(密钥已销毁,清理前也读不出来)。额度流水保留(不加外键)。
|
||||
正在解析、检查、生成报告时不删:先取消(预扣退回),否则任务会往已删除的项目里写。"""
|
||||
require(ctx, Permission.PROJECT_DELETE)
|
||||
p = await get(s, ctx, project_id)
|
||||
running = (
|
||||
await s.execute(
|
||||
text(
|
||||
"SELECT 1 FROM parse_job WHERE project_id = :p AND status IN ('queued', 'running')"
|
||||
" UNION ALL SELECT 1 FROM check_run WHERE project_id = :p AND status IN ('queued', 'running')"
|
||||
" UNION ALL SELECT 1 FROM report_job WHERE project_id = :p AND status IN ('queued', 'running')"
|
||||
" LIMIT 1"
|
||||
),
|
||||
{"p": p.id},
|
||||
)
|
||||
).first()
|
||||
if running:
|
||||
raise AppError("job_already_running", "项目有正在进行的解析、检查或报告,先取消或等它完成再删除")
|
||||
await remove(s, ctx, p)
|
||||
|
||||
|
||||
async def remove(s: AsyncSession, ctx: TenantContext, p: Project, *, actor_kind: str = "user") -> None:
|
||||
"""删除一个项目的业务内容(delete 和注销账号共用)。"""
|
||||
await s.execute(
|
||||
text(
|
||||
"UPDATE audit_event SET before = NULL, after = NULL, content_scrubbed_at = now()"
|
||||
" WHERE project_id = :p AND content_scrubbed_at IS NULL AND (before IS NOT NULL OR after IS NOT NULL)"
|
||||
),
|
||||
{"p": p.id},
|
||||
)
|
||||
await audit.record(s, ctx, "project.delete", "project", p.id, project_id=p.id, actor_kind=actor_kind)
|
||||
await s.delete(p)
|
||||
await s.flush()
|
||||
await keys.destroy_project_key(s, ctx, p.id)
|
||||
await queue.enqueue(s, "purge_project_files", {"tenant_id": str(ctx.tenant_id), "project_id": str(p.id)})
|
||||
|
||||
|
||||
def ensure_editable(p: Project) -> None:
|
||||
if p.status in ("submitted", "archived"):
|
||||
raise AppError("project_read_only")
|
||||
|
||||
@@ -6,6 +6,7 @@ worker 直连数据库(Procrastinate 靠 LISTEN/NOTIFY 发现新任务,不
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime
|
||||
from uuid import UUID
|
||||
|
||||
import procrastinate
|
||||
@@ -42,6 +43,30 @@ async def generate_report(tenant_id: str, job_id: str) -> None:
|
||||
await runner.run(UUID(tenant_id), UUID(job_id))
|
||||
|
||||
|
||||
@app.task(name="purge_project_files", retry=3)
|
||||
async def purge_project_files(tenant_id: str, project_id: str) -> None:
|
||||
"""删除项目以后清对象存储(data-model §6.1)。密钥已经销毁,清不掉也读不出来;失败重试几次。"""
|
||||
from bid_doctor.core.storage import project_prefix, storage
|
||||
|
||||
storage().delete_prefix(project_prefix(tenant_id, project_id))
|
||||
|
||||
|
||||
@app.task(name="close_tenant", retry=3)
|
||||
async def close_tenant(tenant_id: str) -> None:
|
||||
from bid_doctor.identity import closure
|
||||
|
||||
await closure.finish(UUID(tenant_id))
|
||||
|
||||
|
||||
@app.periodic(cron="5 * * * *", periodic_id="billing_tick")
|
||||
@app.task(name="billing_tick", retry=False, queueing_lock="billing_tick")
|
||||
async def billing_tick(timestamp: int) -> None:
|
||||
"""每小时一次:到期清零、排队的续费生效、到期前 30 / 7 / 0 天提醒(决策 M6-5)。每个企业一个事务。"""
|
||||
from bid_doctor.billing import tasks
|
||||
|
||||
await tasks.tick(datetime.fromtimestamp(timestamp, UTC))
|
||||
|
||||
|
||||
async def run_worker() -> None:
|
||||
async with app.open_async():
|
||||
await app.run_worker_async(concurrency=4, install_signal_handlers=True)
|
||||
|
||||
@@ -19,6 +19,7 @@ os.environ.update(
|
||||
"BD_ENV": "test",
|
||||
"BD_DATABASE_URL": f"postgresql+psycopg://bd_app:dev-app@{PG}/bid_doctor_test",
|
||||
"BD_MIGRATE_DATABASE_URL": f"postgresql+psycopg://bd_owner:dev-owner@{PG}/bid_doctor_test",
|
||||
"BD_OPS_DATABASE_URL": f"postgresql+psycopg://bd_ops:dev-ops@{PG}/bid_doctor_test",
|
||||
"BD_VALKEY_URL": os.environ.get("BD_TEST_VALKEY", "redis://127.0.0.1:63799/1"),
|
||||
"BD_STORAGE_DIR": tempfile.mkdtemp(prefix="bd-test-objects-"),
|
||||
"BD_AI_PROVIDER": "fake",
|
||||
@@ -38,7 +39,7 @@ def _recreate_database() -> None:
|
||||
with psycopg.connect(f"postgresql://bd_owner:dev-owner@{PG}/postgres", autocommit=True) as conn:
|
||||
conn.execute("DROP DATABASE IF EXISTS bid_doctor_test WITH (FORCE)")
|
||||
conn.execute("CREATE DATABASE bid_doctor_test")
|
||||
conn.execute("GRANT CONNECT ON DATABASE bid_doctor_test TO bd_app")
|
||||
conn.execute("GRANT CONNECT ON DATABASE bid_doctor_test TO bd_app, bd_ops")
|
||||
|
||||
|
||||
@pytest.fixture(scope="session", autouse=True)
|
||||
|
||||
205
server/tests/test_billing_flow.py
Normal file
205
server/tests/test_billing_flow.py
Normal file
@@ -0,0 +1,205 @@
|
||||
"""M6 剩下的 P0(m6-plan):额度流水、80% / 100% 提醒、开通和续费、到期只读、删除项目、注销账号、通知已读。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Any
|
||||
from uuid import UUID, uuid4
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
from sqlalchemy import text
|
||||
|
||||
from bid_doctor.billing import service as billing
|
||||
from bid_doctor.billing import tasks
|
||||
from bid_doctor.core.db import TenantContext, tenant_tx
|
||||
from tests.conftest import User, balance, project_with_tender, register, run_parse
|
||||
|
||||
OPS = uuid4() # 运营后台还没做:直接调 service,记一个运营编号
|
||||
INVOICE = {
|
||||
"title": "测试集成公司",
|
||||
"tax_no": "91320100MA1XXXXX0Y",
|
||||
"invoice_type": "general",
|
||||
"contact_name": "张工",
|
||||
"contact_phone": "13800000000",
|
||||
}
|
||||
|
||||
|
||||
def ctx_of(u: User) -> TenantContext:
|
||||
return TenantContext(tenant_id=UUID(u.tenant_id), user_id=UUID(int=0), actor_name="系统")
|
||||
|
||||
|
||||
async def activate(u: User, request_id: str) -> None:
|
||||
async with tenant_tx(ctx_of(u)) as s:
|
||||
await billing.activate(s, ctx_of(u), UUID(request_id), ops_id=OPS)
|
||||
|
||||
|
||||
async def entries(u: User, **params: Any) -> list[dict[str, Any]]:
|
||||
r = await u.get("/quota/entries", params=params)
|
||||
assert r.status_code == 200, r.text
|
||||
return r.json()["items"]
|
||||
|
||||
|
||||
async def test_ledger_alerts_and_pages(user: User) -> None:
|
||||
"""流水:任务能对上项目和用途,余额逐条可核;用到 80%、100% 各提醒一次(免费版 3 次)。"""
|
||||
project_id, doc_id = await project_with_tender(user)
|
||||
assert (await run_parse(user, doc_id))["status"] == "succeeded"
|
||||
rows = await entries(user)
|
||||
assert [(r["kind"], r["amount"], r["balance_after"]) for r in rows] == [
|
||||
("settle", 0, 2),
|
||||
("hold", -1, 2),
|
||||
("grant", 3, 3),
|
||||
]
|
||||
hold = rows[1]
|
||||
assert hold["project"] == {"id": project_id, "name": hold["project"]["name"], "deleted": False}
|
||||
assert hold["ref"]["type"] == "parse_job" and hold["ref"]["label"] == "招标文件"
|
||||
assert rows[0]["hold_entry_id"] == hold["id"] and rows[2]["reason"] == "注册赠送"
|
||||
|
||||
# 分页:每页 1 条,接着上一页往后
|
||||
first = (await user.get("/quota/entries", params={"limit": 1})).json()
|
||||
second = (await user.get("/quota/entries", params={"limit": 1, "cursor": first["next_cursor"]})).json()
|
||||
assert first["items"][0]["kind"] == "settle" and second["items"][0]["kind"] == "hold"
|
||||
|
||||
# 再解析两次:第 3 次时余额 0,80% 和 100% 一起提醒(每档只一次)
|
||||
for _ in range(2):
|
||||
r = await user.post(f"/documents/{doc_id}/parse-jobs", json={"expected_charge": 1})
|
||||
from bid_doctor.document.pipeline import runner
|
||||
|
||||
await runner.run(UUID(user.tenant_id), UUID(r.json()["id"]))
|
||||
assert await balance(user) == 0
|
||||
kinds = [n["kind"] for n in (await user.get("/notifications")).json()["items"]]
|
||||
assert kinds.count("quota_80") == 1 and kinds.count("quota_100") == 1
|
||||
q = (await user.get("/quota")).json()
|
||||
assert q["usage"]["tender"] == 3 and q["accounts"][0]["period_used"] == 3
|
||||
|
||||
|
||||
async def test_activation_renewal_expiry_read_only(app_client: httpx.AsyncClient) -> None:
|
||||
u = await register(app_client, "开通测试公司")
|
||||
bad = await u.post("/activation-requests", json={"invoice_info": {**INVOICE, "tax_no": "123"}})
|
||||
assert bad.status_code == 422
|
||||
special = await u.post("/activation-requests", json={"invoice_info": {**INVOICE, "invoice_type": "special"}})
|
||||
assert special.status_code == 422 and special.json()["code"] == "validation_failed"
|
||||
r = await u.post("/activation-requests", json={"invoice_info": INVOICE})
|
||||
assert r.status_code == 201, r.text
|
||||
req = r.json()
|
||||
assert req["status"] == "submitted" and req["request_no"].startswith("KT-") and req["payment"]["payee_name"]
|
||||
assert (await u.post("/activation-requests", json={"invoice_info": INVOICE})).json()["code"] == "activation_pending"
|
||||
assert (await u.get("/quota")).json()["pending_request"]["id"] == req["id"]
|
||||
|
||||
# 运营开通:免费版剩下的 3 次清零,发放专业版 500 次
|
||||
await activate(u, req["id"])
|
||||
me = (await u.get("/me")).json()
|
||||
assert me["plan"]["code"] == "pro" and me["plan"]["read_only"] is False
|
||||
rows = await entries(u)
|
||||
assert [(x["kind"], x["amount"]) for x in rows[:2]] == [("grant", 500), ("expire", -3)]
|
||||
assert await balance(u) == 500
|
||||
ends = datetime.fromisoformat(me["plan"]["ends_at"])
|
||||
notes = [n["kind"] for n in (await u.get("/notifications")).json()["items"]]
|
||||
assert "subscription_activated" in notes
|
||||
|
||||
# 续费:当前一期还没到期,新一期排队,从旧一期结束时开始
|
||||
r2 = (await u.post("/activation-requests", json={"invoice_info": INVOICE})).json()
|
||||
await activate(u, r2["id"])
|
||||
q = (await u.get("/quota")).json()
|
||||
assert q["renewal"] and datetime.fromisoformat(q["renewal"]["starts_at"]) == ends and await balance(u) == 500
|
||||
|
||||
# 到期前 30 天、7 天提醒各一次;重复跑不重复提醒(续过费的不提醒,先把排队的一期撤掉)
|
||||
async with tenant_tx(ctx_of(u)) as s:
|
||||
await s.execute(text("UPDATE subscription SET status = 'cancelled' WHERE status = 'scheduled'"))
|
||||
for days in (25, 25, 5):
|
||||
await tasks.tick(ends - timedelta(days=days))
|
||||
reminders = [n for n in (await u.get("/notifications")).json()["items"] if n["kind"] == "subscription_expiring"]
|
||||
assert [n["title"] for n in reminders] == ["专业版还有 5 天到期", "专业版还有 25 天到期"]
|
||||
|
||||
# 到期:余额清零,只读——新建项目被拒,开通申请、通知已读照常
|
||||
await tasks.tick(ends + timedelta(minutes=1))
|
||||
me = (await u.get("/me")).json()
|
||||
assert me["plan"]["read_only"] is True and await balance(u) == 0
|
||||
assert (await u.post("/projects", json={})).json()["code"] == "plan_expired"
|
||||
assert (await u.post("/notifications/read", json={})).status_code == 204
|
||||
assert (await u.get("/notifications", params={"unread": True})).json()["unread_count"] == 0
|
||||
r3 = await u.post("/activation-requests", json={"invoice_info": INVOICE})
|
||||
assert r3.status_code == 201
|
||||
await activate(u, r3.json()["id"]) # 过期后续费:马上开始新一期
|
||||
assert (await u.get("/me")).json()["plan"]["read_only"] is False and await balance(u) == 500
|
||||
|
||||
|
||||
async def test_delete_project(user: User) -> None:
|
||||
project_id, doc_id = await project_with_tender(user)
|
||||
queued = await user.post(f"/documents/{doc_id}/parse-jobs", json={"expected_charge": 1})
|
||||
assert queued.status_code == 201
|
||||
blocked = await user.delete(f"/projects/{project_id}")
|
||||
assert blocked.status_code == 409 and blocked.json()["code"] == "job_already_running"
|
||||
assert (await user.post(f"/parse-jobs/{queued.json()['id']}/cancel")).status_code in (200, 202)
|
||||
from bid_doctor.document.pipeline import runner
|
||||
|
||||
await runner.run(UUID(user.tenant_id), UUID(queued.json()["id"])) # 取消:预扣退回
|
||||
assert (await run_parse(user, doc_id))["status"] == "succeeded"
|
||||
|
||||
r = await user.delete(f"/projects/{project_id}")
|
||||
assert r.status_code == 204, r.text
|
||||
assert (await user.get(f"/projects/{project_id}")).status_code == 404
|
||||
# 流水还在,项目标「已删除」;没有净扣点的任务(取消的那次)照样对得上
|
||||
rows = [x for x in await entries(user) if x["project"]]
|
||||
assert rows and all(x["project"]["deleted"] and x["project"]["name"] is None for x in rows)
|
||||
assert sum(x["amount"] for x in rows) == -1
|
||||
async with tenant_tx(ctx_of(user)) as s:
|
||||
key = (await s.execute(text("SELECT wrapped_key FROM data_key WHERE project_id = :p"), {"p": project_id})).one()
|
||||
audit = (
|
||||
await s.execute(
|
||||
text(
|
||||
"SELECT count(*) FILTER (WHERE before IS NOT NULL OR after IS NOT NULL), count(*)"
|
||||
" FROM audit_event WHERE project_id = :p"
|
||||
),
|
||||
{"p": project_id},
|
||||
)
|
||||
).one()
|
||||
from bid_doctor.core import keys
|
||||
from bid_doctor.core.errors import AppError
|
||||
|
||||
with pytest.raises(AppError) as err: # 删除后不再建新密钥(还在跑的任务写不回文件)
|
||||
await keys.data_key(s, ctx_of(user), UUID(project_id))
|
||||
assert err.value.code == "not_found"
|
||||
assert key[0] is None and audit[0] == 0 and audit[1] >= 2
|
||||
|
||||
|
||||
async def test_close_tenant(app_client: httpx.AsyncClient) -> None:
|
||||
u = await register(app_client, "注销测试公司")
|
||||
project_id, doc_id = await project_with_tender(u)
|
||||
assert (await run_parse(u, doc_id))["status"] == "succeeded"
|
||||
from bid_doctor.core.crypto import phone_lookup_key
|
||||
from bid_doctor.core.valkey import valkey
|
||||
|
||||
await valkey().delete(f"sms:rate:{phone_lookup_key(u.phone)}") # 注册时刚发过一条,60 秒内不能再发
|
||||
assert (await u.post("/me/sms-codes", json={"purpose": "close_tenant"})).status_code == 202
|
||||
wrong = await u.post("/tenant/close", json={"sms_code": "000000", "confirm_tenant_name": "别的公司"})
|
||||
assert wrong.status_code == 422 and wrong.json()["code"] == "tenant_name_mismatch"
|
||||
bad_code = await u.post("/tenant/close", json={"sms_code": "111111", "confirm_tenant_name": "注销测试公司"})
|
||||
assert bad_code.json()["code"] == "sms_code_invalid"
|
||||
assert (await u.post("/me/sms-codes", json={"purpose": "close_tenant"})).status_code in (202, 429)
|
||||
r = await u.post("/tenant/close", json={"sms_code": "000000", "confirm_tenant_name": "注销测试公司"})
|
||||
assert r.status_code == 202, r.text
|
||||
assert (await u.get("/me")).status_code == 401
|
||||
|
||||
from bid_doctor.identity import closure
|
||||
|
||||
await closure.finish(UUID(u.tenant_id))
|
||||
await closure.finish(UUID(u.tenant_id)) # 重复执行没事
|
||||
async with tenant_tx(ctx_of(u)) as s:
|
||||
left = (
|
||||
await s.execute(
|
||||
text(
|
||||
"SELECT (SELECT count(*) FROM project),"
|
||||
" (SELECT count(*) FROM data_key WHERE wrapped_key IS NOT NULL),"
|
||||
" (SELECT count(*) FROM quota_entry), (SELECT status FROM tenant),"
|
||||
" (SELECT count(*) FROM audit_event WHERE actor_name IS NOT NULL OR after IS NOT NULL)"
|
||||
)
|
||||
)
|
||||
).one()
|
||||
assert left[0] == 0 and left[1] == 0 and left[2] > 0 and left[3] == "closed" and left[4] == 0
|
||||
# 手机号可以重新注册(新企业)
|
||||
assert (await app_client.post("/auth/sms-codes", json={"phone": u.phone, "purpose": "register"})).status_code in (
|
||||
202,
|
||||
429,
|
||||
)
|
||||
del project_id
|
||||
181
server/tests/test_ops_console.py
Normal file
181
server/tests/test_ops_console.py
Normal file
@@ -0,0 +1,181 @@
|
||||
"""运营后台(FR-M6-09,m6-plan §2,决策 M6-2 / M6-4 / M6-7):独立进程、用户名 + 密码 + TOTP、IP 白名单;
|
||||
跨企业只读走 bd_ops,对某个企业的操作走它自己的租户事务并写审计;不显示业务内容。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import itertools
|
||||
from collections.abc import AsyncIterator
|
||||
from urllib.parse import parse_qs, urlsplit
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy.exc import ProgrammingError
|
||||
|
||||
from bid_doctor.core.crypto import totp
|
||||
from bid_doctor.core.db import TenantContext, ops_tx, tenant_tx
|
||||
from tests.conftest import User, project_with_tender, register, run_parse
|
||||
from tests.test_billing_flow import INVOICE, entries
|
||||
|
||||
_names = itertools.count(1)
|
||||
|
||||
|
||||
async def ops_client(ip: str = "127.0.0.1") -> httpx.AsyncClient:
|
||||
from bid_doctor.ops.app import create_ops_app
|
||||
|
||||
transport = httpx.ASGITransport(app=create_ops_app(), client=(ip, 51000))
|
||||
return httpx.AsyncClient(transport=transport, base_url="http://ops/ops/v1")
|
||||
|
||||
|
||||
async def new_admin() -> tuple[str, bytes]:
|
||||
from bid_doctor.ops.auth import create_admin
|
||||
|
||||
username = f"ops{next(_names)}"
|
||||
uri = await create_admin(username, "运营小王", "correct horse battery")
|
||||
b32 = parse_qs(urlsplit(uri).query)["secret"][0]
|
||||
return username, base64.b32decode(b32 + "=" * (-len(b32) % 8))
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def ops() -> AsyncIterator[httpx.AsyncClient]:
|
||||
username, secret = await new_admin()
|
||||
async with await ops_client() as c:
|
||||
r = await c.post(
|
||||
"/auth/login", json={"username": username, "password": "correct horse battery", "totp": totp(secret)}
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
c.headers["X-CSRF-Token"] = c.cookies["bd_ops_csrf"]
|
||||
yield c
|
||||
|
||||
|
||||
async def test_login_needs_password_totp_and_allowed_ip() -> None:
|
||||
username, secret = await new_admin()
|
||||
async with await ops_client() as c:
|
||||
bad = await c.post(
|
||||
"/auth/login", json={"username": username, "password": "correct horse battery", "totp": "000000"}
|
||||
)
|
||||
assert bad.status_code == 401 and bad.json()["code"] == "ops_login_failed"
|
||||
assert (await c.get("/me")).status_code == 401
|
||||
async with await ops_client("10.1.2.3") as far:
|
||||
denied = await far.post("/auth/login", json={"username": username, "password": "x", "totp": totp(secret)})
|
||||
assert denied.status_code == 403 and denied.json()["code"] == "ops_ip_denied"
|
||||
# 用户端的 bd_app 读不到运营账号表
|
||||
async with tenant_tx(
|
||||
TenantContext(tenant_id=__import__("uuid").uuid4(), user_id=__import__("uuid").UUID(int=0))
|
||||
) as s:
|
||||
with pytest.raises(ProgrammingError):
|
||||
await s.execute(text("SELECT 1 FROM ops_admin"))
|
||||
|
||||
|
||||
async def test_tenants_activation_and_adjust(ops: httpx.AsyncClient, app_client: httpx.AsyncClient) -> None:
|
||||
u: User = await register(app_client, "运营测试甲公司")
|
||||
project_id, doc_id = await project_with_tender(u)
|
||||
assert (await run_parse(u, doc_id))["status"] == "succeeded"
|
||||
|
||||
found = (await ops.get("/tenants", params={"q": "运营测试甲"})).json()["items"]
|
||||
assert [t["name"] for t in found] == ["运营测试甲公司"] and found[0]["balance"] == 2
|
||||
assert (await ops.get("/tenants", params={"q": u.phone})).json()["items"][0]["id"] == u.tenant_id
|
||||
detail = (await ops.get(f"/tenants/{u.tenant_id}")).json()
|
||||
assert detail["projects"] == 1 and detail["jobs"]["parse_job"] == {"succeeded": 1}
|
||||
assert "未命名项目" not in str(detail) and project_id not in str(detail) # 不显示项目名称和编号
|
||||
|
||||
# 开通:用户提交 → 运营标记到账 → 开通 → 登记发票;全程写审计(操作人是运营)
|
||||
req = (await u.post("/activation-requests", json={"invoice_info": INVOICE})).json()
|
||||
listed = (await ops.get("/activation-requests", params={"status": "submitted"})).json()["items"]
|
||||
assert req["id"] in [x["id"] for x in listed]
|
||||
assert (await ops.get("/tenants", params={"q": req["request_no"]})).json()["items"][0]["id"] == u.tenant_id
|
||||
assert (await ops.post(f"/activation-requests/{req['id']}/invoice", json={"invoice_no": "No.1"})).status_code == 409
|
||||
assert (await ops.post(f"/activation-requests/{req['id']}/paid")).status_code == 200
|
||||
r = await ops.post(f"/activation-requests/{req['id']}/activate", json={})
|
||||
assert r.status_code == 200 and r.json()["subscription"]["status"] == "active", r.text
|
||||
assert (await ops.post(f"/activation-requests/{req['id']}/invoice", json={"invoice_no": "No.1"})).status_code == 200
|
||||
assert (await u.get("/me")).json()["plan"]["code"] == "pro"
|
||||
|
||||
# 再申请一份,运营退回(要写原因),用户看得到原因
|
||||
r2 = (await u.post("/activation-requests", json={"invoice_info": INVOICE})).json()
|
||||
assert (await ops.post(f"/activation-requests/{r2['id']}/reject", json={"note": ""})).status_code == 422
|
||||
assert (
|
||||
await ops.post(f"/activation-requests/{r2['id']}/reject", json={"note": "抬头和转账单位不一致"})
|
||||
).status_code == 200
|
||||
mine = (await u.get("/activation-requests")).json()["items"]
|
||||
assert mine[0]["status"] == "rejected" and mine[0]["ops_note"] == "抬头和转账单位不一致"
|
||||
|
||||
# 调额度:原因必填;不能扣成负数;流水里看得到
|
||||
assert (
|
||||
await ops.post(f"/tenants/{u.tenant_id}/quota-adjustments", json={"amount": 20, "reason": ""})
|
||||
).status_code == 422
|
||||
too_much = await ops.post(f"/tenants/{u.tenant_id}/quota-adjustments", json={"amount": -9999, "reason": "测试扣减"})
|
||||
assert too_much.status_code == 422
|
||||
assert (
|
||||
await ops.post(f"/tenants/{u.tenant_id}/quota-adjustments", json={"amount": 20, "reason": "试用补偿"})
|
||||
).status_code == 201
|
||||
adj = (await entries(u))[0]
|
||||
assert (adj["kind"], adj["amount"], adj["reason"]) == ("adjust", 20, "试用补偿")
|
||||
|
||||
events = (await ops.get("/audit-events", params={"tenant_id": u.tenant_id, "action": "ops."})).json()["items"]
|
||||
actions = {e["action"] for e in events}
|
||||
assert {
|
||||
"ops.tenant.view",
|
||||
"ops.activation.paid",
|
||||
"ops.activation.activate",
|
||||
"ops.activation.reject",
|
||||
"ops.activation.invoice",
|
||||
"ops.quota.adjust",
|
||||
} <= actions
|
||||
assert all(e["actor_kind"] == "ops" and e["actor_name"] == "运营小王" for e in events)
|
||||
|
||||
|
||||
async def test_feedback_and_failed_jobs_without_content(ops: httpx.AsyncClient, user: User) -> None:
|
||||
from tests.test_check_flow import checked_project
|
||||
|
||||
project_id, _, run = await checked_project(user)
|
||||
finding = (await user.get(f"/check-runs/{run['id']}/findings")).json()["items"][0]
|
||||
r = await user.post(
|
||||
"/feedback",
|
||||
json={
|
||||
"project_id": project_id,
|
||||
"object_type": "finding",
|
||||
"object_id": finding["id"],
|
||||
"kind": "false_positive",
|
||||
"note": "这不是问题(误报)",
|
||||
},
|
||||
)
|
||||
assert r.status_code == 201
|
||||
items = (await ops.get("/feedback", params={"kind": "false_positive"})).json()["items"]
|
||||
mine = next(x for x in items if x["note"] == "这不是问题(误报)")
|
||||
assert mine["title"] == finding["title"] and mine["rule_set_version"] and mine["pages"]
|
||||
assert "quote" not in str(mine) and "text" not in mine # 不带原文
|
||||
async with ops_tx() as s: # bd_ops 只读:改不了业务表
|
||||
with pytest.raises(ProgrammingError):
|
||||
await s.execute(text("UPDATE project SET name = 'x'"))
|
||||
assert isinstance((await ops.get("/failed-jobs")).json()["items"], list)
|
||||
|
||||
|
||||
async def test_every_endpoint_checks_permission() -> None:
|
||||
"""角色只留结构:现在只有管理员(全部权限);没定义的角色什么权限都没有,接口一律 403,/me 照常给出权限点。"""
|
||||
username, secret = await new_admin()
|
||||
async with ops_tx() as s:
|
||||
await s.execute(text("UPDATE ops_admin SET role = 'sales' WHERE username = :u"), {"u": username})
|
||||
async with await ops_client() as c:
|
||||
r = await c.post(
|
||||
"/auth/login", json={"username": username, "password": "correct horse battery", "totp": totp(secret)}
|
||||
)
|
||||
assert r.status_code == 200 and r.json()["permissions"] == [] and r.json()["role"] == "sales"
|
||||
c.headers["X-CSRF-Token"] = c.cookies["bd_ops_csrf"]
|
||||
for path in ("/tenants", "/activation-requests", "/feedback", "/failed-jobs", "/audit-events"):
|
||||
assert (await c.get(path)).status_code == 403, path
|
||||
assert (await c.get("/summary")).json() == {"open_requests": 0, "failed_jobs_7d": 0, "feedback_7d": 0}
|
||||
async with await ops_client() as c: # 管理员
|
||||
username, secret = await new_admin()
|
||||
me = (
|
||||
await c.post(
|
||||
"/auth/login", json={"username": username, "password": "correct horse battery", "totp": totp(secret)}
|
||||
)
|
||||
).json()
|
||||
assert (
|
||||
me["role"] == "admin"
|
||||
and me["role_label"] == "管理员"
|
||||
and "audit.view" in me["permissions"]
|
||||
and me["env"] == "test"
|
||||
)
|
||||
Reference in New Issue
Block a user