feat: 完成配置与SQLite地基
- 新增 appconfig 配置读写、默认值、AI 参数和敏感字段拦截 - 新增 SQLite batches/accounts/tasks schema 与阶段写库函数 - 校准本地配置、DB、图片和登录态文件的 gitignore 规则 - 更新任务看板、模块合约、当前状态和进度记录
This commit is contained in:
+5
-1
@@ -1,6 +1,6 @@
|
||||
# ── 凭证 / 业务数据 / 本地产物(绝不提交)──
|
||||
# AI 模型清单 ai_models.json(含密钥)
|
||||
config/
|
||||
config/ai_models.json
|
||||
# 应用配置(路径/模型选择/参数)
|
||||
config.json
|
||||
# SQLite(账号/任务/结果,含密码)
|
||||
@@ -9,6 +9,10 @@ cmshopee.db
|
||||
chrome_user_data_dir/
|
||||
# 采集的旧封面 / AI 生成的新封面
|
||||
images/
|
||||
# 本地测试图片 / 临时启动脚本
|
||||
1_TY030.jpg
|
||||
cmshopee_t001_cover.jpg
|
||||
dev.bat
|
||||
|
||||
# ── Python ──
|
||||
__pycache__/
|
||||
|
||||
@@ -0,0 +1,165 @@
|
||||
"""Application-level configuration for cmshopee.
|
||||
|
||||
This module owns `config.json`, which stores local app settings and AI role/
|
||||
generation parameters. AI provider definitions and API keys are intentionally
|
||||
kept out of this file; T-005 will own `config/ai_models.json`.
|
||||
"""
|
||||
|
||||
import copy
|
||||
import json
|
||||
import os
|
||||
|
||||
|
||||
CONFIG_PATH = "config.json"
|
||||
|
||||
DEFAULT_CONFIG = {
|
||||
"chrome_path": r"C:\Program Files\Google\Chrome\Application\chrome.exe",
|
||||
"user_data_root": "chrome_user_data_dir",
|
||||
"image_dir": "images",
|
||||
"db_path": "cmshopee.db",
|
||||
"default_debug_port": 9222,
|
||||
"debug_port_range": [9222, 9260],
|
||||
"cdp_ready_timeout": 60,
|
||||
"ai": {
|
||||
"default_text_model": "GPT-5.5 文本",
|
||||
"default_image_model": "Nano Banana 2",
|
||||
"title_concurrency": 4,
|
||||
"image_concurrency": 4,
|
||||
"retry": 2,
|
||||
"jpg_quality": 90,
|
||||
"resolution": "1k",
|
||||
"resolution_timeouts": {
|
||||
"512": 180,
|
||||
"1k": 240,
|
||||
"2k": 360,
|
||||
"4k": 600,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
SECRET_FIELD_NAMES = {"api_key", "apikey", "key", "token", "password"}
|
||||
|
||||
|
||||
class ConfigError(RuntimeError):
|
||||
"""Raised when app configuration is missing or malformed."""
|
||||
|
||||
|
||||
def default_config() -> dict:
|
||||
"""Return a new copy of the default config."""
|
||||
|
||||
return copy.deepcopy(DEFAULT_CONFIG)
|
||||
|
||||
|
||||
def _deep_merge(defaults, loaded):
|
||||
if not isinstance(defaults, dict):
|
||||
return copy.deepcopy(loaded) if loaded is not None else copy.deepcopy(defaults)
|
||||
merged = copy.deepcopy(defaults)
|
||||
if not isinstance(loaded, dict):
|
||||
return merged
|
||||
for key, value in loaded.items():
|
||||
if isinstance(merged.get(key), dict) and isinstance(value, dict):
|
||||
merged[key] = _deep_merge(merged[key], value)
|
||||
else:
|
||||
merged[key] = copy.deepcopy(value)
|
||||
return merged
|
||||
|
||||
|
||||
def _assert_no_secrets(config):
|
||||
def visit(value, path):
|
||||
if isinstance(value, dict):
|
||||
for key, child in value.items():
|
||||
lowered = str(key).lower()
|
||||
if lowered in SECRET_FIELD_NAMES or lowered.endswith("_key"):
|
||||
raise ConfigError(
|
||||
f"config.json 不允许保存敏感字段: {'.'.join(path + [str(key)])}"
|
||||
)
|
||||
visit(child, path + [str(key)])
|
||||
elif isinstance(value, list):
|
||||
for index, child in enumerate(value):
|
||||
visit(child, path + [str(index)])
|
||||
|
||||
visit(config, [])
|
||||
|
||||
|
||||
def save_config(config, path=CONFIG_PATH) -> dict:
|
||||
"""Persist config to JSON and return the normalized config."""
|
||||
|
||||
normalized = _deep_merge(DEFAULT_CONFIG, config)
|
||||
_assert_no_secrets(normalized)
|
||||
directory = os.path.dirname(os.path.abspath(path))
|
||||
if directory:
|
||||
os.makedirs(directory, exist_ok=True)
|
||||
with open(path, "w", encoding="utf-8") as fh:
|
||||
json.dump(normalized, fh, ensure_ascii=False, indent=2)
|
||||
fh.write("\n")
|
||||
return normalized
|
||||
|
||||
|
||||
def load_config(path=CONFIG_PATH) -> dict:
|
||||
"""Load config, writing defaults first if the file does not exist."""
|
||||
|
||||
if not os.path.exists(path):
|
||||
return save_config(default_config(), path=path)
|
||||
with open(path, "r", encoding="utf-8") as fh:
|
||||
try:
|
||||
loaded = json.load(fh)
|
||||
except json.JSONDecodeError as exc:
|
||||
raise ConfigError(f"配置文件不是有效 JSON: {path}") from exc
|
||||
normalized = _deep_merge(DEFAULT_CONFIG, loaded)
|
||||
_assert_no_secrets(normalized)
|
||||
return normalized
|
||||
|
||||
|
||||
def update_config(updates, path=CONFIG_PATH) -> dict:
|
||||
"""Merge updates into the persisted config."""
|
||||
|
||||
config = load_config(path)
|
||||
return save_config(_deep_merge(config, updates), path=path)
|
||||
|
||||
|
||||
def _config_or_load(config):
|
||||
return load_config() if config is None else config
|
||||
|
||||
|
||||
def chrome_path(config=None) -> str:
|
||||
return _config_or_load(config).get("chrome_path", "")
|
||||
|
||||
|
||||
def user_data_root(config=None) -> str:
|
||||
return _config_or_load(config).get("user_data_root", "chrome_user_data_dir")
|
||||
|
||||
|
||||
def image_dir(config=None) -> str:
|
||||
return _config_or_load(config).get("image_dir", "images")
|
||||
|
||||
|
||||
def db_path(config=None) -> str:
|
||||
return _config_or_load(config).get("db_path", "cmshopee.db")
|
||||
|
||||
|
||||
def default_debug_port(config=None) -> int:
|
||||
return int(_config_or_load(config).get("default_debug_port", 9222))
|
||||
|
||||
|
||||
def debug_port_range(config=None) -> tuple:
|
||||
values = _config_or_load(config).get("debug_port_range", [9222, 9260])
|
||||
if not isinstance(values, list) or len(values) != 2:
|
||||
raise ConfigError("debug_port_range 必须是 [start, end]")
|
||||
return int(values[0]), int(values[1])
|
||||
|
||||
|
||||
def cdp_ready_timeout(config=None) -> int:
|
||||
return int(_config_or_load(config).get("cdp_ready_timeout", 60))
|
||||
|
||||
|
||||
def ai_config(config=None) -> dict:
|
||||
return copy.deepcopy(_config_or_load(config).get("ai", DEFAULT_CONFIG["ai"]))
|
||||
|
||||
|
||||
def response_timeout(config=None) -> int:
|
||||
ai = ai_config(config)
|
||||
resolution = str(ai.get("resolution", DEFAULT_CONFIG["ai"]["resolution"]))
|
||||
timeouts = ai.get("resolution_timeouts", {})
|
||||
if resolution not in timeouts:
|
||||
raise ConfigError(f"未配置分辨率 {resolution} 的返回超时")
|
||||
return int(timeouts[resolution])
|
||||
@@ -0,0 +1,564 @@
|
||||
"""SQLite persistence for accounts, import batches, and tasks."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import sqlite3
|
||||
import uuid
|
||||
from contextlib import contextmanager
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
from typing import Iterable, Optional
|
||||
|
||||
from . import appconfig
|
||||
|
||||
|
||||
DEFAULT_BUSY_TIMEOUT_MS = 5000
|
||||
VALID_BATCH_FIELDS = {"source_files_json", "status", "note"}
|
||||
VALID_ACCOUNT_FIELDS = {
|
||||
"account_name",
|
||||
"region_host",
|
||||
"slug",
|
||||
"user_data_dir",
|
||||
"debug_port",
|
||||
"password",
|
||||
"note",
|
||||
"last_login_at",
|
||||
}
|
||||
PHASE_ATTEMPT_FIELDS = {
|
||||
"collect": "collect_attempts",
|
||||
"collected": "collect_attempts",
|
||||
"generate": "generate_attempts",
|
||||
"generated": "generate_attempts",
|
||||
"apply": "apply_attempts",
|
||||
"applied": "apply_attempts",
|
||||
"update": "apply_attempts",
|
||||
"updated": "apply_attempts",
|
||||
}
|
||||
|
||||
|
||||
class DbError(RuntimeError):
|
||||
"""Raised when SQLite persistence cannot complete an operation."""
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Batch:
|
||||
id: str
|
||||
source_files_json: str
|
||||
status: str
|
||||
note: Optional[str]
|
||||
created_at: str
|
||||
updated_at: str
|
||||
|
||||
@property
|
||||
def source_files(self) -> list[str]:
|
||||
return json.loads(self.source_files_json)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Account:
|
||||
id: int
|
||||
account_name: str
|
||||
alias: str
|
||||
region_host: str
|
||||
slug: str
|
||||
user_data_dir: str
|
||||
debug_port: int
|
||||
password: Optional[str]
|
||||
note: Optional[str]
|
||||
created_at: str
|
||||
updated_at: str
|
||||
last_login_at: Optional[str]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Task:
|
||||
id: int
|
||||
batch_id: str
|
||||
source_file: str
|
||||
source_file_abs: str
|
||||
source_sheet: str
|
||||
source_row: int
|
||||
row_key: str
|
||||
account_name: Optional[str]
|
||||
alias: str
|
||||
item_id: str
|
||||
old_title: Optional[str]
|
||||
old_cover_path: Optional[str]
|
||||
new_title: Optional[str]
|
||||
new_cover_path: Optional[str]
|
||||
committed: int
|
||||
stage: str
|
||||
status: str
|
||||
last_error: Optional[str]
|
||||
collect_attempts: int
|
||||
generate_attempts: int
|
||||
apply_attempts: int
|
||||
imported_at: str
|
||||
collected_at: Optional[str]
|
||||
generated_at: Optional[str]
|
||||
applied_at: Optional[str]
|
||||
updated_at: str
|
||||
|
||||
|
||||
SCHEMA_SQL = """
|
||||
CREATE TABLE IF NOT EXISTS batches (
|
||||
id TEXT PRIMARY KEY,
|
||||
source_files_json TEXT NOT NULL,
|
||||
status TEXT NOT NULL DEFAULT 'active',
|
||||
note TEXT,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS accounts (
|
||||
id INTEGER PRIMARY KEY,
|
||||
account_name TEXT NOT NULL,
|
||||
alias TEXT UNIQUE NOT NULL,
|
||||
region_host TEXT NOT NULL,
|
||||
slug TEXT UNIQUE NOT NULL,
|
||||
user_data_dir TEXT NOT NULL,
|
||||
debug_port INTEGER NOT NULL,
|
||||
password TEXT,
|
||||
note TEXT,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
last_login_at TEXT
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS tasks (
|
||||
id INTEGER PRIMARY KEY,
|
||||
batch_id TEXT NOT NULL REFERENCES batches(id),
|
||||
source_file TEXT NOT NULL,
|
||||
source_file_abs TEXT NOT NULL,
|
||||
source_sheet TEXT NOT NULL,
|
||||
source_row INTEGER NOT NULL,
|
||||
row_key TEXT NOT NULL UNIQUE,
|
||||
account_name TEXT,
|
||||
alias TEXT NOT NULL,
|
||||
item_id TEXT NOT NULL,
|
||||
old_title TEXT,
|
||||
old_cover_path TEXT,
|
||||
new_title TEXT,
|
||||
new_cover_path TEXT,
|
||||
committed INTEGER NOT NULL DEFAULT 0,
|
||||
stage TEXT NOT NULL DEFAULT 'imported',
|
||||
status TEXT NOT NULL DEFAULT 'pending',
|
||||
last_error TEXT,
|
||||
collect_attempts INTEGER NOT NULL DEFAULT 0,
|
||||
generate_attempts INTEGER NOT NULL DEFAULT 0,
|
||||
apply_attempts INTEGER NOT NULL DEFAULT 0,
|
||||
imported_at TEXT NOT NULL,
|
||||
collected_at TEXT,
|
||||
generated_at TEXT,
|
||||
applied_at TEXT,
|
||||
updated_at TEXT NOT NULL,
|
||||
UNIQUE(batch_id, source_file_abs, source_sheet, source_row)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_tasks_batch_stage_status
|
||||
ON tasks(batch_id, stage, status);
|
||||
CREATE INDEX IF NOT EXISTS idx_tasks_alias ON tasks(alias);
|
||||
CREATE INDEX IF NOT EXISTS idx_tasks_item ON tasks(item_id);
|
||||
"""
|
||||
|
||||
|
||||
def _now() -> str:
|
||||
return datetime.now().isoformat(timespec="seconds")
|
||||
|
||||
|
||||
def _db_path(path=None) -> str:
|
||||
return path or appconfig.db_path()
|
||||
|
||||
|
||||
def connect(path=None) -> sqlite3.Connection:
|
||||
"""Open a SQLite connection with the project concurrency pragmas."""
|
||||
|
||||
db_path = _db_path(path)
|
||||
if db_path != ":memory:":
|
||||
directory = os.path.dirname(os.path.abspath(db_path))
|
||||
if directory:
|
||||
os.makedirs(directory, exist_ok=True)
|
||||
conn = sqlite3.connect(db_path, timeout=DEFAULT_BUSY_TIMEOUT_MS / 1000)
|
||||
conn.row_factory = sqlite3.Row
|
||||
conn.execute("PRAGMA foreign_keys=ON")
|
||||
conn.execute("PRAGMA journal_mode=WAL")
|
||||
conn.execute(f"PRAGMA busy_timeout={DEFAULT_BUSY_TIMEOUT_MS}")
|
||||
conn.execute("PRAGMA synchronous=NORMAL")
|
||||
return conn
|
||||
|
||||
|
||||
@contextmanager
|
||||
def _connection(conn=None, path=None):
|
||||
owned = conn is None
|
||||
database = connect(path) if owned else conn
|
||||
try:
|
||||
yield database
|
||||
finally:
|
||||
if owned:
|
||||
database.close()
|
||||
|
||||
|
||||
def _as_dataclass(row, cls):
|
||||
return None if row is None else cls(**dict(row))
|
||||
|
||||
|
||||
def _fetch_one(conn, sql, params, cls):
|
||||
return _as_dataclass(conn.execute(sql, params).fetchone(), cls)
|
||||
|
||||
|
||||
def _fetch_all(conn, sql, params, cls):
|
||||
return [_as_dataclass(row, cls) for row in conn.execute(sql, params).fetchall()]
|
||||
|
||||
|
||||
def _validate_fields(fields, allowed):
|
||||
unknown = sorted(set(fields) - allowed)
|
||||
if unknown:
|
||||
raise DbError(f"不支持更新字段: {', '.join(unknown)}")
|
||||
|
||||
|
||||
def _slug_from_alias(alias: str) -> str:
|
||||
base = re.sub(r"[^a-z0-9_]+", "_", alias.lower()).strip("_")
|
||||
suffix = hashlib.sha1(alias.encode("utf-8")).hexdigest()[:8]
|
||||
return f"{base}_{suffix}" if base else f"account_{suffix}"
|
||||
|
||||
|
||||
def _row_key(batch_id, source_file_abs, source_sheet, source_row) -> str:
|
||||
return f"{batch_id}:{source_file_abs}:{source_sheet}:{source_row}"
|
||||
|
||||
|
||||
def _attempt_field(phase: str) -> str:
|
||||
key = str(phase).lower()
|
||||
if key not in PHASE_ATTEMPT_FIELDS:
|
||||
raise DbError(f"未知阶段: {phase}")
|
||||
return PHASE_ATTEMPT_FIELDS[key]
|
||||
|
||||
|
||||
def init_db(path=None, conn=None) -> None:
|
||||
"""Create tables and indexes if they do not already exist."""
|
||||
|
||||
with _connection(conn, path) as database:
|
||||
with database:
|
||||
database.executescript(SCHEMA_SQL)
|
||||
|
||||
|
||||
def create_batch(file_paths: Iterable[str], note=None, path=None, conn=None) -> str:
|
||||
batch_id = datetime.now().strftime("%Y%m%d_%H%M%S_") + uuid.uuid4().hex[:8]
|
||||
files = [os.path.abspath(file_path) for file_path in file_paths]
|
||||
now = _now()
|
||||
with _connection(conn, path) as database:
|
||||
with database:
|
||||
database.execute(
|
||||
"""
|
||||
INSERT INTO batches
|
||||
(id, source_files_json, status, note, created_at, updated_at)
|
||||
VALUES (?, ?, 'active', ?, ?, ?)
|
||||
""",
|
||||
(batch_id, json.dumps(files, ensure_ascii=False), note, now, now),
|
||||
)
|
||||
return batch_id
|
||||
|
||||
|
||||
def get_batch(batch_id, path=None, conn=None):
|
||||
with _connection(conn, path) as database:
|
||||
return _fetch_one(
|
||||
database,
|
||||
"SELECT * FROM batches WHERE id = ?",
|
||||
(batch_id,),
|
||||
Batch,
|
||||
)
|
||||
|
||||
|
||||
def list_batches(status=None, path=None, conn=None):
|
||||
sql = "SELECT * FROM batches"
|
||||
params = []
|
||||
if status is not None:
|
||||
sql += " WHERE status = ?"
|
||||
params.append(status)
|
||||
sql += " ORDER BY created_at DESC, id DESC"
|
||||
with _connection(conn, path) as database:
|
||||
return _fetch_all(database, sql, params, Batch)
|
||||
|
||||
|
||||
def update_batch(batch_id, path=None, conn=None, **fields) -> None:
|
||||
_validate_fields(fields, VALID_BATCH_FIELDS)
|
||||
if not fields:
|
||||
return
|
||||
fields["updated_at"] = _now()
|
||||
assignments = ", ".join(f"{field} = ?" for field in fields)
|
||||
params = list(fields.values()) + [batch_id]
|
||||
with _connection(conn, path) as database:
|
||||
with database:
|
||||
database.execute(
|
||||
f"UPDATE batches SET {assignments} WHERE id = ?",
|
||||
params,
|
||||
)
|
||||
|
||||
|
||||
def list_accounts(path=None, conn=None):
|
||||
with _connection(conn, path) as database:
|
||||
return _fetch_all(
|
||||
database,
|
||||
"SELECT * FROM accounts ORDER BY alias",
|
||||
(),
|
||||
Account,
|
||||
)
|
||||
|
||||
|
||||
def get_account_by_alias(alias, path=None, conn=None):
|
||||
with _connection(conn, path) as database:
|
||||
return _fetch_one(
|
||||
database,
|
||||
"SELECT * FROM accounts WHERE alias = ?",
|
||||
(alias,),
|
||||
Account,
|
||||
)
|
||||
|
||||
|
||||
def add_account(
|
||||
account_name,
|
||||
alias,
|
||||
region_host,
|
||||
debug_port,
|
||||
password=None,
|
||||
note=None,
|
||||
slug=None,
|
||||
user_data_dir=None,
|
||||
path=None,
|
||||
conn=None,
|
||||
):
|
||||
slug = slug or _slug_from_alias(alias)
|
||||
user_data_dir = user_data_dir or os.path.join("chrome_user_data_dir", slug)
|
||||
now = _now()
|
||||
with _connection(conn, path) as database:
|
||||
try:
|
||||
with database:
|
||||
database.execute(
|
||||
"""
|
||||
INSERT INTO accounts
|
||||
(account_name, alias, region_host, slug, user_data_dir,
|
||||
debug_port, password, note, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(
|
||||
account_name,
|
||||
alias,
|
||||
region_host,
|
||||
slug,
|
||||
user_data_dir,
|
||||
int(debug_port),
|
||||
password,
|
||||
note,
|
||||
now,
|
||||
now,
|
||||
),
|
||||
)
|
||||
except sqlite3.IntegrityError as exc:
|
||||
raise DbError(f"账号别名或 slug 已存在: {alias}") from exc
|
||||
return get_account_by_alias(alias, conn=database)
|
||||
|
||||
|
||||
def update_account(alias, path=None, conn=None, **fields) -> None:
|
||||
_validate_fields(fields, VALID_ACCOUNT_FIELDS)
|
||||
if not fields:
|
||||
return
|
||||
fields["updated_at"] = _now()
|
||||
assignments = ", ".join(f"{field} = ?" for field in fields)
|
||||
params = list(fields.values()) + [alias]
|
||||
with _connection(conn, path) as database:
|
||||
with database:
|
||||
database.execute(
|
||||
f"UPDATE accounts SET {assignments} WHERE alias = ?",
|
||||
params,
|
||||
)
|
||||
|
||||
|
||||
def delete_account(alias, path=None, conn=None) -> None:
|
||||
with _connection(conn, path) as database:
|
||||
with database:
|
||||
database.execute("DELETE FROM accounts WHERE alias = ?", (alias,))
|
||||
|
||||
|
||||
def insert_tasks(batch_id, rows, path=None, conn=None) -> int:
|
||||
now = _now()
|
||||
values = []
|
||||
for row in rows:
|
||||
source_file_abs = os.path.abspath(row["source_file_abs"])
|
||||
source_sheet = row["source_sheet"]
|
||||
source_row = int(row["source_row"])
|
||||
values.append(
|
||||
(
|
||||
batch_id,
|
||||
row.get("source_file") or source_file_abs,
|
||||
source_file_abs,
|
||||
source_sheet,
|
||||
source_row,
|
||||
row.get("row_key") or _row_key(batch_id, source_file_abs, source_sheet, source_row),
|
||||
row.get("account_name"),
|
||||
row["alias"],
|
||||
str(row["item_id"]),
|
||||
now,
|
||||
now,
|
||||
)
|
||||
)
|
||||
with _connection(conn, path) as database:
|
||||
try:
|
||||
with database:
|
||||
database.executemany(
|
||||
"""
|
||||
INSERT INTO tasks
|
||||
(batch_id, source_file, source_file_abs, source_sheet,
|
||||
source_row, row_key, account_name, alias, item_id,
|
||||
imported_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
values,
|
||||
)
|
||||
except sqlite3.IntegrityError as exc:
|
||||
raise DbError("任务行重复或批次不存在") from exc
|
||||
return len(values)
|
||||
|
||||
|
||||
def list_tasks(batch_id=None, stage=None, status=None, alias=None, path=None, conn=None):
|
||||
clauses = []
|
||||
params = []
|
||||
filters = {
|
||||
"batch_id": batch_id,
|
||||
"stage": stage,
|
||||
"status": status,
|
||||
"alias": alias,
|
||||
}
|
||||
for field, value in filters.items():
|
||||
if value is not None:
|
||||
clauses.append(f"{field} = ?")
|
||||
params.append(value)
|
||||
sql = "SELECT * FROM tasks"
|
||||
if clauses:
|
||||
sql += " WHERE " + " AND ".join(clauses)
|
||||
sql += " ORDER BY id"
|
||||
with _connection(conn, path) as database:
|
||||
return _fetch_all(database, sql, params, Task)
|
||||
|
||||
|
||||
def mark_running(task_id, phase, path=None, conn=None) -> None:
|
||||
_attempt_field(phase)
|
||||
with _connection(conn, path) as database:
|
||||
with database:
|
||||
database.execute(
|
||||
"""
|
||||
UPDATE tasks
|
||||
SET status = 'running', last_error = NULL, updated_at = ?
|
||||
WHERE id = ?
|
||||
""",
|
||||
(_now(), int(task_id)),
|
||||
)
|
||||
|
||||
|
||||
def mark_failed(task_id, phase, error, path=None, conn=None) -> None:
|
||||
attempt_field = _attempt_field(phase)
|
||||
with _connection(conn, path) as database:
|
||||
with database:
|
||||
database.execute(
|
||||
f"""
|
||||
UPDATE tasks
|
||||
SET status = 'failed',
|
||||
last_error = ?,
|
||||
{attempt_field} = {attempt_field} + 1,
|
||||
updated_at = ?
|
||||
WHERE id = ?
|
||||
""",
|
||||
(str(error), _now(), int(task_id)),
|
||||
)
|
||||
|
||||
|
||||
def mark_skipped(task_id, reason, path=None, conn=None) -> None:
|
||||
with _connection(conn, path) as database:
|
||||
with database:
|
||||
database.execute(
|
||||
"""
|
||||
UPDATE tasks
|
||||
SET status = 'skipped', last_error = ?, updated_at = ?
|
||||
WHERE id = ?
|
||||
""",
|
||||
(str(reason), _now(), int(task_id)),
|
||||
)
|
||||
|
||||
|
||||
def set_collected(task_id, old_title, old_cover_path, path=None, conn=None) -> None:
|
||||
now = _now()
|
||||
with _connection(conn, path) as database:
|
||||
with database:
|
||||
database.execute(
|
||||
"""
|
||||
UPDATE tasks
|
||||
SET old_title = ?,
|
||||
old_cover_path = ?,
|
||||
stage = 'collected',
|
||||
status = 'success',
|
||||
last_error = NULL,
|
||||
collect_attempts = collect_attempts + 1,
|
||||
collected_at = ?,
|
||||
updated_at = ?
|
||||
WHERE id = ?
|
||||
""",
|
||||
(old_title, old_cover_path, now, now, int(task_id)),
|
||||
)
|
||||
|
||||
|
||||
def set_generated(task_id, new_title, new_cover_path, path=None, conn=None) -> None:
|
||||
now = _now()
|
||||
with _connection(conn, path) as database:
|
||||
with database:
|
||||
database.execute(
|
||||
"""
|
||||
UPDATE tasks
|
||||
SET new_title = ?,
|
||||
new_cover_path = ?,
|
||||
stage = 'generated',
|
||||
status = 'success',
|
||||
last_error = NULL,
|
||||
generate_attempts = generate_attempts + 1,
|
||||
generated_at = ?,
|
||||
updated_at = ?
|
||||
WHERE id = ?
|
||||
""",
|
||||
(new_title, new_cover_path, now, now, int(task_id)),
|
||||
)
|
||||
|
||||
|
||||
def set_applied(task_id, committed, error=None, path=None, conn=None) -> None:
|
||||
now = _now()
|
||||
success = bool(committed) and error is None
|
||||
with _connection(conn, path) as database:
|
||||
with database:
|
||||
if success:
|
||||
database.execute(
|
||||
"""
|
||||
UPDATE tasks
|
||||
SET committed = 1,
|
||||
stage = 'applied',
|
||||
status = 'success',
|
||||
last_error = NULL,
|
||||
apply_attempts = apply_attempts + 1,
|
||||
applied_at = ?,
|
||||
updated_at = ?
|
||||
WHERE id = ?
|
||||
""",
|
||||
(now, now, int(task_id)),
|
||||
)
|
||||
else:
|
||||
database.execute(
|
||||
"""
|
||||
UPDATE tasks
|
||||
SET committed = 0,
|
||||
status = 'failed',
|
||||
last_error = ?,
|
||||
apply_attempts = apply_attempts + 1,
|
||||
updated_at = ?
|
||||
WHERE id = ?
|
||||
""",
|
||||
(str(error or "未提交更新"), now, int(task_id)),
|
||||
)
|
||||
+3
-3
@@ -25,9 +25,9 @@
|
||||
| --- | --- | --- | --- | --- |
|
||||
| T-000 | 正式代码包结构:创建 `app/`、迁入 `cdp.py` 为 `app/cdp.py`、新增 `app/__init__.py`、`app/__main__.py`、根入口 `main.py`、最小 `app/gui.py` 占位入口,并修正 prototypes 导入 | - | `python -m compileall app main.py` 通过;`python -m app`/`python main.py` 可进入入口(本机 `python` 不符合版本时用 `py -3 -m app`);GUI 未完成时给明确提示并退出;`prototypes/demo.py` 可从项目根导入 `app.cdp` | DONE |
|
||||
| T-001 | `app/editor.py`:改标题/换封面/点更新/登录检测/**采集(读旧标题+旧封面下载)**/apply_task,复用 `app/cdp.py` | T-000 | 函数可调用,在测试商品跑通;与 `prototypes/demo.py` 行为一致 | DONE |
|
||||
| T-002 | `app/appconfig.py` + `config.json`(含 image_dir、ai 选择/参数段、端口等默认值;不含 AI Key) | T-000 | 读写正常;不存在则写默认;AI Key 留给 `config/ai_models.json`/T-501 | TODO |
|
||||
| T-003 | `app/db.py` + SQLite 建表(batches/accounts/tasks,含 Excel 行定位、状态、时间戳、重试字段) | T-000 | `init_db` 幂等;`connect` 设置 WAL/busy_timeout/foreign_keys;账号/批次/任务/各 set_* 可用;schema 同架构 5.2 | TODO |
|
||||
| T-004 | `.gitignore`:排除 `config.json`、`config/ai_models.json`、`cmshopee.db`、`chrome_user_data_dir/`、`images/` | T-002, T-003 | 配置、密钥、凭证、业务数据、图片不被提交 | TODO |
|
||||
| T-002 | `app/appconfig.py` + `config.json`(含 image_dir、ai 选择/参数段、端口等默认值;不含 AI Key) | T-000 | 读写正常;不存在则写默认;AI Key 留给 `config/ai_models.json`/T-501 | DONE |
|
||||
| T-003 | `app/db.py` + SQLite 建表(batches/accounts/tasks,含 Excel 行定位、状态、时间戳、重试字段) | T-000 | `init_db` 幂等;`connect` 设置 WAL/busy_timeout/foreign_keys;账号/批次/任务/各 set_* 可用;schema 同架构 5.2 | DONE |
|
||||
| T-004 | `.gitignore`:排除 `config.json`、`config/ai_models.json`、`cmshopee.db`、`chrome_user_data_dir/`、`images/` | T-002, T-003 | 配置、密钥、凭证、业务数据、图片不被提交 | DONE |
|
||||
| T-005 | AI 模型清单后端:`config/ai_models.json` 读写 + category 过滤 + 测试连接 | T-002 | 本地明文 api_key;UI/API 打码显示;日志脱敏;至少 text/image 各一个;`get_model` 返回调用所需字段 | TODO |
|
||||
| T-006 | 单元测试基座:`tests/` + appconfig/db/excel/prompts 最小测试 | T-002, T-003 | `python -m unittest discover -s tests` 可跑;不依赖真实 Shopee/AI;临时文件在测试目录清理 | TODO |
|
||||
|
||||
|
||||
+38
-24
@@ -9,22 +9,34 @@
|
||||
- 凭证:登录态在 user-data-dir;密码、AI Key 本地明文存于 config/DB;`config.json`、`config/ai_models.json`、`cmshopee.db`、`chrome_user_data_dir/`、`images/` 必须 gitignore;UI 打码显示,不出现在日志/导出。
|
||||
- 失败处理:抛带中文说明的异常或返回状态字段;GUI 负责提示,不静默吞错。
|
||||
|
||||
## appconfig 模块(`app/appconfig.py`,待建)
|
||||
## appconfig 模块(`app/appconfig.py`,已建)
|
||||
|
||||
读写 `config.json`(schema 见 [架构 5.1](04-architecture.md))。
|
||||
|
||||
```python
|
||||
class ConfigError(RuntimeError): ...
|
||||
default_config() -> dict
|
||||
load_config(path="config.json") -> dict # 不存在则写默认
|
||||
chrome_path() -> str
|
||||
user_data_root() -> str
|
||||
image_dir() -> str
|
||||
db_path() -> str
|
||||
ai_config() -> dict # default_text_model/default_image_model/
|
||||
save_config(config, path="config.json") -> dict
|
||||
update_config(updates, path="config.json") -> dict
|
||||
chrome_path(config=None) -> str
|
||||
user_data_root(config=None) -> str
|
||||
image_dir(config=None) -> str
|
||||
db_path(config=None) -> str
|
||||
default_debug_port(config=None) -> int
|
||||
debug_port_range(config=None) -> tuple # (start, end)
|
||||
cdp_ready_timeout(config=None) -> int
|
||||
ai_config(config=None) -> dict # default_text_model/default_image_model/
|
||||
# title_concurrency/image_concurrency/retry/jpg_quality/
|
||||
# resolution/resolution_timeouts
|
||||
response_timeout() -> int # = resolution_timeouts[resolution](返回超时,随分辨率)
|
||||
response_timeout(config=None) -> int # = resolution_timeouts[resolution](返回超时,随分辨率)
|
||||
```
|
||||
|
||||
# AI 模型清单 config/ai_models.json(含本地明文密钥;CRUD 由 ⑤ 设置)
|
||||
`config.json` 不保存 AI Key;写入 `api_key` / `*_key` / `token` / `password` 等敏感字段时抛 `ConfigError`。AI Key 留给 `config/ai_models.json`。
|
||||
|
||||
AI 模型清单(`config/ai_models.json`,含本地明文密钥;CRUD 由 ⑤ 设置,T-005 待建):
|
||||
|
||||
```python
|
||||
list_ai_models(category=None) -> list[dict] # category=text/image 过滤;含 connect_timeout_seconds 等
|
||||
add_ai_model(model) -> None # name 唯一校验
|
||||
update_ai_model(name, **fields) -> None
|
||||
@@ -33,32 +45,34 @@ test_ai_model(name) -> dict # 「测试连接」:用 key/url
|
||||
get_model(name) -> dict # 返回模型定义,含 api_key(调用方不得写日志)
|
||||
```
|
||||
|
||||
## db 模块(`app/db.py`,待建)
|
||||
## db 模块(`app/db.py`,已建)
|
||||
|
||||
SQLite 读写,表见 [架构 5.2](04-architecture.md)。
|
||||
|
||||
```python
|
||||
class DbError(RuntimeError): ...
|
||||
Batch / Account / Task # dataclass,字段同 SQLite schema
|
||||
connect(path=None) -> sqlite3.Connection # 设置 foreign_keys/WAL/busy_timeout/synchronous/row_factory
|
||||
init_db(path)
|
||||
create_batch(file_paths, note=None) -> str
|
||||
get_batch(batch_id) -> Batch|None
|
||||
list_batches(status=None) -> list[Batch]
|
||||
update_batch(batch_id, **fields) -> None
|
||||
init_db(path=None)
|
||||
create_batch(file_paths, note=None, path=None) -> str
|
||||
get_batch(batch_id, path=None) -> Batch|None
|
||||
list_batches(status=None, path=None) -> list[Batch]
|
||||
update_batch(batch_id, **fields) -> None # 支持 status/note/source_files_json
|
||||
# 账号
|
||||
list_accounts() -> list[Account]
|
||||
get_account_by_alias(alias) -> Account|None
|
||||
list_accounts(path=None) -> list[Account]
|
||||
get_account_by_alias(alias, path=None) -> Account|None
|
||||
add_account(account_name, alias, region_host, debug_port, password=None, note=None) -> Account
|
||||
update_account(alias, **fields) -> None
|
||||
update_account(alias, **fields) -> None # 支持账号展示字段、端口、密码、note、last_login_at
|
||||
delete_account(alias) -> None
|
||||
# 任务 / 各阶段结果
|
||||
insert_tasks(batch_id, rows) -> int # 写输入列;rows 含 source_file_abs/source_sheet/source_row/row_key
|
||||
list_tasks(batch_id=None, stage=None, status=None, alias=None) -> list[Task]
|
||||
insert_tasks(batch_id, rows, path=None) -> int # 写输入列;rows 含 source_file_abs/source_sheet/source_row/row_key
|
||||
list_tasks(batch_id=None, stage=None, status=None, alias=None, path=None) -> list[Task]
|
||||
mark_running(task_id, phase) -> None
|
||||
mark_failed(task_id, phase, error) -> None
|
||||
mark_skipped(task_id, reason) -> None
|
||||
set_collected(task_id, old_title, old_cover_path) -> None # stage=collected,status=success, attempts+1
|
||||
set_generated(task_id, new_title, new_cover_path) -> None # stage=generated,status=success, attempts+1
|
||||
set_applied(task_id, committed, error=None) -> None # stage=applied,status=success/failed, attempts+1
|
||||
mark_failed(task_id, phase, error) -> None # status=failed,stage 不前进,对应 attempts+1
|
||||
mark_skipped(task_id, reason) -> None # status=skipped,stage 不前进
|
||||
set_collected(task_id, old_title, old_cover_path) -> None # stage=collected,status=success,collect_attempts+1
|
||||
set_generated(task_id, new_title, new_cover_path) -> None # stage=generated,status=success,generate_attempts+1
|
||||
set_applied(task_id, committed, error=None) -> None # 成功 stage=applied;失败 status=failed 且 stage 不前进
|
||||
```
|
||||
|
||||
`stage` 表示最后成功业务阶段(imported→collected→generated→applied);`status` 表示当前处理结果(pending/running/success/failed/skipped/cancelled)。任意步失败写 `last_error` 且 `status=failed`,`stage` 不前进。无 confirmed 阶段。
|
||||
|
||||
+20
-9
@@ -5,12 +5,12 @@
|
||||
|
||||
## 当前快照
|
||||
|
||||
- 日期:2026-06-26
|
||||
- 阶段:V0 单账号 CDP 流程已验证;V1 已完成 T-000 正式代码包结构与 T-001 `app/editor.py` 模块化,下一步开始应用配置。
|
||||
- 日期:2026-06-27
|
||||
- 阶段:V0 单账号 CDP 流程已验证;V1 已完成 T-000 正式代码包结构、T-001 `app/editor.py` 模块化、T-002 `app/appconfig.py` 应用配置、T-003 SQLite 持久化地基、T-004 本地数据忽略规则。
|
||||
- 技术栈:Python 3.10+,自研 CDP(websocket-client + requests),SQLite(sqlite3)+ `config.json` + openpyxl + AI(服务商待定),GUI PySide6 5 Tab(已定)。
|
||||
- 生产代码:已建立 `app/` 包 + 根入口 `main.py`;`app/cdp.py` 为已验证 CDP 底座;`app/editor.py` 已封装标题/封面/采集/更新按钮能力;`app/gui.py` 目前是入口占位,完整 PySide6 主窗口待 T-104。
|
||||
- 生产代码:已建立 `app/` 包 + 根入口 `main.py`;`app/cdp.py` 为已验证 CDP 底座;`app/editor.py` 已封装标题/封面/采集/更新按钮能力;`app/appconfig.py` 已实现 `config.json` 默认值、读写、更新、AI 参数与端口配置读取;`app/db.py` 已实现 SQLite schema、连接 PRAGMA、批次/账号/任务与阶段写库函数;`app/gui.py` 目前是入口占位,完整 PySide6 主窗口待 T-104。
|
||||
- 测试:当前以 `compileall` + 测试商品手动 CDP 验证为主;`tests/` 与 `python -m unittest discover -s tests` 由 T-006 建立,T-006 完成前不把缺少 `tests/` 视为验证失败。
|
||||
- 数据:无 `config.json`、`config/ai_models.json`、`cmshopee.db`、`chrome_user_data_dir/`、`images/`(待 Phase 0/1 建立,均须 gitignore)。
|
||||
- 数据:`config.json`、`config/ai_models.json`、`cmshopee.db`、`chrome_user_data_dir/`、`images/` 已由 `.gitignore` 排除;`app/appconfig.py` 首次读取缺失的 `config.json` 时会在本地写默认配置,`app/db.py` 调用 `init_db()` 时会在本地创建 SQLite DB。
|
||||
|
||||
## 既定设计要点(文档已定)
|
||||
|
||||
@@ -29,13 +29,15 @@
|
||||
| --- | --- | --- |
|
||||
| `docs/` | 已有 | 本 harness coding 文档集合 |
|
||||
| `app/cdp.py` | 已有 | CDP 底座:连接/找 tab/开 tab/执行 JS/拖拽 |
|
||||
| `prototypes/` | 已有 | 已验证原型/探查脚本(demo/set_title/set_cover/get_title/cookies/inspect_images/grab/1.py),逻辑待并入 `app/editor.py` 后清理;见 `prototypes/README.md` |
|
||||
| `prototypes/` | 已有 | 已验证原型/探查脚本(demo/set_title/set_cover/get_title/cookies/inspect_images/grab/1.py),保留作人工回归与探查参考;见 `prototypes/README.md` |
|
||||
| `chrome-remote-debug-lan.md` | 已有 | WSL→Windows CDP 转发排查记录 |
|
||||
| `app/__init__.py` / `app/__main__.py` / `main.py` | 已有 | 正式包与启动入口;`python main.py` / `python -m app` 可运行占位入口 |
|
||||
| `app/gui.py` | 已有 | GUI 占位入口;完整 PySide6 主窗口待 T-104 |
|
||||
| `app/editor.py` | 已有 | T-001 产出:登录检测、打开商品页、读/写标题、读/下载封面、上传拖封面、更新按钮、apply_task |
|
||||
| `app/appconfig.py` / `app/db.py` / `app/excel.py` / `app/config.py` / `app/chrome.py` / `app/workers.py` | 待建 | Phase 0-3 产出 |
|
||||
| `config.json` / `config/ai_models.json` / `cmshopee.db` / `chrome_user_data_dir/` / `images/` | 待建 | 含配置、密钥、业务、登录态、图片,须 gitignore |
|
||||
| `app/appconfig.py` | 已有 | T-002 产出:`config.json` 默认值、读写、更新、路径/端口/AI 参数读取;拒绝敏感字段写入 |
|
||||
| `app/db.py` | 已有 | T-003 产出:batches/accounts/tasks schema;WAL/busy_timeout/foreign_keys;账号/批次/任务与 set_* 阶段写库 |
|
||||
| `app/excel.py` / `app/config.py` / `app/chrome.py` / `app/workers.py` | 待建 | Phase 0-3 产出 |
|
||||
| `config.json` / `config/ai_models.json` / `cmshopee.db` / `chrome_user_data_dir/` / `images/` | 本地待建,已忽略 | 含配置、密钥、业务、登录态、图片,不提交版本库 |
|
||||
|
||||
## 已验证能力(单账号)
|
||||
|
||||
@@ -49,9 +51,9 @@
|
||||
|
||||
任务状态以 [`06-tasks.md`](06-tasks.md) 为准,历史记录见 [`../progress.md`](../progress.md)。
|
||||
|
||||
- 已完成:T-000(正式代码包结构)、T-001(`app/editor.py` 模块化)。
|
||||
- 已完成:T-000(正式代码包结构)、T-001(`app/editor.py` 模块化)、T-002(`app/appconfig.py` + `config.json`)、T-003(`app/db.py` + SQLite 建表)、T-004(本地数据 gitignore)。
|
||||
- 正在进行:无。
|
||||
- 下一个可领取任务:**T-002(`app/appconfig.py` + `config.json`)**。虽然 T-003 也依赖满足,但任务领取规则固定为“取 `06-tasks.md` 中第一个 TODO 且依赖均 DONE 的任务”,因此当前不能任选。
|
||||
- 下一个可领取任务:**T-005(AI 模型清单后端)**。
|
||||
|
||||
## 当前可运行内容
|
||||
|
||||
@@ -59,6 +61,15 @@
|
||||
# 语法检查(T-000 后;本机优先用 py -3)
|
||||
py -3 -m compileall app main.py
|
||||
|
||||
# appconfig 默认配置读写(写入临时目录)
|
||||
py -3 -c "import os,tempfile; from app import appconfig; d=tempfile.TemporaryDirectory(dir='.'); print(appconfig.load_config(os.path.join(d.name,'config.json'))['image_dir'])"
|
||||
|
||||
# db 临时库初始化与 PRAGMA 检查
|
||||
py -3 -c "import os,tempfile; from app import db; d=tempfile.TemporaryDirectory(dir='.'); p=os.path.join(d.name,'cmshopee.db'); db.init_db(p); c=db.connect(p); print(c.execute('PRAGMA foreign_keys').fetchone()[0], c.execute('PRAGMA journal_mode').fetchone()[0], c.execute('PRAGMA busy_timeout').fetchone()[0]); c.close()"
|
||||
|
||||
# gitignore 核心本地数据检查
|
||||
git check-ignore -v -- config.json config/ai_models.json cmshopee.db chrome_user_data_dir/ images/
|
||||
|
||||
# 当前入口占位
|
||||
python main.py
|
||||
py -3 -m app
|
||||
|
||||
+27
@@ -249,3 +249,30 @@
|
||||
- 下一步:按任务看板领取 T-002。
|
||||
|
||||
<!-- 新一轮从这里向下追加记录。 -->
|
||||
|
||||
## 【2026-06-27】T-002 appconfig 应用配置
|
||||
|
||||
- 状态:DONE
|
||||
- 变更:新增 `app/appconfig.py`,实现 `config.json` 默认配置、缺失时写默认、读取/保存/合并更新、路径/端口/CDP 等待时间/AI 参数读取、按分辨率派生返回超时;同步 `docs/06-tasks.md`、`docs/current-state.md`、`docs/api.md`。
|
||||
- 配置边界:`config.json` 只保存应用路径、端口、AI 角色选择与生成参数;`api_key` / `*_key` / `token` / `password` 等敏感字段写入时抛 `ConfigError`,AI Key 仍留给 T-005 的 `config/ai_models.json`。
|
||||
- 验证:`py -3 -m compileall app main.py` 通过;临时 `config.json` 的 `load_config` / `update_config` / `response_timeout` 验证通过;尝试写入 `api_key` 返回 `ConfigError: config.json 不允许保存敏感字段: api_key`。
|
||||
- 注意:`python -m unittest discover -s tests` 仍等 T-006 建立测试基座后再运行。
|
||||
- 下一步:按任务看板领取 T-003。
|
||||
|
||||
## 【2026-06-27】T-003 SQLite 持久化地基
|
||||
|
||||
- 状态:DONE
|
||||
- 变更:新增 `app/db.py`,按架构 5.2 建立 `batches`、`accounts`、`tasks` 三张表和索引;实现 `connect/init_db`、批次、账号、任务导入、任务筛选,以及 `mark_running/mark_failed/mark_skipped/set_collected/set_generated/set_applied`;同步 `docs/06-tasks.md`、`docs/current-state.md`、`docs/api.md`。
|
||||
- 连接规则:`connect()` 设置 `foreign_keys=ON`、`journal_mode=WAL`、`busy_timeout=5000`、`synchronous=NORMAL`、`row_factory=sqlite3.Row`;每个函数默认短事务写入并提交,worker 后续可按线程自建 connection。
|
||||
- 阶段规则:失败和跳过只更新 `status/last_error/attempts`,`stage` 保持最后成功阶段;成功采集/生成/应用分别推进到 `collected/generated/applied`。
|
||||
- 验证:`py -3 -m compileall app main.py` 通过;临时 SQLite 文件中重复 `init_db` 通过;PRAGMA 输出 `(1, 'wal', 5000)`;批次/账号/任务写入、失败状态、采集/生成/应用阶段推进验证通过。
|
||||
- 注意:`python -m unittest discover -s tests` 仍等 T-006 建立测试基座后再运行。
|
||||
- 下一步:按任务看板领取 T-004。
|
||||
|
||||
## 【2026-06-27】T-004 本地数据 gitignore
|
||||
|
||||
- 状态:DONE
|
||||
- 变更:校准 `.gitignore`,明确排除 `config.json`、`config/ai_models.json`、`cmshopee.db`、`chrome_user_data_dir/`、`images/`;保留本地测试图片与临时启动脚本忽略项;同步 `docs/06-tasks.md`、`docs/current-state.md`。
|
||||
- 决策:将原来的 `config/` 整目录忽略收窄为 `config/ai_models.json`,与文档安全红线保持一致,避免后续非敏感模板或说明文件被误隐藏。
|
||||
- 验证:`git check-ignore -v -- config.json config/ai_models.json cmshopee.db chrome_user_data_dir/ images/` 五个路径均命中仓库 `.gitignore`;命令同时提示全局 ignore 文件权限不可读,但不影响仓库规则生效。
|
||||
- 下一步:按任务看板领取 T-005。
|
||||
|
||||
Reference in New Issue
Block a user