zhuyuan-ice-gl-zy001/ZCODE-DEV-20260826/public-spine/shell-frozen-src/src-tauri/src/education_workspace.rs

1855 lines
65 KiB
Rust

// SPDX-License-Identifier: AGPL-3.0-or-later
//! 教育行业初始化频道的原生文档与表格数据层。
//!
//! 公共主世界只投影模块状态;真实内容必须进入已登录账号的独立存储根。
//! WebView 不直接持有文件路径或 SQLite 连接,所有写入都要求当前修订号,防止静默覆盖。
use ring::digest::{digest, SHA256};
use rusqlite::{params, types::Type, Connection, OptionalExtension};
use serde::de::DeserializeOwned;
use serde::{Deserialize, Serialize};
use std::collections::HashSet;
use std::fs;
use std::path::{Path, PathBuf};
use std::time::{Duration, SystemTime, UNIX_EPOCH};
use tauri::AppHandle;
use uuid::Uuid;
const WORKSPACE_SCHEMA: &str = "hololake.education-workspace/v1";
const MAX_DOCUMENT_BYTES: usize = 2_000_000;
const MAX_TITLE_BYTES: usize = 300;
const MAX_TABLE_COLUMNS: usize = 30;
const MAX_TABLE_ROWS: usize = 1_000;
const MAX_CELL_BYTES: usize = 10_000;
const MODULE_NUMBER: &str = "HLP-MOD-OFFICIAL-EDUCATION-WORKBENCH-0001";
const ADAPTER: &str = "education-workbench-v1";
fn require_active(app: &AppHandle) -> Result<(), String> {
crate::module_package_runtime::require_active_module_adapter(app, MODULE_NUMBER, ADAPTER)
}
#[derive(Clone, Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct EducationDocumentSummary {
pub document_id: String,
pub title: String,
pub revision: i64,
pub updated_at_unix_ms: i64,
}
#[derive(Clone, Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct EducationDocument {
pub document_id: String,
pub title: String,
pub body: String,
pub revision: i64,
pub created_at_unix_ms: i64,
pub updated_at_unix_ms: i64,
}
#[derive(Clone, Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct EducationTableSummary {
pub table_id: String,
pub title: String,
pub revision: i64,
pub column_count: usize,
pub row_count: usize,
pub updated_at_unix_ms: i64,
}
#[derive(Clone, Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct UnassignedTableSummary {
pub table_id: String,
pub title: String,
pub column_count: usize,
pub row_count: usize,
pub imported_at_unix_ms: i64,
}
#[derive(Clone, Debug, Deserialize, Serialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct EducationTableColumn {
pub column_id: String,
pub title: String,
}
#[derive(Clone, Debug, Deserialize, Serialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct EducationTableRow {
pub row_id: String,
pub cells: Vec<String>,
}
#[derive(Clone, Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct EducationTable {
pub table_id: String,
pub title: String,
pub columns: Vec<EducationTableColumn>,
pub rows: Vec<EducationTableRow>,
pub revision: i64,
pub created_at_unix_ms: i64,
pub updated_at_unix_ms: i64,
}
#[derive(Clone, Debug)]
pub(crate) struct ImportedEducationTable {
pub title: String,
pub columns: Vec<EducationTableColumn>,
pub rows: Vec<EducationTableRow>,
}
#[derive(Clone, Debug)]
pub(crate) struct EducationImportRegistration {
pub source_filename: String,
pub source_format: String,
pub source_sha256: String,
pub profile_json: String,
pub imported_at_unix_ms: i64,
}
#[derive(Clone, Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct EducationImportRegistrySummary {
pub import_id: String,
pub source_filename: String,
pub source_format: String,
pub table_count: usize,
pub imported_at_unix_ms: i64,
}
#[derive(Clone, Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct EducationWorkspaceSnapshot {
pub schema: &'static str,
pub state: &'static str,
pub documents: Vec<EducationDocumentSummary>,
pub tables: Vec<EducationTableSummary>,
pub automation_rules: Vec<EducationAutomationRule>,
pub recent_automation_runs: Vec<EducationAutomationRunReceipt>,
pub recent_imports: Vec<EducationImportRegistrySummary>,
pub unassigned_tables: Vec<UnassignedTableSummary>,
pub document_count: usize,
pub table_count: usize,
pub automation_rule_count: usize,
pub unassigned_count: usize,
pub storage: &'static str,
pub authority: &'static str,
}
#[derive(Clone, Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct EducationAutomationRule {
pub rule_id: String,
pub title: String,
pub table_id: String,
pub condition_column_id: String,
pub operator: String,
pub condition_value: String,
pub action_column_id: String,
pub action_value: String,
pub enabled: bool,
pub revision: i64,
pub created_at_unix_ms: i64,
pub updated_at_unix_ms: i64,
}
#[derive(Clone, Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct EducationAutomationPreview {
pub state: &'static str,
pub rule_id: String,
pub table_id: String,
pub rule_revision: i64,
pub table_revision: i64,
pub matched_rows: usize,
pub changed_cells: usize,
pub preview_token: String,
}
#[derive(Clone, Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct EducationAutomationRunReceipt {
pub state: String,
pub run_id: String,
pub rule_id: String,
pub table_id: String,
pub matched_rows: usize,
pub changed_cells: usize,
pub from_table_revision: i64,
pub to_table_revision: i64,
pub ran_at_unix_ms: i64,
}
#[derive(Clone, Debug, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct CreateEducationItemInput {
pub title: String,
}
#[derive(Clone, Debug, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct ReadEducationDocumentInput {
pub document_id: String,
}
#[derive(Clone, Debug, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct SaveEducationDocumentInput {
pub document_id: String,
pub title: String,
pub body: String,
pub expected_revision: i64,
}
#[derive(Clone, Debug, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct ReadEducationTableInput {
pub table_id: String,
}
#[derive(Clone, Debug, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct SaveEducationTableInput {
pub table_id: String,
pub title: String,
pub columns: Vec<EducationTableColumn>,
pub rows: Vec<EducationTableRow>,
pub expected_revision: i64,
}
#[derive(Clone, Debug, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct CreateEducationAutomationRuleInput {
pub title: String,
pub table_id: String,
pub condition_column_id: String,
pub operator: String,
pub condition_value: String,
pub action_column_id: String,
pub action_value: String,
}
#[derive(Clone, Debug, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct SaveEducationAutomationRuleInput {
pub rule_id: String,
pub title: String,
pub table_id: String,
pub condition_column_id: String,
pub operator: String,
pub condition_value: String,
pub action_column_id: String,
pub action_value: String,
pub enabled: bool,
pub expected_revision: i64,
}
#[derive(Clone, Debug, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct EducationAutomationRuleInput {
pub rule_id: String,
}
#[derive(Clone, Debug, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct PreviewEducationAutomationRuleInput {
pub rule_id: String,
pub expected_rule_revision: i64,
pub expected_table_revision: i64,
}
#[derive(Clone, Debug, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct ExecuteEducationAutomationRuleInput {
pub rule_id: String,
pub expected_rule_revision: i64,
pub expected_table_revision: i64,
pub preview_token: String,
}
#[derive(Clone, Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct EducationArchiveReceipt {
pub state: &'static str,
pub entity_id: String,
}
pub async fn get_education_workspace_snapshot(
app: AppHandle,
) -> Result<EducationWorkspaceSnapshot, String> {
require_active(&app)?;
let database = education_workspace_database(&app)?;
tauri::async_runtime::spawn_blocking(move || snapshot_at(&database))
.await
.map_err(|error| format!("HOLOLAKE_EDUCATION_JOIN_FAILED: {error}"))?
}
pub async fn create_education_document(
app: AppHandle,
input: CreateEducationItemInput,
) -> Result<EducationDocument, String> {
require_active(&app)?;
let database = education_workspace_database(&app)?;
tauri::async_runtime::spawn_blocking(move || create_document_at(&database, input))
.await
.map_err(|error| format!("HOLOLAKE_EDUCATION_JOIN_FAILED: {error}"))?
}
pub async fn read_education_document(
app: AppHandle,
input: ReadEducationDocumentInput,
) -> Result<EducationDocument, String> {
require_active(&app)?;
let database = education_workspace_database(&app)?;
tauri::async_runtime::spawn_blocking(move || read_document_at(&database, &input.document_id))
.await
.map_err(|error| format!("HOLOLAKE_EDUCATION_JOIN_FAILED: {error}"))?
}
pub async fn save_education_document(
app: AppHandle,
input: SaveEducationDocumentInput,
) -> Result<EducationDocument, String> {
require_active(&app)?;
let database = education_workspace_database(&app)?;
tauri::async_runtime::spawn_blocking(move || save_document_at(&database, input))
.await
.map_err(|error| format!("HOLOLAKE_EDUCATION_JOIN_FAILED: {error}"))?
}
pub async fn archive_education_document(
app: AppHandle,
input: ReadEducationDocumentInput,
) -> Result<EducationArchiveReceipt, String> {
require_active(&app)?;
let database = education_workspace_database(&app)?;
tauri::async_runtime::spawn_blocking(move || {
archive_at(
&database,
"education_documents",
"document_id",
input.document_id,
)
})
.await
.map_err(|error| format!("HOLOLAKE_EDUCATION_JOIN_FAILED: {error}"))?
}
pub async fn create_education_table(
app: AppHandle,
input: CreateEducationItemInput,
) -> Result<EducationTable, String> {
require_active(&app)?;
let database = education_workspace_database(&app)?;
tauri::async_runtime::spawn_blocking(move || create_table_at(&database, input))
.await
.map_err(|error| format!("HOLOLAKE_EDUCATION_JOIN_FAILED: {error}"))?
}
pub async fn read_education_table(
app: AppHandle,
input: ReadEducationTableInput,
) -> Result<EducationTable, String> {
require_active(&app)?;
let database = education_workspace_database(&app)?;
tauri::async_runtime::spawn_blocking(move || read_table_at(&database, &input.table_id))
.await
.map_err(|error| format!("HOLOLAKE_EDUCATION_JOIN_FAILED: {error}"))?
}
pub async fn save_education_table(
app: AppHandle,
input: SaveEducationTableInput,
) -> Result<EducationTable, String> {
require_active(&app)?;
let database = education_workspace_database(&app)?;
tauri::async_runtime::spawn_blocking(move || save_table_at(&database, input))
.await
.map_err(|error| format!("HOLOLAKE_EDUCATION_JOIN_FAILED: {error}"))?
}
pub async fn archive_education_table(
app: AppHandle,
input: ReadEducationTableInput,
) -> Result<EducationArchiveReceipt, String> {
require_active(&app)?;
let database = education_workspace_database(&app)?;
tauri::async_runtime::spawn_blocking(move || {
archive_at(&database, "education_tables", "table_id", input.table_id)
})
.await
.map_err(|error| format!("HOLOLAKE_EDUCATION_JOIN_FAILED: {error}"))?
}
pub async fn assign_imported_table_to_education(
app: AppHandle,
input: ReadEducationTableInput,
) -> Result<EducationTable, String> {
require_active(&app)?;
let database = education_workspace_database(&app)?;
tauri::async_runtime::spawn_blocking(move || {
assign_imported_table_to_education_at(&database, &input.table_id)
})
.await
.map_err(|error| format!("HOLOLAKE_EDUCATION_JOIN_FAILED: {error}"))?
}
pub async fn create_education_automation_rule(
app: AppHandle,
input: CreateEducationAutomationRuleInput,
) -> Result<EducationAutomationRule, String> {
require_active(&app)?;
let database = education_workspace_database(&app)?;
tauri::async_runtime::spawn_blocking(move || create_automation_rule_at(&database, input))
.await
.map_err(|error| format!("HOLOLAKE_EDUCATION_JOIN_FAILED: {error}"))?
}
pub async fn save_education_automation_rule(
app: AppHandle,
input: SaveEducationAutomationRuleInput,
) -> Result<EducationAutomationRule, String> {
require_active(&app)?;
let database = education_workspace_database(&app)?;
tauri::async_runtime::spawn_blocking(move || save_automation_rule_at(&database, input))
.await
.map_err(|error| format!("HOLOLAKE_EDUCATION_JOIN_FAILED: {error}"))?
}
pub async fn archive_education_automation_rule(
app: AppHandle,
input: EducationAutomationRuleInput,
) -> Result<EducationArchiveReceipt, String> {
require_active(&app)?;
let database = education_workspace_database(&app)?;
tauri::async_runtime::spawn_blocking(move || {
archive_automation_rule_at(&database, input.rule_id)
})
.await
.map_err(|error| format!("HOLOLAKE_EDUCATION_JOIN_FAILED: {error}"))?
}
pub async fn preview_education_automation_rule(
app: AppHandle,
input: PreviewEducationAutomationRuleInput,
) -> Result<EducationAutomationPreview, String> {
require_active(&app)?;
let database = education_workspace_database(&app)?;
tauri::async_runtime::spawn_blocking(move || preview_automation_rule_at(&database, input))
.await
.map_err(|error| format!("HOLOLAKE_EDUCATION_JOIN_FAILED: {error}"))?
}
pub async fn execute_education_automation_rule(
app: AppHandle,
input: ExecuteEducationAutomationRuleInput,
) -> Result<EducationAutomationRunReceipt, String> {
require_active(&app)?;
let database = education_workspace_database(&app)?;
tauri::async_runtime::spawn_blocking(move || execute_automation_rule_at(&database, input))
.await
.map_err(|error| format!("HOLOLAKE_EDUCATION_JOIN_FAILED: {error}"))?
}
pub(crate) fn education_workspace_database(app: &AppHandle) -> Result<PathBuf, String> {
let root = crate::authenticated_storage::account_storage_root(app, "education-workspace-v1")?;
create_private_directory(&root)?;
Ok(root.join("education-workspace.sqlite3"))
}
fn create_private_directory(path: &Path) -> Result<(), String> {
fs::create_dir_all(path)
.map_err(|error| format!("HOLOLAKE_EDUCATION_STORAGE_UNAVAILABLE: {error}"))?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
fs::set_permissions(path, fs::Permissions::from_mode(0o700))
.map_err(|error| format!("HOLOLAKE_EDUCATION_STORAGE_PERMISSION_FAILED: {error}"))?;
}
Ok(())
}
fn open_database(path: &Path) -> Result<Connection, String> {
if let Some(parent) = path.parent() {
create_private_directory(parent)?;
}
let connection = Connection::open(path)
.map_err(|error| format!("HOLOLAKE_EDUCATION_DATABASE_UNAVAILABLE: {error}"))?;
connection
.busy_timeout(Duration::from_secs(5))
.map_err(|error| format!("HOLOLAKE_EDUCATION_DATABASE_UNAVAILABLE: {error}"))?;
connection
.execute_batch(
"PRAGMA foreign_keys = ON;
PRAGMA journal_mode = DELETE;
PRAGMA synchronous = FULL;
PRAGMA trusted_schema = OFF;
CREATE TABLE IF NOT EXISTS workspace_meta (
key TEXT PRIMARY KEY NOT NULL,
value TEXT NOT NULL
);
INSERT OR IGNORE INTO workspace_meta(key, value) VALUES ('schema_version', '1');
CREATE TABLE IF NOT EXISTS education_documents (
document_id TEXT PRIMARY KEY NOT NULL,
title TEXT NOT NULL,
body TEXT NOT NULL,
revision INTEGER NOT NULL,
created_at_unix_ms INTEGER NOT NULL,
updated_at_unix_ms INTEGER NOT NULL,
archived INTEGER NOT NULL DEFAULT 0 CHECK(archived IN (0, 1))
);
CREATE TABLE IF NOT EXISTS education_tables (
table_id TEXT PRIMARY KEY NOT NULL,
title TEXT NOT NULL,
columns_json TEXT NOT NULL,
rows_json TEXT NOT NULL,
revision INTEGER NOT NULL,
created_at_unix_ms INTEGER NOT NULL,
updated_at_unix_ms INTEGER NOT NULL,
assignment_scope TEXT NOT NULL DEFAULT 'EDUCATION' CHECK(assignment_scope IN ('EDUCATION', 'UNASSIGNED')),
archived INTEGER NOT NULL DEFAULT 0 CHECK(archived IN (0, 1))
);
CREATE TABLE IF NOT EXISTS education_automation_rules (
rule_id TEXT PRIMARY KEY NOT NULL,
title TEXT NOT NULL,
table_id TEXT NOT NULL,
condition_column_id TEXT NOT NULL,
operator TEXT NOT NULL,
condition_value TEXT NOT NULL,
action_column_id TEXT NOT NULL,
action_value TEXT NOT NULL,
enabled INTEGER NOT NULL DEFAULT 1 CHECK(enabled IN (0, 1)),
revision INTEGER NOT NULL,
created_at_unix_ms INTEGER NOT NULL,
updated_at_unix_ms INTEGER NOT NULL,
archived INTEGER NOT NULL DEFAULT 0 CHECK(archived IN (0, 1))
);
CREATE TABLE IF NOT EXISTS education_automation_runs (
run_id TEXT PRIMARY KEY NOT NULL,
rule_id TEXT NOT NULL,
table_id TEXT NOT NULL,
matched_rows INTEGER NOT NULL,
changed_cells INTEGER NOT NULL,
from_table_revision INTEGER NOT NULL,
to_table_revision INTEGER NOT NULL,
ran_at_unix_ms INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS education_import_registry (
import_id TEXT PRIMARY KEY NOT NULL,
source_filename TEXT NOT NULL,
source_format TEXT NOT NULL,
source_sha256 TEXT NOT NULL,
profile_json TEXT NOT NULL,
table_ids_json TEXT NOT NULL,
imported_at_unix_ms INTEGER NOT NULL
);",
)
.map_err(|error| format!("HOLOLAKE_EDUCATION_DATABASE_MIGRATION_FAILED: {error}"))?;
if !table_has_column(&connection, "education_tables", "assignment_scope")? {
connection
.execute(
"ALTER TABLE education_tables ADD COLUMN assignment_scope TEXT NOT NULL DEFAULT 'EDUCATION' CHECK(assignment_scope IN ('EDUCATION', 'UNASSIGNED'))",
[],
)
.map_err(database_write_error)?;
let imported_table_ids = {
let mut statement = connection
.prepare("SELECT table_ids_json FROM education_import_registry")
.map_err(database_read_error)?;
let rows = statement
.query_map([], |row| row.get::<_, String>(0))
.map_err(database_read_error)?;
let encoded = rows
.collect::<Result<Vec<_>, _>>()
.map_err(database_read_error)?;
encoded
.into_iter()
.map(|value| decode_sqlite_json::<Vec<String>>(&value, 0))
.collect::<Result<Vec<_>, _>>()
.map_err(database_read_error)?
.into_iter()
.flatten()
.collect::<Vec<_>>()
};
for table_id in imported_table_ids {
connection
.execute(
"UPDATE education_tables SET assignment_scope = 'UNASSIGNED' WHERE table_id = ?1 AND archived = 0",
[table_id],
)
.map_err(database_write_error)?;
}
connection
.execute(
"INSERT INTO workspace_meta(key, value) VALUES ('schema_version', '2') ON CONFLICT(key) DO UPDATE SET value = excluded.value",
[],
)
.map_err(database_write_error)?;
}
#[cfg(unix)]
if path.exists() {
use std::os::unix::fs::PermissionsExt;
fs::set_permissions(path, fs::Permissions::from_mode(0o600))
.map_err(|error| format!("HOLOLAKE_EDUCATION_DATABASE_PERMISSION_FAILED: {error}"))?;
}
Ok(connection)
}
fn snapshot_at(path: &Path) -> Result<EducationWorkspaceSnapshot, String> {
let connection = open_database(path)?;
let documents = {
let mut statement = connection
.prepare(
"SELECT document_id, title, revision, updated_at_unix_ms
FROM education_documents WHERE archived = 0
ORDER BY updated_at_unix_ms DESC, title ASC",
)
.map_err(database_read_error)?;
let rows = statement
.query_map([], |row| {
Ok(EducationDocumentSummary {
document_id: row.get(0)?,
title: row.get(1)?,
revision: row.get(2)?,
updated_at_unix_ms: row.get(3)?,
})
})
.map_err(database_read_error)?;
rows.collect::<Result<Vec<_>, _>>()
.map_err(database_read_error)?
};
let tables = {
let mut statement = connection
.prepare(
"SELECT table_id, title, columns_json, rows_json, revision, updated_at_unix_ms
FROM education_tables WHERE archived = 0 AND assignment_scope = 'EDUCATION'
ORDER BY updated_at_unix_ms DESC, title ASC",
)
.map_err(database_read_error)?;
let rows = statement
.query_map([], |row| {
let columns_json: String = row.get(2)?;
let rows_json: String = row.get(3)?;
let columns: Vec<EducationTableColumn> = decode_sqlite_json(&columns_json, 2)?;
let rows: Vec<EducationTableRow> = decode_sqlite_json(&rows_json, 3)?;
Ok(EducationTableSummary {
table_id: row.get(0)?,
title: row.get(1)?,
column_count: columns.len(),
row_count: rows.len(),
revision: row.get(4)?,
updated_at_unix_ms: row.get(5)?,
})
})
.map_err(database_read_error)?;
rows.collect::<Result<Vec<_>, _>>()
.map_err(database_read_error)?
};
let automation_rules = read_automation_rules_with_connection(&connection)?;
let recent_automation_runs = read_recent_automation_runs_with_connection(&connection)?;
let recent_imports = read_recent_imports_with_connection(&connection)?;
let unassigned_tables = read_unassigned_tables_with_connection(&connection)?;
Ok(EducationWorkspaceSnapshot {
schema: WORKSPACE_SCHEMA,
state: "READY",
document_count: documents.len(),
table_count: tables.len(),
automation_rule_count: automation_rules.len(),
documents,
tables,
automation_rules,
recent_automation_runs,
recent_imports,
unassigned_count: unassigned_tables.len(),
unassigned_tables,
storage: "AUTHENTICATED_ACCOUNT_SCOPED_SQLITE",
authority: "CURRENT_AUTHENTICATED_ACCOUNT_ONLY",
})
}
fn read_unassigned_tables_with_connection(
connection: &Connection,
) -> Result<Vec<UnassignedTableSummary>, String> {
let mut statement = connection
.prepare(
"SELECT table_id, title, columns_json, rows_json, updated_at_unix_ms
FROM education_tables
WHERE archived = 0 AND assignment_scope = 'UNASSIGNED'
ORDER BY updated_at_unix_ms DESC, title ASC",
)
.map_err(database_read_error)?;
let rows = statement
.query_map([], |row| {
let columns_json: String = row.get(2)?;
let rows_json: String = row.get(3)?;
let columns: Vec<EducationTableColumn> = decode_sqlite_json(&columns_json, 2)?;
let rows: Vec<EducationTableRow> = decode_sqlite_json(&rows_json, 3)?;
Ok(UnassignedTableSummary {
table_id: row.get(0)?,
title: row.get(1)?,
column_count: columns.len(),
row_count: rows.len(),
imported_at_unix_ms: row.get(4)?,
})
})
.map_err(database_read_error)?;
rows.collect::<Result<Vec<_>, _>>()
.map_err(database_read_error)
}
fn read_recent_imports_with_connection(
connection: &Connection,
) -> Result<Vec<EducationImportRegistrySummary>, String> {
let mut statement = connection
.prepare(
"SELECT import_id, source_filename, source_format, table_ids_json, imported_at_unix_ms
FROM education_import_registry ORDER BY imported_at_unix_ms DESC LIMIT 20",
)
.map_err(database_read_error)?;
let rows = statement
.query_map([], |row| {
let table_ids_json: String = row.get(3)?;
let table_ids: Vec<String> = decode_sqlite_json(&table_ids_json, 3)?;
Ok(EducationImportRegistrySummary {
import_id: row.get(0)?,
source_filename: row.get(1)?,
source_format: row.get(2)?,
table_count: table_ids.len(),
imported_at_unix_ms: row.get(4)?,
})
})
.map_err(database_read_error)?;
rows.collect::<Result<Vec<_>, _>>()
.map_err(database_read_error)
}
fn read_automation_rules_with_connection(
connection: &Connection,
) -> Result<Vec<EducationAutomationRule>, String> {
let mut statement = connection
.prepare(
"SELECT rule_id, title, table_id, condition_column_id, operator,
condition_value, action_column_id, action_value, enabled, revision,
created_at_unix_ms, updated_at_unix_ms
FROM education_automation_rules WHERE archived = 0
ORDER BY updated_at_unix_ms DESC, title ASC",
)
.map_err(database_read_error)?;
let rows = statement
.query_map([], |row| {
Ok(EducationAutomationRule {
rule_id: row.get(0)?,
title: row.get(1)?,
table_id: row.get(2)?,
condition_column_id: row.get(3)?,
operator: row.get(4)?,
condition_value: row.get(5)?,
action_column_id: row.get(6)?,
action_value: row.get(7)?,
enabled: row.get::<_, i64>(8)? == 1,
revision: row.get(9)?,
created_at_unix_ms: row.get(10)?,
updated_at_unix_ms: row.get(11)?,
})
})
.map_err(database_read_error)?;
rows.collect::<Result<Vec<_>, _>>()
.map_err(database_read_error)
}
fn read_recent_automation_runs_with_connection(
connection: &Connection,
) -> Result<Vec<EducationAutomationRunReceipt>, String> {
let mut statement = connection
.prepare(
"SELECT run_id, rule_id, table_id, matched_rows, changed_cells,
from_table_revision, to_table_revision, ran_at_unix_ms
FROM education_automation_runs ORDER BY ran_at_unix_ms DESC LIMIT 20",
)
.map_err(database_read_error)?;
let rows = statement
.query_map([], |row| {
let changed_cells = row.get::<_, i64>(4)? as usize;
Ok(EducationAutomationRunReceipt {
state: if changed_cells > 0 {
"APPLIED"
} else {
"UNCHANGED"
}
.into(),
run_id: row.get(0)?,
rule_id: row.get(1)?,
table_id: row.get(2)?,
matched_rows: row.get::<_, i64>(3)? as usize,
changed_cells,
from_table_revision: row.get(5)?,
to_table_revision: row.get(6)?,
ran_at_unix_ms: row.get(7)?,
})
})
.map_err(database_read_error)?;
rows.collect::<Result<Vec<_>, _>>()
.map_err(database_read_error)
}
fn create_document_at(
path: &Path,
input: CreateEducationItemInput,
) -> Result<EducationDocument, String> {
let title = normalized_title(&input.title, "未命名教育文档")?;
let document_id = format!("EDU-DOC-{}", Uuid::new_v4());
let now = now_ms();
let connection = open_database(path)?;
connection
.execute(
"INSERT INTO education_documents(
document_id, title, body, revision, created_at_unix_ms, updated_at_unix_ms, archived
) VALUES (?1, ?2, '', 1, ?3, ?3, 0)",
params![document_id, title, now],
)
.map_err(database_write_error)?;
read_document_with_connection(&connection, &document_id)
}
fn read_document_at(path: &Path, document_id: &str) -> Result<EducationDocument, String> {
let connection = open_database(path)?;
read_document_with_connection(&connection, document_id)
}
fn read_document_with_connection(
connection: &Connection,
document_id: &str,
) -> Result<EducationDocument, String> {
validate_entity_id(document_id, "EDU-DOC-")?;
connection
.query_row(
"SELECT document_id, title, body, revision, created_at_unix_ms, updated_at_unix_ms
FROM education_documents WHERE document_id = ?1 AND archived = 0",
[document_id],
|row| {
Ok(EducationDocument {
document_id: row.get(0)?,
title: row.get(1)?,
body: row.get(2)?,
revision: row.get(3)?,
created_at_unix_ms: row.get(4)?,
updated_at_unix_ms: row.get(5)?,
})
},
)
.optional()
.map_err(database_read_error)?
.ok_or_else(|| "HOLOLAKE_EDUCATION_DOCUMENT_NOT_FOUND".to_string())
}
fn save_document_at(
path: &Path,
input: SaveEducationDocumentInput,
) -> Result<EducationDocument, String> {
validate_entity_id(&input.document_id, "EDU-DOC-")?;
let title = normalized_title(&input.title, "未命名教育文档")?;
if input.body.len() > MAX_DOCUMENT_BYTES || input.expected_revision < 1 {
return Err("HOLOLAKE_EDUCATION_DOCUMENT_INPUT_INVALID".into());
}
let connection = open_database(path)?;
let changed = connection
.execute(
"UPDATE education_documents
SET title = ?1, body = ?2, revision = revision + 1, updated_at_unix_ms = ?3
WHERE document_id = ?4 AND revision = ?5 AND archived = 0",
params![
title,
input.body,
now_ms(),
input.document_id,
input.expected_revision
],
)
.map_err(database_write_error)?;
if changed != 1 {
return Err("HOLOLAKE_EDUCATION_DOCUMENT_REVISION_CONFLICT".into());
}
read_document_with_connection(&connection, &input.document_id)
}
fn create_table_at(path: &Path, input: CreateEducationItemInput) -> Result<EducationTable, String> {
let title = normalized_title(&input.title, "未命名教育表格")?;
let table_id = format!("EDU-TABLE-{}", Uuid::new_v4());
let columns = vec![
EducationTableColumn {
column_id: format!("COL-{}", Uuid::new_v4()),
title: "姓名".into(),
},
EducationTableColumn {
column_id: format!("COL-{}", Uuid::new_v4()),
title: "类别".into(),
},
EducationTableColumn {
column_id: format!("COL-{}", Uuid::new_v4()),
title: "状态".into(),
},
];
let rows: Vec<EducationTableRow> = Vec::new();
let now = now_ms();
let connection = open_database(path)?;
connection
.execute(
"INSERT INTO education_tables(
table_id, title, columns_json, rows_json, revision,
created_at_unix_ms, updated_at_unix_ms, assignment_scope, archived
) VALUES (?1, ?2, ?3, ?4, 1, ?5, ?5, 'EDUCATION', 0)",
params![
table_id,
title,
serde_json::to_string(&columns).map_err(json_write_error)?,
serde_json::to_string(&rows).map_err(json_write_error)?,
now
],
)
.map_err(database_write_error)?;
read_table_with_connection(&connection, &table_id)
}
pub(crate) fn read_table_at(path: &Path, table_id: &str) -> Result<EducationTable, String> {
let connection = open_database(path)?;
read_table_with_connection(&connection, table_id)
}
pub(crate) fn import_table_batch_at(
path: &Path,
imported: Vec<ImportedEducationTable>,
registration: EducationImportRegistration,
) -> Result<(String, Vec<EducationTable>), String> {
if imported.is_empty() {
return Err("HOLOLAKE_EDUCATION_IMPORT_EMPTY".into());
}
for table in &imported {
normalized_title(&table.title, "导入的教育表格")?;
validate_table_data(&table.columns, &table.rows)?;
}
let mut connection = open_database(path)?;
let transaction = connection.transaction().map_err(database_write_error)?;
let now = now_ms();
let mut table_ids = Vec::with_capacity(imported.len());
for table in imported {
let table_id = format!("EDU-TABLE-{}", Uuid::new_v4());
let title = normalized_title(&table.title, "导入的教育表格")?;
transaction
.execute(
"INSERT INTO education_tables(
table_id, title, columns_json, rows_json, revision,
created_at_unix_ms, updated_at_unix_ms, assignment_scope, archived
) VALUES (?1, ?2, ?3, ?4, 1, ?5, ?5, 'UNASSIGNED', 0)",
params![
table_id,
title,
serde_json::to_string(&table.columns).map_err(json_write_error)?,
serde_json::to_string(&table.rows).map_err(json_write_error)?,
now
],
)
.map_err(database_write_error)?;
table_ids.push(table_id);
}
let import_id = format!("EDU-IMPORT-{}", Uuid::new_v4());
transaction
.execute(
"INSERT INTO education_import_registry(
import_id, source_filename, source_format, source_sha256,
profile_json, table_ids_json, imported_at_unix_ms
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
params![
import_id,
registration.source_filename,
registration.source_format,
registration.source_sha256,
registration.profile_json,
serde_json::to_string(&table_ids).map_err(json_write_error)?,
registration.imported_at_unix_ms
],
)
.map_err(database_write_error)?;
transaction.commit().map_err(database_write_error)?;
let tables = table_ids
.iter()
.map(|table_id| read_table_with_connection(&connection, table_id))
.collect::<Result<Vec<_>, _>>()?;
Ok((import_id, tables))
}
fn assign_imported_table_to_education_at(
path: &Path,
table_id: &str,
) -> Result<EducationTable, String> {
validate_entity_id(table_id, "EDU-TABLE-")?;
let connection = open_database(path)?;
let changed = connection
.execute(
"UPDATE education_tables
SET assignment_scope = 'EDUCATION', updated_at_unix_ms = ?1
WHERE table_id = ?2 AND assignment_scope = 'UNASSIGNED' AND archived = 0",
params![now_ms(), table_id],
)
.map_err(database_write_error)?;
if changed != 1 {
return Err("HOLOLAKE_EDUCATION_IMPORT_ASSIGNMENT_NOT_FOUND".into());
}
read_table_with_connection(&connection, table_id)
}
fn table_has_column(connection: &Connection, table: &str, column: &str) -> Result<bool, String> {
let mut statement = connection
.prepare(&format!("PRAGMA table_info({table})"))
.map_err(database_read_error)?;
let rows = statement
.query_map([], |row| row.get::<_, String>(1))
.map_err(database_read_error)?;
let found = rows.filter_map(Result::ok).any(|name| name == column);
Ok(found)
}
fn read_table_with_connection(
connection: &Connection,
table_id: &str,
) -> Result<EducationTable, String> {
validate_entity_id(table_id, "EDU-TABLE-")?;
let stored = connection
.query_row(
"SELECT table_id, title, columns_json, rows_json, revision,
created_at_unix_ms, updated_at_unix_ms
FROM education_tables WHERE table_id = ?1 AND archived = 0",
[table_id],
|row| {
Ok((
row.get::<_, String>(0)?,
row.get::<_, String>(1)?,
row.get::<_, String>(2)?,
row.get::<_, String>(3)?,
row.get::<_, i64>(4)?,
row.get::<_, i64>(5)?,
row.get::<_, i64>(6)?,
))
},
)
.optional()
.map_err(database_read_error)?
.ok_or_else(|| "HOLOLAKE_EDUCATION_TABLE_NOT_FOUND".to_string())?;
let columns = serde_json::from_str(&stored.2)
.map_err(|error| format!("HOLOLAKE_EDUCATION_TABLE_DATA_INVALID: {error}"))?;
let rows = serde_json::from_str(&stored.3)
.map_err(|error| format!("HOLOLAKE_EDUCATION_TABLE_DATA_INVALID: {error}"))?;
Ok(EducationTable {
table_id: stored.0,
title: stored.1,
columns,
rows,
revision: stored.4,
created_at_unix_ms: stored.5,
updated_at_unix_ms: stored.6,
})
}
fn save_table_at(path: &Path, input: SaveEducationTableInput) -> Result<EducationTable, String> {
validate_entity_id(&input.table_id, "EDU-TABLE-")?;
let title = normalized_title(&input.title, "未命名教育表格")?;
validate_table_data(&input.columns, &input.rows)?;
if input.expected_revision < 1 {
return Err("HOLOLAKE_EDUCATION_TABLE_INPUT_INVALID".into());
}
let columns_json = serde_json::to_string(&input.columns).map_err(json_write_error)?;
let rows_json = serde_json::to_string(&input.rows).map_err(json_write_error)?;
let connection = open_database(path)?;
let changed = connection
.execute(
"UPDATE education_tables
SET title = ?1, columns_json = ?2, rows_json = ?3,
revision = revision + 1, updated_at_unix_ms = ?4
WHERE table_id = ?5 AND revision = ?6 AND archived = 0",
params![
title,
columns_json,
rows_json,
now_ms(),
input.table_id,
input.expected_revision
],
)
.map_err(database_write_error)?;
if changed != 1 {
return Err("HOLOLAKE_EDUCATION_TABLE_REVISION_CONFLICT".into());
}
read_table_with_connection(&connection, &input.table_id)
}
fn normalized_operator(value: &str) -> Result<String, String> {
let operator = value.trim().to_ascii_uppercase();
if matches!(
operator.as_str(),
"EQUALS" | "CONTAINS" | "IS_EMPTY" | "IS_NOT_EMPTY"
) {
Ok(operator)
} else {
Err("HOLOLAKE_EDUCATION_AUTOMATION_OPERATOR_INVALID".into())
}
}
fn validate_automation_rule_against_table(
table: &EducationTable,
condition_column_id: &str,
action_column_id: &str,
action_value: &str,
) -> Result<(), String> {
let column_ids = table
.columns
.iter()
.map(|column| column.column_id.as_str())
.collect::<HashSet<_>>();
if !column_ids.contains(condition_column_id)
|| !column_ids.contains(action_column_id)
|| action_value.len() > MAX_CELL_BYTES
{
return Err("HOLOLAKE_EDUCATION_AUTOMATION_COLUMN_INVALID".into());
}
Ok(())
}
fn create_automation_rule_at(
path: &Path,
input: CreateEducationAutomationRuleInput,
) -> Result<EducationAutomationRule, String> {
validate_entity_id(&input.table_id, "EDU-TABLE-")?;
let title = normalized_title(&input.title, "未命名自动化规则")?;
let operator = normalized_operator(&input.operator)?;
if input.condition_value.len() > MAX_CELL_BYTES {
return Err("HOLOLAKE_EDUCATION_AUTOMATION_VALUE_INVALID".into());
}
let connection = open_database(path)?;
let table = read_table_with_connection(&connection, &input.table_id)?;
validate_automation_rule_against_table(
&table,
&input.condition_column_id,
&input.action_column_id,
&input.action_value,
)?;
let rule_id = format!("EDU-AUTO-{}", Uuid::new_v4());
let now = now_ms();
connection
.execute(
"INSERT INTO education_automation_rules(
rule_id, title, table_id, condition_column_id, operator, condition_value,
action_column_id, action_value, enabled, revision,
created_at_unix_ms, updated_at_unix_ms, archived
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, 1, 1, ?9, ?9, 0)",
params![
rule_id,
title,
input.table_id,
input.condition_column_id,
operator,
input.condition_value,
input.action_column_id,
input.action_value,
now
],
)
.map_err(database_write_error)?;
read_automation_rule_with_connection(&connection, &rule_id)
}
fn read_automation_rule_with_connection(
connection: &Connection,
rule_id: &str,
) -> Result<EducationAutomationRule, String> {
validate_entity_id(rule_id, "EDU-AUTO-")?;
connection
.query_row(
"SELECT rule_id, title, table_id, condition_column_id, operator,
condition_value, action_column_id, action_value, enabled, revision,
created_at_unix_ms, updated_at_unix_ms
FROM education_automation_rules WHERE rule_id = ?1 AND archived = 0",
[rule_id],
|row| {
Ok(EducationAutomationRule {
rule_id: row.get(0)?,
title: row.get(1)?,
table_id: row.get(2)?,
condition_column_id: row.get(3)?,
operator: row.get(4)?,
condition_value: row.get(5)?,
action_column_id: row.get(6)?,
action_value: row.get(7)?,
enabled: row.get::<_, i64>(8)? == 1,
revision: row.get(9)?,
created_at_unix_ms: row.get(10)?,
updated_at_unix_ms: row.get(11)?,
})
},
)
.optional()
.map_err(database_read_error)?
.ok_or_else(|| "HOLOLAKE_EDUCATION_AUTOMATION_RULE_NOT_FOUND".to_string())
}
fn save_automation_rule_at(
path: &Path,
input: SaveEducationAutomationRuleInput,
) -> Result<EducationAutomationRule, String> {
validate_entity_id(&input.rule_id, "EDU-AUTO-")?;
validate_entity_id(&input.table_id, "EDU-TABLE-")?;
if input.expected_revision < 1 || input.condition_value.len() > MAX_CELL_BYTES {
return Err("HOLOLAKE_EDUCATION_AUTOMATION_INPUT_INVALID".into());
}
let title = normalized_title(&input.title, "未命名自动化规则")?;
let operator = normalized_operator(&input.operator)?;
let connection = open_database(path)?;
let table = read_table_with_connection(&connection, &input.table_id)?;
validate_automation_rule_against_table(
&table,
&input.condition_column_id,
&input.action_column_id,
&input.action_value,
)?;
let changed = connection
.execute(
"UPDATE education_automation_rules
SET title = ?1, table_id = ?2, condition_column_id = ?3, operator = ?4,
condition_value = ?5, action_column_id = ?6, action_value = ?7,
enabled = ?8, revision = revision + 1, updated_at_unix_ms = ?9
WHERE rule_id = ?10 AND revision = ?11 AND archived = 0",
params![
title,
input.table_id,
input.condition_column_id,
operator,
input.condition_value,
input.action_column_id,
input.action_value,
if input.enabled { 1 } else { 0 },
now_ms(),
input.rule_id,
input.expected_revision
],
)
.map_err(database_write_error)?;
if changed != 1 {
return Err("HOLOLAKE_EDUCATION_AUTOMATION_RULE_REVISION_CONFLICT".into());
}
read_automation_rule_with_connection(&connection, &input.rule_id)
}
fn archive_automation_rule_at(
path: &Path,
rule_id: String,
) -> Result<EducationArchiveReceipt, String> {
validate_entity_id(&rule_id, "EDU-AUTO-")?;
let connection = open_database(path)?;
let changed = connection
.execute(
"UPDATE education_automation_rules
SET archived = 1, updated_at_unix_ms = ?1
WHERE rule_id = ?2 AND archived = 0",
params![now_ms(), rule_id],
)
.map_err(database_write_error)?;
if changed != 1 {
return Err("HOLOLAKE_EDUCATION_AUTOMATION_RULE_NOT_FOUND".into());
}
Ok(EducationArchiveReceipt {
state: "ARCHIVED_RECOVERABLE",
entity_id: rule_id,
})
}
fn rule_matches(operator: &str, condition_value: &str, cell: &str) -> bool {
match operator {
"EQUALS" => cell == condition_value,
"CONTAINS" => cell.contains(condition_value),
"IS_EMPTY" => cell.trim().is_empty(),
"IS_NOT_EMPTY" => !cell.trim().is_empty(),
_ => false,
}
}
fn automation_changes(
rule: &EducationAutomationRule,
table: &EducationTable,
) -> Result<(usize, usize, Vec<EducationTableRow>), String> {
validate_automation_rule_against_table(
table,
&rule.condition_column_id,
&rule.action_column_id,
&rule.action_value,
)?;
let condition_index = table
.columns
.iter()
.position(|column| column.column_id == rule.condition_column_id)
.ok_or_else(|| "HOLOLAKE_EDUCATION_AUTOMATION_COLUMN_INVALID".to_string())?;
let action_index = table
.columns
.iter()
.position(|column| column.column_id == rule.action_column_id)
.ok_or_else(|| "HOLOLAKE_EDUCATION_AUTOMATION_COLUMN_INVALID".to_string())?;
let mut matched_rows = 0;
let mut changed_cells = 0;
let mut rows = table.rows.clone();
for row in &mut rows {
if rule_matches(
&rule.operator,
&rule.condition_value,
&row.cells[condition_index],
) {
matched_rows += 1;
if row.cells[action_index] != rule.action_value {
row.cells[action_index] = rule.action_value.clone();
changed_cells += 1;
}
}
}
Ok((matched_rows, changed_cells, rows))
}
fn automation_preview_token(
rule: &EducationAutomationRule,
table: &EducationTable,
matched_rows: usize,
changed_cells: usize,
) -> String {
let material = format!(
"{}\n{}\n{}\n{}\n{}\n{}\n{}\n{}\n{}\n{}\n{}",
rule.rule_id,
rule.revision,
table.table_id,
table.revision,
rule.condition_column_id,
rule.operator,
rule.condition_value,
rule.action_column_id,
rule.action_value,
matched_rows,
changed_cells
);
digest(&SHA256, material.as_bytes())
.as_ref()
.iter()
.map(|byte| format!("{byte:02x}"))
.collect()
}
fn preview_automation_rule_with_connection(
connection: &Connection,
input: &PreviewEducationAutomationRuleInput,
) -> Result<
(
EducationAutomationPreview,
EducationAutomationRule,
EducationTable,
Vec<EducationTableRow>,
),
String,
> {
let rule = read_automation_rule_with_connection(connection, &input.rule_id)?;
if !rule.enabled {
return Err("HOLOLAKE_EDUCATION_AUTOMATION_RULE_DISABLED".into());
}
let table = read_table_with_connection(connection, &rule.table_id)?;
if rule.revision != input.expected_rule_revision
|| table.revision != input.expected_table_revision
{
return Err("HOLOLAKE_EDUCATION_AUTOMATION_PREVIEW_STALE".into());
}
let (matched_rows, changed_cells, rows) = automation_changes(&rule, &table)?;
let preview_token = automation_preview_token(&rule, &table, matched_rows, changed_cells);
let preview = EducationAutomationPreview {
state: "PREVIEW_ONLY_NO_WRITE",
rule_id: rule.rule_id.clone(),
table_id: table.table_id.clone(),
rule_revision: rule.revision,
table_revision: table.revision,
matched_rows,
changed_cells,
preview_token,
};
Ok((preview, rule, table, rows))
}
fn preview_automation_rule_at(
path: &Path,
input: PreviewEducationAutomationRuleInput,
) -> Result<EducationAutomationPreview, String> {
let connection = open_database(path)?;
preview_automation_rule_with_connection(&connection, &input).map(|result| result.0)
}
fn execute_automation_rule_at(
path: &Path,
input: ExecuteEducationAutomationRuleInput,
) -> Result<EducationAutomationRunReceipt, String> {
if input.preview_token.len() != 64
|| !input
.preview_token
.chars()
.all(|character| character.is_ascii_hexdigit())
{
return Err("HOLOLAKE_EDUCATION_AUTOMATION_PREVIEW_TOKEN_INVALID".into());
}
let mut connection = open_database(path)?;
let transaction = connection
.transaction_with_behavior(rusqlite::TransactionBehavior::Immediate)
.map_err(database_write_error)?;
let preview_input = PreviewEducationAutomationRuleInput {
rule_id: input.rule_id.clone(),
expected_rule_revision: input.expected_rule_revision,
expected_table_revision: input.expected_table_revision,
};
let (preview, _rule, table, rows) =
preview_automation_rule_with_connection(&transaction, &preview_input)?;
if preview.preview_token != input.preview_token {
return Err("HOLOLAKE_EDUCATION_AUTOMATION_PREVIEW_TOKEN_MISMATCH".into());
}
let now = now_ms();
let to_revision = if preview.changed_cells > 0 {
table.revision + 1
} else {
table.revision
};
if preview.changed_cells > 0 {
let rows_json = serde_json::to_string(&rows).map_err(json_write_error)?;
let changed = transaction
.execute(
"UPDATE education_tables SET rows_json = ?1, revision = revision + 1,
updated_at_unix_ms = ?2
WHERE table_id = ?3 AND revision = ?4 AND archived = 0",
params![rows_json, now, table.table_id, table.revision],
)
.map_err(database_write_error)?;
if changed != 1 {
return Err("HOLOLAKE_EDUCATION_AUTOMATION_TABLE_REVISION_CONFLICT".into());
}
}
let run_id = format!("EDU-RUN-{}", Uuid::new_v4());
transaction
.execute(
"INSERT INTO education_automation_runs(
run_id, rule_id, table_id, matched_rows, changed_cells,
from_table_revision, to_table_revision, ran_at_unix_ms
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)",
params![
run_id,
preview.rule_id,
preview.table_id,
preview.matched_rows as i64,
preview.changed_cells as i64,
table.revision,
to_revision,
now
],
)
.map_err(database_write_error)?;
transaction.commit().map_err(database_write_error)?;
Ok(EducationAutomationRunReceipt {
state: if preview.changed_cells > 0 {
"APPLIED"
} else {
"UNCHANGED"
}
.into(),
run_id,
rule_id: input.rule_id,
table_id: table.table_id,
matched_rows: preview.matched_rows,
changed_cells: preview.changed_cells,
from_table_revision: table.revision,
to_table_revision: to_revision,
ran_at_unix_ms: now,
})
}
fn archive_at(
path: &Path,
table: &str,
id_column: &str,
entity_id: String,
) -> Result<EducationArchiveReceipt, String> {
if !matches!(
(table, id_column),
("education_documents", "document_id") | ("education_tables", "table_id")
) {
return Err("HOLOLAKE_EDUCATION_ARCHIVE_TARGET_INVALID".into());
}
let expected_prefix = if table == "education_documents" {
"EDU-DOC-"
} else {
"EDU-TABLE-"
};
validate_entity_id(&entity_id, expected_prefix)?;
let connection = open_database(path)?;
let sql = format!(
"UPDATE {table} SET archived = 1, updated_at_unix_ms = ?1
WHERE {id_column} = ?2 AND archived = 0"
);
let changed = connection
.execute(&sql, params![now_ms(), entity_id])
.map_err(database_write_error)?;
if changed != 1 {
return Err("HOLOLAKE_EDUCATION_ARCHIVE_NOT_FOUND".into());
}
Ok(EducationArchiveReceipt {
state: "ARCHIVED_RECOVERABLE",
entity_id,
})
}
pub(crate) fn validate_table_data(
columns: &[EducationTableColumn],
rows: &[EducationTableRow],
) -> Result<(), String> {
if columns.is_empty() || columns.len() > MAX_TABLE_COLUMNS || rows.len() > MAX_TABLE_ROWS {
return Err("HOLOLAKE_EDUCATION_TABLE_BOUNDS_INVALID".into());
}
let mut column_ids = HashSet::new();
for column in columns {
if !column_ids.insert(column.column_id.as_str())
|| column.column_id.len() > 80
|| column.title.trim().is_empty()
|| column.title.len() > MAX_TITLE_BYTES
{
return Err("HOLOLAKE_EDUCATION_TABLE_COLUMN_INVALID".into());
}
}
let mut row_ids = HashSet::new();
for row in rows {
if !row_ids.insert(row.row_id.as_str())
|| row.row_id.len() > 80
|| row.cells.len() != columns.len()
|| row.cells.iter().any(|cell| cell.len() > MAX_CELL_BYTES)
{
return Err("HOLOLAKE_EDUCATION_TABLE_ROW_INVALID".into());
}
}
Ok(())
}
fn normalized_title(value: &str, fallback: &str) -> Result<String, String> {
let title = if value.trim().is_empty() {
fallback
} else {
value.trim()
};
if title.len() > MAX_TITLE_BYTES
|| title
.chars()
.any(|character| matches!(character, '\0' | '\r' | '\n'))
{
return Err("HOLOLAKE_EDUCATION_TITLE_INVALID".into());
}
Ok(title.to_string())
}
fn validate_entity_id(value: &str, prefix: &str) -> Result<(), String> {
if value.starts_with(prefix)
&& value.len() <= 80
&& value
.chars()
.all(|character| character.is_ascii_alphanumeric() || character == '-')
{
Ok(())
} else {
Err("HOLOLAKE_EDUCATION_ENTITY_ID_INVALID".into())
}
}
fn now_ms() -> i64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|duration| duration.as_millis().min(i64::MAX as u128) as i64)
.unwrap_or(0)
}
fn decode_sqlite_json<T: DeserializeOwned>(value: &str, column: usize) -> rusqlite::Result<T> {
serde_json::from_str(value).map_err(|error| {
rusqlite::Error::FromSqlConversionFailure(column, Type::Text, Box::new(error))
})
}
fn database_read_error(error: rusqlite::Error) -> String {
format!("HOLOLAKE_EDUCATION_DATABASE_READ_FAILED: {error}")
}
fn database_write_error(error: rusqlite::Error) -> String {
format!("HOLOLAKE_EDUCATION_DATABASE_WRITE_FAILED: {error}")
}
fn json_write_error(error: serde_json::Error) -> String {
format!("HOLOLAKE_EDUCATION_TABLE_JSON_FAILED: {error}")
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::tempdir;
#[test]
fn documents_persist_across_reopen_and_reject_stale_revisions() {
let directory = tempdir().unwrap();
let database = directory.path().join("education.sqlite3");
let created = create_document_at(
&database,
CreateEducationItemInput {
title: "师训计划".into(),
},
)
.unwrap();
let saved = save_document_at(
&database,
SaveEducationDocumentInput {
document_id: created.document_id.clone(),
title: created.title.clone(),
body: "# 第一周\n\n完成课程准备。".into(),
expected_revision: created.revision,
},
)
.unwrap();
assert_eq!(saved.revision, 2);
assert_eq!(
read_document_at(&database, &created.document_id)
.unwrap()
.body,
"# 第一周\n\n完成课程准备。"
);
assert!(save_document_at(
&database,
SaveEducationDocumentInput {
document_id: created.document_id,
title: "错误覆盖".into(),
body: "旧修订".into(),
expected_revision: 1,
},
)
.unwrap_err()
.contains("REVISION_CONFLICT"));
}
#[test]
fn tables_persist_cells_and_archive_without_hard_delete() {
let directory = tempdir().unwrap();
let database = directory.path().join("education.sqlite3");
let created = create_table_at(
&database,
CreateEducationItemInput {
title: "学员进度".into(),
},
)
.unwrap();
let row = EducationTableRow {
row_id: format!("ROW-{}", Uuid::new_v4()),
cells: vec!["张同学".into(), "学员".into(), "进行中".into()],
};
let saved = save_table_at(
&database,
SaveEducationTableInput {
table_id: created.table_id.clone(),
title: created.title,
columns: created.columns,
rows: vec![row],
expected_revision: created.revision,
},
)
.unwrap();
assert_eq!(saved.rows[0].cells[0], "张同学");
archive_at(
&database,
"education_tables",
"table_id",
created.table_id.clone(),
)
.unwrap();
assert!(read_table_at(&database, &created.table_id).is_err());
let connection = open_database(&database).unwrap();
let archived: i64 = connection
.query_row(
"SELECT archived FROM education_tables WHERE table_id = ?1",
[created.table_id],
|row| row.get(0),
)
.unwrap();
assert_eq!(archived, 1);
}
#[test]
fn imported_tables_stay_unassigned_until_a_human_routes_them_to_education() {
let directory = tempdir().unwrap();
let database = directory.path().join("education.sqlite3");
let imported = ImportedEducationTable {
title: "待识别业务数据".into(),
columns: vec![EducationTableColumn {
column_id: format!("COL-{}", Uuid::new_v4()),
title: "业务字段".into(),
}],
rows: vec![EducationTableRow {
row_id: format!("ROW-{}", Uuid::new_v4()),
cells: vec!["尚未归属教育".into()],
}],
};
let (_, tables) = import_table_batch_at(
&database,
vec![imported],
EducationImportRegistration {
source_filename: "业务数据.xlsx".into(),
source_format: "XLSX".into(),
source_sha256: "0".repeat(64),
profile_json: "{}".into(),
imported_at_unix_ms: now_ms(),
},
)
.unwrap();
let table_id = tables[0].table_id.clone();
let quarantined = snapshot_at(&database).unwrap();
assert_eq!(quarantined.table_count, 0);
assert_eq!(quarantined.unassigned_count, 1);
assert_eq!(quarantined.unassigned_tables[0].table_id, table_id);
assign_imported_table_to_education_at(&database, &table_id).unwrap();
let assigned = snapshot_at(&database).unwrap();
assert_eq!(assigned.table_count, 1);
assert_eq!(assigned.unassigned_count, 0);
}
#[test]
fn table_bounds_are_enforced() {
let columns = (0..=MAX_TABLE_COLUMNS)
.map(|index| EducationTableColumn {
column_id: format!("COL-{index}"),
title: format!("列 {index}"),
})
.collect::<Vec<_>>();
assert!(validate_table_data(&columns, &[]).is_err());
}
fn seeded_automation_table(database: &Path) -> EducationTable {
let created = create_table_at(
database,
CreateEducationItemInput {
title: "学员跟进".into(),
},
)
.unwrap();
save_table_at(
database,
SaveEducationTableInput {
table_id: created.table_id.clone(),
title: created.title,
columns: created.columns,
rows: vec![
EducationTableRow {
row_id: format!("ROW-{}", Uuid::new_v4()),
cells: vec!["林同学".into(), "学员".into(), "待跟进".into()],
},
EducationTableRow {
row_id: format!("ROW-{}", Uuid::new_v4()),
cells: vec!["周老师".into(), "教师".into(), "已完成".into()],
},
],
expected_revision: created.revision,
},
)
.unwrap()
}
#[test]
fn automation_rules_persist_preview_execute_and_receipt() {
let directory = tempdir().unwrap();
let database = directory.path().join("education.sqlite3");
let table = seeded_automation_table(&database);
let rule = create_automation_rule_at(
&database,
CreateEducationAutomationRuleInput {
title: "待跟进转为已联系".into(),
table_id: table.table_id.clone(),
condition_column_id: table.columns[2].column_id.clone(),
operator: "EQUALS".into(),
condition_value: "待跟进".into(),
action_column_id: table.columns[2].column_id.clone(),
action_value: "已联系".into(),
},
)
.unwrap();
let preview = preview_automation_rule_at(
&database,
PreviewEducationAutomationRuleInput {
rule_id: rule.rule_id.clone(),
expected_rule_revision: rule.revision,
expected_table_revision: table.revision,
},
)
.unwrap();
assert_eq!(preview.matched_rows, 1);
assert_eq!(preview.changed_cells, 1);
assert_eq!(snapshot_at(&database).unwrap().automation_rule_count, 1);
let receipt = execute_automation_rule_at(
&database,
ExecuteEducationAutomationRuleInput {
rule_id: rule.rule_id,
expected_rule_revision: rule.revision,
expected_table_revision: table.revision,
preview_token: preview.preview_token,
},
)
.unwrap();
assert_eq!(receipt.state, "APPLIED");
assert_eq!(receipt.to_table_revision, table.revision + 1);
let updated = read_table_at(&database, &table.table_id).unwrap();
assert_eq!(updated.rows[0].cells[2], "已联系");
assert_eq!(
snapshot_at(&database).unwrap().recent_automation_runs.len(),
1
);
}
#[test]
fn automation_execution_rejects_stale_or_changed_preview() {
let directory = tempdir().unwrap();
let database = directory.path().join("education.sqlite3");
let table = seeded_automation_table(&database);
let rule = create_automation_rule_at(
&database,
CreateEducationAutomationRuleInput {
title: "教师标记".into(),
table_id: table.table_id.clone(),
condition_column_id: table.columns[1].column_id.clone(),
operator: "EQUALS".into(),
condition_value: "教师".into(),
action_column_id: table.columns[2].column_id.clone(),
action_value: "已确认".into(),
},
)
.unwrap();
let preview = preview_automation_rule_at(
&database,
PreviewEducationAutomationRuleInput {
rule_id: rule.rule_id.clone(),
expected_rule_revision: rule.revision,
expected_table_revision: table.revision,
},
)
.unwrap();
let mut wrong_token = preview.preview_token;
wrong_token.replace_range(0..1, if &wrong_token[0..1] == "0" { "1" } else { "0" });
assert!(execute_automation_rule_at(
&database,
ExecuteEducationAutomationRuleInput {
rule_id: rule.rule_id,
expected_rule_revision: rule.revision,
expected_table_revision: table.revision,
preview_token: wrong_token,
},
)
.unwrap_err()
.contains("TOKEN_MISMATCH"));
}
}