Add OpenClaw skills, platform kit, and template docs
Made-with: Cursor
This commit is contained in:
1
content-manager/content_manager/services/__init__.py
Normal file
1
content-manager/content_manager/services/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
# 业务逻辑层(调用 db 仓储,不含 argparse)
|
||||
431
content-manager/content_manager/services/article_service.py
Normal file
431
content-manager/content_manager/services/article_service.py
Normal file
@@ -0,0 +1,431 @@
|
||||
"""文章:业务规则与编排(调用仓储 + llm-manager)。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
from content_manager.config import get_openclaw_root
|
||||
from content_manager.constants import PUBLISH_PLATFORM_CN, PUBLISH_PLATFORM_ALIASES
|
||||
from content_manager.db import articles_repository as ar
|
||||
from content_manager.db import prompts_repository as pr
|
||||
from content_manager.db.connection import get_conn, init_db
|
||||
from content_manager.util.timeutil import now_unix, unix_to_iso
|
||||
|
||||
|
||||
def _row_to_public_dict(row: tuple) -> Dict[str, Any]:
|
||||
rid, title, body, content_html, status, source, account_id, error_msg, llm_target, extra_json, cat, uat = row
|
||||
d: Dict[str, Any] = {
|
||||
"id": int(rid),
|
||||
"title": title,
|
||||
"content": body,
|
||||
"content_html": content_html if content_html else body,
|
||||
"status": status or "draft",
|
||||
"source": source or "manual",
|
||||
"account_id": account_id,
|
||||
"error_msg": error_msg,
|
||||
"llm_target": llm_target,
|
||||
"created_at": unix_to_iso(cat),
|
||||
"updated_at": unix_to_iso(uat),
|
||||
}
|
||||
if extra_json:
|
||||
try:
|
||||
ex = json.loads(extra_json)
|
||||
if isinstance(ex, dict):
|
||||
d["extra"] = ex
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
return d
|
||||
|
||||
|
||||
def resolve_publish_platform(raw: Optional[str]) -> Optional[str]:
|
||||
s = (raw or "").strip().lower()
|
||||
if not s:
|
||||
return "common"
|
||||
for key, aliases in PUBLISH_PLATFORM_ALIASES.items():
|
||||
if s in {a.lower() for a in aliases}:
|
||||
return key
|
||||
return None
|
||||
|
||||
|
||||
def _choose_prompt_template(platform: str) -> Optional[Dict[str, Any]]:
|
||||
init_db()
|
||||
conn = get_conn()
|
||||
try:
|
||||
rows = pr.fetch_active_templates(conn, platform)
|
||||
if not rows and platform != "common":
|
||||
rows = pr.fetch_common_fallback(conn)
|
||||
finally:
|
||||
conn.close()
|
||||
return pr.pick_random_template(rows)
|
||||
|
||||
|
||||
def _build_prompt_from_template(template_text: str, topic: str, platform: str) -> str:
|
||||
platform_name = PUBLISH_PLATFORM_CN.get(platform, "通用")
|
||||
rendered = (
|
||||
template_text.replace("{topic}", topic).replace("{platform}", platform).replace("{platform_name}", platform_name)
|
||||
)
|
||||
return rendered.strip()
|
||||
|
||||
|
||||
def cmd_add(title: str, body: str, source: str = "manual", llm_target: Optional[str] = None) -> None:
|
||||
init_db()
|
||||
title = (title or "").strip() or "未命名"
|
||||
body = body or ""
|
||||
ts = now_unix()
|
||||
conn = get_conn()
|
||||
try:
|
||||
new_id = ar.insert_article(
|
||||
conn,
|
||||
title=title,
|
||||
body=body,
|
||||
content_html=None,
|
||||
status="draft",
|
||||
source=source,
|
||||
account_id=None,
|
||||
error_msg=None,
|
||||
llm_target=llm_target,
|
||||
extra_json=None,
|
||||
created_at=ts,
|
||||
updated_at=ts,
|
||||
)
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
print(f"✅ 已新增文章 id={new_id} | {title}")
|
||||
|
||||
|
||||
def cmd_import_json(path: str) -> None:
|
||||
init_db()
|
||||
path = os.path.abspath(path.strip())
|
||||
if not os.path.isfile(path):
|
||||
print(f"❌ 找不到文件:{path}\n请检查路径是否正确、文件是否存在。")
|
||||
sys.exit(1)
|
||||
with open(path, encoding="utf-8") as f:
|
||||
raw = json.load(f)
|
||||
if isinstance(raw, dict) and "articles" in raw:
|
||||
items = raw["articles"]
|
||||
elif isinstance(raw, list):
|
||||
items = raw
|
||||
else:
|
||||
print(
|
||||
"❌ JSON 格式不对。\n"
|
||||
"正确格式二选一:① 文件里是数组 [ {\"title\":\"…\",\"body\":\"…\"}, … ]\n"
|
||||
"② 或对象 {\"articles\": [ … ] },数组里每项至少要有正文(body 或 content)。"
|
||||
)
|
||||
sys.exit(1)
|
||||
if not items:
|
||||
print("❌ JSON 里没有可导入的文章条目(数组为空)。")
|
||||
sys.exit(1)
|
||||
n = 0
|
||||
for i, item in enumerate(items):
|
||||
if not isinstance(item, dict):
|
||||
print(f"❌ 第 {i + 1} 条不是 JSON 对象(应为 {{ \"title\":…, \"body\":… }})。")
|
||||
sys.exit(1)
|
||||
title = (item.get("title") or item.get("标题") or "").strip()
|
||||
body = item.get("body") or item.get("content") or item.get("正文") or ""
|
||||
if isinstance(body, dict):
|
||||
print(f"❌ 第 {i + 1} 条的 body/content 必须是字符串,不能是别的类型。")
|
||||
sys.exit(1)
|
||||
body = str(body)
|
||||
if not title and not body.strip():
|
||||
continue
|
||||
if not title:
|
||||
title = f"导入-{i + 1}"
|
||||
cmd_add(title, body, source="import")
|
||||
n += 1
|
||||
print(f"✅ 批量导入完成,共写入 {n} 篇")
|
||||
|
||||
|
||||
def _parse_llm_stdout(stdout: str) -> str:
|
||||
if "===LLM_START===" in stdout and "===LLM_END===" in stdout:
|
||||
chunk = stdout.split("===LLM_START===", 1)[1]
|
||||
chunk = chunk.split("===LLM_END===", 1)[0]
|
||||
return chunk.strip()
|
||||
return (stdout or "").strip()
|
||||
|
||||
|
||||
def _default_title_from_body(body: str) -> str:
|
||||
for line in body.splitlines():
|
||||
t = line.strip()
|
||||
if t:
|
||||
return t[:120] if len(t) > 120 else t
|
||||
return f"文稿-{now_unix()}"
|
||||
|
||||
|
||||
def cmd_generate(
|
||||
llm_target: str,
|
||||
topic: str,
|
||||
publish_platform: str = "common",
|
||||
title: Optional[str] = None,
|
||||
) -> None:
|
||||
llm_target = (llm_target or "").strip()
|
||||
topic = (topic or "").strip()
|
||||
publish_platform = (publish_platform or "common").strip().lower()
|
||||
if not llm_target or not topic:
|
||||
print(
|
||||
"❌ 生成参数不完整。\n"
|
||||
"请使用:python main.py article generate <模型> [发布平台] <主题或关键词>\n"
|
||||
"示例:python main.py article generate 豆包 搜狐号 RPA降本增效"
|
||||
)
|
||||
sys.exit(1)
|
||||
template = _choose_prompt_template(publish_platform)
|
||||
if not template:
|
||||
print("❌ 提示词模板库为空,请先补充模板后再执行 generate。")
|
||||
sys.exit(1)
|
||||
prompt = _build_prompt_from_template(template["template_text"], topic, publish_platform)
|
||||
script = os.path.join(get_openclaw_root(), "llm-manager", "scripts", "main.py")
|
||||
if not os.path.isfile(script):
|
||||
print(
|
||||
f"❌ 找不到大模型脚本:{script}\n"
|
||||
"请确认 llm-manager 与 content-manager 在同一上级目录(OpenClaw)下。"
|
||||
)
|
||||
sys.exit(1)
|
||||
proc = subprocess.run(
|
||||
[sys.executable, script, "generate", llm_target, prompt],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
errors="replace",
|
||||
)
|
||||
out = (proc.stdout or "") + "\n" + (proc.stderr or "")
|
||||
std = proc.stdout or ""
|
||||
has_markers = "===LLM_START===" in std and "===LLM_END===" in std
|
||||
if (proc.returncode != 0 and not has_markers) or (
|
||||
proc.returncode == 0 and not has_markers and re.search(r"(?m)^ERROR:", std)
|
||||
):
|
||||
print(
|
||||
(out.strip() or f"大模型进程退出码 {proc.returncode}")
|
||||
+ "\n❌ 生成失败:请根据上面说明处理(常见:先在「模型管理」添加并登录该平台账号,或配置 API Key)。"
|
||||
)
|
||||
sys.exit(1)
|
||||
body = _parse_llm_stdout(proc.stdout or out)
|
||||
if not body:
|
||||
print(
|
||||
"❌ 没有从大模型输出里取到正文。\n"
|
||||
"正常情况输出里应包含 ===LLM_START=== 与 ===LLM_END===;请重试或查看 llm-manager 是否正常打印。"
|
||||
)
|
||||
sys.exit(1)
|
||||
body = body.strip()
|
||||
if body.startswith("ERROR:"):
|
||||
print(out.strip())
|
||||
print(f"\n❌ 生成失败,未写入数据库。\n{body}")
|
||||
sys.exit(1)
|
||||
final_title = (title or "").strip() or _default_title_from_body(body)
|
||||
extra_payload = {
|
||||
"generate_meta": {
|
||||
"mode": "template",
|
||||
"topic": topic,
|
||||
"platform": publish_platform,
|
||||
"platform_cn": PUBLISH_PLATFORM_CN.get(publish_platform, publish_platform),
|
||||
"template_id": template["id"],
|
||||
"template_name": template["name"],
|
||||
}
|
||||
}
|
||||
init_db()
|
||||
ts = now_unix()
|
||||
conn = get_conn()
|
||||
try:
|
||||
new_id = ar.insert_article(
|
||||
conn,
|
||||
title=final_title,
|
||||
body=body,
|
||||
content_html=None,
|
||||
status="draft",
|
||||
source="llm",
|
||||
account_id=None,
|
||||
error_msg=None,
|
||||
llm_target=llm_target,
|
||||
extra_json=json.dumps(extra_payload, ensure_ascii=False),
|
||||
created_at=ts,
|
||||
updated_at=ts,
|
||||
)
|
||||
pr.insert_usage(conn, int(template["id"]), llm_target, publish_platform, topic, int(new_id))
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
print(
|
||||
f"✅ 已写入 LLM 文稿 id={new_id} | {final_title}\n"
|
||||
f" 模板:{template['name']} (id={template['id']}) | 平台:{PUBLISH_PLATFORM_CN.get(publish_platform, publish_platform)} | 主题:{topic}"
|
||||
)
|
||||
|
||||
|
||||
def cmd_prompt_list(platform: Optional[str] = None, limit: int = 30) -> None:
|
||||
init_db()
|
||||
if limit <= 0:
|
||||
limit = 30
|
||||
key = resolve_publish_platform(platform) if platform else None
|
||||
if platform and not key:
|
||||
print(f"❌ 不支持的平台:{platform}")
|
||||
print("支持:通用 / 搜狐号 / 头条号 / 公众号")
|
||||
sys.exit(1)
|
||||
conn = get_conn()
|
||||
try:
|
||||
rows = pr.list_templates(conn, key, limit)
|
||||
finally:
|
||||
conn.close()
|
||||
if not rows:
|
||||
print("暂无提示词模板")
|
||||
return
|
||||
sep_line = "_" * 39
|
||||
for idx, (rid, p, name, active, uat) in enumerate(rows):
|
||||
print(f"id:{rid}")
|
||||
print(f"platform:{p}")
|
||||
print(f"platform_cn:{PUBLISH_PLATFORM_CN.get(p, p)}")
|
||||
print(f"name:{name}")
|
||||
print(f"is_active:{int(active)}")
|
||||
print(f"updated_at:{unix_to_iso(uat) or ''}")
|
||||
if idx != len(rows) - 1:
|
||||
print(sep_line)
|
||||
print()
|
||||
|
||||
|
||||
def cmd_save(article_id: str, title: str, content: str) -> None:
|
||||
init_db()
|
||||
ts = now_unix()
|
||||
conn = get_conn()
|
||||
try:
|
||||
if article_id.isdigit():
|
||||
aid = int(article_id)
|
||||
if ar.exists_id(conn, aid):
|
||||
ar.update_article_body(conn, aid, title, content, ts)
|
||||
conn.commit()
|
||||
print(f"✅ 已更新 id={aid} | {title}")
|
||||
return
|
||||
new_id = ar.insert_article(
|
||||
conn,
|
||||
title=title,
|
||||
body=content,
|
||||
content_html=None,
|
||||
status="draft",
|
||||
source="manual",
|
||||
account_id=None,
|
||||
error_msg=None,
|
||||
llm_target=None,
|
||||
extra_json=None,
|
||||
created_at=ts,
|
||||
updated_at=ts,
|
||||
)
|
||||
conn.commit()
|
||||
print(f"✅ 已新建 id={new_id} | {title}")
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def cmd_get(article_id: str) -> None:
|
||||
init_db()
|
||||
if not str(article_id).strip().isdigit():
|
||||
print("❌ 文章 id 必须是纯数字(整数)。请先 article list 查看最左一列编号。")
|
||||
sys.exit(1)
|
||||
aid = int(article_id)
|
||||
conn = get_conn()
|
||||
try:
|
||||
row = ar.fetch_by_id(conn, aid)
|
||||
finally:
|
||||
conn.close()
|
||||
if not row:
|
||||
print("❌ 没有这篇文章:该 id 在库里不存在。请先执行 article list 核对编号。")
|
||||
sys.exit(1)
|
||||
print(json.dumps(_row_to_public_dict(row), ensure_ascii=False))
|
||||
|
||||
|
||||
def cmd_list(limit: int = 10, max_chars: int = 50) -> None:
|
||||
init_db()
|
||||
conn = get_conn()
|
||||
try:
|
||||
rows = ar.list_recent(conn, limit)
|
||||
finally:
|
||||
conn.close()
|
||||
if not rows:
|
||||
print("暂无文章")
|
||||
return
|
||||
|
||||
def maybe_truncate(text: str) -> str:
|
||||
if not text:
|
||||
return ""
|
||||
if len(text) > max_chars:
|
||||
return text[:max_chars] + "..."
|
||||
return text
|
||||
|
||||
sep_line = "_" * 39
|
||||
for idx, r in enumerate(rows):
|
||||
(
|
||||
rid,
|
||||
title,
|
||||
body,
|
||||
content_html,
|
||||
status,
|
||||
source,
|
||||
account_id,
|
||||
error_msg,
|
||||
llm_target,
|
||||
extra_json,
|
||||
created_at,
|
||||
updated_at,
|
||||
) = r
|
||||
content = content_html if content_html else (body or "")
|
||||
|
||||
print(f"id:{rid}")
|
||||
print(f"title:{title or ''}")
|
||||
print("body:")
|
||||
print(maybe_truncate(body or ""))
|
||||
print("content:")
|
||||
print(maybe_truncate(content or ""))
|
||||
print(f"status:{status or ''}")
|
||||
print(f"source:{source or ''}")
|
||||
print(f"account_id:{account_id or ''}")
|
||||
print(f"error_msg:{error_msg or ''}")
|
||||
print(f"llm_target:{llm_target or ''}")
|
||||
print(f"extra_json:{extra_json or ''}")
|
||||
print(f"created_at:{unix_to_iso(created_at) or ''}")
|
||||
print(f"updated_at:{unix_to_iso(updated_at) or ''}")
|
||||
|
||||
if idx != len(rows) - 1:
|
||||
print(sep_line)
|
||||
print()
|
||||
|
||||
|
||||
def cmd_delete(article_id: str) -> None:
|
||||
init_db()
|
||||
if not str(article_id).strip().isdigit():
|
||||
print("❌ 文章 id 必须是纯数字。请先 article list 查看。")
|
||||
sys.exit(1)
|
||||
aid = int(article_id)
|
||||
conn = get_conn()
|
||||
try:
|
||||
n = ar.delete_by_id(conn, aid)
|
||||
if n == 0:
|
||||
print("❌ 没有 id 为 {} 的文章,无法删除。".format(aid))
|
||||
sys.exit(1)
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
print(f"✅ 已删除 id={aid}")
|
||||
|
||||
|
||||
def cmd_feedback(
|
||||
article_id: str,
|
||||
status: str,
|
||||
account_id: Optional[str] = None,
|
||||
error_msg: Optional[str] = None,
|
||||
) -> None:
|
||||
init_db()
|
||||
if not str(article_id).strip().isdigit():
|
||||
print("❌ 文章 id 必须是纯数字。")
|
||||
sys.exit(1)
|
||||
aid = int(article_id)
|
||||
ts = now_unix()
|
||||
conn = get_conn()
|
||||
try:
|
||||
if not ar.exists_id(conn, aid):
|
||||
print("❌ 没有 id 为 {} 的文章,无法回写状态。".format(aid))
|
||||
sys.exit(1)
|
||||
ar.update_feedback(conn, aid, status, account_id, error_msg, ts)
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
print("✅ 状态已更新")
|
||||
50
content-manager/content_manager/services/file_store.py
Normal file
50
content-manager/content_manager/services/file_store.py
Normal file
@@ -0,0 +1,50 @@
|
||||
"""媒体文件落盘:相对技能数据目录的路径约定。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import shutil
|
||||
from typing import Tuple
|
||||
|
||||
|
||||
def media_subdir(kind: str, media_id: int) -> str:
|
||||
"""kind: images | videos"""
|
||||
return f"{kind}/{media_id}"
|
||||
|
||||
|
||||
def original_basename(src_path: str) -> str:
|
||||
ext = os.path.splitext(src_path)[1]
|
||||
return f"original{ext if ext else ''}"
|
||||
|
||||
|
||||
def copy_into_skill_data(
|
||||
skill_data_dir: str,
|
||||
kind: str,
|
||||
media_id: int,
|
||||
src_path: str,
|
||||
) -> Tuple[str, str]:
|
||||
"""
|
||||
将源文件复制到 {skill_data_dir}/{kind}/{id}/original.ext
|
||||
返回 (relative_path, absolute_dest_path)
|
||||
"""
|
||||
sub = media_subdir(kind, media_id)
|
||||
dest_dir = os.path.join(skill_data_dir, sub.replace("/", os.sep))
|
||||
os.makedirs(dest_dir, exist_ok=True)
|
||||
base = original_basename(src_path)
|
||||
abs_dest = os.path.join(dest_dir, base)
|
||||
shutil.copy2(src_path, abs_dest)
|
||||
rel = f"{kind}/{media_id}/{base}".replace("\\", "/")
|
||||
return rel, abs_dest
|
||||
|
||||
|
||||
def remove_files_for_relative_path(skill_data_dir: str, relative_file_path: str) -> None:
|
||||
"""删除 relative_file_path 所在目录(整 id 目录)。"""
|
||||
rel = (relative_file_path or "").strip().replace("\\", "/")
|
||||
if not rel or "/" not in rel:
|
||||
return
|
||||
parts = rel.split("/")
|
||||
if len(parts) < 2:
|
||||
return
|
||||
id_dir = os.path.join(skill_data_dir, parts[0], parts[1])
|
||||
if os.path.isdir(id_dir):
|
||||
shutil.rmtree(id_dir, ignore_errors=True)
|
||||
171
content-manager/content_manager/services/image_service.py
Normal file
171
content-manager/content_manager/services/image_service.py
Normal file
@@ -0,0 +1,171 @@
|
||||
"""图片:业务规则(文件落盘 + 路径写入 images 表)。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
from content_manager.config import get_skill_data_dir, resolve_stored_path
|
||||
from content_manager.db import images_repository as ir
|
||||
from content_manager.db.connection import get_conn, init_db
|
||||
from content_manager.services import file_store
|
||||
from content_manager.util.timeutil import now_unix, unix_to_iso
|
||||
|
||||
|
||||
def _row_to_public_dict(row: tuple) -> Dict[str, Any]:
|
||||
rid, file_path, title, status, source, account_id, error_msg, extra_json, cat, uat = row
|
||||
abs_path = resolve_stored_path(str(file_path))
|
||||
d: Dict[str, Any] = {
|
||||
"id": int(rid),
|
||||
"kind": "image",
|
||||
"file_path": file_path,
|
||||
"absolute_path": abs_path,
|
||||
"title": title,
|
||||
"status": status or "draft",
|
||||
"source": source or "manual",
|
||||
"account_id": account_id,
|
||||
"error_msg": error_msg,
|
||||
"created_at": unix_to_iso(cat),
|
||||
"updated_at": unix_to_iso(uat),
|
||||
}
|
||||
if extra_json:
|
||||
try:
|
||||
ex = json.loads(extra_json)
|
||||
if isinstance(ex, dict):
|
||||
d["extra"] = ex
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
return d
|
||||
|
||||
|
||||
def cmd_add(src_file: str, title: Optional[str] = None, source: str = "manual") -> None:
|
||||
init_db()
|
||||
src_file = os.path.abspath(src_file.strip())
|
||||
if not os.path.isfile(src_file):
|
||||
print(f"❌ 找不到文件:{src_file}")
|
||||
sys.exit(1)
|
||||
skill_data = get_skill_data_dir()
|
||||
ts = now_unix()
|
||||
conn = get_conn()
|
||||
try:
|
||||
new_id = ir.insert_row(
|
||||
conn,
|
||||
file_path="",
|
||||
title=(title or "").strip() or None,
|
||||
status="draft",
|
||||
source=source,
|
||||
account_id=None,
|
||||
error_msg=None,
|
||||
extra_json=None,
|
||||
created_at=ts,
|
||||
updated_at=ts,
|
||||
)
|
||||
rel, _abs = file_store.copy_into_skill_data(skill_data, "images", new_id, src_file)
|
||||
ir.update_file_path(conn, new_id, rel, now_unix())
|
||||
conn.commit()
|
||||
except Exception:
|
||||
conn.rollback()
|
||||
raise
|
||||
finally:
|
||||
conn.close()
|
||||
print(f"✅ 已新增图片 id={new_id} | 路径:{rel}")
|
||||
|
||||
|
||||
def cmd_get(image_id: str) -> None:
|
||||
init_db()
|
||||
if not str(image_id).strip().isdigit():
|
||||
print("❌ 图片 id 必须是纯数字。请先 image list 查看。")
|
||||
sys.exit(1)
|
||||
iid = int(image_id)
|
||||
conn = get_conn()
|
||||
try:
|
||||
row = ir.fetch_by_id(conn, iid)
|
||||
finally:
|
||||
conn.close()
|
||||
if not row:
|
||||
print("❌ 没有这条图片记录。")
|
||||
sys.exit(1)
|
||||
print(json.dumps(_row_to_public_dict(row), ensure_ascii=False))
|
||||
|
||||
|
||||
def cmd_list(limit: int = 20, max_chars: int = 80) -> None:
|
||||
init_db()
|
||||
conn = get_conn()
|
||||
try:
|
||||
rows = ir.list_recent(conn, limit)
|
||||
finally:
|
||||
conn.close()
|
||||
if not rows:
|
||||
print("暂无图片")
|
||||
return
|
||||
|
||||
def trunc(s: str) -> str:
|
||||
if not s:
|
||||
return ""
|
||||
return s if len(s) <= max_chars else s[:max_chars] + "..."
|
||||
|
||||
sep = "_" * 39
|
||||
for idx, r in enumerate(rows):
|
||||
rid, file_path, title, status, source, account_id, error_msg, extra_json, cat, uat = r
|
||||
print(f"id:{rid}")
|
||||
print(f"file_path:{trunc(str(file_path or ''))}")
|
||||
print(f"title:{title or ''}")
|
||||
print(f"status:{status or ''}")
|
||||
print(f"source:{source or ''}")
|
||||
print(f"account_id:{account_id or ''}")
|
||||
print(f"error_msg:{error_msg or ''}")
|
||||
print(f"created_at:{unix_to_iso(cat) or ''}")
|
||||
print(f"updated_at:{unix_to_iso(uat) or ''}")
|
||||
if idx != len(rows) - 1:
|
||||
print(sep)
|
||||
print()
|
||||
|
||||
|
||||
def cmd_delete(image_id: str) -> None:
|
||||
init_db()
|
||||
if not str(image_id).strip().isdigit():
|
||||
print("❌ 图片 id 必须是纯数字。")
|
||||
sys.exit(1)
|
||||
iid = int(image_id)
|
||||
skill_data = get_skill_data_dir()
|
||||
conn = get_conn()
|
||||
try:
|
||||
row = ir.fetch_by_id(conn, iid)
|
||||
if not row:
|
||||
print("❌ 没有 id 为 {} 的图片记录。".format(iid))
|
||||
sys.exit(1)
|
||||
rel = row[1]
|
||||
n = ir.delete_by_id(conn, iid)
|
||||
if n == 0:
|
||||
sys.exit(1)
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
file_store.remove_files_for_relative_path(skill_data, str(rel))
|
||||
print(f"✅ 已删除图片 id={iid}")
|
||||
|
||||
|
||||
def cmd_feedback(
|
||||
image_id: str,
|
||||
status: str,
|
||||
account_id: Optional[str] = None,
|
||||
error_msg: Optional[str] = None,
|
||||
) -> None:
|
||||
init_db()
|
||||
if not str(image_id).strip().isdigit():
|
||||
print("❌ 图片 id 必须是纯数字。")
|
||||
sys.exit(1)
|
||||
iid = int(image_id)
|
||||
ts = now_unix()
|
||||
conn = get_conn()
|
||||
try:
|
||||
if ir.fetch_by_id(conn, iid) is None:
|
||||
print("❌ 没有 id 为 {} 的图片记录。".format(iid))
|
||||
sys.exit(1)
|
||||
ir.update_feedback(conn, iid, status, account_id, error_msg, ts)
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
print("✅ 状态已更新")
|
||||
179
content-manager/content_manager/services/video_service.py
Normal file
179
content-manager/content_manager/services/video_service.py
Normal file
@@ -0,0 +1,179 @@
|
||||
"""视频:业务规则(文件落盘 + 路径写入 videos 表)。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
from content_manager.config import get_skill_data_dir, resolve_stored_path
|
||||
from content_manager.db import videos_repository as vr
|
||||
from content_manager.db.connection import get_conn, init_db
|
||||
from content_manager.services import file_store
|
||||
from content_manager.util.timeutil import now_unix, unix_to_iso
|
||||
|
||||
|
||||
def _row_to_public_dict(row: tuple) -> Dict[str, Any]:
|
||||
rid, file_path, title, duration_ms, status, source, account_id, error_msg, extra_json, cat, uat = row
|
||||
abs_path = resolve_stored_path(str(file_path))
|
||||
d: Dict[str, Any] = {
|
||||
"id": int(rid),
|
||||
"kind": "video",
|
||||
"file_path": file_path,
|
||||
"absolute_path": abs_path,
|
||||
"title": title,
|
||||
"duration_ms": duration_ms,
|
||||
"status": status or "draft",
|
||||
"source": source or "manual",
|
||||
"account_id": account_id,
|
||||
"error_msg": error_msg,
|
||||
"created_at": unix_to_iso(cat),
|
||||
"updated_at": unix_to_iso(uat),
|
||||
}
|
||||
if extra_json:
|
||||
try:
|
||||
ex = json.loads(extra_json)
|
||||
if isinstance(ex, dict):
|
||||
d["extra"] = ex
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
return d
|
||||
|
||||
|
||||
def cmd_add(
|
||||
src_file: str,
|
||||
title: Optional[str] = None,
|
||||
duration_ms: Optional[int] = None,
|
||||
source: str = "manual",
|
||||
) -> None:
|
||||
init_db()
|
||||
src_file = os.path.abspath(src_file.strip())
|
||||
if not os.path.isfile(src_file):
|
||||
print(f"❌ 找不到文件:{src_file}")
|
||||
sys.exit(1)
|
||||
skill_data = get_skill_data_dir()
|
||||
ts = now_unix()
|
||||
conn = get_conn()
|
||||
try:
|
||||
new_id = vr.insert_row(
|
||||
conn,
|
||||
file_path="",
|
||||
title=(title or "").strip() or None,
|
||||
duration_ms=duration_ms,
|
||||
status="draft",
|
||||
source=source,
|
||||
account_id=None,
|
||||
error_msg=None,
|
||||
extra_json=None,
|
||||
created_at=ts,
|
||||
updated_at=ts,
|
||||
)
|
||||
rel, _abs = file_store.copy_into_skill_data(skill_data, "videos", new_id, src_file)
|
||||
vr.update_file_path(conn, new_id, rel, now_unix())
|
||||
conn.commit()
|
||||
except Exception:
|
||||
conn.rollback()
|
||||
raise
|
||||
finally:
|
||||
conn.close()
|
||||
print(f"✅ 已新增视频 id={new_id} | 路径:{rel}")
|
||||
|
||||
|
||||
def cmd_get(video_id: str) -> None:
|
||||
init_db()
|
||||
if not str(video_id).strip().isdigit():
|
||||
print("❌ 视频 id 必须是纯数字。请先 video list 查看。")
|
||||
sys.exit(1)
|
||||
vid = int(video_id)
|
||||
conn = get_conn()
|
||||
try:
|
||||
row = vr.fetch_by_id(conn, vid)
|
||||
finally:
|
||||
conn.close()
|
||||
if not row:
|
||||
print("❌ 没有这条视频记录。")
|
||||
sys.exit(1)
|
||||
print(json.dumps(_row_to_public_dict(row), ensure_ascii=False))
|
||||
|
||||
|
||||
def cmd_list(limit: int = 20, max_chars: int = 80) -> None:
|
||||
init_db()
|
||||
conn = get_conn()
|
||||
try:
|
||||
rows = vr.list_recent(conn, limit)
|
||||
finally:
|
||||
conn.close()
|
||||
if not rows:
|
||||
print("暂无视频")
|
||||
return
|
||||
|
||||
def trunc(s: str) -> str:
|
||||
if not s:
|
||||
return ""
|
||||
return s if len(s) <= max_chars else s[:max_chars] + "..."
|
||||
|
||||
sep = "_" * 39
|
||||
for idx, r in enumerate(rows):
|
||||
rid, file_path, title, duration_ms, status, source, account_id, error_msg, extra_json, cat, uat = r
|
||||
print(f"id:{rid}")
|
||||
print(f"file_path:{trunc(str(file_path or ''))}")
|
||||
print(f"title:{title or ''}")
|
||||
print(f"duration_ms:{duration_ms if duration_ms is not None else ''}")
|
||||
print(f"status:{status or ''}")
|
||||
print(f"source:{source or ''}")
|
||||
print(f"account_id:{account_id or ''}")
|
||||
print(f"error_msg:{error_msg or ''}")
|
||||
print(f"created_at:{unix_to_iso(cat) or ''}")
|
||||
print(f"updated_at:{unix_to_iso(uat) or ''}")
|
||||
if idx != len(rows) - 1:
|
||||
print(sep)
|
||||
print()
|
||||
|
||||
|
||||
def cmd_delete(video_id: str) -> None:
|
||||
init_db()
|
||||
if not str(video_id).strip().isdigit():
|
||||
print("❌ 视频 id 必须是纯数字。")
|
||||
sys.exit(1)
|
||||
vid = int(video_id)
|
||||
skill_data = get_skill_data_dir()
|
||||
conn = get_conn()
|
||||
try:
|
||||
row = vr.fetch_by_id(conn, vid)
|
||||
if not row:
|
||||
print("❌ 没有 id 为 {} 的视频记录。".format(vid))
|
||||
sys.exit(1)
|
||||
rel = row[1]
|
||||
n = vr.delete_by_id(conn, vid)
|
||||
if n == 0:
|
||||
sys.exit(1)
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
file_store.remove_files_for_relative_path(skill_data, str(rel))
|
||||
print(f"✅ 已删除视频 id={vid}")
|
||||
|
||||
|
||||
def cmd_feedback(
|
||||
video_id: str,
|
||||
status: str,
|
||||
account_id: Optional[str] = None,
|
||||
error_msg: Optional[str] = None,
|
||||
) -> None:
|
||||
init_db()
|
||||
if not str(video_id).strip().isdigit():
|
||||
print("❌ 视频 id 必须是纯数字。")
|
||||
sys.exit(1)
|
||||
vid = int(video_id)
|
||||
ts = now_unix()
|
||||
conn = get_conn()
|
||||
try:
|
||||
if vr.fetch_by_id(conn, vid) is None:
|
||||
print("❌ 没有 id 为 {} 的视频记录。".format(vid))
|
||||
sys.exit(1)
|
||||
vr.update_feedback(conn, vid, status, account_id, error_msg, ts)
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
print("✅ 状态已更新")
|
||||
Reference in New Issue
Block a user