neobot-storage 1.0.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. neobot_storage-1.0.0/PKG-INFO +44 -0
  2. neobot_storage-1.0.0/README.md +31 -0
  3. neobot_storage-1.0.0/pyproject.toml +27 -0
  4. neobot_storage-1.0.0/pyproject.toml.orig +23 -0
  5. neobot_storage-1.0.0/src/neobot_storage/__init__.py +18 -0
  6. neobot_storage-1.0.0/src/neobot_storage/_retry.py +90 -0
  7. neobot_storage-1.0.0/src/neobot_storage/alembic/env.py +72 -0
  8. neobot_storage-1.0.0/src/neobot_storage/alembic/script.py.mako +27 -0
  9. neobot_storage-1.0.0/src/neobot_storage/alembic/versions/0001_initial_schema.py +81 -0
  10. neobot_storage-1.0.0/src/neobot_storage/alembic/versions/0002_lowercase_table_names.py +38 -0
  11. neobot_storage-1.0.0/src/neobot_storage/alembic/versions/0003_add_archive_memories.py +43 -0
  12. neobot_storage-1.0.0/src/neobot_storage/alembic/versions/0004_add_images_table.py +47 -0
  13. neobot_storage-1.0.0/src/neobot_storage/alembic/versions/0005_add_user_profile_refresh_fields.py +32 -0
  14. neobot_storage-1.0.0/src/neobot_storage/alembic/versions/0006_add_emojis_table.py +46 -0
  15. neobot_storage-1.0.0/src/neobot_storage/alembic/versions/0007_add_creator_images.py +52 -0
  16. neobot_storage-1.0.0/src/neobot_storage/alembic/versions/0008_add_user_avatar_analysis.py +27 -0
  17. neobot_storage-1.0.0/src/neobot_storage/alembic/versions/0009_add_emoji_use_count.py +27 -0
  18. neobot_storage-1.0.0/src/neobot_storage/alembic/versions/0010_add_favorability.py +27 -0
  19. neobot_storage-1.0.0/src/neobot_storage/alembic/versions/0011_add_image_source.py +22 -0
  20. neobot_storage-1.0.0/src/neobot_storage/alembic/versions/0012_add_scheduled_tasks.py +80 -0
  21. neobot_storage-1.0.0/src/neobot_storage/alembic/versions/0017_add_model_usage_records.py +43 -0
  22. neobot_storage-1.0.0/src/neobot_storage/alembic/versions/0018_rename_cost_usd_to_cny.py +34 -0
  23. neobot_storage-1.0.0/src/neobot_storage/alembic/versions/0019_add_cache_hit_tokens_to_usage.py +35 -0
  24. neobot_storage-1.0.0/src/neobot_storage/alembic/versions/0020_add_bilibili_links.py +40 -0
  25. neobot_storage-1.0.0/src/neobot_storage/alembic/versions/0021_unique_completed_scheduled_tasks_task_uuid.py +38 -0
  26. neobot_storage-1.0.0/src/neobot_storage/alembic/versions/0022_add_maintenance_runs.py +42 -0
  27. neobot_storage-1.0.0/src/neobot_storage/alembic/versions/0023_add_creator_image_gallery_no.py +54 -0
  28. neobot_storage-1.0.0/src/neobot_storage/alembic/versions/0024_add_creator_image_sequences.py +46 -0
  29. neobot_storage-1.0.0/src/neobot_storage/alembic/versions/0025_add_usage_cost_source.py +42 -0
  30. neobot_storage-1.0.0/src/neobot_storage/alembic/versions/0026_add_archive_snapshots.py +54 -0
  31. neobot_storage-1.0.0/src/neobot_storage/alembic/versions/0027_add_user_avatar_storage.py +58 -0
  32. neobot_storage-1.0.0/src/neobot_storage/alembic.ini +44 -0
  33. neobot_storage-1.0.0/src/neobot_storage/backup.py +87 -0
  34. neobot_storage-1.0.0/src/neobot_storage/engine.py +62 -0
  35. neobot_storage-1.0.0/src/neobot_storage/models.py +328 -0
  36. neobot_storage-1.0.0/src/neobot_storage/repositories/__init__.py +27 -0
  37. neobot_storage-1.0.0/src/neobot_storage/repositories/archive.py +422 -0
  38. neobot_storage-1.0.0/src/neobot_storage/repositories/archive_snapshot.py +160 -0
  39. neobot_storage-1.0.0/src/neobot_storage/repositories/creator_image.py +329 -0
  40. neobot_storage-1.0.0/src/neobot_storage/repositories/emoji.py +233 -0
  41. neobot_storage-1.0.0/src/neobot_storage/repositories/image.py +185 -0
  42. neobot_storage-1.0.0/src/neobot_storage/repositories/maintenance.py +72 -0
  43. neobot_storage-1.0.0/src/neobot_storage/repositories/memory.py +50 -0
  44. neobot_storage-1.0.0/src/neobot_storage/repositories/message.py +81 -0
  45. neobot_storage-1.0.0/src/neobot_storage/repositories/profile.py +122 -0
  46. neobot_storage-1.0.0/src/neobot_storage/repositories/scheduled_task.py +366 -0
  47. neobot_storage-1.0.0/src/neobot_storage/repositories/usage.py +128 -0
  48. neobot_storage-1.0.0/src/neobot_storage/uow.py +77 -0
@@ -0,0 +1,44 @@
1
+ Metadata-Version: 2.3
2
+ Name: neobot-storage
3
+ Version: 1.0.0
4
+ Summary: Storage layer for NeoBot — SQLAlchemy + Alembic
5
+ Author: wsrsq, tangtian
6
+ Author-email: wsrsq <wsrsq001@163.com>, tangtian <a14b@126.com>
7
+ Requires-Dist: sqlalchemy[asyncio]>=2.0
8
+ Requires-Dist: aiosqlite>=0.20.0
9
+ Requires-Dist: alembic>=1.15.0
10
+ Requires-Dist: neobot-contracts
11
+ Requires-Python: >=3.13
12
+ Description-Content-Type: text/markdown
13
+
14
+ # neobot-storage
15
+
16
+ SQLAlchemy 2.0 async storage layer for NeoBot. Implements the repository and unit-of-work ports defined in `neobot-contracts`.
17
+
18
+ ## Usage
19
+
20
+ ```python
21
+ from neobot_storage import create_engine, make_uow_factory
22
+
23
+ engine = create_engine("sqlite+aiosqlite:///neobot.db")
24
+ uow_factory = make_uow_factory(engine)
25
+
26
+ async with uow_factory() as uow:
27
+ await uow.messages.save_message(msg)
28
+ await uow.commit()
29
+ ```
30
+
31
+ ## Migrations
32
+
33
+ Migrations are run automatically on app startup via `run_migrations(db_url)` (absolute path
34
+ is injected, so no database file is created inside the source tree).
35
+
36
+ To run the Alembic CLI manually, always pass an **absolute** database path:
37
+
38
+ ```bash
39
+ cd packages/storage/src/neobot_storage
40
+ alembic -x db_url=sqlite+aiosqlite:////absolute/path/to/neobot.db upgrade head
41
+ ```
42
+
43
+ `alembic.ini` intentionally leaves `sqlalchemy.url` empty; a relative URL there would create a
44
+ stray empty `neobot.db` in the working directory (which must never be committed).
@@ -0,0 +1,31 @@
1
+ # neobot-storage
2
+
3
+ SQLAlchemy 2.0 async storage layer for NeoBot. Implements the repository and unit-of-work ports defined in `neobot-contracts`.
4
+
5
+ ## Usage
6
+
7
+ ```python
8
+ from neobot_storage import create_engine, make_uow_factory
9
+
10
+ engine = create_engine("sqlite+aiosqlite:///neobot.db")
11
+ uow_factory = make_uow_factory(engine)
12
+
13
+ async with uow_factory() as uow:
14
+ await uow.messages.save_message(msg)
15
+ await uow.commit()
16
+ ```
17
+
18
+ ## Migrations
19
+
20
+ Migrations are run automatically on app startup via `run_migrations(db_url)` (absolute path
21
+ is injected, so no database file is created inside the source tree).
22
+
23
+ To run the Alembic CLI manually, always pass an **absolute** database path:
24
+
25
+ ```bash
26
+ cd packages/storage/src/neobot_storage
27
+ alembic -x db_url=sqlite+aiosqlite:////absolute/path/to/neobot.db upgrade head
28
+ ```
29
+
30
+ `alembic.ini` intentionally leaves `sqlalchemy.url` empty; a relative URL there would create a
31
+ stray empty `neobot.db` in the working directory (which must never be committed).
@@ -0,0 +1,27 @@
1
+ [project]
2
+ name = "neobot-storage"
3
+ version = "1.0.0"
4
+ description = "Storage layer for NeoBot — SQLAlchemy + Alembic"
5
+ readme = "README.md"
6
+ requires-python = ">=3.13"
7
+ dependencies = [
8
+ "sqlalchemy[asyncio]>=2.0",
9
+ "aiosqlite>=0.20.0",
10
+ "alembic>=1.15.0",
11
+ "neobot-contracts",
12
+ ]
13
+
14
+ [[project.authors]]
15
+ name = "wsrsq"
16
+ email = "wsrsq001@163.com"
17
+
18
+ [[project.authors]]
19
+ name = "tangtian"
20
+ email = "a14b@126.com"
21
+
22
+ [tool.uv.sources.neobot-contracts]
23
+ workspace = true
24
+
25
+ [build-system]
26
+ requires = ["uv_build>=0.9.27,<0.10.0"]
27
+ build-backend = "uv_build"
@@ -0,0 +1,23 @@
1
+ [project]
2
+ name = "neobot-storage"
3
+ version = "1.0.0"
4
+ description = "Storage layer for NeoBot — SQLAlchemy + Alembic"
5
+ readme = "README.md"
6
+ authors = [
7
+ { name = "wsrsq", email = "wsrsq001@163.com" },
8
+ { name = "tangtian", email = "a14b@126.com" },
9
+ ]
10
+ requires-python = ">=3.13"
11
+ dependencies = [
12
+ "sqlalchemy[asyncio]>=2.0",
13
+ "aiosqlite>=0.20.0",
14
+ "alembic>=1.15.0",
15
+ "neobot-contracts",
16
+ ]
17
+
18
+ [tool.uv.sources]
19
+ neobot-contracts = { workspace = true }
20
+
21
+ [build-system]
22
+ requires = ["uv_build>=0.9.27,<0.10.0"]
23
+ build-backend = "uv_build"
@@ -0,0 +1,18 @@
1
+ """neobot_storage 公共 API。"""
2
+
3
+ from neobot_storage.backup import backup_sqlite_database
4
+ from neobot_storage.engine import create_engine, run_migrations, sqlite_url
5
+ from neobot_storage.models import Base, MaintenanceRunRecord, ModelUsageRecord
6
+ from neobot_storage.uow import SqlAlchemyUnitOfWork, make_uow_factory
7
+
8
+ __all__ = [
9
+ "create_engine",
10
+ "run_migrations",
11
+ "sqlite_url",
12
+ "backup_sqlite_database",
13
+ "Base",
14
+ "MaintenanceRunRecord",
15
+ "ModelUsageRecord",
16
+ "SqlAlchemyUnitOfWork",
17
+ "make_uow_factory",
18
+ ]
@@ -0,0 +1,90 @@
1
+ """针对 SQLite 瞬时锁错误的重试辅助工具。"""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import random
7
+ from typing import Awaitable, Callable, Iterator, TypeVar
8
+
9
+ T = TypeVar("T")
10
+
11
+ # SQLite returns this error code / message when it cannot acquire a write lock
12
+ _LOCKED_MESSAGES = (
13
+ "database is locked",
14
+ "database table is locked",
15
+ "locking protocol",
16
+ "deadlock",
17
+ )
18
+
19
+
20
+ def _iter_exception_chain(exc: BaseException) -> Iterator[BaseException]:
21
+ """按顺序产出 *exc* 及其 ``__cause__``/``__context__`` 异常链,循环安全。"""
22
+ seen: set[int] = set()
23
+ link: BaseException | None = exc
24
+ while link is not None and id(link) not in seen:
25
+ seen.add(id(link))
26
+ yield link
27
+ link = link.__cause__ or link.__context__
28
+
29
+
30
+ def _is_locked_error(exc: Exception) -> bool:
31
+ # PendingRollbackError 的 message 内嵌原始 flush 异常
32
+ # ("Original exception was: ... database is locked"),
33
+ # 沿 __cause__/__context__ 链匹配可覆盖该场景,且不会对
34
+ # 普通含 "rolled back" 字样的错误过度匹配。
35
+ for link in _iter_exception_chain(exc):
36
+ msg = str(link).casefold()
37
+ if any(pattern in msg for pattern in _LOCKED_MESSAGES):
38
+ return True
39
+ return False
40
+
41
+
42
+ async def retry_on_lock(
43
+ coro_factory: Callable[[], Awaitable[T]],
44
+ *,
45
+ max_retries: int = 3,
46
+ base_delay: float = 0.1,
47
+ max_delay: float = 2.0,
48
+ on_retry: Callable[[], Awaitable[None]] | None = None,
49
+ ) -> T:
50
+ """执行 *coro_factory*,当 SQLite 报告数据库被锁定时,
51
+ 按指数退避 + 随机抖动重试。
52
+
53
+ Parameters
54
+ ----------
55
+ coro_factory : callable returning awaitable
56
+ 每次调用都返回新协程的工厂函数(以便重试)。
57
+ max_retries : int
58
+ 重试次数上限,超过后抛出最后一次异常。
59
+ base_delay : float
60
+ 初始延迟(秒),每次重试翻倍。
61
+ max_delay : float
62
+ 延迟上限。
63
+ on_retry : optional callable returning awaitable
64
+ 每次重试尝试前(退避延迟之后)调用。失败的 flush
65
+ 会让 SQLAlchemy 会话处于已回滚状态,可传入类似
66
+ ``session.rollback`` 的调用以恢复会话;重试的事务体
67
+ 将在全新事务上重新执行。
68
+
69
+ Raises
70
+ ------
71
+ 重试全部耗尽时抛出最后遇到的异常。
72
+ """
73
+ last_exc: Exception | None = None
74
+ for attempt in range(max_retries + 1):
75
+ try:
76
+ return await coro_factory()
77
+ except Exception as exc:
78
+ last_exc = exc
79
+ if not _is_locked_error(exc):
80
+ raise
81
+ if attempt >= max_retries:
82
+ break
83
+ delay = min(base_delay * (2**attempt), max_delay)
84
+ jitter = random.uniform(0, delay * 0.25)
85
+ await asyncio.sleep(delay + jitter)
86
+ if on_retry is not None:
87
+ await on_retry()
88
+
89
+ # All retries exhausted
90
+ raise last_exc # type: ignore[misc]
@@ -0,0 +1,72 @@
1
+ """面向异步 SQLAlchemy 的 Alembic 环境。"""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ from logging.config import fileConfig
7
+
8
+ from alembic import context
9
+ from sqlalchemy.ext.asyncio import create_async_engine
10
+
11
+ from neobot_storage.models import Base
12
+
13
+ config = context.config
14
+ if config.config_file_name:
15
+ fileConfig(config.config_file_name)
16
+
17
+ target_metadata = Base.metadata
18
+
19
+
20
+ def _resolve_database_url() -> str:
21
+ """解析数据库 URL:优先 ini(由 run_migrations 注入),其次命令行 -x db_url=...。
22
+
23
+ 不回退到相对路径,避免 alembic CLI 在源码目录生成 neobot.db 这类构建产物。
24
+ """
25
+ url = config.get_main_option("sqlalchemy.url") or ""
26
+ if not url:
27
+ url = context.get_x_argument(as_dictionary=True).get("db_url", "")
28
+ if not url:
29
+ raise RuntimeError(
30
+ "未配置数据库 URL:应用启动时由 run_migrations(db_url) 注入;"
31
+ "命令行请使用 alembic -x db_url=sqlite+aiosqlite:///<绝对路径>/neobot.db upgrade head"
32
+ )
33
+ return url
34
+
35
+
36
+ def run_migrations_offline() -> None:
37
+ context.configure(
38
+ url=_resolve_database_url(),
39
+ target_metadata=target_metadata,
40
+ literal_binds=True,
41
+ dialect_opts={"paramstyle": "named"},
42
+ )
43
+ with context.begin_transaction():
44
+ context.run_migrations()
45
+
46
+
47
+ async def run_migrations_online() -> None:
48
+ engine = create_async_engine(_resolve_database_url())
49
+ async with engine.connect() as conn:
50
+ await conn.run_sync(
51
+ lambda sync_conn: context.configure(
52
+ connection=sync_conn,
53
+ target_metadata=target_metadata,
54
+ )
55
+ )
56
+ async with conn.begin():
57
+ await conn.run_sync(lambda _: context.run_migrations())
58
+ await engine.dispose()
59
+
60
+
61
+ if context.is_offline_mode():
62
+ run_migrations_offline()
63
+ else:
64
+ try:
65
+ asyncio.get_running_loop()
66
+ # 已在 event loop 中(如 app 启动时调用),用新线程跑
67
+ import concurrent.futures
68
+ with concurrent.futures.ThreadPoolExecutor() as pool:
69
+ pool.submit(asyncio.run, run_migrations_online()).result()
70
+ except RuntimeError:
71
+ # 没有 event loop(CLI 直接调用)
72
+ asyncio.run(run_migrations_online())
@@ -0,0 +1,27 @@
1
+ """${message}
2
+
3
+ Revision ID: ${up_revision}
4
+ Revises: ${down_revision | comma,n}
5
+ Create Date: ${create_date}
6
+
7
+ """
8
+ from __future__ import annotations
9
+
10
+ from typing import Sequence, Union
11
+
12
+ from alembic import op
13
+ import sqlalchemy as sa
14
+ ${imports if imports else ""}
15
+
16
+ revision: str = ${repr(up_revision)}
17
+ down_revision: Union[str, None] = ${repr(down_revision)}
18
+ branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)}
19
+ depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)}
20
+
21
+
22
+ def upgrade() -> None:
23
+ ${upgrades if upgrades else "pass"}
24
+
25
+
26
+ def downgrade() -> None:
27
+ ${downgrades if downgrades else "pass"}
@@ -0,0 +1,81 @@
1
+ """初始表结构
2
+
3
+ Revision ID: 0001
4
+ Revises:
5
+ Create Date: 2026-03-21 00:00:00.000000
6
+
7
+ """
8
+ from __future__ import annotations
9
+
10
+ from typing import Sequence, Union
11
+
12
+ from alembic import op
13
+ import sqlalchemy as sa
14
+
15
+ revision: str = "0001"
16
+ down_revision: Union[str, None] = None
17
+ branch_labels: Union[str, Sequence[str], None] = None
18
+ depends_on: Union[str, Sequence[str], None] = None
19
+
20
+
21
+ def upgrade() -> None:
22
+ op.create_table(
23
+ "USER_DATA",
24
+ sa.Column("user_id", sa.String(), primary_key=True),
25
+ sa.Column("nick_name", sa.Text()),
26
+ sa.Column("relation_ship", sa.Text()),
27
+ sa.Column("profile", sa.Text()),
28
+ sa.Column("birthday", sa.Text()),
29
+ sa.Column("sex", sa.Text()),
30
+ sa.Column("city", sa.Text()),
31
+ sa.Column("country", sa.Text()),
32
+ sa.Column("labs", sa.Text()),
33
+ sa.Column("remark", sa.Text()),
34
+ sa.Column("age", sa.Integer()),
35
+ sa.Column("long_nick", sa.Text()),
36
+ )
37
+
38
+ op.create_table(
39
+ "GROUP_DATA",
40
+ sa.Column("group_id", sa.String(), primary_key=True),
41
+ sa.Column("group_name", sa.Text()),
42
+ sa.Column("profile", sa.Text()),
43
+ sa.Column("is_quite", sa.Boolean(), server_default=sa.false()),
44
+ )
45
+
46
+ op.create_table(
47
+ "EVENT_DATA",
48
+ sa.Column("event_id", sa.String(), primary_key=True),
49
+ sa.Column("event_message", sa.Text()),
50
+ sa.Column("embedded_data", sa.Text()),
51
+ )
52
+
53
+ op.create_table(
54
+ "messages",
55
+ sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
56
+ sa.Column("event_id", sa.String(), nullable=False, unique=True),
57
+ sa.Column("conversation_kind", sa.String(), nullable=False),
58
+ sa.Column("conversation_id", sa.String(), nullable=False),
59
+ sa.Column("sender_id", sa.String(), nullable=False),
60
+ sa.Column("sender_name", sa.Text(), nullable=False),
61
+ sa.Column("text", sa.Text(), nullable=False),
62
+ sa.Column("occurred_at", sa.DateTime(), nullable=False),
63
+ )
64
+
65
+ op.create_table(
66
+ "memories",
67
+ sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
68
+ sa.Column("conversation_kind", sa.String(), nullable=False),
69
+ sa.Column("conversation_id", sa.String(), nullable=False),
70
+ sa.Column("speaker_id", sa.String(), nullable=False),
71
+ sa.Column("content", sa.Text(), nullable=False),
72
+ sa.Column("created_at", sa.DateTime(), nullable=False),
73
+ )
74
+
75
+
76
+ def downgrade() -> None:
77
+ op.drop_table("memories")
78
+ op.drop_table("messages")
79
+ op.drop_table("EVENT_DATA")
80
+ op.drop_table("GROUP_DATA")
81
+ op.drop_table("USER_DATA")
@@ -0,0 +1,38 @@
1
+ """表名转小写
2
+
3
+ Revision ID: 0002
4
+ Revises: 0001
5
+ Create Date: 2026-03-21 00:00:00.000000
6
+
7
+ """
8
+ from __future__ import annotations
9
+
10
+ from typing import Sequence, Union
11
+
12
+ from alembic import op
13
+
14
+ revision: str = "0002"
15
+ down_revision: Union[str, None] = "0001"
16
+ branch_labels: Union[str, Sequence[str], None] = None
17
+ depends_on: Union[str, Sequence[str], None] = None
18
+
19
+
20
+ def upgrade() -> None:
21
+ # SQLite treats table names as case-insensitive, so we need a two-step rename
22
+ for old, tmp, new in [
23
+ ("USER_DATA", "_tmp_user_data", "user_data"),
24
+ ("GROUP_DATA", "_tmp_group_data", "group_data"),
25
+ ("EVENT_DATA", "_tmp_event_data", "event_data"),
26
+ ]:
27
+ op.rename_table(old, tmp)
28
+ op.rename_table(tmp, new)
29
+
30
+
31
+ def downgrade() -> None:
32
+ for old, tmp, new in [
33
+ ("user_data", "_tmp_user_data", "USER_DATA"),
34
+ ("group_data", "_tmp_group_data", "GROUP_DATA"),
35
+ ("event_data", "_tmp_event_data", "EVENT_DATA"),
36
+ ]:
37
+ op.rename_table(old, tmp)
38
+ op.rename_table(tmp, new)
@@ -0,0 +1,43 @@
1
+ """新增 archive_memories 表
2
+
3
+ Revision ID: 0003
4
+ Revises: 0002
5
+ Create Date: 2026-03-22 00:00:00.000000
6
+
7
+ """
8
+ from __future__ import annotations
9
+
10
+ from typing import Sequence, Union
11
+
12
+ from alembic import op
13
+ import sqlalchemy as sa
14
+
15
+ revision: str = "0003"
16
+ down_revision: Union[str, None] = "0002"
17
+ branch_labels: Union[str, Sequence[str], None] = None
18
+ depends_on: Union[str, Sequence[str], None] = None
19
+
20
+
21
+ def upgrade() -> None:
22
+ op.create_table(
23
+ "archive_memories",
24
+ sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
25
+ sa.Column("table_name", sa.String(), nullable=False),
26
+ sa.Column("key", sa.String(), nullable=False),
27
+ sa.Column("value", sa.Text(), nullable=False),
28
+ sa.Column("tags", sa.Text()),
29
+ sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
30
+ sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
31
+ sa.Column("version", sa.Integer(), nullable=False, server_default="1"),
32
+ sa.UniqueConstraint("table_name", "key", name="uq_archive_memories_table_key"),
33
+ )
34
+
35
+ # 创建索引和唯一约束
36
+ op.create_index(
37
+ "ix_archive_memories_table_name_updated_at",
38
+ "archive_memories",
39
+ ["table_name", "updated_at"],
40
+ )
41
+ def downgrade() -> None:
42
+ op.drop_index("ix_archive_memories_table_name_updated_at", "archive_memories")
43
+ op.drop_table("archive_memories")
@@ -0,0 +1,47 @@
1
+ """新增 images 表
2
+
3
+ Revision ID: 0004
4
+ Revises: 0003
5
+ Create Date: 2026-04-09
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from typing import Union
11
+
12
+ from alembic import op
13
+ import sqlalchemy as sa
14
+
15
+
16
+ revision: str = "0004"
17
+ down_revision: Union[str, None] = "0003"
18
+ branch_labels = None
19
+ depends_on = None
20
+
21
+
22
+ def upgrade() -> None:
23
+ op.create_table(
24
+ "images",
25
+ sa.Column("id", sa.Integer(), autoincrement=True, nullable=False),
26
+ sa.Column("file_hash", sa.String(), nullable=False),
27
+ sa.Column("source", sa.Text(), nullable=True),
28
+ sa.Column("mime_type", sa.String(), nullable=True),
29
+ sa.Column("original_width", sa.Integer(), nullable=True),
30
+ sa.Column("original_height", sa.Integer(), nullable=True),
31
+ sa.Column("processed_width", sa.Integer(), nullable=True),
32
+ sa.Column("processed_height", sa.Integer(), nullable=True),
33
+ sa.Column("analysis_text", sa.Text(), nullable=True),
34
+ sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
35
+ sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
36
+ sa.Column("version", sa.Integer(), nullable=False),
37
+ sa.PrimaryKeyConstraint("id"),
38
+ sa.UniqueConstraint("file_hash", name="uq_images_file_hash"),
39
+ )
40
+ op.create_index("ix_images_updated_at", "images", ["updated_at"])
41
+ op.create_index("ix_images_source", "images", ["source"])
42
+
43
+
44
+ def downgrade() -> None:
45
+ op.drop_index("ix_images_source", table_name="images")
46
+ op.drop_index("ix_images_updated_at", table_name="images")
47
+ op.drop_table("images")
@@ -0,0 +1,32 @@
1
+ """新增用户画像刷新字段
2
+
3
+ Revision ID: 0005
4
+ Revises: 0004
5
+ Create Date: 2026-04-12
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from typing import Union
11
+
12
+ from alembic import op
13
+ import sqlalchemy as sa
14
+
15
+
16
+ revision: str = "0005"
17
+ down_revision: Union[str, None] = "0004"
18
+ branch_labels = None
19
+ depends_on = None
20
+
21
+
22
+ def upgrade() -> None:
23
+ op.add_column("user_data", sa.Column("known_gender", sa.Text(), nullable=True))
24
+ op.add_column(
25
+ "user_data",
26
+ sa.Column("fetched_at", sa.DateTime(timezone=True), nullable=True),
27
+ )
28
+
29
+
30
+ def downgrade() -> None:
31
+ op.drop_column("user_data", "fetched_at")
32
+ op.drop_column("user_data", "known_gender")
@@ -0,0 +1,46 @@
1
+ """新增 emojis 表
2
+
3
+ Revision ID: 0006
4
+ Revises: 0005
5
+ Create Date: 2026-04-24
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from typing import Union
11
+
12
+ from alembic import op
13
+ import sqlalchemy as sa
14
+
15
+
16
+ revision: str = "0006"
17
+ down_revision: Union[str, None] = "0005"
18
+ branch_labels = None
19
+ depends_on = None
20
+
21
+
22
+ def upgrade() -> None:
23
+ op.create_table(
24
+ "emojis",
25
+ sa.Column("id", sa.Integer(), autoincrement=True, nullable=False),
26
+ sa.Column("file_hash", sa.String(), nullable=False),
27
+ sa.Column("file_name", sa.String(), nullable=False),
28
+ sa.Column("file_path", sa.String(), nullable=False),
29
+ sa.Column("mime_type", sa.String(), nullable=True),
30
+ sa.Column("original_width", sa.Integer(), nullable=True),
31
+ sa.Column("original_height", sa.Integer(), nullable=True),
32
+ sa.Column("analysis_text", sa.Text(), nullable=True),
33
+ sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
34
+ sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
35
+ sa.Column("version", sa.Integer(), nullable=False),
36
+ sa.PrimaryKeyConstraint("id"),
37
+ sa.UniqueConstraint("file_hash", name="uq_emojis_file_hash"),
38
+ )
39
+ op.create_index("ix_emojis_updated_at", "emojis", ["updated_at"])
40
+ op.create_index("ix_emojis_file_name", "emojis", ["file_name"])
41
+
42
+
43
+ def downgrade() -> None:
44
+ op.drop_index("ix_emojis_file_name", table_name="emojis")
45
+ op.drop_index("ix_emojis_updated_at", table_name="emojis")
46
+ op.drop_table("emojis")
@@ -0,0 +1,52 @@
1
+ """新增 creator_images 表
2
+
3
+ Revision ID: 0007
4
+ Revises: 0006
5
+ Create Date: 2026-04-25
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from typing import Union
11
+
12
+ from alembic import op
13
+ import sqlalchemy as sa
14
+
15
+
16
+ revision: str = "0007"
17
+ down_revision: Union[str, None] = "0006"
18
+ branch_labels = None
19
+ depends_on = None
20
+
21
+
22
+ def upgrade() -> None:
23
+ op.create_table(
24
+ "creator_images",
25
+ sa.Column("id", sa.Integer(), autoincrement=True, nullable=False),
26
+ sa.Column("image_id", sa.String(), nullable=False),
27
+ sa.Column("source", sa.String(), nullable=False),
28
+ sa.Column("file_hash", sa.String(), nullable=False),
29
+ sa.Column("file_path", sa.String(), nullable=False),
30
+ sa.Column("prompt", sa.Text(), nullable=True),
31
+ sa.Column("description", sa.Text(), nullable=True),
32
+ sa.Column("mime_type", sa.String(), nullable=True),
33
+ sa.Column("original_width", sa.Integer(), nullable=True),
34
+ sa.Column("original_height", sa.Integer(), nullable=True),
35
+ sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
36
+ sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
37
+ sa.Column("version", sa.Integer(), nullable=False),
38
+ sa.PrimaryKeyConstraint("id"),
39
+ sa.UniqueConstraint("image_id", name="uq_creator_images_image_id"),
40
+ )
41
+ op.create_index(
42
+ "ix_creator_images_source_updated_at",
43
+ "creator_images",
44
+ ["source", "updated_at"],
45
+ )
46
+ op.create_index("ix_creator_images_file_hash", "creator_images", ["file_hash"])
47
+
48
+
49
+ def downgrade() -> None:
50
+ op.drop_index("ix_creator_images_file_hash", table_name="creator_images")
51
+ op.drop_index("ix_creator_images_source_updated_at", table_name="creator_images")
52
+ op.drop_table("creator_images")