This commit is contained in:
fengchuanhn@gmail.com
2026-05-21 00:19:08 +08:00
parent 3215d39a32
commit 3b0a8d777c
63 changed files with 12483 additions and 1114 deletions

View File

@@ -3,6 +3,8 @@
use base64::{engine::general_purpose::STANDARD, Engine as _};
use std::path::{Component, Path, PathBuf};
use crate::ffmpeg;
fn normalize_path(path: &str) -> PathBuf {
Path::new(path)
.components()
@@ -16,6 +18,15 @@ fn is_allowed_local_media(path: &Path) -> bool {
if lower.contains("aiclient-voice-preview-cache")
|| lower.contains("aiclient-node-pipeline")
|| lower.contains("aiclient-generated-speech")
|| lower.contains("aiclient-runninghub")
|| lower.contains("aiclient-digital-human")
|| lower.contains("generated_audios")
|| lower.contains("generated_videos")
|| lower.contains("aiclient-video-edit")
|| lower.contains("aiclient-subtitle-bgm")
|| lower.contains("aiclient-cover")
|| lower.contains("aiclient-cover-previews")
|| lower.contains("avatars")
{
return true;
}
@@ -45,3 +56,21 @@ pub async fn read_local_file_base64(path: String) -> Result<String, String> {
.map_err(|e| format!("读取文件失败: {e}"))?;
Ok(STANDARD.encode(bytes))
}
/// 获取本地音视频时长(秒),供口播视频云端任务对齐音频长度。
#[tauri::command]
pub async fn get_media_duration_seconds(path: String) -> Result<f64, String> {
let normalized = normalize_path(path.trim());
if normalized.as_os_str().is_empty() {
return Err("路径为空".into());
}
if !normalized.is_file() {
return Err(format!("文件不存在: {}", normalized.display()));
}
if !is_allowed_local_media(&normalized) {
return Err("不允许读取该路径下的文件".into());
}
ffmpeg::probe_media_duration(&normalized)
.await
.map_err(|e| e.to_string())
}

View File

@@ -6,4 +6,5 @@ pub mod avatar;
pub mod video;
pub mod fs_util;
pub mod nodejs;
pub mod publish;
pub mod quickjs;

View File

@@ -63,7 +63,7 @@ fn log_node_event(script_name: &str, level: &str, msg: &str, fields: &Option<Val
}
}
fn spawn_event_pump(
pub(crate) fn spawn_event_pump(
app: AppHandle,
window: Window,
script_name: String,

View File

@@ -0,0 +1,276 @@
//! 视频发布:账号管理与 Node 发布脚本桥接
use serde::Deserialize;
use serde_json::{json, Value};
use tauri::{AppHandle, Manager, State, Window};
use crate::app_config::AppConfig;
use crate::publish_db::PlatformAccountRecord;
use crate::publish_store::PublishStore;
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PublishLoginParams {
pub platform: String,
pub account_id: Option<i64>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PublishCheckLoginParams {
pub platform: String,
pub account_id: Option<i64>,
#[serde(default)]
pub check_browser: bool,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PublishPlatformItem {
pub platform: String,
pub account_id: Option<i64>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PublishExecuteParams {
pub platforms: Vec<PublishPlatformItem>,
pub video_path: String,
pub cover_path: String,
pub title: String,
pub description: Option<String>,
pub tags: Option<Vec<String>>,
#[serde(default)]
pub auto_publish: bool,
}
async fn run_publish_script(
app: AppHandle,
window: Window,
script_name: &str,
params: Value,
) -> Result<Value, String> {
let app_config = app.state::<AppConfig>();
let config_env = app_config.env_for_node().await;
let (tx, rx) = tokio::sync::mpsc::unbounded_channel();
let pump = crate::commands::nodejs::spawn_event_pump(
app.clone(),
window,
script_name.to_string(),
rx,
);
let result = crate::nodejs::run_node_script(
script_name.to_string(),
params,
tx,
config_env,
)
.await
.map_err(|e| e.to_string())?;
pump.await.ok();
Ok(result)
}
#[tauri::command]
pub async fn publish_list_accounts(
store: State<'_, PublishStore>,
platform: Option<String>,
) -> Result<Vec<PlatformAccountRecord>, String> {
store.list(platform).await
}
#[tauri::command]
pub async fn publish_delete_account(
store: State<'_, PublishStore>,
account_id: i64,
) -> Result<(), String> {
store.delete(account_id).await
}
#[tauri::command]
pub async fn publish_login(
app: AppHandle,
window: Window,
store: State<'_, PublishStore>,
app_config: State<'_, AppConfig>,
params: PublishLoginParams,
) -> Result<Value, String> {
let _ = app_config;
let result = run_publish_script(
app,
window,
"publish_login.js",
json!({
"platform": params.platform,
"accountId": params.account_id,
}),
)
.await?;
if result.get("success").and_then(|v| v.as_bool()) == Some(true) {
let nickname = result
.get("nickname")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
let uid = result
.get("uid")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
let cookies = result
.get("cookies")
.map(|v| serde_json::to_string(v).unwrap_or_else(|_| "[]".to_string()))
.unwrap_or_else(|| "[]".to_string());
let rec = store
.upsert_login(
params.platform.clone(),
nickname,
uid,
cookies,
params.account_id,
)
.await?;
return Ok(json!({
"success": true,
"message": result.get("message").and_then(|v| v.as_str()).unwrap_or("登录成功"),
"account": rec,
}));
}
Ok(result)
}
#[tauri::command]
pub async fn publish_check_login(
app: AppHandle,
window: Window,
store: State<'_, PublishStore>,
params: PublishCheckLoginParams,
) -> Result<Value, String> {
if !params.check_browser {
let accounts = store.list(Some(params.platform.clone())).await?;
let account = if let Some(id) = params.account_id {
accounts.into_iter().find(|a| a.id == id)
} else {
accounts
.into_iter()
.find(|a| a.login_status == "active" && a.has_cookies)
};
if let Some(acc) = account {
return Ok(json!({
"success": true,
"isLoggedIn": acc.login_status == "active" && acc.has_cookies,
"userInfo": { "nickname": acc.nickname, "userId": acc.uid },
}));
}
return Ok(json!({
"success": true,
"isLoggedIn": false,
}));
}
let mut cookies = json!([]);
if let Some(id) = params.account_id {
if let Some((_, c)) = store.get_with_cookies(id).await? {
cookies = serde_json::from_str(&c).unwrap_or(json!([]));
}
} else if let Ok(list) = store.list(Some(params.platform.clone())).await {
if let Some(active) = list
.iter()
.find(|a| a.login_status == "active" && a.has_cookies)
{
if let Some((_, c)) = store.get_with_cookies(active.id).await? {
cookies = serde_json::from_str(&c).unwrap_or(json!([]));
}
}
}
let result = run_publish_script(
app,
window,
"publish_check_login.js",
json!({
"platform": params.platform,
"accountId": params.account_id,
"cookies": cookies,
}),
)
.await?;
if result.get("isLoggedIn").and_then(|v| v.as_bool()) == Some(true) {
if let (Some(nickname), Some(account_id)) = (
result
.get("userInfo")
.and_then(|u| u.get("nickname"))
.and_then(|v| v.as_str()),
params.account_id,
) {
let _ = store.set_login_status(account_id, "active".to_string()).await;
if let Ok(Some((mut rec, _))) = store.get_with_cookies(account_id).await {
if !nickname.is_empty() {
rec.nickname = nickname.to_string();
}
}
}
} else if let Some(account_id) = params.account_id {
let _ = store.set_login_status(account_id, "inactive".to_string()).await;
}
Ok(result)
}
#[tauri::command]
pub async fn publish_execute(
app: AppHandle,
window: Window,
store: State<'_, PublishStore>,
params: PublishExecuteParams,
) -> Result<Value, String> {
let mut platform_payloads = Vec::new();
for item in &params.platforms {
let account_id = item.account_id;
let (rec, cookies) = if let Some(id) = account_id {
store
.get_with_cookies(id)
.await?
.ok_or_else(|| format!("未找到账号 id={id}"))?
} else {
let list = store.list(Some(item.platform.clone())).await?;
let active = list
.into_iter()
.find(|a| a.login_status == "active" && a.has_cookies)
.ok_or_else(|| format!("平台 {} 无已登录账号", item.platform))?;
let full = store
.get_with_cookies(active.id)
.await?
.ok_or_else(|| "读取账号失败".to_string())?;
full
};
if rec.login_status != "active" {
return Err(format!("账号 {} 未登录", rec.nickname));
}
platform_payloads.push(json!({
"platform": item.platform,
"accountId": rec.id,
"cookies": serde_json::from_str::<Value>(&cookies).unwrap_or(json!([])),
"nickname": rec.nickname,
}));
}
run_publish_script(
app,
window,
"video_publish.js",
json!({
"platforms": platform_payloads,
"videoPath": params.video_path,
"coverPath": params.cover_path,
"title": params.title,
"description": params.description.unwrap_or_default(),
"tags": params.tags.unwrap_or_default(),
"autoPublish": params.auto_publish,
}),
)
.await
}

View File

@@ -0,0 +1,98 @@
//! 封面 Python 运行时与脚本目录定位(对齐 Electron bundled python-runtime
use std::path::{Path, PathBuf};
fn exe_name() -> &'static str {
if cfg!(windows) {
"python.exe"
} else {
"python3"
}
}
/// bundled `python-runtimebackup` 或系统 / 环境变量 Python。
pub fn locate_python() -> Option<PathBuf> {
if let Ok(p) = std::env::var("AICLIENT_PYTHON_PATH") {
let pb = PathBuf::from(&p);
if pb.is_file() {
return Some(pb);
}
}
if let Ok(exe) = std::env::current_exe() {
if let Some(dir) = exe.parent() {
let bundled = dir
.join("resources")
.join("resources-bundles")
.join("python-runtimebackup")
.join(exe_name());
if bundled.exists() {
return Some(bundled);
}
let cover_only = dir.join("resources").join("cover-python").join(exe_name());
if cover_only.exists() {
return Some(cover_only);
}
}
}
let dev_bundled = PathBuf::from("src-tauri/resources/resources-bundles/python-runtimebackup")
.join(exe_name());
if dev_bundled.exists() {
return Some(dev_bundled);
}
let dev_cover = PathBuf::from("src-tauri/resources/cover-python").join(exe_name());
if dev_cover.exists() {
return Some(dev_cover);
}
None
}
/// 封面脚本目录(`advanced_cover_generator.py` 等)。
pub fn locate_cover_scripts_dir() -> Option<PathBuf> {
if let Ok(p) = std::env::var("AICLIENT_COVER_SCRIPTS_DIR") {
let pb = PathBuf::from(&p);
if pb.join("advanced_cover_generator.py").is_file() {
return Some(pb);
}
}
if let Ok(exe) = std::env::current_exe() {
if let Some(dir) = exe.parent() {
let bundled = dir.join("resources").join("cover-python");
if bundled.join("advanced_cover_generator.py").is_file() {
return Some(bundled);
}
}
}
let dev = PathBuf::from("src-tauri/resources/cover-python");
if dev.join("advanced_cover_generator.py").is_file() {
return Some(dev);
}
let backend = PathBuf::from("../pythonbackend/scripts/cover");
if backend.join("advanced_cover_generator.py").is_file() {
return backend.canonicalize().ok();
}
None
}
/// 将 ffmpeg 所在目录 prepend 到 PATH供 Python 子进程调用 ffmpeg
pub fn prepend_ffmpeg_to_path(env_path: &str) -> String {
let sep = if cfg!(windows) { ";" } else { ":" };
if let Some(ffmpeg) = crate::ffmpeg::locate_ffmpeg() {
if let Some(dir) = ffmpeg.parent() {
return format!("{}{}{}", dir.display(), sep, env_path);
}
}
env_path.to_string()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn cover_scripts_dev_path_exists() {
let dev = Path::new("src-tauri/resources/cover-python/advanced_cover_generator.py");
if dev.is_file() {
assert!(locate_cover_scripts_dir().is_some());
}
}
}

View File

@@ -26,6 +26,57 @@ fn exe_name() -> &'static str {
if cfg!(windows) { "ffmpeg.exe" } else { "ffmpeg" }
}
fn ffprobe_exe_name() -> &'static str {
if cfg!(windows) {
"ffprobe.exe"
} else {
"ffprobe"
}
}
/// 与 `locate_ffmpeg` 相同目录策略,优先 bundled `binaries/ffprobe`。
pub fn locate_ffprobe() -> Option<PathBuf> {
if let Ok(p) = std::env::var("AICLIENT_FFPROBE_PATH") {
let pb = PathBuf::from(p);
if pb.exists() {
return Some(pb);
}
}
if let Some(ffmpeg) = locate_ffmpeg() {
if let Some(dir) = ffmpeg.parent() {
let sibling = dir.join(ffprobe_exe_name());
if sibling.exists() {
return Some(sibling);
}
}
}
if let Ok(exe) = std::env::current_exe() {
if let Some(dir) = exe.parent() {
for candidate in [
dir.join("binaries").join(ffprobe_exe_name()),
dir.join(ffprobe_exe_name()),
dir.join("..")
.join("..")
.join("binaries")
.join(ffprobe_exe_name()),
] {
if candidate.exists() {
return Some(candidate);
}
}
}
}
for dev in [
PathBuf::from("src-tauri/binaries").join(ffprobe_exe_name()),
PathBuf::from("binaries").join(ffprobe_exe_name()),
] {
if dev.exists() {
return Some(dev);
}
}
Some(PathBuf::from(ffprobe_exe_name()))
}
pub fn locate_ffmpeg() -> Option<PathBuf> {
if let Ok(p) = std::env::var("AICLIENT_FFMPEG_PATH") {
let pb = PathBuf::from(p);
@@ -86,3 +137,62 @@ pub async fn extract_audio(video_path: &Path, dest_wav: &Path) -> Result<(), Ffm
}
Ok(())
}
fn parse_duration_hms(text: &str) -> Option<f64> {
let marker = "Duration:";
let idx = text.find(marker)?;
let rest = text[idx + marker.len()..].trim_start();
let time_part = rest.split(',').next()?.trim();
let mut parts = time_part.split(':');
let hours: f64 = parts.next()?.parse().ok()?;
let minutes: f64 = parts.next()?.parse().ok()?;
let seconds: f64 = parts.next()?.parse().ok()?;
Some(hours * 3600.0 + minutes * 60.0 + seconds)
}
/// 读取媒体时长(秒),优先 ffprobe回退解析 `ffmpeg -i` 的 stderr。
pub async fn probe_media_duration(path: &Path) -> Result<f64, FfmpegError> {
if !path.is_file() {
return Err(FfmpegError::Io(std::io::Error::new(
std::io::ErrorKind::NotFound,
format!("文件不存在: {}", path.display()),
)));
}
if let Some(ffprobe) = locate_ffprobe() {
let output = Command::new(&ffprobe)
.arg("-v")
.arg("error")
.arg("-show_entries")
.arg("format=duration")
.arg("-of")
.arg("default=noprint_wrappers=1:nokey=1")
.arg(path)
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.output()
.await?;
if output.status.success() {
let stdout = String::from_utf8_lossy(&output.stdout);
if let Ok(secs) = stdout.trim().parse::<f64>() {
if secs > 0.0 {
return Ok(secs);
}
}
}
}
let ffmpeg = locate_ffmpeg().ok_or(FfmpegError::NotFound)?;
let output = Command::new(&ffmpeg)
.arg("-i")
.arg(path)
.stdout(Stdio::null())
.stderr(Stdio::piped())
.output()
.await?;
let stderr = String::from_utf8_lossy(&output.stderr);
parse_duration_hms(&stderr).ok_or_else(|| FfmpegError::Failed {
status: output.status.code(),
stderr: format!("无法解析媒体时长: {}", stderr.chars().take(400).collect::<String>()),
})
}

View File

@@ -12,11 +12,14 @@ pub mod asr;
pub mod auth_session;
pub mod chat;
pub mod commands;
pub mod cover_python;
pub mod ffmpeg;
pub mod http;
pub mod js_runtime;
pub mod nodejs;
pub mod oss;
pub mod publish_db;
pub mod publish_store;
use tauri::Manager;
@@ -30,6 +33,7 @@ pub fn run() {
.manage(avatar_store::AvatarStore::new())
.manage(audio_store::AudioStore::new())
.manage(video_store::VideoStore::new())
.manage(publish_store::PublishStore::new())
.setup(|app| {
let handle = app.handle().clone();
tauri::async_runtime::block_on(async move {
@@ -37,6 +41,7 @@ pub fn run() {
let avatar_store = handle.state::<avatar_store::AvatarStore>();
let audio_store = handle.state::<audio_store::AudioStore>();
let video_store = handle.state::<video_store::VideoStore>();
let publish_store = handle.state::<publish_store::PublishStore>();
let dir = handle
.path()
.app_data_dir()
@@ -46,6 +51,7 @@ pub fn run() {
avatar_store.init(dir.join("avatars.db")).await?;
audio_store.init(dir.join("generated_audios.db")).await?;
video_store.init(dir.join("generated_videos.db")).await?;
publish_store.init(dir.join("publish_accounts.db")).await?;
Ok::<(), String>(())
})
.map_err(|e| -> Box<dyn std::error::Error> { e.into() })?;
@@ -77,6 +83,7 @@ pub fn run() {
commands::nodejs::run_nodejs_script_source,
commands::nodejs::list_nodejs_scripts,
commands::fs_util::read_local_file_base64,
commands::fs_util::get_media_duration_seconds,
commands::avatar::list_avatars,
commands::avatar::insert_avatar,
commands::avatar::update_avatar_name,
@@ -90,7 +97,19 @@ pub fn run() {
commands::video::insert_generated_video,
commands::video::import_generated_video,
commands::video::delete_generated_video,
commands::publish::publish_list_accounts,
commands::publish::publish_login,
commands::publish::publish_check_login,
commands::publish::publish_execute,
commands::publish::publish_delete_account,
])
.run(tauri::generate_context!())
.expect("error while running tauri application");
.build(tauri::generate_context!())
.expect("error while building tauri application")
.run(|app_handle, event| {
if let tauri::RunEvent::Ready = event {
if let Some(window) = app_handle.get_webview_window("main") {
let _ = window.maximize();
}
}
});
}

View File

@@ -369,6 +369,12 @@ async fn run_script_bundle(
cmd.env("AICLIENT_FFMPEG_PATH", ffmpeg_path);
}
}
if let Some(py) = crate::cover_python::locate_python() {
cmd.env("AICLIENT_PYTHON_PATH", py);
}
if let Some(dir) = crate::cover_python::locate_cover_scripts_dir() {
cmd.env("AICLIENT_COVER_SCRIPTS_DIR", dir);
}
let mut child = cmd.spawn()?;
// 把 params JSON 灌进 stdin

193
src-tauri/src/publish_db.rs Normal file
View File

@@ -0,0 +1,193 @@
//! 平台发布账号 SQLite对齐 Electron platform_accounts
use std::path::PathBuf;
use std::sync::Mutex;
use rusqlite::{params, Connection};
use serde::Serialize;
use thiserror::Error;
#[derive(Debug, Error)]
pub enum PublishDbError {
#[error("{0}")]
Db(#[from] rusqlite::Error),
#[error("{0}")]
Message(String),
}
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct PlatformAccountRecord {
pub id: i64,
pub platform: String,
pub nickname: String,
pub uid: String,
pub avatar: String,
pub login_status: String,
pub has_cookies: bool,
pub updated_at: i64,
}
#[derive(Debug)]
pub struct PublishDb {
conn: Mutex<Connection>,
}
impl PublishDb {
pub fn open(path: PathBuf) -> Result<Self, PublishDbError> {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)
.map_err(|e| PublishDbError::Message(format!("创建目录失败: {e}")))?;
}
let conn = Connection::open(path)?;
conn.execute_batch(
r#"
PRAGMA journal_mode = WAL;
CREATE TABLE IF NOT EXISTS platform_accounts (
id INTEGER PRIMARY KEY AUTOINCREMENT,
platform TEXT NOT NULL,
nickname TEXT NOT NULL DEFAULT '',
uid TEXT NOT NULL DEFAULT '',
avatar TEXT NOT NULL DEFAULT '',
cookies TEXT NOT NULL DEFAULT '[]',
tokens TEXT NOT NULL DEFAULT '',
login_status TEXT NOT NULL DEFAULT 'inactive',
expires_at INTEGER,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_platform_accounts_platform
ON platform_accounts(platform);
"#,
)?;
Ok(Self {
conn: Mutex::new(conn),
})
}
pub fn list(&self, platform: Option<&str>) -> Result<Vec<PlatformAccountRecord>, PublishDbError> {
let conn = self.conn.lock().map_err(|_| {
PublishDbError::Message("数据库锁异常".into())
})?;
let (sql, use_platform) = match platform {
Some(_) => (
"SELECT id, platform, nickname, uid, avatar, cookies, login_status, updated_at
FROM platform_accounts WHERE platform = ?1 ORDER BY updated_at DESC",
true,
),
None => (
"SELECT id, platform, nickname, uid, avatar, cookies, login_status, updated_at
FROM platform_accounts ORDER BY platform ASC, updated_at DESC",
false,
),
};
let mut stmt = conn.prepare(sql)?;
let map_row = |row: &rusqlite::Row<'_>| {
let cookies: String = row.get(5)?;
Ok(PlatformAccountRecord {
id: row.get(0)?,
platform: row.get(1)?,
nickname: row.get(2)?,
uid: row.get(3)?,
avatar: row.get(4)?,
login_status: row.get(6)?,
has_cookies: cookies.len() > 4,
updated_at: row.get(7)?,
})
};
let rows = if use_platform {
stmt.query_map(params![platform.unwrap()], map_row)?
} else {
stmt.query_map([], map_row)?
};
let mut out = Vec::new();
for r in rows {
out.push(r?);
}
Ok(out)
}
pub fn get(&self, id: i64) -> Result<Option<(PlatformAccountRecord, String)>, PublishDbError> {
let conn = self.conn.lock().map_err(|_| {
PublishDbError::Message("数据库锁异常".into())
})?;
let mut stmt = conn.prepare(
"SELECT id, platform, nickname, uid, avatar, cookies, login_status, updated_at
FROM platform_accounts WHERE id = ?1",
)?;
let mut rows = stmt.query(params![id])?;
if let Some(row) = rows.next()? {
let cookies: String = row.get(5)?;
let rec = PlatformAccountRecord {
id: row.get(0)?,
platform: row.get(1)?,
nickname: row.get(2)?,
uid: row.get(3)?,
avatar: row.get(4)?,
login_status: row.get(6)?,
has_cookies: cookies.len() > 4,
updated_at: row.get(7)?,
};
return Ok(Some((rec, cookies)));
}
Ok(None)
}
pub fn upsert_login(
&self,
platform: &str,
nickname: &str,
uid: &str,
cookies_json: &str,
account_id: Option<i64>,
) -> Result<PlatformAccountRecord, PublishDbError> {
let conn = self.conn.lock().map_err(|_| {
PublishDbError::Message("数据库锁异常".into())
})?;
let now = chrono::Utc::now().timestamp_millis();
let expires = now + 30_i64 * 24 * 60 * 60 * 1000;
if let Some(id) = account_id {
conn.execute(
"UPDATE platform_accounts SET nickname = ?1, uid = ?2, cookies = ?3,
login_status = 'active', expires_at = ?4, updated_at = ?5 WHERE id = ?6",
params![nickname, uid, cookies_json, expires, now, id],
)?;
return self
.get(id)?
.map(|(r, _)| r)
.ok_or_else(|| PublishDbError::Message("更新账号失败".into()));
}
conn.execute(
"INSERT INTO platform_accounts (platform, nickname, uid, avatar, cookies, tokens,
login_status, expires_at, created_at, updated_at)
VALUES (?1, ?2, ?3, '', ?4, '', 'active', ?5, ?6, ?6)",
params![platform, nickname, uid, cookies_json, expires, now],
)?;
let id = conn.last_insert_rowid();
self.get(id)?
.map(|(r, _)| r)
.ok_or_else(|| PublishDbError::Message("插入账号失败".into()))
}
pub fn set_login_status(&self, id: i64, status: &str) -> Result<(), PublishDbError> {
let conn = self.conn.lock().map_err(|_| {
PublishDbError::Message("数据库锁异常".into())
})?;
let now = chrono::Utc::now().timestamp_millis();
conn.execute(
"UPDATE platform_accounts SET login_status = ?1, updated_at = ?2 WHERE id = ?3",
params![status, now, id],
)?;
Ok(())
}
pub fn delete(&self, id: i64) -> Result<(), PublishDbError> {
let conn = self.conn.lock().map_err(|_| {
PublishDbError::Message("数据库锁异常".into())
})?;
conn.execute("DELETE FROM platform_accounts WHERE id = ?1", params![id])?;
Ok(())
}
}

View File

@@ -0,0 +1,92 @@
//! 发布账号 Tauri 状态
use std::path::PathBuf;
use std::sync::Arc;
use tokio::sync::RwLock;
use crate::publish_db::{PlatformAccountRecord, PublishDb};
#[derive(Debug, Default)]
pub struct PublishStore {
inner: Arc<RwLock<Option<Arc<PublishDb>>>>,
}
impl PublishStore {
pub fn new() -> Self {
Self::default()
}
pub async fn init(&self, db_path: PathBuf) -> Result<(), String> {
let db = tokio::task::spawn_blocking(move || {
PublishDb::open(db_path).map_err(|e| e.to_string())
})
.await
.map_err(|e| e.to_string())??;
*self.inner.write().await = Some(Arc::new(db));
Ok(())
}
async fn with_db<F, T>(&self, f: F) -> Result<T, String>
where
F: FnOnce(Arc<PublishDb>) -> Result<T, String> + Send + 'static,
T: Send + 'static,
{
let db = self
.inner
.read()
.await
.clone()
.ok_or_else(|| "发布账号数据库未初始化".to_string())?;
tokio::task::spawn_blocking(move || f(db))
.await
.map_err(|e| e.to_string())?
}
pub async fn list(&self, platform: Option<String>) -> Result<Vec<PlatformAccountRecord>, String> {
self.with_db(move |db| {
db.list(platform.as_deref())
.map_err(|e| e.to_string())
})
.await
}
pub async fn get_with_cookies(
&self,
id: i64,
) -> Result<Option<(PlatformAccountRecord, String)>, String> {
self.with_db(move |db| db.get(id).map_err(|e| e.to_string()))
.await
}
pub async fn upsert_login(
&self,
platform: String,
nickname: String,
uid: String,
cookies_json: String,
account_id: Option<i64>,
) -> Result<PlatformAccountRecord, String> {
self.with_db(move |db| {
db.upsert_login(
&platform,
&nickname,
&uid,
&cookies_json,
account_id,
)
.map_err(|e| e.to_string())
})
.await
}
pub async fn set_login_status(&self, id: i64, status: String) -> Result<(), String> {
self.with_db(move |db| db.set_login_status(id, &status).map_err(|e| e.to_string()))
.await
}
pub async fn delete(&self, id: i64) -> Result<(), String> {
self.with_db(move |db| db.delete(id).map_err(|e| e.to_string()))
.await
}
}