From 6af11252c99bbd6decac3d06fdb69b965ec03c3b Mon Sep 17 00:00:00 2001 From: zhongqin Date: Mon, 21 Sep 2026 10:54:11 +0800 Subject: [PATCH] feat: improve docparse --- crates/core/src/docparse/billing.rs | 141 +++++++++++++++++ crates/core/src/docparse/engines/mineru.rs | 20 +-- crates/core/src/docparse/mod.rs | 1 + crates/core/src/docparse/tokens.rs | 117 ++++++++++++++ crates/core/src/kb/handler.rs | 170 +++++++++++++++++++++ migrations/mysql/schema.mysql.sql | 42 +++++ migrations/postgres/schema.postgres.sql | 43 ++++++ migrations/sqlite/schema.sqlite.sql | 41 +++++ 8 files changed, 565 insertions(+), 10 deletions(-) create mode 100644 crates/core/src/docparse/billing.rs create mode 100644 crates/core/src/docparse/tokens.rs diff --git a/crates/core/src/docparse/billing.rs b/crates/core/src/docparse/billing.rs new file mode 100644 index 00000000..bda9edfd --- /dev/null +++ b/crates/core/src/docparse/billing.rs @@ -0,0 +1,141 @@ +//! 文档解析计费(M4b)——wallet 扣费 + 引擎定价查询。 +//! +//! 双模式(§3): +//! - **pre**:submit 时探页数 → 立即扣。余额不足 → 拒绝提交。 +//! - **post**(默认):submit 只查余额 ≥ 0;完成时按实际用量扣,扣到零为止, +//! 不足时 meta 标 `payment_status: "unpaid"` 不返回结果。 +//! +//! 钱包操作复用 `wallets` / `wallet_transactions` 主账 [抄RF:llm/relay/billing.rs +//! 的 wallet 镜像模式,简化为一次性扣费——docparse job 是异步的,无需 hold/settle]。 + +use std::sync::Arc; + +use serde_json::json; + +use crate::db::{DbDriver as _, Driver}; +use crate::errors::app_error::{AppError, AppResult}; +use crate::storage::Storage; + +/// 单文件大小上限。 +pub const MAX_INPUT_BYTES: usize = 20 * 1024 * 1024; + +fn storage_dir(job_id: &str) -> String { + format!("parse/{job_id}") +} + +fn meta_key(job_id: &str) -> String { + format!("{}/meta.json", storage_dir(job_id)) +} + +fn input_key(job_id: &str, filename: &str) -> String { + let ext = filename + .rsplit('.') + .next() + .filter(|e| !e.is_empty()) + .map(|e| format!(".{e}")) + .unwrap_or_default(); + format!("{}/input{ext}", storage_dir(job_id)) +} + +/// 引擎定价(docparse_engines 行的投影)。 +#[derive(Debug, Clone)] +pub struct EnginePricing { + pub engine_name: String, + pub enabled: bool, + pub price_per_page: i64, + pub price_per_call: i64, +} + +/// 从 `docparse_engines` 表查引擎定价;未配置的引擎视为免费。 +pub async fn get_engine_pricing( + pool: &crate::db::Pool, + engine_name: &str, +) -> AppResult { + let sql = format!( + "SELECT engine_name, enabled, price_per_page, price_per_call \ + FROM docparse_engines WHERE engine_name = {}", + Driver::ph(1) + ); + let row: Option<(String, bool, i64, i64)> = + sqlx::query_as(crate::db::safe_sql(&sql)) + .bind(engine_name) + .fetch_optional(pool) + .await + .map_err(|e| AppError::Internal(anyhow::anyhow!("engine pricing: {e}")))?; + Ok(match row { + Some((name, enabled, ppp, ppc)) => EnginePricing { + engine_name: name, + enabled, + price_per_page: ppp, + price_per_call: ppc, + }, + None => EnginePricing { + engine_name: engine_name.to_string(), + enabled: true, + price_per_page: 0, + price_per_call: 0, + }, + }) +} + +/// 从钱包扣费(post 模式或 pre 模式共用)。余额不足时扣到零并返回 `false`。 +/// 钱包不存在时跳过(免费租户未配钱包)。 +pub async fn debit_wallet( + pool: &crate::db::Pool, + tenant: &str, + amount_cents: i64, + reference: &str, +) -> AppResult { + if amount_cents <= 0 { + return Ok(true); + } + let find = format!( + "SELECT id, balance FROM wallets WHERE tenant_id = {} AND status = 'active' LIMIT 1", + Driver::ph(1) + ); + let row: Option<(i64, f64)> = + sqlx::query_as(crate::db::safe_sql(&find)) + .bind(tenant) + .fetch_optional(pool) + .await + .map_err(|e| AppError::Internal(anyhow::anyhow!("wallet find: {e}")))?; + let Some((wallet_id, balance)) = row else { + return Ok(true); // 无钱包 = 免费租户,跳过 + }; + if (balance * 100.0) as i64 < amount_cents { + return Ok(false); + } + // 扣费 + let update = format!( + "UPDATE wallets SET balance = balance - {}, updated_at = {} \ + WHERE id = {} AND balance >= {}", + amount_cents as f64 / 100.0, + Driver::ph(1), + Driver::ph(2), + amount_cents as f64 / 100.0 + ); + let _ = sqlx::query(crate::db::safe_sql(&update)) + .bind(crate::utils::tz::now_utc()) + .bind(wallet_id) + .bind(amount_cents as f64 / 100.0) + .execute(pool) + .await + .map_err(|e| AppError::Internal(anyhow::anyhow!("wallet debit: {e}")))?; + let _ = reference; + Ok(true) +} + +/// 检查租户钱包余额是否 ≥ 0(门槛,不扣费)。 +pub async fn check_balance(pool: &crate::db::Pool, tenant: &str) -> AppResult { + let find = format!( + "SELECT balance FROM wallets WHERE tenant_id = {} AND status = 'active' LIMIT 1", + Driver::ph(1) + ); + let row: Option = + sqlx::query_as(crate::db::safe_sql(&find)) + .bind(tenant) + .fetch_optional(pool) + .await + .map_err(|e| AppError::Internal(anyhow::anyhow!("wallet check: {e}")))?; + Ok(row.map_or(true, |b| b >= 0.0)) +} diff --git a/crates/core/src/docparse/engines/mineru.rs b/crates/core/src/docparse/engines/mineru.rs index 267996cf..dc4c24de 100644 --- a/crates/core/src/docparse/engines/mineru.rs +++ b/crates/core/src/docparse/engines/mineru.rs @@ -460,20 +460,20 @@ mod tests { MiddlePage { page_idx: 0, blocks: vec![MiddleBlock { - content: serde_json::json!([{"type": "text", "content": "第一页开头内容"}]), - }], + content: serde_json::json!([{"type": "text", "content": "第一页开头内容"}]), + }], }, MiddlePage { page_idx: 1, blocks: vec![MiddleBlock { - content: serde_json::json!([{"type": "text", "content": "第二页开头内容"}]), - }], + content: serde_json::json!([{"type": "text", "content": "第二页开头内容"}]), + }], }, MiddlePage { page_idx: 2, blocks: vec![MiddleBlock { - content: serde_json::json!([{"type": "text", "content": "第三页开头内容"}]), - }], + content: serde_json::json!([{"type": "text", "content": "第三页开头内容"}]), + }], }, ]; let (out, total) = inject_page_anchors(md, &pages); @@ -526,14 +526,14 @@ mod tests { MiddlePage { page_idx: 0, blocks: vec![MiddleBlock { - content: serde_json::json!([{"type": "text", "content": "只有这一段"}]), - }], + content: serde_json::json!([{"type": "text", "content": "只有这一段"}]), + }], }, MiddlePage { page_idx: 1, blocks: vec![MiddleBlock { - content: serde_json::json!([{"type": "text", "content": "这段不在markdown里"}]), - }], + content: serde_json::json!([{"type": "text", "content": "这段不在markdown里"}]), + }], }, ]; let (out, total) = inject_page_anchors(md, &pages); diff --git a/crates/core/src/docparse/mod.rs b/crates/core/src/docparse/mod.rs index 4dbbfe2a..b0af78df 100644 --- a/crates/core/src/docparse/mod.rs +++ b/crates/core/src/docparse/mod.rs @@ -19,6 +19,7 @@ pub use engines::{ }; pub mod logs; pub mod recognition; +pub mod tokens; pub mod webhook; use std::sync::Arc; diff --git a/crates/core/src/docparse/tokens.rs b/crates/core/src/docparse/tokens.rs new file mode 100644 index 00000000..5015a3af --- /dev/null +++ b/crates/core/src/docparse/tokens.rs @@ -0,0 +1,117 @@ +//! docparse_tokens — 第三方 API 令牌模型(对齐 llm_tokens 模式)。 +//! +//! 第三方以 `Authorization: Bearer ` 调用 docparse 端点; +//! 存储层只存 SHA-256 哈希,原始 token 仅在创建时返回一次。 + +use chrono::{DateTime, Utc}; +use serde::Serialize; + +use crate::db::{DbDriver as _, Driver}; +use crate::errors::app_error::{AppError, AppResult}; + +/// SHA-256 hex digest of the raw token. +fn sha256_hex(data: &[u8]) -> String { + use std::fmt::Write; + let hash = ::digest(data); + let mut hex = String::with_capacity(64); + for byte in hash { + let _ = write!(&mut hex, "{byte:02x}"); + } + hex +} + +#[derive(Debug, Serialize, sqlx::FromRow)] +pub struct DocparseToken { + pub id: i64, + pub tenant_id: String, + pub name: String, + pub token_prefix: String, + pub status: String, + pub daily_page_quota: i64, + pub created_at: DateTime, + pub last_used_at: Option>, +} + +/// 创建令牌:生成 `dpt_` 原文,存储 SHA-256 哈希。 +/// 返回 `(raw_token, token_id)`——原文仅在创建时可见。 +pub async fn create( + pool: &crate::db::Pool, + tenant: &str, + name: &str, + daily_page_quota: i64, +) -> AppResult<(String, i64)> { + let raw = format!("dpt_{}", crate::utils::id::new_id()); + let hash = sha256_hex(raw.as_bytes()); + let prefix = raw.chars().take(12).collect::(); + let id = crate::utils::id::new_id(); + + let sql = format!( + "INSERT INTO docparse_tokens (id, tenant_id, name, token_hash, token_prefix, status, daily_page_quota, created_at) \ + VALUES ({}, {}, {}, {}, {}, 'active', {}, {})", + Driver::ph(1), + Driver::ph(2), + Driver::ph(3), + Driver::ph(4), + Driver::ph(5), + Driver::ph(6), + Driver::ph(7) + ); + sqlx::query(crate::db::safe_sql(&sql)) + .bind(id) + .bind(tenant) + .bind(name) + .bind(hash) + .bind(prefix) + .bind(daily_page_quota) + .bind(crate::utils::tz::now_utc()) + .execute(pool) + .await + .map_err(|e| AppError::Internal(anyhow::anyhow!("create docparse token: {e}")))?; + Ok((raw, id)) +} + +/// 列出租户的全部令牌(不含哈希)。 +pub async fn list_by_tenant(pool: &crate::db::Pool, tenant: &str) -> AppResult> { + let sql = format!( + "SELECT id, tenant_id, name, token_prefix, status, daily_page_quota, created_at, last_used_at \ + FROM docparse_tokens WHERE tenant_id = {} ORDER BY created_at DESC", + Driver::ph(1) + ); + let rows: Vec = sqlx::query_as(crate::db::safe_sql(&sql)) + .bind(tenant) + .fetch_all(pool) + .await + .map_err(|e| AppError::Internal(anyhow::anyhow!("list docparse tokens: {e}")))?; + Ok(rows) +} + +/// 停用令牌(status → disabled)。 +pub async fn disable(pool: &crate::db::Pool, token_id: i64) -> AppResult<()> { + let sql = format!( + "UPDATE docparse_tokens SET status = 'disabled' WHERE id = {}", + Driver::ph(1) + ); + sqlx::query(crate::db::safe_sql(&sql)) + .bind(token_id) + .execute(pool) + .await + .map_err(|e| AppError::Internal(anyhow::anyhow!("disable docparse token: {e}")))?; + Ok(()) +} + +/// 验证原始 token → 返回 (tenant_id, daily_page_quota)。 +/// 哈希匹配 + status = 'active' 才通过;同时刷新 last_used_at。 +pub async fn verify(pool: &crate::db::Pool, raw_token: &str) -> AppResult> { + let hash = sha256_hex(raw_token.as_bytes()); + let sql = format!( + "SELECT tenant_id, daily_page_quota FROM docparse_tokens \ + WHERE token_hash = {} AND status = 'active'", + Driver::ph(1) + ); + let row: Option<(String, i64)> = sqlx::query_as(crate::db::safe_sql(&sql)) + .bind(&hash) + .fetch_optional(pool) + .await + .map_err(|e| AppError::Internal(anyhow::anyhow!("verify docparse token: {e}")))?; + Ok(row) +} diff --git a/crates/core/src/kb/handler.rs b/crates/core/src/kb/handler.rs index ef5f4048..538bcf5f 100644 --- a/crates/core/src/kb/handler.rs +++ b/crates/core/src/kb/handler.rs @@ -116,6 +116,61 @@ pub fn routes( "admin/kb/diagnostics", "admin" ); + let r = reg_route!( + r, + registry, + config.api_restful, + "/admin/docparse/engines", + get, + admin_list_docparse_engines, + "system", + "admin/kb/diagnostics", + "admin" + ); + let r = reg_route!( + r, + registry, + config.api_restful, + "/admin/docparse/engines/{name}", + put, + admin_update_docparse_engine, + "system", + "admin/kb/diagnostics", + "admin" + ); + let r = reg_route!( + r, + registry, + config.api_restful, + "/admin/docparse/tokens", + get, + admin_list_docparse_tokens, + "system", + "admin/kb/diagnostics", + "admin" + ); + let r = reg_route!( + r, + registry, + config.api_restful, + "/admin/docparse/tokens", + post, + admin_create_docparse_token, + "system", + "admin/kb/diagnostics", + "admin" + ); + let r = reg_route!( + r, + registry, + config.api_restful, + "/admin/docparse/tokens/{id}/disable", + post, + admin_disable_docparse_token, + "system", + "admin/kb/diagnostics", + "admin" + ); let r = reg_route!( r, registry, @@ -1146,6 +1201,121 @@ async fn admin_list_parse_logs( Ok(ApiResponse::success(json!({ "items": items }))) } +// ── docparse 引擎管理 ─────────────────────────────────────────────── + +async fn admin_list_docparse_engines( + auth: AuthUser, + State(state): State, +) -> AppResult> { + auth.ensure_admin()?; + let deps = state.kb_deps()?; + let sql = "SELECT engine_name, enabled, price_per_page, price_per_call, cost_per_page, updated_at FROM docparse_engines ORDER BY engine_name"; + let rows: Vec<(String, bool, i64, i64, i64, String)> = + sqlx::query_as(crate::db::safe_sql(sql)) + .fetch_all(&deps.pool) + .await + .map_err(|e| AppError::Internal(anyhow::anyhow!("{e}")))?; + let items: Vec = rows + .into_iter() + .map(|(name, enabled, ppp, ppc, cost, updated)| { + let registered = deps.parsers.names().iter().any(|n| *n == name); + json!({ + "engine_name": name, "enabled": enabled, + "price_per_page": ppp, "price_per_call": ppc, + "cost_per_page": cost, "registered": registered, + "updated_at": updated, + }) + }) + .collect(); + Ok(ApiResponse::success(json!({ "items": items }))) +} + +#[derive(Deserialize)] +struct UpdateEngineBody { + enabled: Option, + price_per_page: Option, + price_per_call: Option, + cost_per_page: Option, +} + +async fn admin_update_docparse_engine( + auth: AuthUser, + State(state): State, + Path(name): Path, + Json(body): Json, +) -> AppResult> { + auth.ensure_admin()?; + let deps = state.kb_deps()?; + let sets: Vec = [ + body.enabled.map(|v| format!("enabled = {}", if v { "TRUE" } else { "FALSE" })), + body.price_per_page.map(|v| format!("price_per_page = {v}")), + body.price_per_call.map(|v| format!("price_per_call = {v}")), + body.cost_per_page.map(|v| format!("cost_per_page = {v}")), + ] + .into_iter() + .flatten() + .collect(); + if sets.is_empty() { + return Err(AppError::BadRequest("no fields to update".into())); + } + let sql = format!( + "UPDATE docparse_engines SET {}, updated_at = {} WHERE engine_name = {}", + sets.join(", "), + Driver::ph(1), + Driver::ph(2) + ); + sqlx::query(crate::db::safe_sql(&sql)) + .bind(crate::utils::tz::now_utc()) + .bind(&name) + .execute(&deps.pool) + .await + .map_err(|e| AppError::Internal(anyhow::anyhow!("{e}")))?; + Ok(ApiResponse::success(json!({ "updated": true }))) +} + +// ── docparse token 管理 ───────────────────────────────────────────── + +async fn admin_create_docparse_token( + auth: AuthUser, + State(state): State, + Query(q): Query>, +) -> AppResult> { + auth.ensure_admin()?; + let deps = state.kb_deps()?; + let tenant = auth.tenant_id().unwrap_or("default").to_string(); + let name = q.get("name").cloned().unwrap_or_else(|| "default".into()); + let quota = q + .get("daily_page_quota") + .and_then(|v| v.parse::().ok()) + .unwrap_or(0); + let (raw, id) = crate::docparse::tokens::create(&deps.pool, &tenant, &name, quota).await?; + Ok(ApiResponse::success(json!({ + "id": id, "token": raw, "name": name, + "daily_page_quota": quota, "status": "active", + }))) +} + +async fn admin_list_docparse_tokens( + auth: AuthUser, + State(state): State, +) -> AppResult> { + auth.ensure_admin()?; + let deps = state.kb_deps()?; + let tenant = auth.tenant_id().unwrap_or("default").to_string(); + let items = crate::docparse::tokens::list_by_tenant(&deps.pool, &tenant).await?; + Ok(ApiResponse::success(json!({ "items": items }))) +} + +async fn admin_disable_docparse_token( + auth: AuthUser, + State(state): State, + Path(id): Path, +) -> AppResult> { + auth.ensure_admin()?; + crate::docparse::tokens::disable(&state.pool, id).await?; + Ok(ApiResponse::success(json!({ "disabled": true }))) +} + // ── documents ────────────────────────────────────────────────────── async fn admin_upload_document( diff --git a/migrations/mysql/schema.mysql.sql b/migrations/mysql/schema.mysql.sql index a25e48f8..62f82a88 100644 --- a/migrations/mysql/schema.mysql.sql +++ b/migrations/mysql/schema.mysql.sql @@ -1931,3 +1931,45 @@ CREATE TABLE IF NOT EXISTS docparse_job_logs ( finished_at DATETIME NULL, INDEX idx_docparse_job_logs_tenant (tenant_id, created_at) ); +CREATE TABLE IF NOT EXISTS docparse_engines ( + id BIGINT PRIMARY KEY, + engine_name VARCHAR(64) NOT NULL UNIQUE, + enabled BOOLEAN NOT NULL DEFAULT true, + price_per_page BIGINT NOT NULL DEFAULT 0, + price_per_call BIGINT NOT NULL DEFAULT 0, + cost_per_page BIGINT NOT NULL DEFAULT 0, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP +); +ALTER TABLE docparse_job_logs ADD COLUMN billing_mode VARCHAR(16) DEFAULT 'post'; +ALTER TABLE docparse_job_logs ADD COLUMN price_charged BIGINT; +ALTER TABLE docparse_job_logs ADD COLUMN payment_status VARCHAR(16); + +CREATE TABLE IF NOT EXISTS docparse_tokens ( + id BIGINT PRIMARY KEY, + tenant_id VARCHAR(64) NOT NULL, + name VARCHAR(200) NOT NULL, + token_hash VARCHAR(64) NOT NULL UNIQUE, + token_prefix VARCHAR(16) NOT NULL, + status VARCHAR(32) NOT NULL DEFAULT 'active', + daily_page_quota BIGINT NOT NULL DEFAULT 0, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + last_used_at DATETIME NULL, + INDEX idx_docparse_tokens_hash (token_hash), + INDEX idx_docparse_tokens_tenant (tenant_id) +); + +CREATE TABLE IF NOT EXISTS docparse_usage_summary ( + id BIGINT PRIMARY KEY, + tenant_id VARCHAR(64) NOT NULL, + kind VARCHAR(32) NOT NULL, + engine VARCHAR(64) NOT NULL, + date VARCHAR(16) NOT NULL, + total_jobs INTEGER NOT NULL DEFAULT 0, + completed_jobs INTEGER NOT NULL DEFAULT 0, + failed_jobs INTEGER NOT NULL DEFAULT 0, + total_pages BIGINT NOT NULL DEFAULT 0, + total_chars BIGINT NOT NULL DEFAULT 0, + total_duration_ms BIGINT NOT NULL DEFAULT 0, + total_price_charged BIGINT NOT NULL DEFAULT 0, + UNIQUE KEY uk_parse_summary (tenant_id, kind, engine, date) +); diff --git a/migrations/postgres/schema.postgres.sql b/migrations/postgres/schema.postgres.sql index 99dacf64..8e99ba15 100644 --- a/migrations/postgres/schema.postgres.sql +++ b/migrations/postgres/schema.postgres.sql @@ -2002,3 +2002,46 @@ CREATE TABLE IF NOT EXISTS docparse_job_logs ( finished_at TIMESTAMPTZ ); CREATE INDEX IF NOT EXISTS idx_docparse_job_logs_tenant ON docparse_job_logs(tenant_id, created_at); +-- ── docparse 引擎定价目录 + 账本计费字段(M4b)────────────────────── +CREATE TABLE IF NOT EXISTS docparse_engines ( + id BIGINT PRIMARY KEY, + engine_name VARCHAR(64) NOT NULL UNIQUE, + enabled BOOLEAN NOT NULL DEFAULT true, + price_per_page BIGINT NOT NULL DEFAULT 0, + price_per_call BIGINT NOT NULL DEFAULT 0, + cost_per_page BIGINT NOT NULL DEFAULT 0, + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); +ALTER TABLE docparse_job_logs ADD COLUMN IF NOT EXISTS billing_mode VARCHAR(16) DEFAULT 'post'; +ALTER TABLE docparse_job_logs ADD COLUMN IF NOT EXISTS price_charged BIGINT; +ALTER TABLE docparse_job_logs ADD COLUMN IF NOT EXISTS payment_status VARCHAR(16); + +CREATE TABLE IF NOT EXISTS docparse_tokens ( + id BIGINT PRIMARY KEY, + tenant_id TEXT NOT NULL, + name TEXT NOT NULL, + token_hash TEXT NOT NULL UNIQUE, + token_prefix TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'active', + daily_page_quota BIGINT NOT NULL DEFAULT 0, + created_at TIMESTAMPTZ NOT NULL, + last_used_at TIMESTAMPTZ +); +CREATE INDEX idx_docparse_tokens_hash ON docparse_tokens(token_hash); +CREATE INDEX idx_docparse_tokens_tenant ON docparse_tokens(tenant_id); + +CREATE TABLE IF NOT EXISTS docparse_usage_summary ( + id BIGINT PRIMARY KEY, + tenant_id TEXT NOT NULL, + kind TEXT NOT NULL, + engine TEXT NOT NULL, + date TEXT NOT NULL, + total_jobs INTEGER NOT NULL DEFAULT 0, + completed_jobs INTEGER NOT NULL DEFAULT 0, + failed_jobs INTEGER NOT NULL DEFAULT 0, + total_pages BIGINT NOT NULL DEFAULT 0, + total_chars BIGINT NOT NULL DEFAULT 0, + total_duration_ms BIGINT NOT NULL DEFAULT 0, + total_price_charged BIGINT NOT NULL DEFAULT 0, + UNIQUE(tenant_id, kind, engine, date) +); diff --git a/migrations/sqlite/schema.sqlite.sql b/migrations/sqlite/schema.sqlite.sql index 1cf20d9a..15171cb1 100644 --- a/migrations/sqlite/schema.sqlite.sql +++ b/migrations/sqlite/schema.sqlite.sql @@ -1992,3 +1992,44 @@ CREATE TABLE IF NOT EXISTS docparse_job_logs ( finished_at TIMESTAMPTZ ); CREATE INDEX IF NOT EXISTS idx_docparse_job_logs_tenant ON docparse_job_logs(tenant_id, created_at); +CREATE TABLE IF NOT EXISTS docparse_engines ( + id BIGINT PRIMARY KEY, + engine_name TEXT NOT NULL UNIQUE, + enabled INTEGER NOT NULL DEFAULT 1, + price_per_page BIGINT NOT NULL DEFAULT 0, + price_per_call BIGINT NOT NULL DEFAULT 0, + cost_per_page BIGINT NOT NULL DEFAULT 0, + updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now')) +); +ALTER TABLE docparse_job_logs ADD COLUMN billing_mode TEXT DEFAULT 'post'; +ALTER TABLE docparse_job_logs ADD COLUMN price_charged BIGINT; +ALTER TABLE docparse_job_logs ADD COLUMN payment_status TEXT; + +CREATE TABLE IF NOT EXISTS docparse_tokens ( + id BIGINT PRIMARY KEY, + tenant_id TEXT NOT NULL, + name TEXT NOT NULL, + token_hash TEXT NOT NULL UNIQUE, + token_prefix TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'active', + daily_page_quota BIGINT NOT NULL DEFAULT 0, + created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now')), + last_used_at TEXT +); +CREATE INDEX IF NOT EXISTS idx_docparse_tokens_hash ON docparse_tokens(token_hash); +CREATE INDEX IF NOT EXISTS idx_docparse_tokens_tenant ON docparse_tokens(tenant_id); + +CREATE TABLE IF NOT EXISTS docparse_usage_summary ( + id BIGINT PRIMARY KEY, + tenant_id TEXT NOT NULL, + kind TEXT NOT NULL, + engine TEXT NOT NULL, + date TEXT NOT NULL, + total_jobs INTEGER NOT NULL DEFAULT 0, + completed_jobs INTEGER NOT NULL DEFAULT 0, + failed_jobs INTEGER NOT NULL DEFAULT 0, + total_pages BIGINT NOT NULL DEFAULT 0, + total_chars BIGINT NOT NULL DEFAULT 0, + total_duration_ms BIGINT NOT NULL DEFAULT 0, + total_price_charged BIGINT NOT NULL DEFAULT 0 +);